Files
rond-with-you/internal/web/static/js/topic-areas.test.js
T
tamakyi 6aee7e441b 给显示区编辑器补上长度参照:比例尺 + 直径标注
用户反馈「专题里的圆无论怎么缩放大小都一样,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 报错。
2026-10-13 10:37:10 +08:00

146 lines
7.6 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('=== 半径的「人话版」跨度(直径)===');
(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);