/** * T36 主场景区配置 纯逻辑检查(票 36)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t36-primary-area.mjs * * 覆盖验收第 1–7 条里能脱浏览器断言的部分: * · 四类输入:合法 / 顺序写反 / 缺字段 / 数值个数不对(验收第 7 条点名要这四类); * · **不自动翻转**:把甲方给的 [W, N, E, S] 原样喂进去必须判非法,而不是被悄悄修好; * · 关闭路径保留原因:未配置与非法各给得出不同的原因词; * · 配置问题清单里确实记了一条(不只是"界面看起来没开"); * · 本部署显式值与甲方归一化值逐位相同、且与 config.example.json 形状一致; * · 静态守卫:`map.primaryArea` 只在配置服务里读、不内置沅陵地理常量、不改前端源码即可生效。 */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { PRIMARY_AREA_LABEL, PRIMARY_AREA_OFF_SUFFIX, PRIMARY_AREA_REASON, PRIMARY_AREA_STATE, planPrimaryArea, primaryAreaStatusText, } from '../../../src/modules/cockpit/domain/primaryArea.js' import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js' /** 本部署的显式值(甲方框定范围归一化后:[西, 南, 东, 北];与决策票 01 的契约示例逐位一致)。 */ const DEPLOY_BOUNDS = [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907] /** 甲方原样给的顺序([W, N, E, S])—— 部署期要转换,运行时不转换。 */ const OWNER_RAW_BOUNDS = [110.32874994141685, 28.491303899290685, 110.46577922666275, 28.3917230659234] /** 同上,但用契约示例的精度(第 ⑧ 条那处"关引擎也要读得到"的断言用这一份)。 */ const SWAPPED_IN_VERIFY = [110.328749941417, 28.4913038992907, 110.465779226663, 28.3917230659234] // ---------------------------------------------------------------- 1. 合法 const ok = planPrimaryArea({ primaryArea: { bounds: DEPLOY_BOUNDS } }) assert.equal(ok.state, PRIMARY_AREA_STATE.READY, '顺序正确的四元数组应判 ready') assert.equal(ok.enabled, true, 'ready 即启用(限位与自建雾可开)') assert.deepEqual(ok.bounds, DEPLOY_BOUNDS, '矩形应原样透传') assert.equal(ok.reason, '', '启用时不该有原因') assert.match(ok.sizeText, /^\d+\.\d×\d+\.\d km$/, `尺寸应是「宽×高 km」,实际 ${ok.sizeText}`) assert.match(primaryAreaStatusText(ok), /13\.4×11\.1 km/, '沅陵县城范围约 13.4×11.1 km(浮标读得到尺寸)') assert.equal(primaryAreaStatusText(ok).includes(PRIMARY_AREA_OFF_SUFFIX), false, '启用时不得出现「关闭」字样') // 边界:零跨度不算合法(west east)也应判非法', ) // ---------------------------------------------------------------- 3. 缺字段 const missing = planPrimaryArea({}) assert.equal(missing.state, PRIMARY_AREA_STATE.MISSING, '没有 primaryArea 应判 missing') assert.equal(missing.enabled, false, '未配置不得启用') assert.equal(missing.reason, PRIMARY_AREA_REASON.MISSING, '原因应是「未配置」') const missingText = primaryAreaStatusText(missing) assert.match(missingText, /未配置/, '浮标应说未配置') assert.match(missingText, new RegExp(PRIMARY_AREA_LABEL), '浮标应点名主场景区') assert.equal(missingText.includes(PRIMARY_AREA_OFF_SUFFIX), true, '未配置也必须说明两个能力关了') assert.equal(missingText.includes('未配置(未配置)'), false, '不得把同一个词复述两遍(人读的读数要干净)') assert.equal(/\((未配置|顺序应为[^)]*)\)\s*$/.test(missingText.replace(` · ${PRIMARY_AREA_OFF_SUFFIX}`, '')), false, '未配置时不该再套一层括号原因') // `primaryArea: {}`(写了字段但没填 bounds)由**配置层**判成"写错了"(它知道部署确实写了这个字段); // 直接调 domain 时只有 `{}`、没有 `configured`,无从区分"没写"与"写空",如实按未配置处理。 const emptyObject = planPrimaryArea({ primaryArea: { configured: true } }) assert.equal(emptyObject.state, PRIMARY_AREA_STATE.INVALID, '给了 primaryArea 却没有 bounds,应判非法而不是未配置') assert.equal(planPrimaryArea({ primaryArea: {} }).state, PRIMARY_AREA_STATE.MISSING, '直接调用且没有任何线索时按未配置') assert.equal( normalizeConfig({ map: { primaryArea: {} } }).config.map.primaryArea.configured, true, '配置层必须知道「部署写了这个字段」,否则空对象会被读成一句无害的省略', ) // ---------------------------------------------------------------- 4. 数值个数不对 for (const [label, bad] of [ ['三个数字', [110, 28, 111]], ['五个数字', [110, 28, 111, 29, 30]], ['空数组', []], ['混入字符串', [110, 28, '111', 29]], ['混入 null', [110, 28, null, 29]], ['NaN', [110, 28, Number.NaN, 29]], ['不是数组', '110,28,111,29'], ['对象', { west: 110 }], ]) { const plan = planPrimaryArea({ primaryArea: { bounds: bad, configured: true } }) assert.equal(plan.state, PRIMARY_AREA_STATE.INVALID, `${label} 应判非法`) assert.equal(plan.reason, PRIMARY_AREA_REASON.SHAPE, `${label} 的原因应是形状问题`) assert.equal(plan.bounds, null, `${label} 不得给出矩形`) } assert.match(primaryAreaStatusText(planPrimaryArea({ primaryArea: { bounds: [1, 2, 3] } })), /四个有限数字/, '形状错应说清要几个数字') // ---------------------------------------------------------------- 5. 关闭路径在**配置层**也留痕 // 三条关闭路径各记一条 issue,且原因词与 domain 那份一致(不是两边各写各的)。 const missingConfig = normalizeConfig({}) assert.equal(missingConfig.config.map.primaryArea.configured, false, '没配时内置默认应记成「未配置」') assert.deepEqual(missingConfig.config.map.primaryArea.bounds, null, '内置默认不得预置任何地理常量(Q3=A)') assert.equal( missingConfig.issues.some((item) => item.includes('primaryArea')), false, '**没配**是合法部署,不该报成配置问题(只由浮标提示)', ) const swappedConfig = normalizeConfig({ map: { primaryArea: { bounds: OWNER_RAW_BOUNDS } } }) assert.equal(swappedConfig.config.map.primaryArea.configured, true, '给了 bounds 就该记成已配置') assert.deepEqual( swappedConfig.config.map.primaryArea.bounds, OWNER_RAW_BOUNDS, '归一化必须原样收下(不翻转)—— 翻转会让配置抄错在运行期隐身', ) assert.equal(swappedConfig.issues.length, 1, `顺序非法应记一条 issue,实际 ${JSON.stringify(swappedConfig.issues)}`) assert.match(swappedConfig.issues[0], /primaryArea\.bounds/, 'issue 要点名字段') assert.match(swappedConfig.issues[0], /不自动翻转/, 'issue 要带上 domain 那份原因(原因不在链路上丢)') assert.match(swappedConfig.issues[0], /限位与雾气关闭/, 'issue 要说清后果') const shapeConfig = normalizeConfig({ map: { primaryArea: { bounds: [110, 28, 111] } } }) assert.equal(shapeConfig.issues.length, 1, '个数不对应记一条 issue') assert.match(shapeConfig.issues[0], /四个有限数字/, 'issue 应点明形状要求') const notObjectConfig = normalizeConfig({ map: { primaryArea: [110, 28, 111, 29] } }) assert.equal(notObjectConfig.issues.length, 1, 'primaryArea 不是对象应记一条 issue') assert.equal(notObjectConfig.config.map.primaryArea.configured, false, '不是对象时按未配置走关闭路径') const okConfig = normalizeConfig({ map: { primaryArea: { bounds: DEPLOY_BOUNDS } } }) assert.deepEqual(okConfig.issues, [], '合法配置不该报任何问题') assert.equal(okConfig.config.map.primaryArea.configured, true, '合法配置应记成已配置') assert.deepEqual(okConfig.config.map.primaryArea.bounds, DEPLOY_BOUNDS, '合法配置的矩形应逐位保留') // 内置默认本身(不经过任何来源)也要能判成未配置 assert.equal(planPrimaryArea(builtinConfig().map).state, PRIMARY_AREA_STATE.MISSING, '内置默认 = 未配置') // ---------------------------------------------------------------- 6. 本部署显式值 + 模板形状 // 期望值**从甲方原值现算**(只把纬度的南北两值对调),而不是拿 DEPLOY_BOUNDS 自己比自己。 // 注意决策票 01 的契约示例把角度写成了 12 位小数(110.32874994141685 → 110.328749941417), // 所以这里断言的是「同一个地方」:逐位差 < 1e-9 度(≈0.1 毫米),且**只差截位、不差位置**。 const expectedNormalized = [OWNER_RAW_BOUNDS[0], OWNER_RAW_BOUNDS[3], OWNER_RAW_BOUNDS[2], OWNER_RAW_BOUNDS[1]] const deployFile = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8')) const deployBounds = deployFile.map.primaryArea.bounds assert.equal(deployBounds.length, 4, '本部署配置里的主场景区应是四元数组') for (let i = 0; i < 4; i++) { assert.equal( Math.abs(deployBounds[i] - expectedNormalized[i]) < 1e-9, true, `第 ${i} 个角应与甲方原值归一化后同一个地方(差 ${deployBounds[i] - expectedNormalized[i]},仅允许截位)`, ) } assert.deepEqual( [deployBounds[0], deployBounds[2]], [110.328749941417, 110.465779226663], '经度两值应等于契约示例给出的值(顺序归一化 + 截位,不做别的加工)', ) assert.deepEqual( [deployBounds[1], deployBounds[3]], [28.3917230659234, 28.4913038992907], '纬度两值应等于契约示例给出的值(南 < 北)', ) const exampleFile = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.example.json', import.meta.url), 'utf8')) assert.equal(Array.isArray(exampleFile.map.primaryArea.bounds), true, 'config.example.json 要同步该字段形状') assert.equal(exampleFile.map.primaryArea.bounds.length, 4, '模板里的主场景区也是四元数组') assert.equal( planPrimaryArea({ primaryArea: { bounds: exampleFile.map.primaryArea.bounds } }).state, PRIMARY_AREA_STATE.READY, '模板里的示例值本身必须是合法的(抄模板不该抄出一个非法配置)', ) // ---------------------------------------------------------------- 7. 静态守卫(对抗式) const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url) const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8') // ① 主场景区只该在「配置服务 + domain 计划」里判,别处不许自己判一遍顺序。 const areaFiles = [ 'CockpitShell.vue', 'components/MapCanvas.vue', 'components/PanelBlocks.vue', 'domain/camera.js', 'domain/viewModes.js', 'domain/viewPresets.js', 'services/mapService.js', 'services/cameraControl.js', 'stores/filterStore.js', ].filter((file) => /primaryArea/.test(readModule(file))) assert.deepEqual(areaFiles, ['services/mapService.js'], `主场景区只该由 domain 计划 + 地图服务消费,实际:${areaFiles.join(', ')}`) // ② 不得内置沅陵/本部署的地理常量(决策票 01 Q3=A:不写死源码,兜底归部署期)。 const sourceFiles = [ 'domain/primaryArea.js', 'domain/viewPresets.js', 'services/cockpitConfig.js', 'services/mapService.js', ] for (const file of sourceFiles) { const text = readModule(file) for (const literal of ['110.3287499', '110.4657792', '28.3917230', '28.4913038', '110.39028164', '28.4436787']) { // viewPresets 的 HOME_DEFAULTS 是票 30 已交付的既有默认,本票不动它;只守主场景区这条链。 if (file === 'domain/viewPresets.js') continue assert.equal(text.includes(literal), false, `${file} 不该内置本部署的地理常量 ${literal}(应由配置给)`) } } // ③ 运行时不翻转:主场景区这条链上不许出现 sort/reverse,也不许对 bounds 的下标做对调。 // **必须先剥注释再查**:说明里本来就有「不自动翻转」这类字样,直接对全文匹配会被自己的散文判假红 // (仓库里 F12 被注释里的 `O(n²)` 判红过同一个坑)。 const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') /** 抽出所有「由若干 `x[数字]` 组成的数组字面量」的下标序列。 */ function indexSequences(source) { const out = [] const re = /\[((?:\s*\w+\s*\[\s*\d+\s*\]\s*,?)+)\]/g let match while ((match = re.exec(source))) { out.push([...match[1].matchAll(/\[\s*(\d+)\s*\]/g)].map((item) => Number(item[1]))) } return out } /** 有没有非升序的下标序列(即"把几个角换了个位置")。 */ function hasIndexSwap(source) { return indexSequences(source).some((seq) => seq.some((value, index) => index > 0 && value < seq[index - 1])) } const areaCode = stripComments(readModule('domain/primaryArea.js')) assert.equal(/\.(sort|reverse)\s*\(/.test(areaCode), false, '主场景区判定不得对 bounds 做排序/翻转(顺序是部署责任)') assert.equal(hasIndexSwap(areaCode), false, '判定层不得对 bounds 的下标做对调(那等于替部署翻转)') // **守卫有牙**:把一段真会翻转的代码喂进同一个判据,必须认出来。 // (上一版守卫是全文正则,对本案与对这段样本都判不出东西 —— 变异测试当场证明它无效。) assert.equal(hasIndexSwap(stripComments('function f(b){ return [b[0], b[3], b[2], b[1]] }')), true, '守卫要能认出真正的下标对调') assert.equal(hasIndexSwap(stripComments('// [b[0], b[3], b[2], b[1]] 这样不行')), false, '守卫不得被注释里的样本判红') assert.equal(hasIndexSwap(stripComments('const a = [b[0], b[1], b[2], b[3]]')), false, '升序取值是正常的,不该判红') // ④ 只读出口存在:票 37/38 消费的入口必须是"读计划"而不是"猜配置"。 const serviceSource = readModule('services/mapService.js') assert.match(serviceSource, /export function getPrimaryAreaPlan\(/, '地图服务要给出 getPrimaryAreaPlan()(票 37/38 的唯一入口)') assert.match(serviceSource, /function applyPrimaryArea\(/, '地图服务要按配置算一次计划') assert.match(serviceSource, /dataset\.mapPrimaryArea\b/, '宿主要有 data-map-primary-area 读数(走查与人工排查读它)') assert.match(serviceSource, /dataset\.mapPrimaryAreaReason\b/, '宿主要有 data-map-primary-area-reason 保留原因') // 引擎被配置关掉时,主场景区读数仍要如实(早退之前就得算)。 assert.match( serviceSource, /applyPrimaryArea\(mapConfig\)[\s\S]{0,400}cesiumConfig\.enabled === false/, 'applyPrimaryArea 必须在「引擎已关闭」这条早退之前调用,否则关引擎的部署读不到主场景区状态', ) // ⑤ 出口的**行为**(不只存在):未读到配置时如实是未配置,且不得凭空给出矩形; // 重置后不得留着上一轮的读数(同进程复跑断言会互相污染)。 // 这里只做脱浏览器断言 —— 真机走查按 AGENTS.md 由维护者明确要求时才跑。 const { getPrimaryAreaPlan, getLifecycleStats, resetLifecycleStatsForTest } = await import( '../../../src/modules/cockpit/services/mapService.js' ) resetLifecycleStatsForTest() const coldPlan = getPrimaryAreaPlan() assert.equal(coldPlan.state, PRIMARY_AREA_STATE.MISSING, '还没读到配置时,出口应如实说未配置') assert.equal(coldPlan.enabled, false, '还没读到配置时不得启用限位/雾气') assert.equal(coldPlan.bounds, null, '还没读到配置时不得凭空给出矩形') const coldStats = getLifecycleStats() assert.equal(coldStats.primaryArea, PRIMARY_AREA_STATE.MISSING, '生命周期读数应带上主场景区状态') assert.equal(coldStats.primaryAreaBounds, null, '未配置时读数里的矩形应为 null') // 出口给的是**副本**:消费方改它不得污染服务层那份计划(否则"谁开的、开哪儿"会有第二个来源)。 const copyProbe = getPrimaryAreaPlan() copyProbe.state = 'tampered' copyProbe.bounds = [0, 0, 1, 1] assert.equal(getPrimaryAreaPlan().state, PRIMARY_AREA_STATE.MISSING, '出口不得被调用方改写(应返回副本)') resetLifecycleStatsForTest() assert.equal(getPrimaryAreaPlan().bounds, null, '重置后不得残留矩形') // ⑥ 关闭路径的措辞必须**区分**"没配"与"配错":两者都关闭,但原因不同,人工排查才不会跑偏。 assert.notEqual( planPrimaryArea({}).reason, planPrimaryArea({ primaryArea: { bounds: [1, 2, 3] } }).reason, '未配置与形状非法的原因必须不同(否则排查时无从下手)', ) assert.notEqual( planPrimaryArea({ primaryArea: { bounds: [1, 2, 3] } }).reason, planPrimaryArea({ primaryArea: { bounds: OWNER_RAW_BOUNDS } }).reason, '形状非法与顺序非法的原因必须不同', ) // ⑦ 浮标与 dataset 的**真实执行**(脱浏览器):criterion 4 说"人工与走查都能读到", // 只靠正则查源码存在不算数 —— 这里用一个最小 DOM 宿主真的把 `paintEngineState` 跑一遍, // 读回它写下的 `dataset` 与浮标文本。按 AGENTS.md 不起浏览器/不走 build,这条是等价的执行证据。 function makeFakeHost() { const makeEl = (tag) => ({ nodeType: 1, tagName: String(tag).toUpperCase(), dataset: {}, style: { cssText: '' }, textContent: '', className: '', children: [], attributes: {}, setAttribute(name, value) { this.attributes[name] = String(value) }, getAttribute(name) { return this.attributes[name] === undefined ? null : this.attributes[name] }, appendChild(child) { this.children.push(child) child.parentNode = this return child }, querySelector(selector) { // 浮标是唯一的子节点,走查用 [data-map-engine-status] 找它。 return this.children.find((c) => c.attributes[selector.replace(/[[\]]/g, '')] !== undefined) || null }, remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this) }, }) const host = makeEl('div') host.ownerDocument = { createElement: (tag) => makeEl(tag) } host.querySelectorAll = () => [] return host } // mountMap 会同步走 setEngineState(record,'booting') → paintEngineState;引擎那条异步链路 // 在 unmount 后自行退出(本模块的 `instance !== record` 守卫),所以本条不需要等引擎。 const { mountMap, unmountMap } = await import('../../../src/modules/cockpit/services/mapService.js') resetLifecycleStatsForTest() const fakeHost = makeFakeHost() mountMap(fakeHost) assert.equal(fakeHost.dataset.mapPrimaryArea, PRIMARY_AREA_STATE.MISSING, '宿主 dataset 要真的写上主场景区状态') const paintedPill = fakeHost.children[0] assert.equal(!!paintedPill, true, '宿主里应真的建出状态浮标节点') assert.equal(paintedPill.attributes['data-map-engine-status'], '', '浮标要带 data-map-engine-status(走查找它)') // **配置还没读到**时不得宣布任何结论(那时「未配置」只是模块初值,不是核实过的事实)。 assert.equal( paintedPill.textContent.includes('主场景区'), false, `配置没到手前,浮标不该替部署宣布主场景区的结论,实际「${paintedPill.textContent}」`, ) unmountMap() // 让 bootEngine 的异步分支自己退出(它读到 instance 已被销毁就不留实例、不写状态)。 await new Promise((resolve) => setTimeout(resolve, 30)) resetLifecycleStatsForTest() assert.equal(!!fakeHost.dataset.mapPrimaryArea, true, '重置生命周期计数不该动宿主上已有的读数') // ⑧ 关引擎的部署**必须**在浮标上看到主场景区(原生实现里这条是失声的)。 // 真的走一遍 `bootEngine` 的配置分支:喂一份 `cesium.enabled=false` + 非法顺序的配置, // 等它落到 disabled,再读浮标 —— 这道断言就是"DOM 探针抓到真 bug"的那条回归。 { const { resetCockpitConfigForTest, loadCockpitConfig } = await import( '../../../src/modules/cockpit/services/cockpitConfig.js' ) resetCockpitConfigForTest() await loadCockpitConfig({ scope: { __COCKPIT_CONFIG__: { map: { cesium: { enabled: false }, primaryArea: { bounds: SWAPPED_IN_VERIFY } } } }, env: {}, base: './', documentBase: 'http://localhost/', }) resetLifecycleStatsForTest() const offHost = makeFakeHost() mountMap(offHost) // 等 bootEngine 走完配置分支(它要 await 一次 loadCockpitConfig)。 for (let i = 0; i < 60 && offHost.dataset.mapEngine !== 'disabled'; i++) { await new Promise((resolve) => setTimeout(resolve, 25)) } assert.equal(offHost.dataset.mapEngine, 'disabled', 'map.cesium.enabled=false 应让引擎落到 disabled') assert.equal(offHost.dataset.mapPrimaryArea, PRIMARY_AREA_STATE.INVALID, '关引擎不影响主场景区的判定') assert.equal( offHost.dataset.mapPrimaryAreaReason, PRIMARY_AREA_REASON.ORDER, '关引擎时原因照样要留在 dataset 上', ) const offPill = offHost.children[0] assert.match( offPill.textContent, /主场景区:配置无效(顺序应为 \[西,南,东,北\],不自动翻转) · 限位与雾气关闭/, `关引擎的部署也必须在浮标上看到主场景区(原生实现这句是失声的),实际「${offPill.textContent}」`, ) unmountMap() await new Promise((resolve) => setTimeout(resolve, 30)) resetCockpitConfigForTest() } console.log( `T36 检查通过:四类输入(合法 · 顺序写反 · 缺字段 · 个数不对)判定一致 / 不自动翻转(甲方 [W,N,E,S] 原样判非法)/ 三条关闭路径各自留原因 / 配置问题清单记到字段名与后果 / 本部署显式值与甲方原值只差纬度对调 / 模板同步 / 静态守卫(判定唯一、无内置地理常量、无翻转、只读出口齐)。`, )