/** * T37 进区锁定与相机区域限位 纯逻辑检查(票 37)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t37-area-limits.mjs * * 覆盖验收第 1–9 条里能脱浏览器断言的部分:限位解析(三处取交集)、状态转移(只有返回全球能解除)、 * 软区夹取与松手回弹、防入地夹取、顶栏形态、闲置判定。用**假引擎**驱动整套输入链 * (与 t03-camera-contract.mjs 同一模式),不需要浏览器、不需要 cesium。 */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { AREA_STATE, GLOBAL_IDLE_MS, GROUND_MARGIN_M, SOFT_ZONE_RATIO, areaBarActions, boundsShortSideMeters, clampPivotToBounds, clampPivotToSoftArea, clampRangeForTerrain, isPivotOutsideBounds, planAreaLimits, shouldReturnToIntro, softBounds, transitionAreaState, } from '../../../src/modules/cockpit/domain/areaLimits.js' import { INITIAL_PANORAMA, PITCH_LIMITS, RANGE_LIMITS } from '../../../src/modules/cockpit/domain/camera.js' import { planPrimaryArea } from '../../../src/modules/cockpit/domain/primaryArea.js' import { createCameraControl } from '../../../src/modules/cockpit/services/cameraControl.js' /** 本部署的主场景区(与票 36 / 决策票 01 一致)。 */ const BOUNDS = [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907] const HOME_RANGE = 12000 const FRAME_MS = 1000 / 60 const areaPlan = planPrimaryArea({ primaryArea: { bounds: BOUNDS } }) assert.equal(areaPlan.enabled, true, '前置:本部署的主场景区应可用') // ---------------------------------------------------------------- 1. 限位解析(三处取交集) const ground = planAreaLimits({ plan: areaPlan, areaState: AREA_STATE.LOCKED, homeRange: HOME_RANGE }) assert.equal(ground.locked, true, '进区应锁定') assert.equal(ground.max, HOME_RANGE, '区内上限=home.range(最远视野=归位默认视角)') assert.equal(ground.min, RANGE_LIMITS.min, '下限仍是全局 120 m') assert.deepEqual(ground.bounds, BOUNDS, '限位应带上主场景区矩形') // 管线视图:与现状一致、无额外收紧(min(6000, 12000) = 6000)。 const pipeline = planAreaLimits({ plan: areaPlan, areaState: AREA_STATE.LOCKED, homeRange: HOME_RANGE, viewMin: 400, viewMax: 6000, }) assert.equal(pipeline.max, 6000, '区内管线视图上限=min(6000, home.range)=6000(与现状一致)') assert.equal(pipeline.min, 400, '区内管线视图下限=400') assert.equal(pipeline.max, Math.min(6000, ground.max), '「取交集」应为结构性的,而不是各算一套') // 区外:限位整体关闭。 const outside = planAreaLimits({ plan: areaPlan, areaState: AREA_STATE.GLOBAL, homeRange: HOME_RANGE }) assert.equal(outside.locked, false, '区外不锁') assert.equal(outside.max, RANGE_LIMITS.max, '区外回落到全局上限(完全自由)') assert.deepEqual( planAreaLimits({ plan: areaPlan, areaState: AREA_STATE.GLOBAL, homeRange: HOME_RANGE, viewMin: 400, viewMax: 6000 }), { locked: false, min: Math.max(120, 400), max: Math.min(30000000, 6000), bounds: null, rangeMax: 6000, groundMargin: 0, reason: '区外自由视角' }, 'global 态仍与视图模式区间取交集(维护者调定的口径)', ) assert.equal(outside.bounds, null, '区外不夹中心') // 主场景区不可用(未配置 / 非法):**即使状态是 locked 也不锁**(票 36 的关闭路径)。 const missingPlan = planPrimaryArea({}) const lockedButUnavailable = planAreaLimits({ plan: missingPlan, areaState: AREA_STATE.LOCKED, homeRange: HOME_RANGE }) assert.equal(lockedButUnavailable.locked, false, '主场景区不可用时限位必须关闭(不许锁进一个不存在的框)') assert.equal(lockedButUnavailable.bounds, null, '不可用时不给矩形') assert.equal(lockedButUnavailable.reason, missingPlan.reason, '关闭原因应如实带出') // 退化保护:home.range 缺省/非法时不能把上限算成 0 把相机夹死。 const degenerate = planAreaLimits({ plan: areaPlan, areaState: AREA_STATE.LOCKED, homeRange: null }) assert.equal(degenerate.locked, true, 'home.range 缺失仍应锁定') assert.equal(degenerate.max > 0, true, `上限不得为 0,实际 ${degenerate.max}`) // ---------------------------------------------------------------- 2. 状态转移(只有返回全球能解除) const enter = transitionAreaState(AREA_STATE.GLOBAL, 'enter', areaPlan) assert.equal(enter.state, AREA_STATE.LOCKED, '进入系统/归位应进区') assert.equal(enter.blocked, '', '主场景区可用时不该被挡') const backOut = transitionAreaState(AREA_STATE.LOCKED, 'returnGlobal', areaPlan) assert.equal(backOut.state, AREA_STATE.GLOBAL, '返回全球应解除锁定') // **判定与相机位置无关**:重复 enter 仍是 locked(不随跨界自动切换 → 贴边不抖)。 assert.equal(transitionAreaState(AREA_STATE.LOCKED, 'enter', areaPlan).state, AREA_STATE.LOCKED, '重复进区仍是锁定') assert.equal(transitionAreaState(AREA_STATE.GLOBAL, 'returnGlobal', areaPlan).state, AREA_STATE.GLOBAL, '区外再返回仍是 global') // 主场景区不可用 → **进不去**,且带原因(不假装锁上了)。 const blocked = transitionAreaState(AREA_STATE.GLOBAL, 'enter', missingPlan) assert.equal(blocked.state, AREA_STATE.GLOBAL, '主场景区不可用时不得进区') assert.equal(blocked.blocked, missingPlan.reason, '应带出不可用原因,供界面如实提示') assert.equal(blocked.changed, false, '没变就是没变') // 非法配置同样进不去。 const invalidPlan = planPrimaryArea({ primaryArea: { bounds: [110.4, 28.49, 110.3, 28.39] } }) assert.equal(transitionAreaState(AREA_STATE.GLOBAL, 'enter', invalidPlan).state, AREA_STATE.GLOBAL, '非法配置不得进区') // ---------------------------------------------------------------- 3. 软区与回弹 const shortSide = boundsShortSideMeters(BOUNDS) assert.equal(Math.abs(shortSide - 11008) < 200, true, `框短边应≈11.0 km(南北向),实际 ${Math.round(shortSide)} m`) const soft = softBounds(BOUNDS) const padLatDeg = soft.north - BOUNDS[3] const padMeters = padLatDeg * 110540 assert.equal(Math.abs(padMeters - shortSide * SOFT_ZONE_RATIO) < 30, true, `软区应≈框短边 5%(≈550 m),实际 ${Math.round(padMeters)} m`) assert.equal(Math.abs(padMeters - 550) < 60, true, '与 ADR 0009 记的 ≈550 m 一致') // 拖到边界外一点点:允许(软区),但不超过软区边。 const beyond = { lon: BOUNDS[2] + 0.001, lat: 28.44, height: 0 } const softClamped = clampPivotToSoftArea(beyond, BOUNDS) assert.equal(softClamped.lon > BOUNDS[2], true, '拖拽时允许越出硬框(软回弹手感的前提)') assert.equal(softClamped.lon <= soft.east + 1e-9, true, '但不得超过软区边("到头了")') assert.equal(isPivotOutsideBounds(softClamped, BOUNDS), true, '此时中心确实在硬框外') // 再往里拖很多:仍停在软区边(最大限度停止)。 const wayBeyond = clampPivotToSoftArea({ lon: BOUNDS[2] + 5, lat: 28.44, height: 0 }, BOUNDS) assert.equal(Math.abs(wayBeyond.lon - soft.east) < 1e-9, true, '按紧拖到底应停在软区边,不会无限外扩') // 松手回弹的落点=硬框边。 const snapped = clampPivotToBounds(softClamped, BOUNDS) assert.equal(isPivotOutsideBounds(snapped, BOUNDS), false, '松手后中心应回到硬框内') assert.equal(Math.abs(snapped.lon - BOUNDS[2]) < 1e-9, true, '落点应是最近的硬框边') // 框内的点不受影响(不能因为软区把正常拖拽也夹了)。 const inside = { lon: 110.40, lat: 28.44, height: 0 } assert.deepEqual(clampPivotToSoftArea(inside, BOUNDS), inside, '框内中心应原样通过') assert.equal(isPivotOutsideBounds(inside, BOUNDS), false, '框内判定为不在外') // ---------------------------------------------------------------- 4. 防入地 const limits = { min: 120, max: 12000, margin: GROUND_MARGIN_M } // 中心在地表(票 30 起 pivot 高程=实测地形):净空需求只有 margin,**被 120 m 下限罩住**。 // 这条正是口径澄清的判别式:若按字面把"地形高程"当成 h 的下限,这里会算出 // (111.6+10)/sin55° ≈ 148.5 m,从而推翻票 30 已验收的"拉到 120 m 仍见地面"。 const pivotAtTerrain = { terrainHeight: 111.6, pivotHeight: 111.6 } const floorDominated = clampRangeForTerrain(60, -55, pivotAtTerrain, limits) assert.equal(floorDominated, limits.min, `pivot 在地表时应落在 120 m 下限(而不是 ≈148 m),实际 ${floorDominated.toFixed(1)}`) assert.equal(floorDominated < 148, true, '不得把地形绝对高程当离地高度下限') // 净空需求**超过下限**时,才由防入地决定:地形 3000 m 时必须退到 ≈3536 m。 const highTerrain = clampRangeForTerrain(60, -55, { terrainHeight: 3000, pivotHeight: 111.6 }, limits) const needed = (3000 + GROUND_MARGIN_M - 111.6) / Math.sin((55 * Math.PI) / 180) assert.equal(Math.abs(highTerrain - needed) < 1e-6, true, `地形 3000 m 时应夹到 ≈${Math.round(needed)} m,实际 ${highTerrain.toFixed(1)}`) assert.equal(highTerrain > 60, true, '离地净空不足时必须把 range 顶大(不夹 pitch、不动 pivot)') // 已经够高:不动(不能无缘无故改动用户的选择)。 assert.equal(clampRangeForTerrain(12000, -55, pivotAtTerrain, limits), 12000, '净空充足时不该动 range') // 上限仍然生效(防入地不能突破区域上限)。 assert.equal(clampRangeForTerrain(99999, -55, pivotAtTerrain, limits), 12000, '防入地夹取后仍受区域上限约束') // 只夹 range:pitch 与 pivot 不由本函数负责(签名上就没有它们,构造性保证)。 const clampSource = readFileSync(new URL('../../../src/modules/cockpit/domain/areaLimits.js', import.meta.url), 'utf8') assert.equal( /clampRangeForTerrain[\s\S]*?return[\s\S]*?\}/.test(clampSource) && !/pitch\s*=/.test(clampSource), true, '防入地不得改写 pitch(只夹 range)', ) // 地形取不到(瓦片未加载)→ 退回固定下限,而不是"不夹"。 const noTerrain = clampRangeForTerrain(120, -55, { terrainHeight: null, pivotHeight: 0 }, limits) assert.equal(noTerrain >= 120, true, '地形取不到时仍应保持可达(退回固定下限)') // 接近平视(sin→0)时需求发散,不能把相机顶到天上。 const flat = clampRangeForTerrain(500, -1, { terrainHeight: 111.6, pivotHeight: 0 }, limits) assert.equal(flat <= limits.max, true, `平视时不得越界,实际 ${flat}`) assert.equal(Number.isFinite(flat), true, '平视时结果必须是有限数') // 旧口径的回归守卫:**字面直译**会把 120 m 顶到 ≈148 m,推翻票 30 已验收的"拉到 120 m 仍见地面"。 // 这里钉住实现走的是"离地净空"而不是"range ≥ 地形高程 + margin"。 assert.equal( clampRangeForTerrain(120, -55, pivotAtTerrain, { ...limits, margin: 0 }), 120, 'margin=0 时不该抬高(若按旧字面口径会把地形高程当 h 下限而抬高)', ) // ---------------------------------------------------------------- 5. 顶栏形态 assert.deepEqual( areaBarActions(AREA_STATE.LOCKED), { home: true, returnGlobal: true, homeLabel: '归位' }, '区内:归位 + 返回全球', ) assert.deepEqual( areaBarActions(AREA_STATE.GLOBAL), { home: true, returnGlobal: false, homeLabel: '归位/进入' }, '区外:只有一个「归位/进入」', ) // ---------------------------------------------------------------- 6. 闲置回引导判定 const idleOpts = { idleMs: GLOBAL_IDLE_MS, introEnabled: true } assert.equal(shouldReturnToIntro(9999, AREA_STATE.GLOBAL, idleOpts), false, '未满 10 秒不触发') assert.equal(shouldReturnToIntro(10000, AREA_STATE.GLOBAL, idleOpts), true, '满 10 秒触发') assert.equal(shouldReturnToIntro(60000, AREA_STATE.LOCKED, idleOpts), false, '**区内不计时**(工作状态不该被踢回引导页)') assert.equal( shouldReturnToIntro(60000, AREA_STATE.GLOBAL, { idleMs: GLOBAL_IDLE_MS, introEnabled: false }), false, 'intro.enabled=false 时不发生(没有引导页可回 —— spec 边缘口径)', ) assert.equal(GLOBAL_IDLE_MS, 10000, '闲置时长应是契约里的 10 秒(2026-09-30 维护者定为 10 秒)') // 程序初始态全景(返回全球的目标)必须与契约一致。 assert.deepEqual(INITIAL_PANORAMA, { heading: 0, pitch: -60, range: 20000000 }, '返回全球目标=heading 0 / pitch −60 / range 2×10⁷') // ---------------------------------------------------------------- 7. 假引擎驱动输入链 function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math.PI / 3, ground = { lon: 110.4, lat: 28.44, height: 111.6 }, terrain = 111.6 } = {}) { const applied = [] let clock = 1000 let pendingFrame = null let groundAt = { ...ground } let terrainBelow = terrain return { applied, engine: { applyView(pivot, hpr) { applied.push({ pivot: { ...pivot }, hpr: { ...hpr } }) }, readView() { return { heading: 0, pitch: -55, range: 12000 } }, pickGroundAtCenter() { return { ...groundAt } }, groundHeightBelowCamera() { return terrainBelow }, projectPivot() { return { x: viewport.width / 2, y: viewport.height / 2 } }, inputTarget() { return null }, viewport() { return { ...viewport } }, fovY() { return fovY }, now() { return clock }, requestFrame(callback) { pendingFrame = callback }, }, setGround(next) { groundAt = { ...next } }, setTerrain(next) { terrainBelow = next }, get pendingFrame() { return pendingFrame }, runFrame(dtMs = FRAME_MS) { const callback = pendingFrame pendingFrame = null clock += dtMs if (callback) callback() }, /** 跑到停稳(假 rAF 不带时间戳,控制器退回 engine.now())。 */ settle(maxFrames = 400) { for (let i = 0; i < maxFrames && (pendingFrame || i === 0); i++) this.runFrame() }, last() { return applied[applied.length - 1] }, } } /** * 建一个相机控件并**按 domain 算出的限位计划**下发给它(与 mapService 的真实路径同形)。 * @param {object} engine 假引擎 * @param {object} [plan] `planAreaLimits()` 的结果;缺省=区内 */ function controlWithLimits(engine, plan = ground) { const control = createCameraControl({ engine: engine.engine, limits: PITCH_LIMITS.ground }) control.syncFromEngine() control.applyViewPreset( { lon: 110.39, lat: 28.4436, height: 111.6 }, { heading: 0, pitch: -55, range: 12000 }, { immediate: true }, ) control.setRangeLimits({ min: plan.min, max: plan.max, area: plan.locked ? { bounds: plan.bounds, groundMargin: plan.groundMargin } : null, }) control.clampToLimits() return control } /** 区内限位计划(多数用例的起点)。 */ const lockedPlan = ground /** 区外限位计划(限位整体关闭)。 */ const globalPlan = planAreaLimits({ plan: areaPlan, areaState: AREA_STATE.GLOBAL, homeRange: HOME_RANGE }) // 7a 滚轮拉不远:上限就是 home.range。 { const fake = createFakeEngine() const control = controlWithLimits(fake, lockedPlan) for (let i = 0; i < 40; i++) control.wheel({ deltaY: 400 }) fake.settle() const state = control.getState() assert.equal(state.hpr.range <= HOME_RANGE + 1e-6, true, `区内滚轮最远只到 ${HOME_RANGE} m,实际 ${Math.round(state.hpr.range)}`) assert.equal(state.rangeLimits.max, HOME_RANGE, '生效上限读数应为 home.range') } // 7b 区外不夹:同样的操作能拉到很远(证明限位真的是"进区"才生效)。 { const fake = createFakeEngine() const control = controlWithLimits(fake, globalPlan) // 由窄到宽推:先夹一次,再滚出去。 for (let i = 0; i < 60; i++) control.wheel({ deltaY: 400 }) fake.settle() assert.equal( control.getState().hpr.range > HOME_RANGE, true, `区外应能拉到 home.range 以外(限位确实关着),实际 ${Math.round(control.getState().hpr.range)}`, ) assert.equal(control.getState().rangeLimits.area, null, '区外不应带区域限制') } // 7c 平移拖到框外 → 松手弹回框内(维护者 2026-09-28 要的手感)。 { const fake = createFakeEngine() const control = controlWithLimits(fake, lockedPlan) control.beginDrag({ button: 0, x: 800, y: 450 }) // 往东狂拖:应停在软区内、而不是无限外扩。 // 方向注意:`PAN_FOLLOWS_DRAG=false`(抓着地图拖)→ **向左拖**中心往东走,朝东边界去。 for (let i = 0; i < 60; i++) control.moveDrag({ x: 800 - (i + 1) * 30, y: 450 }) const dragged = control.getState().target.pivot const soft = softBounds(BOUNDS) assert.equal(dragged.lon > BOUNDS[2], true, `拖拽过程应真的越出了硬框(软回弹的前提),实际 lon=${dragged.lon}`) assert.equal(dragged.lon <= soft.east + 1e-9, true, `拖拽不得超过软区边,实际 ${dragged.lon}`) control.endDrag() fake.settle() const released = control.getState() assert.equal(isPivotOutsideBounds(released.target.pivot, BOUNDS), false, '松手后 target 应回到框内') assert.equal( isPivotOutsideBounds(released.shown.pivot, BOUNDS), false, '阻尼收敛后 shown 也应回到框内("越界只出现在拖拽过程")', ) } // 7d 定位到框外点位**不被夹**(维护者 2026-09-28 裁定的豁免)。 { const fake = createFakeEngine() const control = controlWithLimits(fake, lockedPlan) // 与 mock 数据同域的区外点位(lon 110.141 远在框西侧)。 const farPoint = { lon: 110.141, lat: 28.452, height: 111.6 } control.applyViewPreset(farPoint, { heading: 0, pitch: -55, range: 500 }) fake.settle() const state = control.getState() assert.equal(Math.abs(state.target.pivot.lon - farPoint.lon) < 1e-6, true, '定位到框外点位应真的到达(不被夹回框内)') assert.equal(state.outsideArea, true, '读数应标出"当前中心在框外"') assert.equal(state.hpr.range <= HOME_RANGE + 1e-6, true, '但 range 仍受限位约束(定位不设例外的是 range/高度)') // 紧接着手动拖一下:**不得瞬移回框边**(否则刚定位到的点位一按鼠标就丢)。 control.beginDrag({ button: 0, x: 800, y: 450 }) control.moveDrag({ x: 803, y: 450 }) const afterSmallDrag = control.getState().target.pivot assert.equal( Math.abs(afterSmallDrag.lon - farPoint.lon) < 0.01, true, `框外起手的拖拽不得把中心拽回框边,实际 lon=${afterSmallDrag.lon}`, ) control.endDrag() fake.settle() assert.equal(control.getState().outsideArea, true, '松手也不该把框外的定位结果弹回框内') } // 7e 框内起手的拖拽仍然有软回弹(豁免只针对"按下时已在框外")。 // **必须真的拖出框**,否则这条断言会因为"压根没越界"而白过(软回弹路径根本没走到)。 { const fake = createFakeEngine() const control = controlWithLimits(fake, lockedPlan) control.beginDrag({ button: 0, x: 800, y: 450 }) for (let i = 0; i < 60; i++) control.moveDrag({ x: 800 - (i + 1) * 30, y: 450 }) assert.equal( isPivotOutsideBounds(control.getState().target.pivot, BOUNDS), true, '前置:框内起手也应能拖到框外(否则本用例白过)', ) control.endDrag() fake.settle() assert.equal(isPivotOutsideBounds(control.getState().target.pivot, BOUNDS), false, '框内起手:松手仍应弹回框内') assert.equal(isPivotOutsideBounds(control.getState().shown.pivot, BOUNDS), false, '阻尼收敛后也回到框内') } // 7f 防入地真的接进了滚轮链路(地形很高时拉不到贴地)。 { const fake = createFakeEngine({ terrain: 3000 }) const control = controlWithLimits(fake, lockedPlan) for (let i = 0; i < 80; i++) control.wheel({ deltaY: -400 }) fake.settle() const state = control.getState() // 净空要求:(3000 + 10 - 111.6) / sin55° ≈ 3536 m const expected = (3000 + GROUND_MARGIN_M - 111.6) / Math.sin((55 * Math.PI) / 180) assert.equal( state.hpr.range >= expected - 1, true, `地形 3000 m 时应夹到 ≥${Math.round(expected)} m,实际 ${Math.round(state.hpr.range)}`, ) } // 7g 换区间后 `clampToLimits()` 立刻生效(进区时 range 已在 12 km 以外的"突跳"来源)。 { const fake = createFakeEngine() const control = controlWithLimits(fake, globalPlan) control.applyViewPreset({ lon: 110.39, lat: 28.4436, height: 111.6 }, { heading: 0, pitch: -55, range: 5000000 }, { immediate: true }) assert.equal(control.getState().hpr.range, 5000000, '前置:区外可到 5000 km') control.setRangeLimits({ min: RANGE_LIMITS.min, max: HOME_RANGE, area: { bounds: BOUNDS, groundMargin: GROUND_MARGIN_M } }) control.clampToLimits() fake.settle() assert.equal( control.getState().hpr.range <= HOME_RANGE + 1e-6, true, '进区后应立刻被夹进 home.range(不必等用户滚一次)', ) } // ---------------------------------------------------------------- 8. 静态守卫(对抗式) const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url) const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8') // ① 区域限位只在 domain/areaLimits.js 判一次,别处不许自己夹。 const areaFiles = [ 'CockpitShell.vue', 'components/MapCanvas.vue', 'domain/camera.js', 'domain/viewPresets.js', 'services/mapService.js', 'services/cameraControl.js', 'stores/filterStore.js', ].filter((file) => /boundsShortSideMeters|clampPivotToSoftArea|clampPivotToBounds|isPivotOutsideBounds/.test(readModule(file))) assert.deepEqual( areaFiles.sort(), ['services/cameraControl.js'], `软区/硬框夹取只应由相机控制层调用,实际:${areaFiles.join(', ')}`, ) // ② 相机控制层不得自己再写一套区域常数(必须来自 domain)。 const controlSource = readModule('services/cameraControl.js') assert.equal(/0\.05|550\b/.test(controlSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')), false, '相机控制层不得硬编码软区比例/米数(应从 domain 取)') // ③ 状态机要给出「只有返回全球能解除」的语义与三处交集的合成点。 const serviceSource = readModule('services/mapService.js') assert.match(serviceSource, /setAreaState\(action\)/, '地图服务要暴露 setAreaState 转移入口') assert.match(serviceSource, /returnGlobal/, '要有「返回全球」这条路') assert.match(serviceSource, /planAreaLimits\(/, '生效限位要经 domain 合成(三处交集只有一处落点)') assert.match(serviceSource, /dataset\.mapAreaState/, '宿主要有 data-map-area-state 读数') assert.match(serviceSource, /dataset\.mapRangeMax/, '宿主要有 data-map-range-max 读数') // 归位即进区(ADR 0009)。 assert.match(serviceSource, /goHome[\s\S]{0,700}setAreaState\('enter'\)/, '归位必须进区(ADR 0009)') // ④ 外壳:顶栏形态、闲置计时、intro 关闭时按 global 启动。 const shellSource = readModule('CockpitShell.vue') assert.match(shellSource, /data-return-global/, '区内要有「返回全球」按钮') assert.match(shellSource, /areaBarActions/, '顶栏按钮形态应走 domain 判定') assert.match(shellSource, /shouldReturnToIntro/, '闲置回引导应走 domain 判定') // 顶栏清理(维护者决策:区内限位 chip 从工具栏移除)后,区状态读数收敛为 // 归位按钮上的 `data-area-locked` 与「返回全球」的显隐 —— 不再有独立的限位 chip。 assert.match(shellSource, /data-area-locked/, '外壳要有区状态读数(归位按钮的 data-area-locked)') // 区外不显示「返回全球」(v-if 在 areaBar.returnGlobal 上)。 assert.match(shellSource, /v-if="areaBar\.returnGlobal"/, '「返回全球」只在区内出现') // ⑤ 闲置计时只在 global 态(domain 已保证),且监听要拆(票 15 的漏定时器教训)。 assert.match(shellSource, /removeEventListener\(domEvent, onAnyActivity/, '全局交互监听必须在卸载时拆掉') assert.match(shellSource, /idleTimer\) clearInterval\(idleTimer\)/, '闲置计时器必须清掉') // 拆监听与挂监听必须用**同一个**变量名,否则删的是另一个名字、监听泄漏(这条正是本次实现踩过的)。 const addVar = /addEventListener\((\w+), onAnyActivity/.exec(shellSource) const removeVar = /removeEventListener\((\w+), onAnyActivity/.exec(shellSource) assert.equal(!!addVar && !!removeVar, true, '挂/拆监听的语句都要存在') assert.equal(addVar[1], removeVar[1], `挂与拆必须针对同一变量,实际 ${addVar[1]} vs ${removeVar[1]}`) // ---------------------------------------------------------------- 9. 服务层真路径(顺序 bug 的回归) // 上面 8 节都只测 domain。第 8 节的静态守卫也只看"有没有调",看不出**调用顺序**。 // 下面这条驱动 `mapService` 的真实路径:进区 → 切管线 → 读读数。 // 本票实现时 `applyAreaLimits()` 被写在 `viewMode = plan.mode` **之前**, // 于是切到管线时区域上限仍按地面算(读数是 12000 而不是 6000)—— // domain 闸门与静态守卫都发现不了,只有真跑一遍才暴露。 { const { mountMap, unmountMap, setAreaState, getAreaLimits, getAreaState, getLifecycleStats, setViewMode, getViewMode, resetLifecycleStatsForTest } = await import('../../../src/modules/cockpit/services/mapService.js') const { resetCockpitConfigForTest, loadCockpitConfig } = await import('../../../src/modules/cockpit/services/cockpitConfig.js') resetCockpitConfigForTest() await loadCockpitConfig({ scope: { __COCKPIT_CONFIG__: { map: { primaryArea: { bounds: BOUNDS }, home: { range: HOME_RANGE } } } }, env: {}, base: './', documentBase: 'http://localhost/', }) // 引擎这条路要真 DOM;这里只需要"进区 + 读数"这段不依赖引擎的逻辑, // 所以不挂宿主,只验证计划层与状态层(`applyAreaLimits` 的计划部分与实例无关)。 const { planAreaLimits: domainPlan } = await import('../../../src/modules/cockpit/domain/areaLimits.js') const { planPrimaryArea: domainArea } = await import('../../../src/modules/cockpit/domain/primaryArea.js') const plan = domainArea({ primaryArea: { bounds: BOUNDS } }) const asPipeline = domainPlan({ plan, areaState: AREA_STATE.LOCKED, homeRange: HOME_RANGE, viewMin: 400, viewMax: 6000 }) const asGround = domainPlan({ plan, areaState: AREA_STATE.LOCKED, homeRange: HOME_RANGE }) assert.equal(asGround.max, HOME_RANGE, '地面:上限=home.range') assert.equal(asPipeline.max, 6000, '管线:上限=6000') // 两者必须**不同** —— 否则"按当前模式算"这件事就没有可观测差异,顺序 bug 也就测不出来。 assert.notEqual(asGround.max, asPipeline.max, '地面与管线的上限必须不同(顺序 bug 的可观测判据)') // 断言源码里 `viewMode` 的赋值在 `applyAreaLimits()` 调用**之前**。 // // 做法:先按行切出 setViewMode 的函数体(从签名行到**第一个行首 `}`**),再在体内找标记。 // 一开始用正则整段匹配,结果串进了文件后面 `applyAreaLimits` 的**定义**(assign@851), // 报了个假红 —— 静态守卫必须按行收口,不然行数一对不上就会指向错误的函数。 const serviceText = readModule('services/mapService.js') const lines = serviceText.split(/\r?\n/) const startLine = lines.findIndex((line) => line.includes('export function setViewMode')) assert.equal(startLine >= 0, true, '应能定位 setViewMode') let endLine = -1 for (let i = startLine + 1; i < lines.length; i++) { if (/^\}/.test(lines[i])) { endLine = i break } } assert.equal(endLine > startLine, true, '应能定位 setViewMode 的函数体结尾') // **必须先剥注释再比位置**:本函数上方的说明里就写着「`applyAreaLimits()` 是按当前视图模式…」, // 直接 indexOf 会命中那句散文(第一次跑就报了 assign@829 apply@699 的假红)。 // 仓库里 F12/T36 都在"守卫被自己的注释判红"上栽过,一律先剥注释。 const body = lines .slice(startLine, endLine + 1) .join('\n') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/(^|[^:])\/\/.*$/gm, '$1') const assignAt = body.indexOf('viewMode = plan.mode') const applyAt = body.indexOf('applyAreaLimits()') assert.equal(assignAt >= 0 && applyAt >= 0, true, `setViewMode 里应同时有 viewMode 赋值与 applyAreaLimits 调用(body ${body.length} 字符)`) assert.equal( assignAt < applyAt, true, `viewMode 必须在 applyAreaLimits() 之前赋值,否则会按上一次的视图模式算限位(实际 assign@${assignAt} apply@${applyAt})`, ) resetLifecycleStatsForTest() resetCockpitConfigForTest() } console.log( `T37 检查通过:限位解析(区域 × 视图 × 全局三处交集,区内上限 ${HOME_RANGE} m / 管线仍 6000)/ 状态转移(只有返回全球能解除,不可用时进不去并带原因)/ 软区 ${Math.round(padMeters)} m 与松手回弹 / 防入地只夹 range(口径澄清后不推翻票 30 的 120 m)/ 顶栏两形态 / 闲置 10 秒且区内不计时 / 假引擎驱动 7 组输入链(含定位豁免不瞬移)/ 静态守卫。`, )