t42-device-layer.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. /**
  2. * T42 关联监测设备临时图层 纯逻辑检查(票 42;规格 14 / 决策票 05)。
  3. *
  4. * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t42-device-layer.mjs
  6. *
  7. * 覆盖验收第 9 条点名的三件事:**集合分类**、**快照生命周期**、**计数互斥**;
  8. * 外加坐标准入只认后端、设备身份不冒充监测点、模式可见性、摘要口径不被污染等静态守卫。
  9. */
  10. import assert from 'node:assert/strict'
  11. import { readFileSync } from 'node:fs'
  12. import {
  13. DEVICE_ATTENTION_STATUSES,
  14. DEVICE_LAYER_KIND,
  15. DEVICE_PLACEMENT,
  16. DEVICE_PLACEMENT_TEXT,
  17. LAYER_MODE,
  18. associatedLegend,
  19. classifyDevice,
  20. countPlacements,
  21. crossEndpointNotice,
  22. isVisibleInMode,
  23. planAssociatedDevices,
  24. uniqueCodes,
  25. } from '../../../src/modules/cockpit/domain/deviceLayer.js'
  26. const checks = []
  27. function check(name, pass, detail = '') {
  28. checks.push({ name, pass, detail })
  29. }
  30. /** 设备卡片工厂:默认「有有效坐标」,便于逐条只改一个维度。 */
  31. function card(overrides = {}) {
  32. return {
  33. code: 'EQ-1',
  34. name: '设备一',
  35. typeName: '压力计',
  36. status: 'NORMAL',
  37. statusLabel: '正常',
  38. statusReasons: [],
  39. longitude: 110.4,
  40. latitude: 28.44,
  41. coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
  42. ...overrides,
  43. }
  44. }
  45. // ================================================================ A 集合分类(每台恰好一类)
  46. {
  47. const drawn = classifyDevice({ card: card(), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.OVERVIEW })
  48. check('A1-valid-coord-is-drawn', drawn === DEVICE_PLACEMENT.DRAWN, `有效坐标 → ${drawn}`)
  49. // 后端判不可上图:**只认 `renderable`**,前端不重算经纬度。
  50. const blocked = classifyDevice({
  51. card: card({ coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, validated: true } }),
  52. inSnapshot: true,
  53. detailOwned: false,
  54. mode: LAYER_MODE.OVERVIEW,
  55. })
  56. check('A2-unverified-goes-no-location', blocked === DEVICE_PLACEMENT.NO_LOCATION,
  57. `UNVERIFIED/renderable=false → ${blocked}(现网正式边界:清单-only)`)
  58. // 坐标准入通过但经纬度缺失 → 数据不可用(**不是**无地图位置,两者原因不同)。
  59. const noCoord = classifyDevice({
  60. card: card({ longitude: null, latitude: null }),
  61. inSnapshot: true,
  62. detailOwned: false,
  63. mode: LAYER_MODE.OVERVIEW,
  64. })
  65. check('A3-missing-numbers-go-unavailable', noCoord === DEVICE_PLACEMENT.UNAVAILABLE,
  66. `renderable=true 但经纬度缺失 → ${noCoord}(与"后端判无位置"分开)`)
  67. // 详情还没回来 → 待确认(不算上图、也不算无位置)。
  68. const pending = classifyDevice({ card: null, inSnapshot: true, detailOwned: false, mode: LAYER_MODE.OVERVIEW })
  69. check('A4-no-card-is-pending', pending === DEVICE_PLACEMENT.PENDING, `详情未到 → ${pending}`)
  70. // 详情独有(不在当前快照清单里)→ 不一致,禁止上图。
  71. const inconsistent = classifyDevice({ card: card(), inSnapshot: false, detailOwned: true, mode: LAYER_MODE.OVERVIEW })
  72. check('A5-detail-only-is-inconsistent', inconsistent === DEVICE_PLACEMENT.INCONSISTENT,
  73. `详情独有 → ${inconsistent}(禁止上图)`)
  74. // 基础设施隐藏;预警处置只留报警/预警。
  75. check('A6-infrastructure-hides', !isVisibleInMode(LAYER_MODE.INFRASTRUCTURE, 'ALARM'),
  76. '基础设施模式整体隐藏关联设备图层')
  77. check('A7-warning-response-only-attention',
  78. isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'ALARM') &&
  79. isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'WARNING') &&
  80. !isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'NORMAL') &&
  81. !isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'OFFLINE') &&
  82. !isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'WORK_ORDER'),
  83. `预警处置只显示 ${DEVICE_ATTENTION_STATUSES.join('/')}(正常/离线/工单都不上图)`)
  84. // 状态未知**不猜成正常**、也不因为未知而在总览被隐藏。
  85. check('A8-unknown-status-still-shown-in-overview', isVisibleInMode(LAYER_MODE.OVERVIEW, ''),
  86. '总体概览/运行监测不凭状态隐藏(状态未知也显示,视觉归票 44)')
  87. const unknownHidden = classifyDevice({
  88. card: card({ status: '' }),
  89. inSnapshot: true,
  90. detailOwned: false,
  91. mode: LAYER_MODE.WARNING_RESPONSE,
  92. })
  93. check('A9-unknown-hidden-in-warning-response', unknownHidden === DEVICE_PLACEMENT.HIDDEN,
  94. `预警处置里状态未知 → ${unknownHidden}(不猜成报警)`)
  95. // 六类归类各有中文说法(界面不另写措辞)。
  96. check('A10-placement-text-complete',
  97. Object.values(DEVICE_PLACEMENT).every((key) => !!DEVICE_PLACEMENT_TEXT[key]),
  98. `归类文案齐备:${Object.values(DEVICE_PLACEMENT_TEXT).join(' / ')}`)
  99. }
  100. // ================================================================ B 快照清单 × 详情(差异如实并列)
  101. {
  102. // 规格点名的算例:快照 `[A,B]`、详情 `[B,C]`。
  103. const plan = planAssociatedDevices({
  104. snapshotCodes: ['A', 'B'],
  105. cards: [card({ code: 'B', name: '乙设备' }), card({ code: 'C', name: '丙设备' })],
  106. mode: LAYER_MODE.OVERVIEW,
  107. pointCode: 'MP-1',
  108. })
  109. check('B1-total-comes-from-snapshot', plan.total === 2,
  110. `总数=当前快照去重 deviceCodes(${plan.total}),不含详情独有`)
  111. // **总数 ≠ 清单行数**:清单会多出「详情独有」的行,拿行数当总数会虚报关联设备数。
  112. // (本票真机走查抓到过这个 UI 口径错误:总数显示 2 而快照只有 1 台。)
  113. check('B1b-total-is-not-row-count', plan.rows.length === 3 && plan.total === 2,
  114. `清单 ${plan.rows.length} 行 ≠ 总数 ${plan.total}(详情独有那行不计入总数)`)
  115. check('B2-snapshot-only-is-pending',
  116. plan.rows.some((row) => row.code === 'A' && row.placement === DEVICE_PLACEMENT.PENDING),
  117. 'A 以编码占位并标「设备详情待确认」')
  118. check('B3-detail-only-is-inconsistent',
  119. plan.rows.some((row) => row.code === 'C' && row.placement === DEVICE_PLACEMENT.INCONSISTENT),
  120. 'C 保留卡片但标「与当前快照不一致」且禁止上图')
  121. check('B4-all-codes-kept', plan.rows.length === 3 && ['A', 'B', 'C'].every((code) => plan.rows.some((r) => r.code === code)),
  122. `两边都不静默丢弃:${plan.rows.map((r) => r.code).join(',')}`)
  123. check('B5-only-snapshot-drawn',
  124. plan.rows.filter((row) => row.placement === DEVICE_PLACEMENT.DRAWN).map((row) => row.code).join(',') === 'B',
  125. '只有同时属于快照清单且坐标准入有效的设备进入显示候选')
  126. const notice = crossEndpointNotice(plan.rows)
  127. check('B6-cross-endpoint-notice', notice.risky && notice.text.includes('不同版'),
  128. `跨端点风险如实提示:「${notice.text}」`)
  129. // 完全一致时不该无端报警。
  130. const clean = planAssociatedDevices({
  131. snapshotCodes: ['A'],
  132. cards: [card({ code: 'A' })],
  133. mode: LAYER_MODE.OVERVIEW,
  134. pointCode: 'MP-1',
  135. })
  136. check('B7-no-notice-when-consistent', crossEndpointNotice(clean.rows).risky === false,
  137. '快照与详情一致时不弹风险提示(避免恒真提示)')
  138. // 去重:后端源顺序去重未排序,前端不重排。
  139. check('B8-dedupe-keeps-order',
  140. JSON.stringify(uniqueCodes(['B', 'A', 'B', '', ' ', 'C', 'A'])) === JSON.stringify(['B', 'A', 'C']),
  141. `去重保序:${uniqueCodes(['B', 'A', 'B', '', ' ', 'C', 'A']).join(',')}`)
  142. // 清单尚未加载(快照里还没有这个点位)→ 全空且如实可辨。
  143. const notLoaded = planAssociatedDevices({ snapshotCodes: [], cards: [], mode: LAYER_MODE.OVERVIEW, pointCode: 'MP-X' })
  144. check('B9-empty-snapshot-yields-no-rows', notLoaded.total === 0 && notLoaded.rows.length === 0,
  145. '点位不在当前快照时总数为 0(调用方据此显示"待当前快照核对",不猜总数)')
  146. }
  147. // ================================================================ C 计数互斥(各类之和可解释总数)
  148. {
  149. // 计数互斥的算例**刻意只用"数据可用"的设备**:把优先级判定留给 C6/C7 专门测,
  150. // 两条断言各管一件事(否则退回模式优先后先红的是 C3,C6 的判据就形同虚设 —— 变异 M12 实测)。
  151. const plan = planAssociatedDevices({
  152. snapshotCodes: ['A', 'C', 'D', 'F'],
  153. cards: [
  154. card({ code: 'A', status: 'ALARM' }), // 报警 → 预警处置里可上图
  155. card({ code: 'C', status: 'NORMAL' }), // 数据没问题,只是本模式不显示
  156. // D 无卡片 → 待确认
  157. card({ code: 'F', status: 'WARNING' }), // 预警 → 也可上图
  158. ],
  159. mode: LAYER_MODE.WARNING_RESPONSE,
  160. pointCode: 'MP-1',
  161. })
  162. const counts = plan.counts
  163. const sum = Object.values(counts).reduce((total, value) => total + value, 0)
  164. check('C1-counts-are-mutually-exclusive', sum === plan.rows.length,
  165. `六类之和 ${sum} == 行数 ${plan.rows.length}(每台恰好一类)`)
  166. check('C2-drawn-count-matches-rows',
  167. counts[DEVICE_PLACEMENT.DRAWN] === 2 && counts[DEVICE_PLACEMENT.DRAWN] === plan.rows.filter((r) => r.placement === DEVICE_PLACEMENT.DRAWN).length,
  168. `已上图计数与行一致:${counts[DEVICE_PLACEMENT.DRAWN]}(报警 + 预警两台)`)
  169. check('C3-each-reason-has-a-row',
  170. counts[DEVICE_PLACEMENT.HIDDEN] === 1 && counts[DEVICE_PLACEMENT.PENDING] === 1,
  171. `差额各有实例可解释:${JSON.stringify(counts)}`)
  172. // **数据可用性先于模式可见性**(本票实现时抓到并修掉的真问题)。
  173. // 算例必须是「模式**会**隐藏它、且数据也有问题」的组合 —— 否则两种顺序结果相同、判据形同虚设:
  174. // · `NORMAL` + `renderable:false`:先判模式 → HIDDEN(谎话:切模式也看不到它);
  175. // 先判数据 → NO_LOCATION(实话:它任何模式都上不了图)。
  176. // (早先用 `ALARM` 当算例是错的:ALARM 在预警处置里可见,两种顺序都落 NO_LOCATION,
  177. // 变异 M12 因此测不出来 —— 这正是变异测试的价值。)
  178. const blockedInWarningMode = classifyDevice({
  179. card: card({ status: 'NORMAL', coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, validated: true } }),
  180. inSnapshot: true,
  181. detailOwned: false,
  182. mode: LAYER_MODE.WARNING_RESPONSE,
  183. })
  184. check('C6-data-availability-beats-mode-visibility',
  185. blockedInWarningMode === DEVICE_PLACEMENT.NO_LOCATION,
  186. `预警处置里正常但坐标被否决 → ${blockedInWarningMode}(**不是**"模式隐藏":切模式也看不到它)`)
  187. // 反过来:数据好的设备在预警处置里因为状态被隐藏,这才是真的"模式隐藏"。
  188. const normalInWarningMode = classifyDevice({
  189. card: card({ status: 'NORMAL' }),
  190. inSnapshot: true,
  191. detailOwned: false,
  192. mode: LAYER_MODE.WARNING_RESPONSE,
  193. })
  194. check('C7-good-data-hidden-by-mode',
  195. normalInWarningMode === DEVICE_PLACEMENT.HIDDEN,
  196. `预警处置里正常设备 → ${normalInWarningMode}(数据没问题,只是本模式不显示)`)
  197. // 独立图例:只统计本图层,且**合计 == 行数**(与摘要图例互不计入)。
  198. //
  199. // 行数**不写死**:分类是跟着规格长的(票 44 追加了 `terrain-pending`),
  200. // 钉死 6 行会让"分类变多"这件事在闸门里表现为假红(本票实测)。
  201. // 真正要守的判据是"图例与计数同源且完整",所以判"行数 == 分类数"。
  202. const legend = associatedLegend(counts)
  203. const legendSum = legend.reduce((total, row) => total + row.count, 0)
  204. const placementKinds = Object.keys(DEVICE_PLACEMENT).length
  205. check('C4-legend-independent-and-complete',
  206. legendSum === plan.rows.length && legend.length === placementKinds,
  207. `设备图例合计 ${legendSum} == 行数 ${plan.rows.length};图例 ${legend.length} 行 == 分类 ${placementKinds} 类(独立于摘要图例)`)
  208. // 空输入不炸、全 0。
  209. const empty = countPlacements([])
  210. check('C5-empty-counts-zero', Object.values(empty).every((value) => value === 0), '空清单各类为 0')
  211. // 票 44:**坐标有效但地形未就绪** → 单列 `terrain-pending`(规格明写要与坐标无效分开)。
  212. const terrainPending = classifyDevice({
  213. card: card(),
  214. inSnapshot: true,
  215. detailOwned: false,
  216. mode: LAYER_MODE.OVERVIEW,
  217. terrainReady: false,
  218. })
  219. check('C8-terrain-pending-is-its-own-class',
  220. terrainPending === DEVICE_PLACEMENT.TERRAIN_PENDING,
  221. `坐标有效 + 地形未就绪 → ${terrainPending}(不是 no-location、也不算已上图)`)
  222. // 对照组:地形就绪时同一台设备正常可上图(证明是地形那一条在起作用)。
  223. const terrainReady = classifyDevice({
  224. card: card(),
  225. inSnapshot: true,
  226. detailOwned: false,
  227. mode: LAYER_MODE.OVERVIEW,
  228. terrainReady: true,
  229. })
  230. check('C9-terrain-ready-same-device-draws',
  231. terrainReady === DEVICE_PLACEMENT.DRAWN,
  232. `同一台设备地形就绪 → ${terrainReady}(对照组,证明判据是地形而非数据)`)
  233. }
  234. // ================================================================ D 静态守卫(口径不被污染)
  235. {
  236. const root = new URL('../../../src/modules/cockpit/', import.meta.url)
  237. const read = (rel) => readFileSync(new URL(rel, root), 'utf8')
  238. const engine = read('services/cesiumEngine.js')
  239. const mapService = read('services/mapService.js')
  240. const store = read('stores/deviceLayerStore.js')
  241. const selection = read('pointSelection.js')
  242. const shell = read('CockpitShell.vue')
  243. // ① 独立数据源:设备图层与摘要标记层必须是两个数据源(结构性保证不写回摘要)。
  244. check('D1-separate-data-source',
  245. /export const DEVICE_MARKER_SOURCE = 'cockpit-associated-device-markers'/.test(engine) &&
  246. /export const POINT_MARKER_SOURCE = 'cockpit-point-markers'/.test(engine) &&
  247. /DEVICE_MARKER_SOURCE/.test(mapService),
  248. '关联设备有自己的数据源,与摘要点位标记分开')
  249. // ② 设备身份同时带 deviceCode 与 monitoringPointCode,**绝不写 pointCode**(不冒充监测点)。
  250. const syncBody = (/export function syncAssociatedDevices[\s\S]*?\n\}/.exec(engine) || [''])[0]
  251. check('D2-device-identity-not-point-identity',
  252. /deviceCode: device\.code/.test(syncBody) && /monitoringPointCode: device\.pointCode/.test(syncBody) &&
  253. !/\bpointCode:/.test(syncBody.replace(/monitoringPointCode/g, '')),
  254. '设备实体带 deviceCode + monitoringPointCode,且不写 pointCode(不冒充监测点)')
  255. // ③ 设备图层不写摘要计数(`data-map-markers*` 只由摘要路径写)。
  256. // 按**函数体**切片:设备图层的读数走 `data-map-devices*`,与摘要的 `data-map-markers*` 分开。
  257. const devicePaintBody = (/function paintDeviceState\(\)[\s\S]*?\n\}/.exec(mapService) || [''])[0]
  258. check('D3-device-layer-does-not-touch-summary-counters',
  259. /dataset\.mapDevices\b/.test(devicePaintBody) && !/mapMarkers/.test(devicePaintBody),
  260. '设备图层只写 data-map-devices*,不碰摘要的 data-map-markers*')
  261. // ④ 坐标准入只认后端 renderable(不重算经纬度范围)。
  262. const domain = read('domain/deviceLayer.js')
  263. check('D4-admission-only-backend-renderable',
  264. /isRenderable\(card\)/.test(domain) && !/longitude\s*[<>]=?\s*-?1[78]0/.test(domain),
  265. '准入只认 isRenderable(后端结论),前端不另算经纬度范围')
  266. // ⑤ 绝不复制监测点坐标:设备行只从卡片取坐标。
  267. check('D5-never-copies-point-coordinates',
  268. /longitude: card \? card\.longitude : null/.test(domain) &&
  269. !/longitude:\s*object\.longitude|point\.longitude/.test(domain),
  270. '设备坐标只来自设备卡片,绝不借用点位坐标或人工偏移')
  271. // ⑥ 快照变化先撤旧图层(规格明写)。
  272. // 守卫必须**只截 `invalidateForSnapshot` 自己的函数体**:整个文件里 `clearAssociatedDevices()`
  273. // 还有别的调用点(`syncForSelection` 的空选择分支、`reset`),对全文匹配会被它们满足
  274. // —— 变异 M16 实测:拿掉这一处仍全绿。这是本仓第四次栽在"守卫比它要守的东西宽"上。
  275. const invalidateBody = (/function invalidateForSnapshot\([^)]*\)[\s\S]*?\n \}/.exec(store) || [''])[0]
  276. check('D6-snapshot-change-clears-first',
  277. /clearAssociatedDevices\(\)/.test(invalidateBody) &&
  278. invalidateBody.indexOf('clearAssociatedDevices()') < invalidateBody.indexOf('syncForSelection') &&
  279. /syncDeviceLayerForSnapshot/.test(shell),
  280. '快照变化时**先**撤下旧图层再等新的(撤下在重算之前;外壳确实调了它)')
  281. // ⑦ 同快照翻页不重建:计划指纹去重。
  282. check('D7-same-plan-not-repainted',
  283. /lastPlanKey/.test(store) && /key === lastPlanKey/.test(store),
  284. '同一份计划(同点位同清单)不重画 ⇒ 翻页不闪烁')
  285. // ⑧ 统一入口:地图与台账都走 selectMonitoringPoint,且它启动设备图层。
  286. check('D8-single-entry-starts-device-layer',
  287. /deviceLayerStore\.syncForSelection/.test(selection),
  288. '统一选择入口启动设备图层(地图与台账因此行为一致)')
  289. // ⑨ 卸载时拆监听并复位(票 15/37 的"漏取消"坑)。
  290. check('D9-teardown',
  291. /for \(const stop of stopDeviceWatches\) stop\(\)/.test(shell) && /deviceLayerStore\.reset\(\)/.test(shell),
  292. '卸载时拆掉四个监听并复位设备图层')
  293. // ⑩ 设备类型键与摘要对象并列而不相同。
  294. check('D10-device-kind-distinct', DEVICE_LAYER_KIND === 'monitoring-device' && !/DEVICE_LAYER_KIND\s*=\s*'monitoring-point'/.test(domain),
  295. `设备类型键 ${DEVICE_LAYER_KIND} 与 monitoring-point 并列而不相同`)
  296. }
  297. const failed = checks.filter((item) => !item.pass)
  298. console.log('')
  299. for (const item of checks) {
  300. if (!item.pass) console.log(` ✗ ${item.name}${item.detail ? ` — ${item.detail}` : ''}`)
  301. }
  302. console.log(`T42 关联设备图层检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  303. if (failed.length) process.exit(1)
  304. console.log('T42 关联设备图层全部通过。')