| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- /**
- * T41 监测点定位与面板锚定 纯逻辑检查(票 41)。
- *
- * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t41-point-locate-anchor.mjs
- *
- * 覆盖验收里能脱浏览器断言的部分:
- * · 定位档位来自配置(`map.locate.point`),缺省 0/−55/500,非法值退回缺省且**不静默**;
- * · 定位走完整视角预设(连 hpr 一起换),而不是 `setCameraFocus` 的"只换中心";
- * · 定位采样迟到不得覆盖后一次选择(按序号作废);
- * · 锚定四向顺序、四向避让、12px 间距不压图标、离屏判据;
- * · **拥挤时不再"no-space 暂隐"**(规格 R-2:允许裁切),只在完全离屏时隐藏(R-4)。
- *
- * 另有一段静态守卫:确认外壳确实用统一入口、且 **不读 Cesium 私有实体**
- * (本票验收第 6 条明写"禁止读取 Cesium 私有实体")。
- */
- import assert from 'node:assert/strict'
- import { readFileSync } from 'node:fs'
- import { LOCATE_DEFAULTS, isStaleLocate, planLocateView } from '../../../src/modules/cockpit/domain/pointLocate.js'
- import { locationVerdict } from '../../../src/modules/cockpit/domain/coordinate.js'
- import { ANCHOR_HIDDEN, ANCHOR_SIDES, solvePointAnchor } from '../../../src/modules/cockpit/domain/pointAnchor.js'
- import { createCameraControl } from '../../../src/modules/cockpit/services/cameraControl.js'
- const checks = []
- function check(name, pass, detail = '') {
- checks.push({ name, pass: !!pass, detail })
- }
- // ================================================================ A 定位档位(配置驱动)
- {
- check('A1-default-point-tier', LOCATE_DEFAULTS.point.range === 500 && LOCATE_DEFAULTS.point.pitch === -55,
- `缺省点位档 ${JSON.stringify(LOCATE_DEFAULTS.point)}`)
- check('A2-default-device-tier', LOCATE_DEFAULTS.device.range === 150,
- `缺省设备档 range=${LOCATE_DEFAULTS.device.range}(供后续设备联动票消费)`)
- // 未配置 → 缺省(组件不硬编码,配置缺省就是规格那一套)。
- // 一律走 `check`(不用裸 `assert`):裸 assert 会在汇总行之前抛出,闸门"红"了却看不出是哪一条
- // —— 变异测试里 M1/M2/M3 正是因此读不到断言名(同一段还有 A3 要守,红的原因会被误读)。
- check('A2b-unconfigured-uses-defaults',
- JSON.stringify(planLocateView({}, 'point')) === JSON.stringify({ heading: 0, pitch: -55, range: 500 })
- && JSON.stringify(planLocateView({ locate: {} }, 'point')) === JSON.stringify({ heading: 0, pitch: -55, range: 500 }),
- '未配置时用缺省档(0/−55/500)')
- // 部署覆盖生效。
- const custom = planLocateView({ locate: { point: { heading: 30, pitch: -40, range: 800 } } }, 'point')
- check('A3-config-overrides',
- custom.heading === 30 && custom.pitch === -40 && custom.range === 800,
- `配置可覆盖点位档,实际 ${JSON.stringify(custom)}`)
- // 非法值**逐项**退回缺省,不整档作废、也不静默接受负距离。
- const dirty = planLocateView({ locate: { point: { heading: null, pitch: 'x', range: -5 } } }, 'point')
- check('A4-invalid-values-fall-back',
- dirty.heading === 0 && dirty.pitch === -55 && dirty.range === 500,
- `非法值逐项退回缺省,实际 ${JSON.stringify(dirty)}(负 range 不生效)`)
- // 认不出的档位回 null —— 不发明第三档。
- check('A5-unknown-tier-null', planLocateView({}, 'nope') === null, '认不出的档位返回 null')
- }
- // ================================================================ B 定位=完整预设(不是只换中心)
- function createFakeEngine({ viewport = { width: 1680, height: 900 }, pick = { lon: 110.14, lat: 28.45, height: 111.6 } } = {}) {
- const applied = []
- let pending = null
- let clock = 0
- return {
- applied,
- engine: {
- applyView: (pivot, hpr) => applied.push({ pivot: { ...pivot }, hpr: { ...hpr } }),
- readView: () => ({ heading: 0, pitch: -55, range: 12000 }),
- pickGroundAtCenter: () => ({ ...pick }),
- groundHeightBelowCamera: () => 111.6,
- projectPivot: () => ({ x: 10, y: 10 }),
- inputTarget: () => null,
- viewport: () => ({ ...viewport }),
- fovY: () => Math.PI / 3,
- now: () => clock,
- requestFrame: (cb) => { pending = cb },
- },
- // 同步跑一帧(阻尼收敛用):
- runFrame(steps = 400) {
- for (let i = 0; i < steps && pending; i++) {
- const cb = pending
- pending = null
- clock += 16
- cb(clock)
- }
- },
- }
- }
- {
- const fake = createFakeEngine()
- const control = createCameraControl({ engine: fake.engine })
- control.syncFromEngine()
- // `setCameraFocus` 的旧语义:只换中心、**range 保持县域 12000**(ADR 0008)。
- control.setFocus({ lon: 110.141, lat: 28.44, height: 0 })
- fake.runFrame()
- const afterFocus = control.getState()
- check('B1-setFocus-keeps-range', Math.round(afterFocus.hpr.range) === 12000,
- `setFocus 后 range=${Math.round(afterFocus.hpr.range)}(旧语义:只换中心)`)
- // 票 41 的定位路径:完整视角预设 —— 中心与 hpr 一起换。
- const hpr = planLocateView({}, 'point')
- control.applyViewPreset({ lon: 110.141, lat: 28.44, height: 111.6 }, hpr)
- fake.runFrame()
- const located = control.getState()
- check('B2-locate-applies-full-preset',
- Math.round(located.hpr.range) === 500 && located.hpr.pitch === -55 && located.hpr.heading === 0,
- `定位后 hpr=${JSON.stringify(located.hpr)}(range 换成点位档 500)`)
- check('B3-locate-center-is-the-target',
- Math.abs(located.pivot.lon - 110.141) < 1e-9 && Math.abs(located.pivot.height - 111.6) < 1e-9,
- `中心落在点位地表实测高程 ${located.pivot.height} m(不是椭球 0)`)
- // 两档都不被区域上限夹(range_max=12000):任何画布比例下行为一致。
- control.setRangeLimits({ min: 120, max: 12000, area: null })
- control.applyViewPreset({ lon: 110.141, lat: 28.44, height: 111.6 }, planLocateView({}, 'device'))
- fake.runFrame()
- check('B4-device-tier-not-clamped', Math.round(control.getState().hpr.range) === 150,
- `设备档 range=${Math.round(control.getState().hpr.range)},未被 12000 上限夹`)
- }
- // ================================================================ C 采样竞态(迟到不得覆盖)
- {
- // 判据取自**实现本身**(`isStaleLocate`),不在本文件重述算法 ——
- // 复述一遍的测试是"自己跟自己比",本仓在票 09/18 已经栽过。
- check('C1-stale-predicate',
- isStaleLocate(1, 2) === true && isStaleLocate(2, 2) === false,
- `作废判据是纯函数(序号不等即作废):isStaleLocate(1,2)=${isStaleLocate(1, 2)}、isStaleLocate(2,2)=${isStaleLocate(2, 2)},实现与闸门共用同一份`)
- // 调用点守卫:服务层必须在**落位前**用它判据,且取消入口要让在途采样失效。
- // 只测纯函数而不钉调用点,就会出现"函数写好了、没人调"(票 18 的徽标真缺陷就是这样)。
- const mapServiceText = readFileSync(new URL('../../../src/modules/cockpit/services/mapService.js', import.meta.url), 'utf8')
- const locateBody = mapServiceText.slice(mapServiceText.indexOf('export async function locateMonitoringPoint'))
- .slice(0, mapServiceText.slice(mapServiceText.indexOf('export async function locateMonitoringPoint')).indexOf('\n}'))
- check('C2-callsite-uses-predicate', /isStaleLocate\(\s*token\s*,\s*pointLocateToken\s*\)/.test(locateBody),
- '定位服务在落位前用 isStaleLocate(token, pointLocateToken) 判定')
- check('C3-predicate-before-apply', locateBody.indexOf('isStaleLocate') < locateBody.indexOf('applyViewPreset'),
- '判据在 applyViewPreset **之前**(迟到采样不得下发到相机)')
- check('C4-cancel-bumps-token', /export function cancelPointLocate\(\)\s*\{\s*pointLocateToken \+= 1/.test(mapServiceText),
- '取消入口(归位/返回全球/手动三键)自增序号即在途采样失效')
- }
- // ================================================================ D 锚定布局(四向 / 间距 / 离屏)
- const CANVAS = { left: 0, top: 0, right: 1600, bottom: 900 }
- {
- // 中间偏左的标记:右边有空间 → 首选右。
- const right = solvePointAnchor({ anchor: { x: 400, y: 450 }, canvas: CANVAS, width: 340, height: 300 })
- check('D1-prefers-right', right.visible && right.side === 'right', `首选右侧(实际 ${right.side})`)
- // 图标 18px、间距 12px → 卡片左缘应在 anchor.x + 9 + 12 = 421 附近,绝不压住图标。
- const gapPx = right.rect.left - 400
- check('D2-gap-not-covering-icon', gapPx >= 21, `卡片左缘距标记中心 ${gapPx.toFixed(1)}px(≥9+12,不压图标)`)
- // 靠右边缘 → 右侧放不下,翻到左。
- const left = solvePointAnchor({ anchor: { x: 1450, y: 300 }, canvas: CANVAS, width: 340, height: 300 })
- check('D3-flips-to-left', left.visible && left.side === 'left', `贴右缘翻到左侧(实际 ${left.side})`)
- // 画布高 900、标记贴顶、卡片内容 3000px:高度被 80% 上限收成 720,四向都能完整放下
- // (规格 R-2 的另一面:**先收高度**,不是先消失)。所以这里必须 visible 且**不**标裁切。
- const tall = solvePointAnchor({ anchor: { x: 800, y: 8 }, canvas: CANVAS, width: 340, height: 3000 })
- check('D4-crowded-still-visible', tall.visible === true,
- `内容超长时仍显示(高度收到画布 80% 上限,不暂隐;实际 visible=${tall.visible})`)
- check('D5-tall-content-not-cropped', tall.cropped === false && Math.round(tall.rect.height) === 720,
- `高度收成 720px 后四向都放得下,不必裁切(实际 height=${Math.round(tall.rect.height)} cropped=${tall.cropped})`)
- // 真正会裁切的算例:**窄画布**(320px)装不下 340px 的卡与两侧 12px 留白 ⇒ 允许裁切、
- // 但**绝不消失**(规格 R-2/R-4:能装多少显示多少,消失只留给"完全离开画面")。
- const narrow = solvePointAnchor({ anchor: { x: 100, y: 450 }, canvas: { left: 0, top: 0, right: 320, bottom: 900 }, width: 340, height: 300 })
- check('D6-narrow-canvas-crops-not-hides', narrow.visible === true && narrow.cropped === true,
- `窄画布下允许被裁切但不隐藏(实际 visible=${narrow.visible} cropped=${narrow.cropped})`)
- // 完全离开画面 → 唯一隐藏条件(R-4)。
- const off = solvePointAnchor({ anchor: { x: -40, y: 450 }, canvas: CANVAS, width: 340, height: 300 })
- check('D6-offscreen-hides', off.visible === false && off.reason === 'marker-offscreen',
- `完全离屏才隐藏(reason=${off.reason})`)
- const noAnchor = solvePointAnchor({ anchor: null, canvas: CANVAS })
- check('D7-no-anchor-hides', noAnchor.visible === false && noAnchor.reason === 'marker-missing',
- `投影拿不到 → 暂隐(reason=${noAnchor.reason})`)
- const noCanvas = solvePointAnchor({ anchor: { x: 1, y: 1 }, canvas: null })
- check('D8-no-canvas-hides', noCanvas.visible === false && noCanvas.reason === 'canvas-missing', '画布尺寸未知 → 暂隐')
- // 四向顺序是契约(票 04 已锁),不是实现细节。
- assert.deepEqual(ANCHOR_SIDES, ['right', 'left', 'below', 'above'])
- check('D9-side-order-locked', true, "四向顺序=右→左→下→上(票 04 已锁)")
- // 每种隐藏原因都有中文说法(顶栏暂隐入口要如实说明)。
- check('D10-hidden-reasons-have-text',
- ['canvas-missing', 'marker-missing', 'marker-offscreen'].every((key) => !!ANCHOR_HIDDEN[key]),
- `暂隐原因文案齐备:${Object.values(ANCHOR_HIDDEN).join('/')}`)
- }
- // ================================================================ E2 无地图位置 vs 暂隐(纯判据)
- {
- const renderablePoint = { code: 'P-OK', coordinate: { renderable: true, validated: true } }
- const blockedPoint = { code: 'P-BAD', coordinate: { renderable: false, validated: true, reason: 'UNVERIFIED_COORDINATE' } }
- const objects = [renderablePoint, blockedPoint]
- const ok = locationVerdict(objects, 'P-OK')
- check('E2a-renderable-point-is-not-fixed', ok.present === true && ok.renderable === true && ok.reasonText === '',
- `可上图点位:present=${ok.present} renderable=${ok.renderable}(走锚定跟随)`)
- const blocked = locationVerdict(objects, 'P-BAD')
- check('E2b-blocked-point-goes-fixed-with-reason',
- blocked.present === true && blocked.renderable === false && blocked.reasonText === '坐标未经校验',
- `后端判不可上图:固定详情 + 原因「${blocked.reasonText}」(不是空原因)`)
- // **最容易被写错的一条**:不在已加载快照里 ≠ 无地图位置。
- // 写成 present:true 会让"分页还没翻到"的点位显示一张"无地图位置"的卡片 —— 那是假结论。
- const missing = locationVerdict(objects, 'P-NOT-LOADED')
- check('E2c-not-loaded-is-not-no-location',
- missing.present === false && missing.renderable === false && missing.reasonText === '',
- `不在快照里:present=${missing.present} 且原因留空(走暂隐 + 保留选择,不冒充"无地图位置")`)
- const empty = locationVerdict(objects, '')
- check('E2d-empty-code-is-not-loaded', empty.present === false, '空编码同样按"未确认"处理')
- }
- // ================================================================ E 静态守卫(统一入口 + 禁止私有实体)
- {
- const root = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (rel) => readFileSync(new URL(rel, root), 'utf8')
- // 验收第 1 条:地图点选与台账选择走同一入口。
- // 守卫要连**判据**一起钉住:只查"文本里出现过 selectMonitoringPoint("会被 `if (false) …` 满足
- // (变异 M9 实测全绿)—— 所以要求它挂在 `picked.kind === 'monitoring-point'` 的分支里。
- const canvas = read('components/MapCanvas.vue')
- const shell = read('CockpitShell.vue')
- const monitoring = read('stores/monitoringStore.js')
- const selectionModule = read('pointSelection.js')
- check('E1-map-uses-shared-entry',
- /if \(picked\.kind === 'monitoring-point'\) selectMonitoringPoint\(picked\.code\)/.test(canvas),
- '地图点选在监测点分支里调用 selectMonitoringPoint(统一入口)')
- check('E2-ledger-uses-shared-entry', /return selectMonitoringPoint\(pointCode\)/.test(monitoring),
- '台账卡片 return selectMonitoringPoint(pointCode)(同一条入口,不再各写一份)')
- // 入口落在**接线模块**而不是 detailStore:两侧都不许自己长出对方(票 10 的 F4 / 票 02 的层次纪律)。
- check('E3-entry-lives-in-wiring-module',
- /export function selectMonitoringPoint/.test(selectionModule)
- && /from '\.\/services\/mapService\.js'/.test(selectionModule)
- && /from '\.\/stores\/detailStore\.js'/.test(selectionModule),
- '统一入口落在 pointSelection.js(同时认识详情 store 与地图服务,两侧各自保持纪律)')
- check('E3b-detail-store-stays-clean',
- !/from '\.\.\/services\/mapService\.js'/.test(read('stores/detailStore.js')),
- 'detailStore 不 import mapService(票 10 的 F4 守卫仍成立)')
- // 统一入口必须在选点位时**先**作废在途采样,否则"选 A 后立刻选 B"仍会被 A 的迟到采样覆盖。
- check('E3c-entry-cancels-pending', /cancelPointLocate\(\)/.test(selectionModule),
- '统一选择入口在打开详情前作废上一次在途采样')
- // 验收第 6 条:禁止读取 Cesium 私有实体。
- const engine = read('services/cesiumEngine.js')
- const privateRead = /\._entity|\._billboard|\._position|drillPick|\._id\b|\._show\b/.test(engine)
- check('E4-no-private-cesium-members', !privateRead, '不读 Cesium 私有成员(只用公开实体/属性与公开拾取)')
- // 锚点的高度回退:**瓦片未加载时按椭球面**,不能因为"配了地形却取不到高度"就判标记离屏。
- // 这是真机走查抓到的缺陷(相机飞到未加载区域时面板整块消失,而标记明明还看得见)。
- //
- // 守卫要**正面**要求回退分支存在,不能只否定一个已删掉的参数名
- // (变异 M15 实测:`!/terrainReady\) return null/` 对删参后的实现恒真、断言没牙)。
- const projectEntry = (/export function projectPointMarker[\s\S]*?\n\}/.exec(engine) || [''])[0]
- const projectBody = (/function projectMarkerEntity[\s\S]*?\n\}/.exec(engine) || [''])[0] || projectEntry
- check('E4b-project-falls-back-to-ellipsoid',
- /if \(Number\.isFinite\(sampled\)\) height = sampled/.test(projectBody)
- && /Cartesian3\.fromRadians\(carto\.longitude, carto\.latitude, height\)/.test(projectBody)
- && !/if \(!Number\.isFinite\(sampled\)\) return null/.test(projectBody),
- '投影取不到地形高度时回退椭球面(与 Cesium 无地形时画在椭球面一致),不误判为离屏')
- // 锚定必须有**渲染帧之外**的触发路径:隐藏标签页里 rAF 冻结、postRender 不触发。
- check('E4c-camera-change-triggers-anchor',
- /export function onCameraStateChange/.test(read('services/mapService.js'))
- && /onCameraStateChange\(/.test(shell),
- '相机状态变化也触发锚定重算(不只依赖渲染帧)')
- // **程序化下发视角**(定位/归位/返回全球)也要通知 —— 它不经过相机输入回调,
- // 只靠输入回调会让「重新定位后面板仍显示隐藏」在隐藏标签页里稳定复现(真机走查抓到)。
- check('E4c2-programmatic-preset-notifies',
- /const applied = instance\.cameraControl\.applyViewPreset\(home, hpr, options\)[\s\S]{0,400}notifyCameraStateChange\(\)/.test(
- read('services/mapService.js'),
- ),
- 'applyViewPreset 下发成功后通知一次相机状态变化(定位/归位/返回全球都覆盖)')
- check('E4d-explicit-refresh-not-blocked-by-hidden',
- /document\.hidden && options\.force !== true/.test(shell),
- '显式重算(选择/尺寸/相机变化)不被「页面不可见」早退挡住,逐帧重算才跳过')
- // 暂隐的面板必须同时关掉 pointer-events(否则看不见却拦截地图点击)。
- check('E4e-hidden-panel-not-clickable',
- /visibility: 'hidden', pointerEvents: 'none'/.test(shell),
- '暂隐时同时关掉 pointer-events(不可见的面板不得拦截地图点击)')
- // 外壳不得自己拼投影(投影只在地图服务/引擎一处)。
- check('E5-shell-no-cesium-import', !/from 'cesium'/.test(shell) && !/from 'cesium'/.test(canvas),
- '外壳与地图组件都不 import cesium(投影归服务层/引擎)')
- // 层级:锚定面板必须**低于**左右栏(规格 R-3:允许被四个详情窗遮盖)。
- check('E6-anchor-below-columns', /\.cockpit-shell__detail\.is-anchored\s*\{[^}]*z-index:\s*1/.test(shell),
- '锚定面板 z-index=1(低于 --z-columns=2,允许被侧栏遮盖)')
- // 暂隐时不转固定卡:固定详情只由**后端判定**触发,且判据取自 mapStore 的只读结论
- // (票 07 的 I3:界面不许自己调 isRenderable / blockReason 判坐标)。
- check('E7-fixed-only-when-blocked',
- /detailAnchorLocation\(target\)/.test(shell)
- && /locationVerdict\(target\.object/.test(read('domain/detailAnchor.js'))
- && !/\b(isRenderable|blockReason)\s*\(/.test(shell),
- '外壳消费当前锚定对象的领域坐标准入结论;设备无位置不借父点坐标,未确认不冒充无位置')
- // 暂隐时的「重新定位」也必须走统一入口(别处改路径不会让这里悄悄失效)。
- check('E7c-relocate-uses-shared-entry',
- /function relocateSelected[\s\S]*?\n\}/.test(shell)
- && /selectMonitoringPoint\(target\.code\)/.test((/function relocateSelected[\s\S]*?\n\}/.exec(shell) || [''])[0]),
- '外壳的「重新定位」调用统一入口(不是旧的 detailStore 方法)')
- // 判定收进 domain(纯函数、可断言),store 只喂已加载对象 —— 与"判定归 domain"的成例一致。
- check('E7b-verdict-lives-in-domain',
- /export function locationVerdict/.test(read('domain/coordinate.js'))
- && /locationVerdict\(summary\.value\.objects, code\)/.test(read('stores/mapStore.js')),
- 'locationVerdict 的判定在 domain/coordinate.js,mapStore 只把已加载对象喂给它')
- // 帧订阅与尺寸监听必须在卸载时拆掉(票 15/37 的"漏取消定时器"同一个坑)。
- // 守卫要**连判据一起**钉住:只查 `stopMapFrame()` 会被 `if (false) { stopMapFrame() }` 满足
- // (票 18 的 H13 就是这么栽的)—— 所以要求调用确实挂在 `if (stopMapFrame)` 之下。
- check('E8-anchor-teardown',
- /if \(stopMapFrame\) \{\s*stopMapFrame\(\)/.test(shell)
- && /if \(stopCameraState\) \{\s*stopCameraState\(\)/.test(shell)
- && /removeEventListener\('resize'/.test(shell),
- '卸载时在 if (stopMapFrame) / if (stopCameraState) 下真的拆掉两条订阅,并移除 resize 监听')
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- for (const item of checks) {
- if (!item.pass) console.log(` ✗ ${item.name}${item.detail ? ` — ${item.detail}` : ''}`)
- }
- console.log(`T41 点位定位与面板锚定检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) process.exit(1)
- console.log('T41 定位与锚定全部通过。')
|