/* 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);