用户诉求的后半:不该让他手填经纬度和半径,应该在地图上圈。三个手填输入框
换成地图 + 圆列表:点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/
定位/删除、可一次清空;改动立刻写进隐藏字段 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 报错。
130 lines
6.7 KiB
JavaScript
130 lines
6.7 KiB
JavaScript
/* 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);
|