/** * T43 面板与三维设备点双向对应 纯逻辑检查(票 43;规格 15 / 决策票 06)。 * * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t43-device-correspondence.mjs * * 覆盖验收第 7 条点名的三件事:**状态迁移**、**拾取优先级**、**弱化阈值**; * 外加"反应不用颜色""引线退化""三个可断言字段""不破坏既有 data-map-picked"等静态守卫。 */ import { readFileSync } from 'node:fs' import { DEVICE_FLIGHT, DEVICE_REACTION, DIM_THRESHOLD, planDeviceFlight, planDeviceLead, planDeviceReactions, reactionStyle, resolvePickOrder, } from '../../../src/modules/cockpit/domain/deviceCorrespondence.js' import { DETAIL_LEVEL } from '../../../src/modules/cockpit/domain/detailFlow.js' const checks = [] function check(name, pass, detail = '') { checks.push({ name, pass, detail }) } /** 设备行工厂。 */ const device = (code, pointCode = 'MP-1') => ({ code, pointCode, longitude: 110.4, latitude: 28.44 }) // ================================================================ A 反应档位(互斥且优先级正确) { const devices = [device('A'), device('B'), device('C')] // 没选没 hover、且台数 ≥ 3 → 全部弱化。 const idle = planDeviceReactions({ devices }) check('A1-idle-dims-when-at-threshold', idle.dimming === true && idle.reactions.every((r) => r.reaction === DEVICE_REACTION.DIMMED), `3 台且无选中 → 全弱化(dimming=${idle.dimming})`) // 选中一台:选中者强化、其余弱化。 const selected = planDeviceReactions({ devices, selectedCode: 'B' }) const byCode = Object.fromEntries(selected.reactions.map((r) => [r.code, r.reaction])) check('A2-selected-is-persistent-and-others-dim', byCode.B === DEVICE_REACTION.SELECTED && byCode.A === DEVICE_REACTION.DIMMED && byCode.C === DEVICE_REACTION.DIMMED, `选中 B:${JSON.stringify(byCode)}`) // hover 一台非选中设备 → 撤弱化 + 轻放大(**不是**选中档,不叠外环/引线)。 const hovered = planDeviceReactions({ devices, selectedCode: 'B', hoveredCode: 'C' }) const h = Object.fromEntries(hovered.reactions.map((r) => [r.code, r.reaction])) check('A3-hover-only-affects-unselected', h.C === DEVICE_REACTION.HOVERED && h.B === DEVICE_REACTION.SELECTED && h.A === DEVICE_REACTION.DIMMED, `hover C(B 已选中):${JSON.stringify(h)}`) // **已选中的设备不被 hover 干扰**(规格原句):hover 的正是已选中的那台 → 仍是 selected。 const hoverSelected = planDeviceReactions({ devices, selectedCode: 'B', hoveredCode: 'B' }) const hs = Object.fromEntries(hoverSelected.reactions.map((r) => [r.code, r.reaction])) check('A4-hover-does-not-disturb-selection', hs.B === DEVICE_REACTION.SELECTED, `hover 到已选中的 B:${hs.B}(选中态优先,不被 hover 顶掉)`) // 少于阈值:不弱化,全常态。 const two = planDeviceReactions({ devices: [device('A'), device('B')] }) check('A5-below-threshold-no-dimming', two.dimming === false && two.reactions.every((r) => r.reaction === DEVICE_REACTION.NORMAL), `2 台(< ${DIM_THRESHOLD})→ 全常态、不弱化`) check('A6-threshold-is-three', DIM_THRESHOLD === 3, `弱化阈值=${DIM_THRESHOLD}(规格锁的数值)`) // 恰好 3 台启用(边界)。 const three = planDeviceReactions({ devices: [device('A'), device('B'), device('C')] }) check('A7-threshold-boundary-inclusive', three.dimming === true, '恰好 3 台即启用(≥ 而非 >)') // 空清单不炸。 const empty = planDeviceReactions({ devices: [] }) check('A8-empty-safe', empty.dimming === false && empty.reactions.length === 0, '空清单:不弱化、无反应项') } // ================================================================ B 视觉量(不用颜色,只尺寸/透明度) { const selected = reactionStyle(DEVICE_REACTION.SELECTED) const hovered = reactionStyle(DEVICE_REACTION.HOVERED) const dimmed = reactionStyle(DEVICE_REACTION.DIMMED) const normal = reactionStyle(DEVICE_REACTION.NORMAL) check('B1-selected-has-ring-and-size', selected.ring === true && selected.scale > 1 && selected.alpha === 1, `选中:外环 ${selected.ring}、放大 ×${selected.scale}`) check('B2-hovered-no-ring', hovered.ring === false && hovered.scale > 1 && hovered.alpha === 1, `hover:轻放大 ×${hovered.scale} 且**不叠外环**(外环是选中专属)`) check('B3-dimmed-dimmer-and-smaller', dimmed.alpha < 1 && dimmed.scale < normal.scale, `弱化:压暗 α${dimmed.alpha} + 缩小 ×${dimmed.scale}`) check('B4-hover-scale-below-selected', hovered.scale < selected.scale, `hover(×${hovered.scale})比选中(×${selected.scale})轻 —— 两档一眼可分`) // 反应只给 scale/alpha/ring,**没有 color/hue/tone**:色键语义不许被反应挪用。 check('B5-no-color-in-reaction-style', !('color' in selected) && !('hue' in selected) && !('tone' in selected), `反应量只有 ${Object.keys(selected).join('/')}(不含颜色/色键)`) } // ================================================================ C 镜头联动(三段行为) { const first = planDeviceFlight({ fromLevel: DETAIL_LEVEL.POINT, toLevel: DETAIL_LEVEL.EQUIPMENT }) check('C1-first-entry-flies-device-tier', first === DEVICE_FLIGHT.DEVICE_TIER, `点位级 → 设备级:${first}(飞 map.locate.device 档一次)`) // 设备级内 A→B:层级没变,**不能**因为没有层级变化就早退(本票实现时差点写成死代码)。 const switchDevice = planDeviceFlight({ fromLevel: DETAIL_LEVEL.EQUIPMENT, toLevel: DETAIL_LEVEL.EQUIPMENT, fromDeviceCode: 'A', toDeviceCode: 'B', }) check('C2-device-switch-center-only', switchDevice === DEVICE_FLIGHT.CENTER_ONLY, `设备级内 A→B:${switchDevice}(只换中心、不重飞)`) // 同一台再点一次:不动作(不白飞一次)。 const sameDevice = planDeviceFlight({ fromLevel: DETAIL_LEVEL.EQUIPMENT, toLevel: DETAIL_LEVEL.EQUIPMENT, fromDeviceCode: 'A', toDeviceCode: 'A', }) check('C3-same-device-no-action', sameDevice === DEVICE_FLIGHT.NONE, `同一台重复点:${sameDevice}(不重复飞行)`) const back = planDeviceFlight({ fromLevel: DETAIL_LEVEL.EQUIPMENT, toLevel: DETAIL_LEVEL.POINT }) check('C4-back-to-point-returns-point-tier', back === DEVICE_FLIGHT.POINT_TIER, `设备级 → 点位级:${back}(回点位档视角;监测点仍选中,不是"取消选中不回退")`) const none = planDeviceFlight({ fromLevel: DETAIL_LEVEL.NONE, toLevel: DETAIL_LEVEL.POINT }) check('C5-no-flight-from-none', none === DEVICE_FLIGHT.NONE, '从未选中 → 点位级:不动作') } // ================================================================ D 拾取优先级(设备优先、监测点兜底) { const deviceHit = { kind: 'monitoring-device', code: 'EQ-1', pointCode: 'MP-1' } const pointHit = { kind: 'monitoring-point', code: 'MP-1' } check('D1-device-wins-when-both-hit', resolvePickOrder({ deviceHit, pointHit }).target === 'device', '设备点与监测点都命中 → 设备优先(点设备区域就是选设备)') check('D2-point-fallback', resolvePickOrder({ deviceHit: null, pointHit }).target === 'point', '只有监测点命中 → 监测点兜底(回点位级)') check('D3-nothing-hit', resolvePickOrder({}).target === 'none' && resolvePickOrder({}).pick === null, '都没命中 → 不动作(点空白不该收掉已打开的详情)') // 设备身份**不冒充监测点**:返回的是设备那条,`kind` 也不是 monitoring-point。 const resolved = resolvePickOrder({ deviceHit, pointHit }) check('D4-device-identity-not-point', resolved.pick.kind === 'monitoring-device' && resolved.pick.code === 'EQ-1', `返回设备身份 kind=${resolved.pick.kind} code=${resolved.pick.code}(不冒充监测点)`) } // ================================================================ E 引线(重合时退化不画) { const normal = planDeviceLead({ deviceLon: 110.4, deviceLat: 28.44, pointLon: 110.401, pointLat: 28.441 }) check('E1-draws-when-separated', normal.draw === true && normal.meters > 100, `相距约 ${Math.round(normal.meters)} m → 画引线`) const coincident = planDeviceLead({ deviceLon: 110.4, deviceLat: 28.44, pointLon: 110.4, pointLat: 28.44 }) check('E2-degenerate-when-coincident', coincident.draw === false && coincident.reason === 'coincident', '设备与监测点坐标重合 → 引线退化不画(靠外环 + 放大表达)') const missing = planDeviceLead({ deviceLon: null, deviceLat: 28.44, pointLon: 110.4, pointLat: 28.44 }) check('E3-no-lead-without-coordinate', missing.draw === false && missing.reason === 'no-coordinate', '设备没有有效坐标 → 不画(不猜一个位置)') } // ================================================================ F 静态守卫(可断言字段与既有契约) { const root = new URL('../../../src/modules/cockpit/', import.meta.url) const read = (rel) => readFileSync(new URL(rel, root), 'utf8') const mapService = read('services/mapService.js') const engine = read('services/cesiumEngine.js') const store = read('stores/deviceLayerStore.js') const canvas = read('components/MapCanvas.vue') const card = read('components/PointDetailCard.vue') const shell = read('CockpitShell.vue') // ① 三个可断言字段都在(规格点名的口径)。 // 守卫必须**按写入语句**判,不能只查 `dataset.mapPanelSelection` 出现过 —— // 那个字样在 `unmountMap` 的 `delete` 里也有(变异 M14 实测:把写入换成 `void level` // 仍然是全绿)。这是本仓第五次栽在"守卫比它要守的东西宽"上。 check('F1-assertion-fields-present', /dataset\.mapDevicePicked = /.test(mapService) && /dataset\.mapDeviceHovered = /.test(mapService) && /dataset\.mapPanelSelection = level/.test(mapService), '三个字段都是**真的写入**(不是只在别处出现过字段名)') // ② **不破坏既有 `data-map-picked`**(票 10 走查在用)。 check('F2-keeps-existing-picked-field', /dataset\.mapPicked = /.test(mapService) && /paintPicked\(/.test(mapService), '既有 data-map-picked 的写入路径原样保留') // ③ 点选走优先链,且**设备命中时不再问监测点**(短路,而不是"两次都拾然后挑一个")。 // 只比 `indexOf` 顺序是不够的:无条件拾两次同样满足"设备在前"(变异 M16 实测全绿)。 const pickBody = (/export function pickDeviceOrPointAt[\s\S]*?\n\}/.exec(mapService) || [''])[0] check('F3-pick-chain-device-first', /pickDeviceMarkerAt\(/.test(pickBody) && /deviceHit \? null :[\s\S]{0,160}pickPointMarkerAt\(/.test(pickBody) && /resolveOverlapPick\(/.test(pickBody), '先拾设备/聚合实体;设备命中就不再拾监测点,最终优先级由纯函数判定') // ④ 反应**不写颜色**(只 scale/alpha/outline)——色键语义留给设备状态。 const reactBody = (/export function applyDeviceReactions[\s\S]*?\n\}/.exec(engine) || [''])[0] check('F4-reaction-avoids-status-color', /billboard\.scale/.test(reactBody) && /withAlpha\(/.test(reactBody) && !/toneColors|--lv1|--lv2|deviceStatus.*color/i.test(reactBody), '反应只改 scale / alpha / outline,不碰状态色键') // ⑤ 每个实体都写回值(否则上次强化会残留)——与票 14 同一条教训。 check('F5-every-entity-gets-written', /byId\.get\(entity\.id\) \|\| 'normal'/.test(reactBody) && /written \+= 1/.test(reactBody), '图层里每个实体都过一遍(含未命中者按常态写回)') // ⑥ hover 节流在服务层(pointermove 高频,不节流会白烧 GPU)。 check('F6-hover-throttled', /HOVER_THROTTLE_MS/.test(mapService) && /HOVER_THROTTLE_MS\) return/.test(mapService), 'pointermove hover 拾取有节流(数值交实现定,规格未锁)') // ⑦ hover 与点选监听都在卸载时拆掉。 // **两层分开断言**:合成一条「服务层或组件层拆了就算过」会漏掉任一层漏拆 // (变异 M20 实测:只改服务层,仍被组件那半满足而全绿)。 check('F7a-service-hover-teardown', /if \(record\.detachHoverInput\) \{\s*record\.detachHoverInput\(\)\s*record\.detachHoverInput = null/.test(mapService), '服务层在卸载时真的调用并清空 `detachHoverInput`') check('F7b-component-hover-teardown', /if \(stopDeviceHover\) \{\s*stopDeviceHover\(\)/.test(canvas), '组件层在卸载时真的退订 hover(两处都要,缺一处就是漏监听)') // ⑧ 图层撤下时一并清选中/hover(否则读数指向不存在的设备)。 const clearBody = (/export function clearAssociatedDevices\(\)[\s\S]*?\n\}/.exec(mapService) || [''])[0] check('F8-layer-teardown-clears-selection', /selectedDeviceCode = ''/.test(clearBody) && /hoveredDeviceCode = ''/.test(clearBody), '图层撤下时清掉选中与 hover') // ⑨ 面板卡片与清单都接上 hover/选中(两侧入口一致)。 check('F9-panel-side-wired', /@mouseenter="deviceLayerStore\.hoverDevice\(card\.code\)"/.test(card) && /deviceLayerStore\.selectDevice\(card\.code\)/.test(card) && /deviceLayerStore\.selectDevice\(row\.code\)/.test(card), '面板卡片与设备清单都接上 hover/选中') // ⑩ 三维 hover 与点选都接到 store。 check('F10-scene-side-wired', /onDeviceHover\(/.test(canvas) && /deviceLayerStore\.hoverDevice\(/.test(canvas) && /deviceLayerStore\.selectDevice\(picked\.code\)/.test(canvas), '三维 hover 与设备点选都接到设备图层 store') // ⑪ 返回点位走层级回退入口(清强化 + 回点位档),不是直接 backToPoint。 check('F11-back-goes-through-layer-entry', /deviceLayerStore\.backToPointLevel\(\)/.test(card) && /function backToPointLevel/.test(store), '「返回点位」走 deviceLayerStore 的层级回退入口(清设备强化 + 回点位档)') // ⑫ 图层重建后刷新反应(否则翻页选中态丢失)。 check('F12-refresh-after-layer-rebuild', /painted\.value = syncAssociatedDevices\(drawn(?:, \{ maxFan: options\.maxFan(?:\s*,\s*overlapGroups: groups)?(?:\s*,\s*effects)? \})?\)[\s\S]{0,240}syncDeviceReactions\(\)/.test(store), '图层整份重建后刷新一次反应(避免"翻页选中态没了")') // ⑬ 外壳写层级字段并同步对照值。 check('F13-shell-wires-level-field', /setPanelSelectionLevel\(level\)/.test(shell) && /noteLevel\(level, deviceCode\)/.test(shell), '外壳把层级写进 data-map-panel-selection 并同步"变化前"对照') // ⑭ **单一选中真相**:选中/hover 的写入必须经 store,服务层不得再暴露一个直写入口。 // 本票真机走查抓到过这一幕:直接调服务层后 store 仍读空串,界面与引擎对"选中了谁"给出两个答案。 check('F14-single-source-of-truth-for-selection', /publishDeviceSelectionHandle\(/.test(shell) && /selectDevice: \(code\) => deviceLayerStore\.selectDevice\(code\)/.test(shell) && /selectDevice: \(code\) => \(devDeviceHandles/.test(mapService) && !/^\s*setSelectedDevice: \(code\)/m.test(mapService), 'DEV 把手经 store(`devDeviceHandles`),服务层不再暴露直写选中态的函数') } 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(`T43 设备双向对应检查:${checks.length - failed.length}/${checks.length} 项通过。`) if (failed.length) process.exit(1) console.log('T43 设备双向对应全部通过。')