/** * 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 定位与锚定全部通过。')