| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- /**
- * 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 设备双向对应全部通过。')
|