t43-device-correspondence.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. /**
  2. * T43 面板与三维设备点双向对应 纯逻辑检查(票 43;规格 15 / 决策票 06)。
  3. *
  4. * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t43-device-correspondence.mjs
  6. *
  7. * 覆盖验收第 7 条点名的三件事:**状态迁移**、**拾取优先级**、**弱化阈值**;
  8. * 外加"反应不用颜色""引线退化""三个可断言字段""不破坏既有 data-map-picked"等静态守卫。
  9. */
  10. import { readFileSync } from 'node:fs'
  11. import {
  12. DEVICE_FLIGHT,
  13. DEVICE_REACTION,
  14. DIM_THRESHOLD,
  15. planDeviceFlight,
  16. planDeviceLead,
  17. planDeviceReactions,
  18. reactionStyle,
  19. resolvePickOrder,
  20. } from '../../../src/modules/cockpit/domain/deviceCorrespondence.js'
  21. import { DETAIL_LEVEL } from '../../../src/modules/cockpit/domain/detailFlow.js'
  22. const checks = []
  23. function check(name, pass, detail = '') {
  24. checks.push({ name, pass, detail })
  25. }
  26. /** 设备行工厂。 */
  27. const device = (code, pointCode = 'MP-1') => ({ code, pointCode, longitude: 110.4, latitude: 28.44 })
  28. // ================================================================ A 反应档位(互斥且优先级正确)
  29. {
  30. const devices = [device('A'), device('B'), device('C')]
  31. // 没选没 hover、且台数 ≥ 3 → 全部弱化。
  32. const idle = planDeviceReactions({ devices })
  33. check('A1-idle-dims-when-at-threshold',
  34. idle.dimming === true && idle.reactions.every((r) => r.reaction === DEVICE_REACTION.DIMMED),
  35. `3 台且无选中 → 全弱化(dimming=${idle.dimming})`)
  36. // 选中一台:选中者强化、其余弱化。
  37. const selected = planDeviceReactions({ devices, selectedCode: 'B' })
  38. const byCode = Object.fromEntries(selected.reactions.map((r) => [r.code, r.reaction]))
  39. check('A2-selected-is-persistent-and-others-dim',
  40. byCode.B === DEVICE_REACTION.SELECTED &&
  41. byCode.A === DEVICE_REACTION.DIMMED && byCode.C === DEVICE_REACTION.DIMMED,
  42. `选中 B:${JSON.stringify(byCode)}`)
  43. // hover 一台非选中设备 → 撤弱化 + 轻放大(**不是**选中档,不叠外环/引线)。
  44. const hovered = planDeviceReactions({ devices, selectedCode: 'B', hoveredCode: 'C' })
  45. const h = Object.fromEntries(hovered.reactions.map((r) => [r.code, r.reaction]))
  46. check('A3-hover-only-affects-unselected',
  47. h.C === DEVICE_REACTION.HOVERED && h.B === DEVICE_REACTION.SELECTED && h.A === DEVICE_REACTION.DIMMED,
  48. `hover C(B 已选中):${JSON.stringify(h)}`)
  49. // **已选中的设备不被 hover 干扰**(规格原句):hover 的正是已选中的那台 → 仍是 selected。
  50. const hoverSelected = planDeviceReactions({ devices, selectedCode: 'B', hoveredCode: 'B' })
  51. const hs = Object.fromEntries(hoverSelected.reactions.map((r) => [r.code, r.reaction]))
  52. check('A4-hover-does-not-disturb-selection',
  53. hs.B === DEVICE_REACTION.SELECTED,
  54. `hover 到已选中的 B:${hs.B}(选中态优先,不被 hover 顶掉)`)
  55. // 少于阈值:不弱化,全常态。
  56. const two = planDeviceReactions({ devices: [device('A'), device('B')] })
  57. check('A5-below-threshold-no-dimming',
  58. two.dimming === false && two.reactions.every((r) => r.reaction === DEVICE_REACTION.NORMAL),
  59. `2 台(< ${DIM_THRESHOLD})→ 全常态、不弱化`)
  60. check('A6-threshold-is-three', DIM_THRESHOLD === 3, `弱化阈值=${DIM_THRESHOLD}(规格锁的数值)`)
  61. // 恰好 3 台启用(边界)。
  62. const three = planDeviceReactions({ devices: [device('A'), device('B'), device('C')] })
  63. check('A7-threshold-boundary-inclusive', three.dimming === true, '恰好 3 台即启用(≥ 而非 >)')
  64. // 空清单不炸。
  65. const empty = planDeviceReactions({ devices: [] })
  66. check('A8-empty-safe', empty.dimming === false && empty.reactions.length === 0, '空清单:不弱化、无反应项')
  67. }
  68. // ================================================================ B 视觉量(不用颜色,只尺寸/透明度)
  69. {
  70. const selected = reactionStyle(DEVICE_REACTION.SELECTED)
  71. const hovered = reactionStyle(DEVICE_REACTION.HOVERED)
  72. const dimmed = reactionStyle(DEVICE_REACTION.DIMMED)
  73. const normal = reactionStyle(DEVICE_REACTION.NORMAL)
  74. check('B1-selected-has-ring-and-size',
  75. selected.ring === true && selected.scale > 1 && selected.alpha === 1,
  76. `选中:外环 ${selected.ring}、放大 ×${selected.scale}`)
  77. check('B2-hovered-no-ring',
  78. hovered.ring === false && hovered.scale > 1 && hovered.alpha === 1,
  79. `hover:轻放大 ×${hovered.scale} 且**不叠外环**(外环是选中专属)`)
  80. check('B3-dimmed-dimmer-and-smaller',
  81. dimmed.alpha < 1 && dimmed.scale < normal.scale,
  82. `弱化:压暗 α${dimmed.alpha} + 缩小 ×${dimmed.scale}`)
  83. check('B4-hover-scale-below-selected',
  84. hovered.scale < selected.scale,
  85. `hover(×${hovered.scale})比选中(×${selected.scale})轻 —— 两档一眼可分`)
  86. // 反应只给 scale/alpha/ring,**没有 color/hue/tone**:色键语义不许被反应挪用。
  87. check('B5-no-color-in-reaction-style',
  88. !('color' in selected) && !('hue' in selected) && !('tone' in selected),
  89. `反应量只有 ${Object.keys(selected).join('/')}(不含颜色/色键)`)
  90. }
  91. // ================================================================ C 镜头联动(三段行为)
  92. {
  93. const first = planDeviceFlight({ fromLevel: DETAIL_LEVEL.POINT, toLevel: DETAIL_LEVEL.EQUIPMENT })
  94. check('C1-first-entry-flies-device-tier', first === DEVICE_FLIGHT.DEVICE_TIER,
  95. `点位级 → 设备级:${first}(飞 map.locate.device 档一次)`)
  96. // 设备级内 A→B:层级没变,**不能**因为没有层级变化就早退(本票实现时差点写成死代码)。
  97. const switchDevice = planDeviceFlight({
  98. fromLevel: DETAIL_LEVEL.EQUIPMENT,
  99. toLevel: DETAIL_LEVEL.EQUIPMENT,
  100. fromDeviceCode: 'A',
  101. toDeviceCode: 'B',
  102. })
  103. check('C2-device-switch-center-only', switchDevice === DEVICE_FLIGHT.CENTER_ONLY,
  104. `设备级内 A→B:${switchDevice}(只换中心、不重飞)`)
  105. // 同一台再点一次:不动作(不白飞一次)。
  106. const sameDevice = planDeviceFlight({
  107. fromLevel: DETAIL_LEVEL.EQUIPMENT,
  108. toLevel: DETAIL_LEVEL.EQUIPMENT,
  109. fromDeviceCode: 'A',
  110. toDeviceCode: 'A',
  111. })
  112. check('C3-same-device-no-action', sameDevice === DEVICE_FLIGHT.NONE,
  113. `同一台重复点:${sameDevice}(不重复飞行)`)
  114. const back = planDeviceFlight({ fromLevel: DETAIL_LEVEL.EQUIPMENT, toLevel: DETAIL_LEVEL.POINT })
  115. check('C4-back-to-point-returns-point-tier', back === DEVICE_FLIGHT.POINT_TIER,
  116. `设备级 → 点位级:${back}(回点位档视角;监测点仍选中,不是"取消选中不回退")`)
  117. const none = planDeviceFlight({ fromLevel: DETAIL_LEVEL.NONE, toLevel: DETAIL_LEVEL.POINT })
  118. check('C5-no-flight-from-none', none === DEVICE_FLIGHT.NONE, '从未选中 → 点位级:不动作')
  119. }
  120. // ================================================================ D 拾取优先级(设备优先、监测点兜底)
  121. {
  122. const deviceHit = { kind: 'monitoring-device', code: 'EQ-1', pointCode: 'MP-1' }
  123. const pointHit = { kind: 'monitoring-point', code: 'MP-1' }
  124. check('D1-device-wins-when-both-hit',
  125. resolvePickOrder({ deviceHit, pointHit }).target === 'device',
  126. '设备点与监测点都命中 → 设备优先(点设备区域就是选设备)')
  127. check('D2-point-fallback',
  128. resolvePickOrder({ deviceHit: null, pointHit }).target === 'point',
  129. '只有监测点命中 → 监测点兜底(回点位级)')
  130. check('D3-nothing-hit',
  131. resolvePickOrder({}).target === 'none' && resolvePickOrder({}).pick === null,
  132. '都没命中 → 不动作(点空白不该收掉已打开的详情)')
  133. // 设备身份**不冒充监测点**:返回的是设备那条,`kind` 也不是 monitoring-point。
  134. const resolved = resolvePickOrder({ deviceHit, pointHit })
  135. check('D4-device-identity-not-point',
  136. resolved.pick.kind === 'monitoring-device' && resolved.pick.code === 'EQ-1',
  137. `返回设备身份 kind=${resolved.pick.kind} code=${resolved.pick.code}(不冒充监测点)`)
  138. }
  139. // ================================================================ E 引线(重合时退化不画)
  140. {
  141. const normal = planDeviceLead({ deviceLon: 110.4, deviceLat: 28.44, pointLon: 110.401, pointLat: 28.441 })
  142. check('E1-draws-when-separated', normal.draw === true && normal.meters > 100,
  143. `相距约 ${Math.round(normal.meters)} m → 画引线`)
  144. const coincident = planDeviceLead({ deviceLon: 110.4, deviceLat: 28.44, pointLon: 110.4, pointLat: 28.44 })
  145. check('E2-degenerate-when-coincident', coincident.draw === false && coincident.reason === 'coincident',
  146. '设备与监测点坐标重合 → 引线退化不画(靠外环 + 放大表达)')
  147. const missing = planDeviceLead({ deviceLon: null, deviceLat: 28.44, pointLon: 110.4, pointLat: 28.44 })
  148. check('E3-no-lead-without-coordinate', missing.draw === false && missing.reason === 'no-coordinate',
  149. '设备没有有效坐标 → 不画(不猜一个位置)')
  150. }
  151. // ================================================================ F 静态守卫(可断言字段与既有契约)
  152. {
  153. const root = new URL('../../../src/modules/cockpit/', import.meta.url)
  154. const read = (rel) => readFileSync(new URL(rel, root), 'utf8')
  155. const mapService = read('services/mapService.js')
  156. const engine = read('services/cesiumEngine.js')
  157. const store = read('stores/deviceLayerStore.js')
  158. const canvas = read('components/MapCanvas.vue')
  159. const card = read('components/PointDetailCard.vue')
  160. const shell = read('CockpitShell.vue')
  161. // ① 三个可断言字段都在(规格点名的口径)。
  162. // 守卫必须**按写入语句**判,不能只查 `dataset.mapPanelSelection` 出现过 ——
  163. // 那个字样在 `unmountMap` 的 `delete` 里也有(变异 M14 实测:把写入换成 `void level`
  164. // 仍然是全绿)。这是本仓第五次栽在"守卫比它要守的东西宽"上。
  165. check('F1-assertion-fields-present',
  166. /dataset\.mapDevicePicked = /.test(mapService) &&
  167. /dataset\.mapDeviceHovered = /.test(mapService) &&
  168. /dataset\.mapPanelSelection = level/.test(mapService),
  169. '三个字段都是**真的写入**(不是只在别处出现过字段名)')
  170. // ② **不破坏既有 `data-map-picked`**(票 10 走查在用)。
  171. check('F2-keeps-existing-picked-field',
  172. /dataset\.mapPicked = /.test(mapService) && /paintPicked\(/.test(mapService),
  173. '既有 data-map-picked 的写入路径原样保留')
  174. // ③ 点选走优先链,且**设备命中时不再问监测点**(短路,而不是"两次都拾然后挑一个")。
  175. // 只比 `indexOf` 顺序是不够的:无条件拾两次同样满足"设备在前"(变异 M16 实测全绿)。
  176. const pickBody = (/export function pickDeviceOrPointAt[\s\S]*?\n\}/.exec(mapService) || [''])[0]
  177. check('F3-pick-chain-device-first',
  178. /pickDeviceMarkerAt\(/.test(pickBody) &&
  179. /deviceHit \? null :[\s\S]{0,160}pickPointMarkerAt\(/.test(pickBody) &&
  180. /resolveOverlapPick\(/.test(pickBody),
  181. '先拾设备/聚合实体;设备命中就不再拾监测点,最终优先级由纯函数判定')
  182. // ④ 反应**不写颜色**(只 scale/alpha/outline)——色键语义留给设备状态。
  183. const reactBody = (/export function applyDeviceReactions[\s\S]*?\n\}/.exec(engine) || [''])[0]
  184. check('F4-reaction-avoids-status-color',
  185. /billboard\.scale/.test(reactBody) && /withAlpha\(/.test(reactBody) &&
  186. !/toneColors|--lv1|--lv2|deviceStatus.*color/i.test(reactBody),
  187. '反应只改 scale / alpha / outline,不碰状态色键')
  188. // ⑤ 每个实体都写回值(否则上次强化会残留)——与票 14 同一条教训。
  189. check('F5-every-entity-gets-written',
  190. /byId\.get\(entity\.id\) \|\| 'normal'/.test(reactBody) && /written \+= 1/.test(reactBody),
  191. '图层里每个实体都过一遍(含未命中者按常态写回)')
  192. // ⑥ hover 节流在服务层(pointermove 高频,不节流会白烧 GPU)。
  193. check('F6-hover-throttled',
  194. /HOVER_THROTTLE_MS/.test(mapService) && /HOVER_THROTTLE_MS\) return/.test(mapService),
  195. 'pointermove hover 拾取有节流(数值交实现定,规格未锁)')
  196. // ⑦ hover 与点选监听都在卸载时拆掉。
  197. // **两层分开断言**:合成一条「服务层或组件层拆了就算过」会漏掉任一层漏拆
  198. // (变异 M20 实测:只改服务层,仍被组件那半满足而全绿)。
  199. check('F7a-service-hover-teardown',
  200. /if \(record\.detachHoverInput\) \{\s*record\.detachHoverInput\(\)\s*record\.detachHoverInput = null/.test(mapService),
  201. '服务层在卸载时真的调用并清空 `detachHoverInput`')
  202. check('F7b-component-hover-teardown',
  203. /if \(stopDeviceHover\) \{\s*stopDeviceHover\(\)/.test(canvas),
  204. '组件层在卸载时真的退订 hover(两处都要,缺一处就是漏监听)')
  205. // ⑧ 图层撤下时一并清选中/hover(否则读数指向不存在的设备)。
  206. const clearBody = (/export function clearAssociatedDevices\(\)[\s\S]*?\n\}/.exec(mapService) || [''])[0]
  207. check('F8-layer-teardown-clears-selection',
  208. /selectedDeviceCode = ''/.test(clearBody) && /hoveredDeviceCode = ''/.test(clearBody),
  209. '图层撤下时清掉选中与 hover')
  210. // ⑨ 面板卡片与清单都接上 hover/选中(两侧入口一致)。
  211. check('F9-panel-side-wired',
  212. /@mouseenter="deviceLayerStore\.hoverDevice\(card\.code\)"/.test(card) &&
  213. /deviceLayerStore\.selectDevice\(card\.code\)/.test(card) &&
  214. /deviceLayerStore\.selectDevice\(row\.code\)/.test(card),
  215. '面板卡片与设备清单都接上 hover/选中')
  216. // ⑩ 三维 hover 与点选都接到 store。
  217. check('F10-scene-side-wired',
  218. /onDeviceHover\(/.test(canvas) && /deviceLayerStore\.hoverDevice\(/.test(canvas) &&
  219. /deviceLayerStore\.selectDevice\(picked\.code\)/.test(canvas),
  220. '三维 hover 与设备点选都接到设备图层 store')
  221. // ⑪ 返回点位走层级回退入口(清强化 + 回点位档),不是直接 backToPoint。
  222. check('F11-back-goes-through-layer-entry',
  223. /deviceLayerStore\.backToPointLevel\(\)/.test(card) &&
  224. /function backToPointLevel/.test(store),
  225. '「返回点位」走 deviceLayerStore 的层级回退入口(清设备强化 + 回点位档)')
  226. // ⑫ 图层重建后刷新反应(否则翻页选中态丢失)。
  227. check('F12-refresh-after-layer-rebuild',
  228. /painted\.value = syncAssociatedDevices\(drawn(?:, \{ maxFan: options\.maxFan(?:\s*,\s*overlapGroups: groups)?(?:\s*,\s*effects)? \})?\)[\s\S]{0,240}syncDeviceReactions\(\)/.test(store),
  229. '图层整份重建后刷新一次反应(避免"翻页选中态没了")')
  230. // ⑬ 外壳写层级字段并同步对照值。
  231. check('F13-shell-wires-level-field',
  232. /setPanelSelectionLevel\(level\)/.test(shell) && /noteLevel\(level, deviceCode\)/.test(shell),
  233. '外壳把层级写进 data-map-panel-selection 并同步"变化前"对照')
  234. // ⑭ **单一选中真相**:选中/hover 的写入必须经 store,服务层不得再暴露一个直写入口。
  235. // 本票真机走查抓到过这一幕:直接调服务层后 store 仍读空串,界面与引擎对"选中了谁"给出两个答案。
  236. check('F14-single-source-of-truth-for-selection',
  237. /publishDeviceSelectionHandle\(/.test(shell) &&
  238. /selectDevice: \(code\) => deviceLayerStore\.selectDevice\(code\)/.test(shell) &&
  239. /selectDevice: \(code\) => \(devDeviceHandles/.test(mapService) &&
  240. !/^\s*setSelectedDevice: \(code\)/m.test(mapService),
  241. 'DEV 把手经 store(`devDeviceHandles`),服务层不再暴露直写选中态的函数')
  242. }
  243. const failed = checks.filter((item) => !item.pass)
  244. console.log('')
  245. for (const item of checks) {
  246. if (!item.pass) console.log(` ✗ ${item.name}${item.detail ? ` — ${item.detail}` : ''}`)
  247. }
  248. console.log(`T43 设备双向对应检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  249. if (failed.length) process.exit(1)
  250. console.log('T43 设备双向对应全部通过。')