t09-point-status.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. /**
  2. * T09 点位状态图例与标记 纯逻辑检查(票 09)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t09-point-status.mjs
  6. *
  7. * 六段:
  8. * A 状态词表:后端 `CockpitMonitoringPointStatus` 六态 + 未关联设备 + 认不出的状态;
  9. * 每个词条必须同时有**形状 + 色键 + 文字**(验收第 3 条:不只靠颜色)。
  10. * B 认不出的状态不得被猜成正常(前端不重算点位状态)。
  11. * C 标记计划:只收坐标准入通过的对象,被拦下的仍留在 `blocked`(不上图 ≠ 不存在)。
  12. * D 图例与标记一致(验收第 4 条):同一份 `byStatus` 生成,计数与形状/色键逐个对齐;
  13. * 并**故意注入不一致**,证明这条判据真的会红(不然它就是恒真的假绿)。
  14. * E 标记图片:形状 + 颜色烘焙进 SVG;色值由调用方传(本文件零硬编码颜色)。
  15. * F 静态断言:不把事件等级色混用为点位状态色(票 09 正文的硬约束)。
  16. *
  17. * 状态与中文标签的依据是**后端源码**而不是猜:
  18. * `E:\pipe-ner-server\...\cockpit\point\CockpitMonitoringPointStatus.java`
  19. * (ALARM 报警 1 / WARNING 预警 2 / WORK_ORDER 工单处置 3 / NORMAL 正常 4 / OFFLINE 离线 5 / EMPTY 无设备 6)。
  20. * 本脚本不复读该文件(跨仓库路径不是本仓库的契约),所以把六个取值与优先级写死在断言里 ——
  21. * 后端改词表时这里会红,那正是它该提醒的事。
  22. */
  23. import assert from 'node:assert/strict'
  24. import { readFileSync } from 'node:fs'
  25. import {
  26. POINT_STATUS,
  27. POINT_STATUS_VISUALS,
  28. SHAPES,
  29. UNASSOCIATED_VISUAL,
  30. UNKNOWN_VISUAL,
  31. isKnownShape,
  32. legendMatchesMarkers,
  33. legendVisuals,
  34. markerSvg,
  35. planPointMarkers,
  36. pointLegend,
  37. pointVisual,
  38. shapeSvg,
  39. summarizeMarkerStatuses,
  40. visualForStatus,
  41. } from '../../../src/modules/cockpit/domain/pointStatus.js'
  42. import { TONES, isKnownTone } from '../../../src/modules/cockpit/domain/statusTones.js'
  43. const checks = []
  44. function check(name, pass, evidence) {
  45. checks.push({ name, pass })
  46. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  47. }
  48. /** 造一个地图对象(形状照抄 `domain/mapSummary.js` 的领域对象)。 */
  49. const object = (code, status, extras = {}) => ({
  50. kind: 'monitoring-point',
  51. code,
  52. name: `${code} 点位`,
  53. status,
  54. statusReasons: [],
  55. longitude: 110.141,
  56. latitude: 28.452,
  57. coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
  58. ...extras,
  59. })
  60. /** 造一个不上图的对象(renderable=false)。 */
  61. const blockedObject = (code, status, reason = 'ZERO_COORDINATE') => ({
  62. ...object(code, status),
  63. longitude: 0,
  64. latitude: 0,
  65. coordinate: { state: 'INVALID', reason, crs: 'WGS84', renderable: false, validated: true },
  66. })
  67. // ================================================================ A 状态词表
  68. const BACKEND_STATUSES = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY']
  69. /** 后端 `CockpitMonitoringPointStatus` 的中文标签,逐字对照。 */
  70. const BACKEND_LABELS = {
  71. ALARM: '报警',
  72. WARNING: '预警',
  73. WORK_ORDER: '工单处置',
  74. NORMAL: '正常',
  75. OFFLINE: '离线',
  76. EMPTY: '无设备',
  77. }
  78. {
  79. check(
  80. 'A1-six-backend-statuses',
  81. BACKEND_STATUSES.every((key) => POINT_STATUS[key] === key && POINT_STATUS_VISUALS[key]),
  82. `六态 ${BACKEND_STATUSES.join(', ')} 都有词条`,
  83. )
  84. const mismatched = BACKEND_STATUSES.filter((key) => POINT_STATUS_VISUALS[key].label !== BACKEND_LABELS[key])
  85. check('A2-labels-match-backend', mismatched.length === 0, mismatched.length ? `不符:${mismatched.join(', ')}` : '中文标签与后端枚举逐字一致')
  86. // 图例按后端优先级排序:ALARM 最先。前端不自己定序。
  87. const priorities = BACKEND_STATUSES.map((key) => POINT_STATUS_VISUALS[key].priority)
  88. check(
  89. 'A3-priority-from-backend',
  90. priorities.join(',') === '1,2,3,4,5,6' &&
  91. POINT_STATUS_VISUALS.ALARM.priority === 1 &&
  92. POINT_STATUS_VISUALS.EMPTY.priority === 6,
  93. `优先级 ${priorities.join(',')}`,
  94. )
  95. // 验收第 3 条:每种颜色都有文字或形状辅助。
  96. const lacking = legendVisuals().filter(
  97. (visual) => !visual.label || !visual.shape || !isKnownShape(visual.shape) || !isKnownTone(visual.tone),
  98. )
  99. check(
  100. 'A4-every-visual-has-shape-tone-label',
  101. lacking.length === 0,
  102. lacking.length ? `缺件:${lacking.map((v) => v.key).join(', ')}` : `${legendVisuals().length} 条词条都是 形状+色键+文字`,
  103. )
  104. // 形状必须两两不同:形状是「不只靠颜色」里真正与颜色无关的那一半。
  105. const shapes = legendVisuals().map((visual) => visual.shape)
  106. check(
  107. 'A5-shapes-pairwise-distinct',
  108. new Set(shapes).size === shapes.length,
  109. `${shapes.length} 条词条用到 ${new Set(shapes).size} 种形状:${shapes.join(', ')}`,
  110. )
  111. // 维护者已要求四种原中性状态使用独立色;业务状态/优先级/形状仍不变。
  112. const formerlyNeutral = ['OFFLINE', 'EMPTY', 'UNKNOWN', 'UNASSOCIATED'].map((key) => legendVisuals().find((visual) => visual.key === key))
  113. check(
  114. 'A6-distinct-status-colors-and-shapes',
  115. formerlyNeutral.map((visual) => visual.tone).join(',') === 'point-offline,point-empty,point-unknown,point-unassociated' &&
  116. new Set(formerlyNeutral.map((visual) => visual.shape)).size === 4,
  117. '离线/无设备/未发布/未关联使用独立色键,四种辅助形状保留',
  118. )
  119. const badTones = legendVisuals().filter((visual) => !TONES.includes(visual.tone))
  120. check('A7-tones-are-known-keys', badTones.length === 0, badTones.length ? badTones.map((v) => v.tone).join(', ') : `色键都取自 ${TONES.join('/')}`)
  121. check('A8-legend-covers-eight-rows', legendVisuals().length === 8, `图例 ${legendVisuals().length} 行(六态 + 状态未发布 + 无关联设备)`)
  122. check(
  123. 'A9-unknown-shape-safe',
  124. isKnownShape('nope') === false && shapeSvg('nope') === SHAPES.circle,
  125. '认不出的形状退回实心圆,不抛错也不留空',
  126. )
  127. }
  128. // ================================================================ B 前端不重算
  129. {
  130. check(
  131. 'B1-unknown-status-not-guessed-as-normal',
  132. visualForStatus('MAINTENANCE').key === UNKNOWN_VISUAL.key &&
  133. visualForStatus('MAINTENANCE').key !== POINT_STATUS.NORMAL &&
  134. visualForStatus('MAINTENANCE').label !== BACKEND_LABELS.NORMAL,
  135. `MAINTENANCE → ${visualForStatus('MAINTENANCE').key}「${visualForStatus('MAINTENANCE').label}」`,
  136. )
  137. check(
  138. 'B2-missing-status-not-guessed',
  139. visualForStatus('').key === UNKNOWN_VISUAL.key && visualForStatus(undefined).key === UNKNOWN_VISUAL.key,
  140. '没给状态时如实说「状态未发布」,不落成正常',
  141. )
  142. check(
  143. 'B3-lowercase-not-coerced',
  144. visualForStatus('normal').key === UNKNOWN_VISUAL.key,
  145. '后端发的是大写枚举;小写不当成同一个状态(不猜、不归一化)',
  146. )
  147. check(
  148. 'B4-unassociated-is-its-own-row',
  149. pointVisual({ kind: 'unassociated-equipment', status: '' }).key === UNASSOCIATED_VISUAL.key &&
  150. pointVisual({ kind: 'unassociated-equipment', status: 'NORMAL' }).key === UNASSOCIATED_VISUAL.key,
  151. '未关联设备不借用在线态(票 05 同一立场:0/NORMAL 都算伪造)',
  152. )
  153. check(
  154. 'B5-status-passed-through-not-derived',
  155. pointVisual(object('MP-1', 'ALARM')).key === 'ALARM' && pointVisual(object('MP-2', 'OFFLINE')).key === 'OFFLINE',
  156. '状态原样取自后端字段,前端不做任何推导',
  157. )
  158. }
  159. // ================================================================ C 标记计划
  160. {
  161. const objects = [
  162. object('MP-1', 'ALARM'),
  163. object('MP-2', 'NORMAL'),
  164. object('MP-3', 'WARNING'),
  165. { ...object('EQ-1', '', { kind: 'unassociated-equipment' }) },
  166. blockedObject('MP-4', 'ALARM'),
  167. blockedObject('EQ-2', '', { kind: 'unassociated-equipment' }),
  168. ]
  169. const plan = planPointMarkers(objects)
  170. check(
  171. 'C1-only-renderable-becomes-marker',
  172. plan.total === 4 && plan.markers.length === 4 && plan.blocked.length === 2,
  173. `标记 ${plan.total} · 拦下 ${plan.blocked.length}(共 ${objects.length} 个对象)`,
  174. )
  175. check(
  176. 'C2-blocked-kept-not-dropped',
  177. plan.blocked.map((item) => item.code).join(',') === 'MP-4,EQ-2',
  178. `拦下的仍在 blocked 里:${plan.blocked.map((item) => item.code).join(', ')}(不上图 ≠ 不存在)`,
  179. )
  180. check(
  181. 'C3-marker-carries-backend-status',
  182. plan.markers.every((marker) => marker.status === (objects.find((o) => o.code === marker.code) || {}).status) &&
  183. plan.markers.find((m) => m.code === 'MP-1').status === 'ALARM',
  184. '标记带后端原始状态串,供排查与后续票消费',
  185. )
  186. check(
  187. 'C4-by-status-counts',
  188. plan.byStatus.ALARM === 1 && plan.byStatus.NORMAL === 1 && plan.byStatus.WARNING === 1 && plan.byStatus.UNASSOCIATED === 1,
  189. JSON.stringify(plan.byStatus),
  190. )
  191. check(
  192. 'C5-marker-visual-matches-table',
  193. plan.markers.every((marker) => {
  194. const visual = legendVisuals().find((item) => item.key === marker.visualKey)
  195. return visual && visual.tone === marker.tone && visual.shape === marker.shape && visual.label === marker.label
  196. }),
  197. '每个标记的色键/形状/文字都能在词表里逐字对上(同一张表)',
  198. )
  199. check(
  200. 'C6-coordinate-criteria-not-reinvented',
  201. planPointMarkers(null).total === 0 && planPointMarkers(undefined).markers.length === 0 && planPointMarkers([]).byStatus && Object.keys(planPointMarkers([]).byStatus).length === 0,
  202. '空输入安全,且准入只走 domain/coordinate.js 的 isRenderable()',
  203. )
  204. const unknownObject = object('MP-9', 'MAINTENANCE')
  205. const withUnknown = planPointMarkers([unknownObject])
  206. check(
  207. 'C7-unknown-status-becomes-own-row',
  208. withUnknown.byStatus.UNKNOWN === 1 && withUnknown.markers[0].shape === UNKNOWN_VISUAL.shape,
  209. `认不出的状态自成一行,不落到真实状态上(${JSON.stringify(withUnknown.byStatus)})`,
  210. )
  211. }
  212. // ================================================================ D 图例与标记一致
  213. {
  214. const objects = [
  215. object('MP-1', 'ALARM'),
  216. object('MP-2', 'ALARM'),
  217. object('MP-3', 'WORK_ORDER'),
  218. object('MP-4', 'OFFLINE'),
  219. object('MP-5', 'EMPTY'),
  220. blockedObject('MP-6', 'NORMAL'),
  221. ]
  222. const plan = planPointMarkers(objects)
  223. const legend = pointLegend(plan.byStatus)
  224. const verdict = legendMatchesMarkers(legend, plan.markers)
  225. check('D1-legend-matches-markers', verdict.ok, verdict.problems.length ? verdict.problems.join(' | ') : '图例与标记逐行一致')
  226. check(
  227. 'D2-legend-counts-equal-markers',
  228. legend.reduce((sum, row) => sum + row.count, 0) === plan.markers.length && legend.length === 8,
  229. `图例合计 ${legend.reduce((sum, row) => sum + row.count, 0)} = 标记 ${plan.markers.length}`,
  230. )
  231. check(
  232. 'D3-legend-row-for-every-used-status',
  233. ['ALARM', 'WORK_ORDER', 'OFFLINE', 'EMPTY'].every((key) => legend.find((row) => row.key === key).count === plan.byStatus[key]),
  234. legend
  235. .filter((row) => row.count > 0)
  236. .map((row) => `${row.label} ${row.count}`)
  237. .join(' · '),
  238. )
  239. check(
  240. 'D4-zero-count-rows-still-listed',
  241. legend.find((row) => row.key === 'WARNING').count === 0 && legend.find((row) => row.key === 'WARNING').shape === 'diamond',
  242. '没出现的状态也留在图例里(形状的含义要能查到),只是计数为 0',
  243. )
  244. // 对抗式:故意把图例改坏,判据必须变红 —— 否则 D1 就是恒真的假绿。
  245. const wrongCount = legend.map((row) => (row.key === 'ALARM' ? { ...row, count: 99 } : row))
  246. const wrongShape = legend.map((row) => (row.key === 'ALARM' ? { ...row, shape: 'circle' } : row))
  247. const missingRow = legend.filter((row) => row.key !== 'ALARM')
  248. const verdicts = [wrongCount, wrongShape, missingRow].map((broken) => legendMatchesMarkers(broken, plan.markers))
  249. check(
  250. 'D5-inconsistency-detected',
  251. verdicts.every((item) => item.ok === false),
  252. `计数错/形状错/缺行 三类篡改都被抓到:${verdicts.map((item) => item.problems.length).join(', ')} 条问题`,
  253. )
  254. check(
  255. 'D6-summary-matches-plan',
  256. JSON.stringify(summarizeMarkerStatuses(plan.markers)) === JSON.stringify(plan.byStatus),
  257. 'byStatus 就是按标记算出来的,不是另算一份',
  258. )
  259. }
  260. // ================================================================ E 标记图片
  261. {
  262. const red = markerSvg('triangle', '#ff4d5e')
  263. const blue = markerSvg('triangle', '#4fd8ff')
  264. const circle = markerSvg('circle', '#ff4d5e')
  265. check('E1-svg-data-url', red.startsWith('data:image/svg+xml;charset=utf-8,'), red.slice(0, 40))
  266. const decoded = decodeURIComponent(red.slice('data:image/svg+xml;charset=utf-8,'.length))
  267. check(
  268. 'E2-color-baked-in',
  269. decoded.includes('#ff4d5e') && decoded.includes('viewBox="0 0 12 12"'),
  270. '颜色烘焙进图片(billboard 读不到页面 CSS 变量,必须由服务层传色进来)',
  271. )
  272. check('E3-color-is-parameter', red !== blue && red !== circle, '换颜色/换形状得到不同图片')
  273. check('E4-shape-markup-included', decoded.includes('<path d="M6 1.5 11.2 10.5H0.8z"/>'), '形状片段来自 SHAPES,不另画一套')
  274. }
  275. // ================================================================ F 不混用事件等级色
  276. {
  277. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  278. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  279. /**
  280. * 去掉注释只留代码。
  281. *
  282. * 为什么必须这样做:本文件的注释**故意**提到 `LEVEL_TAGS` 与 `#ff4d5e`
  283. * (解释「事件四级图例归票 13/19」「颜色由调用方传」),直接对全文做正则会把
  284. * 「讲解」判成「违规」——那就成了拿假阳性换通过率。断言只该管代码。
  285. */
  286. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  287. const pointStatusCode = code(read('domain/pointStatus.js'))
  288. const mapCanvasCode = code(read('components/MapCanvas.vue'))
  289. const engineCode = code(read('services/cesiumEngine.js'))
  290. // 事件四级的词汇(1级..4级)与 LEVEL_TAGS 属于票 13/19 的图例,不得进入点位状态词表。
  291. check(
  292. 'F1-no-event-level-words-in-point-status',
  293. !/[1-4]级/.test(pointStatusCode) &&
  294. !/LEVEL_TAGS/.test(pointStatusCode) &&
  295. !/from '\.\/statusTones\.js'/.test(pointStatusCode),
  296. 'pointStatus.js 的代码里没有事件等级词汇,也没引用 LEVEL_TAGS',
  297. )
  298. check(
  299. 'F2-map-legend-is-point-status-not-event-level',
  300. !/LEVEL_TAGS/.test(mapCanvasCode) &&
  301. /getPointMarkerState|legendRows/.test(mapCanvasCode) &&
  302. /data-map-legend/.test(mapCanvasCode),
  303. '地图图例改成点位状态图例(不再是事件四级),带 data-map-legend 供走查断言',
  304. )
  305. // 票 06 的单一口径:语义色只在 styles/tokens.css 声明。这里断言 domain 层
  306. // **不出现调色板的值、也不声明这些变量** —— 白色兜底不是调色板成员,故不在禁令内。
  307. const PALETTE = {
  308. '--lv1': '#ff4d5e',
  309. '--lv2': '#ffa63d',
  310. '--lv3': '#4fd8ff',
  311. '--lv4': '#35e39a',
  312. '--neutral': '#6d92ae',
  313. }
  314. const offenders = []
  315. for (const [file, text] of [
  316. ['domain/pointStatus.js', pointStatusCode],
  317. ['components/StatusTag.vue', code(read('components/StatusTag.vue'))],
  318. ]) {
  319. for (const [name, value] of Object.entries(PALETTE)) {
  320. // 允许 `var(--lv1, #ff4d5e)` 这种「读 token、带同值兜底」的写法(票 06 既有约定),
  321. // 只禁止**另起一份**调色板:裸色值出现且不在 var() 兜底位里。
  322. const bare = new RegExp(`(?<!var\\([^)]{0,40})${value.replace('#', '#')}`, 'i')
  323. if (bare.test(text) && !new RegExp(`var\\(${name},\\s*${value}`, 'i').test(text)) {
  324. offenders.push(`${file} 出现调色板裸色值 ${value}(${name})`)
  325. }
  326. if (new RegExp(`${name}\\s*:`).test(text)) offenders.push(`${file} 重复声明 ${name}`)
  327. }
  328. }
  329. check('F3-no-second-palette-declaration', offenders.length === 0, offenders.length ? offenders.join(' | ') : 'domain/组件层没有第二份语义色调色板')
  330. check(
  331. 'F3b-domain-has-no-palette-at-all',
  332. !Object.values(PALETTE).some((value) => pointStatusCode.includes(value)),
  333. 'pointStatus.js 的代码里连调色板色值都不出现(颜色全靠调用方传)',
  334. )
  335. // 服务层读 token 而不是写第二份色表。
  336. const mapServiceCode = code(read('services/mapService.js'))
  337. check(
  338. 'F4-colors-read-from-tokens',
  339. /getComputedStyle/.test(mapServiceCode) && /--\$\{tone\}/.test(mapServiceCode),
  340. '地图标记的颜色从 .cockpit-shell 的计算样式读 --lv1..--neutral',
  341. )
  342. check(
  343. 'F4b-engine-and-domain-never-read-css',
  344. !/getComputedStyle/.test(engineCode) && !/getComputedStyle/.test(pointStatusCode),
  345. '读样式只发生在服务层一处(引擎与 domain 都不碰 DOM)',
  346. )
  347. // 引擎层不许认识业务状态词(状态→视觉的映射只有 domain 一份)。
  348. check(
  349. 'F5-engine-knows-shape-not-status',
  350. !/ALARM|WORK_ORDER|报警/.test(engineCode) && /marker\.shape/.test(engineCode) && /marker\.tone/.test(engineCode),
  351. 'cesiumEngine 只认 shape/tone,不认识业务状态名',
  352. )
  353. }
  354. const failed = checks.filter((item) => !item.pass)
  355. console.log('')
  356. console.log(`T09 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  357. if (failed.length) {
  358. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  359. process.exit(1)
  360. }