Files
rond-with-you/internal/web/static/js/topic-areas.test.js
T
tamakyi 7ab33227a5 专题显示区改成在地图上点选(后台编辑器)
用户诉求的后半:不该让他手填经纬度和半径,应该在地图上圈。三个手填输入框
换成地图 + 圆列表:点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/
定位/删除、可一次清空;改动立刻写进隐藏字段 areas(JSON) 与 crs,随专题表单
一起提交。

- 复用 /admin/fog-mask 那套约定:/api/map-config 取底图、底图坐标系随表单带上、
  后端换算成 GCJ-02。不另起炉灶。
- areas 的初值**渲染进隐藏字段**(不是只塞给 JS):脚本没加载上时提交的仍是原值,
  否则一次保存就把已有显示区静默清空。
- 地图初始视野取生效数据集的地点包围盒(placesBounds),一上来就落在自己的地界上,
  而不是全国视野。
- 上限(20 个 / 0.05~2000 km)前端也校验一份,并**禁用提交**;两边数字用守卫比对,
  免得出现「前端放行、后端拒绝,用户不知道改哪里」。

实测抓到并修掉的 bug:/admin/topics 没设 p.HasMap ⇒ 布局不引入 Leaflet,
表现是脚本加载了、rondTopicAreas 也在,就是 window.L 永远 undefined,init 静默
返回、控制台不报错、页面像「什么都没发生」。已加守卫钉住这一点。

其余:
- topic-areas.js 只暴露纯函数(校验/序列化/增删改的不可变性/几何换算)供 node 单测,
  另加一个最小测试钩子 api._map 供无头验证(同 window.rondMap._state 惯例)。
- CSS 用定宽容器装半径输入框:站点全局 input 规则是 width:100% 且带 10 个 :not()
  (特异性 (0,11,1)),直接给 input 写宽度压不过。
- gofmt:文档注释里的缩进列表按 Go 1.19 的规则重排;顺带把工作区的 CRLF 归一到 LF
  (.gitattributes 早就声明 eol=lf,blob 本来也是 LF,所以 git diff 无额外噪声)。

验证:
- topic-areas.test.js 44 条(含「增删改返回新数组不原地改」、半径换算自洽性)。
- 三个新守卫:两边上限一致、模板契约(旧输入框必须没了 + 挂点齐全 + 初值来源)、
  topicsAdminPage 必须设 HasMap。
- 无头端到端:两击成两圆 → 改半径 7.5 → 删第 1 个 → 越界半径被拦且禁用提交 →
  提交 200 且落库 areas=[{lat:24.72,lon:108.1,radiusKm:7.5}](废弃列镜像 7.5);全程无 JS 报错。
2026-10-12 17:35:00 +08:00

