| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551 |
- /**
- * 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 判定')
- assert.match(shellSource, /data-area-state/, '外壳要有区状态读数')
- // 区外不显示「返回全球」(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 组输入链(含定位豁免不瞬移)/ 静态守卫。`,
- )
|