用户反馈「专题里的圆无论怎么缩放大小都一样,20km 却覆盖了整个南宁」。实测 三处画圆(编辑器 L.circle、改半径把手、前台迷雾裁剪圆)**全都是地理锚定的**: 编辑器圆直径 z9/z11/z13 = 178/710/2842px(期望 177/711/2847)、同一缩放下改半径 3/10/25/50km → 42/142/356/710px(期望 43/142/355/711)、把手距圆心 20.038km (该圆半径 20km)、前台裁剪圆换算始终 ≈20km。也就是**复现不出「大小不变」**。 真正的问题是缺**参照物**:编辑器地图以前没有比例尺(前台专题页一直有), 圈圆时只能靠地名猜。用他自己的数据验证「20km 覆盖南宁」是对的——圆心 20km 内 落了他 477 个地点中的 45 个,最远到西乡塘的广西民族大学相思湖校区(14.5km) 与良庆的五象万象汇(10.2km),正是南宁建成区的跨度。 所以这一版补的是参照物,不改任何几何逻辑: - 编辑器地图加 L.control.scale(跟前台一致,公制)。 - 半径旁边标出跨度:`spanLabel()` 把半径换成「约 40 公里宽」(<1 公里改用米, 免得出「约 0.1 公里宽」这种别扭说法);圆列表每行也标,新圆半径旁边实时跟着变。 - 守卫 TestTopicAreaEditorGivesSizeReference 钉住这两样(变异验证过:去掉比例尺立刻红)。 它顺手抓到我自己的疏漏——模板的 init 调用漏传了 spanId,靠脚本默认值兜着,已补上显式传参。 验证:topic-areas.test.js 55 条(新增 11 条 spanLabel 边界);无头实测比例尺在 (z10 显示 10 km)、新圆半径 3→「约 6 公里宽」/25→「约 50 公里宽」实时更新、 圆列表 20→「约 40 公里宽」,全程无 JS 报错。
146 lines
7.6 KiB
JavaScript
146 lines
7.6 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('=== 半径的「人话版」跨度(直径)===');
|
||
(function () {
|
||
eq(api.spanLabel(20), '约 40 公里宽', '半径 20 → 约 40 公里宽');
|
||
eq(api.spanLabel(25), '约 50 公里宽', '半径 25 → 约 50 公里宽');
|
||
eq(api.spanLabel(3.5), '约 7 公里宽', '半径 3.5 → 约 7 公里宽');
|
||
eq(api.spanLabel(0.5), '约 1 公里宽', '半径 0.5 → 约 1 公里宽');
|
||
eq(api.spanLabel(2000), '约 4000 公里宽', '大半径不带小数');
|
||
// 小于 1 公里改用米,否则会写成「约 0.1 公里宽」这种别扭说法
|
||
eq(api.spanLabel(0.05), '约 100 米宽', '半径 0.05 → 约 100 米宽');
|
||
eq(api.spanLabel(0.3), '约 600 米宽', '半径 0.3 → 约 600 米宽');
|
||
ok(api.spanLabel(0) === '', '半径 0 → 空串(调用方直接不显示)');
|
||
ok(api.spanLabel(-1) === '', '负半径 → 空串');
|
||
ok(api.spanLabel(NaN) === '', 'NaN → 空串');
|
||
ok(api.spanLabel('abc') === '', '非数字 → 空串');
|
||
})();
|
||
|
||
console.log('\n=== 汇总 ===');
|
||
console.log(' pass=' + pass + ' fail=' + fail);
|
||
process.exit(fail === 0 ? 0 : 1);
|