130 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* topic-areas.js 纯函数部分的单元测试(node 直接跑,无依赖)。
* 用法:node internal/web/static/js/topic-areas.test.js
*
* 这里只测「不改 DOM 的那半」:校验规则、序列化、增删改的不可变性、几何换算。
* 校验规则必须与后端 normalizeTopicAreas 对齐(放行/拒绝不一致,用户只会看到一句红字
* 却不知道改哪里),Go 侧另有一条守卫直接比对两边写死的上限数字。
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const mod = {};
const sandbox = { window: {}, module: mod };
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(path.join(__dirname, 'topic-areas.js'), 'utf8'), sandbox);
const api = sandbox.window.rondTopicAreas;
let pass = 0, fail = 0;
function ok(cond, label, extra) {
if (cond) { pass++; console.log(' ok ' + label); }
else { fail++; console.log(' FAIL ' + label + (extra ? '\n ' + extra : '')); }
}
function eq(got, want, label) {
const g = JSON.stringify(got), w = JSON.stringify(want);
if (g === w) { pass++; console.log(' ok ' + label); }
else { fail++; console.log(' FAIL ' + label + '\n got ' + g + '\n want ' + w); }
}
console.log('=== 归一化:坐标 6 位、半径 3 位 ===');
(function () {
eq(api.normalize({ lat: 25.1234567891, lon: 100.9876543219, radiusKm: 3.14159265 }),
{ lat: 25.123457, lon: 100.987654, radiusKm: 3.142 }, '超长小数被截到 6/3 位');
eq(api.normalize({ lat: '25.5', lon: '100.25', radiusKm: '3' }),
{ lat: 25.5, lon: 100.25, radiusKm: 3 }, '字符串数字照收');
ok(!isFinite(api.normalize({ lat: 'abc', lon: 1, radiusKm: 1 }).lat), '非数字 → NaN(交给校验去拦)');
ok(!isFinite(api.normalize(null).radiusKm), 'null 输入不抛异常');
})();
console.log('=== 校验:空 = 不限区域,是合法的 ===');
(function () {
eq(api.validate([]), '', '空数组通过');
eq(api.validate(null), '', 'null 通过(当作空)');
eq(api.validate([{ lat: 25, lon: 100, radiusKm: 3 }]), '', '单个正常圆通过');
ok(api.validate([{ lat: NaN, lon: 100, radiusKm: 3 }]).indexOf('数值') >= 0, 'NaN 纬度被拦');
ok(api.validate([{ lat: 91, lon: 100, radiusKm: 3 }]).indexOf('经纬度') >= 0, '纬度越界被拦');
ok(api.validate([{ lat: 25, lon: 181, radiusKm: 3 }]).indexOf('经纬度') >= 0, '经度越界被拦');
ok(api.validate([{ lat: 25, lon: 100, radiusKm: api.MIN_KM / 2 }]).indexOf('半径') >= 0, '半径过小被拦');
ok(api.validate([{ lat: 25, lon: 100, radiusKm: api.MAX_KM + 1 }]).indexOf('半径') >= 0, '半径过大被拦');
ok(api.validate([{ lat: 25, lon: 100, radiusKm: api.MAX_KM }]) === '', '半径正好等于上限:放行');
ok(api.validate([{ lat: 25, lon: 100, radiusKm: api.MIN_KM }]) === '', '半径正好等于下限:放行');
const many = [];
for (let i = 0; i < api.MAX_AREAS + 1; i++) many.push({ lat: 20 + i / 100, lon: 100, radiusKm: 3 });
ok(api.validate(many).indexOf('最多') >= 0, '超过个数上限被拦');
ok(api.validate(many.slice(0, api.MAX_AREAS)) === '', '正好等于个数上限:放行');
// 报错要指出是第几个,否则用户得自己数
ok(api.validate([{ lat: 25, lon: 100, radiusKm: 3 }, { lat: 91, lon: 100, radiusKm: 3 }]).indexOf('第 2 个') >= 0,
'报错指明是第几个圆');
})();
console.log('=== 序列化 / 解析 ===');
(function () {
eq(api.serialize([{ lat: 25.1234567, lon: 100.2, radiusKm: 3.14159 }]),
'[{"lat":25.123457,"lon":100.2,"radiusKm":3.142}]', '序列化顺带归一化');
eq(api.serialize([]), '[]', '空数组序列化成 [](不是 null,后端列上有 NOT NULL)');
eq(api.serialize(null), '[]', 'null 也序列化成 []');
const round = api.parse(api.serialize([{ lat: 25.5, lon: 100.25, radiusKm: 3 }]));
eq(round, [{ lat: 25.5, lon: 100.25, radiusKm: 3 }], '往返一致');
eq(api.parse(''), [], '空串 → 空数组');
eq(api.parse('[{}'), [], '坏 JSON → 空数组(不让编辑页整个炸掉)');
eq(api.parse('{"lat":25}'), [], '不是数组 → 空数组');
})();
console.log('=== 增删改必须返回新数组(不原地改) ===');
(function () {
const base = [{ lat: 25, lon: 100, radiusKm: 3 }];
const snapshot = JSON.stringify(base);
const added = api.add(base, { lat: 26, lon: 101, radiusKm: 4 });
eq(added.length, 2, 'add 之后有两个');
eq(JSON.stringify(base), snapshot, 'add 没动原数组');
ok(added !== base, 'add 返回的是新数组');
const removed = api.remove(added, 0);
eq(removed.length, 1, 'remove 之后剩一个');
eq(removed[0].lat, 26, 'remove 删的是指定下标');
eq(JSON.stringify(added), '[{"lat":25,"lon":100,"radiusKm":3},{"lat":26,"lon":101,"radiusKm":4}]', 'remove 没动原数组');
eq(api.remove(base, 5).length, 1, '越界下标是空操作而不是抛错');
eq(api.remove(base, -1).length, 1, '负下标也是空操作');
const moved = api.setCenter(base, 0, 30.5, 110.25);
eq(moved[0], { lat: 30.5, lon: 110.25, radiusKm: 3 }, 'setCenter 只改圆心、半径保留');
eq(JSON.stringify(base), snapshot, 'setCenter 没动原数组');
const bigger = api.setRadius(base, 0, 7.25);
eq(bigger[0], { lat: 25, lon: 100, radiusKm: 7.25 }, 'setRadius 只改半径、圆心保留');
eq(JSON.stringify(base), snapshot, 'setRadius 没动原数组');
// 个数上限
const full = [];
for (let i = 0; i < api.MAX_AREAS; i++) full.push({ lat: 20 + i / 100, lon: 100, radiusKm: 3 });
eq(api.add(full, { lat: 25, lon: 100, radiusKm: 3 }).length, api.MAX_AREAS, '到上限后 add 不再增长(配合地图点击处的提示)');
})();
console.log('=== 几何换算 ===');
(function () {
ok(Math.abs(api.latDelta(111.32) - 1) < 1e-9, '111.32 公里 ≈ 1 度纬度');
ok(Math.abs(api.lonDelta(0, 111.32) - 1) < 1e-9, '赤道上 111.32 公里 ≈ 1 度经度');
ok(api.lonDelta(60, 111.32) > api.lonDelta(0, 111.32),
'纬度越高,同样的公里数换来的**经度越大**(经线越挤)');
ok(isFinite(api.lonDelta(90, 1)), '极点处不会除出无穷(余弦被夹住)');
const d = api.kmBetween([25, 100], [26, 100]);
ok(Math.abs(d - 111.19) < 0.5, '同经度差 1 度 ≈ 111.2 公里,实得 ' + d.toFixed(2));
eq(api.kmBetween([25, 100], [25, 100]), 0, '同一点距离为 0');
// 拖把手反推半径用的就是这个:把手摆在东侧 lonDelta 处,量回来应当等于原半径
const km = 5;
const lat = 24.7;
const dx = api.lonDelta(lat, km);
ok(Math.abs(api.kmBetween([lat, 108], [lat, 108 + dx]) - km) < 0.05,
'半径换算自洽:按 lonDelta 摆的把手量回来仍是 ' + km + ' 公里');
})();
console.log('\n=== 汇总 ===');
console.log(' pass=' + pass + ' fail=' + fail);
process.exit(fail === 0 ? 0 : 1);