t09-point-status.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  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. // 同一个色键下的多个状态靠形状区分(OFFLINE / EMPTY / UNKNOWN / UNASSOCIATED 都是中性灰)。
  112. const neutralShapes = legendVisuals()
  113. .filter((visual) => visual.tone === 'neutral')
  114. .map((visual) => visual.shape)
  115. check(
  116. 'A6-neutral-group-split-by-shape',
  117. neutralShapes.length >= 2 && new Set(neutralShapes).size === neutralShapes.length,
  118. `中性灰 ${neutralShapes.length} 个状态用 ${new Set(neutralShapes).size} 种形状`,
  119. )
  120. const badTones = legendVisuals().filter((visual) => !TONES.includes(visual.tone))
  121. check('A7-tones-are-known-keys', badTones.length === 0, badTones.length ? badTones.map((v) => v.tone).join(', ') : `色键都取自 ${TONES.join('/')}`)
  122. check('A8-legend-covers-eight-rows', legendVisuals().length === 8, `图例 ${legendVisuals().length} 行(六态 + 状态未发布 + 无关联设备)`)
  123. check(
  124. 'A9-unknown-shape-safe',
  125. isKnownShape('nope') === false && shapeSvg('nope') === SHAPES.circle,
  126. '认不出的形状退回实心圆,不抛错也不留空',
  127. )
  128. }
  129. // ================================================================ B 前端不重算
  130. {
  131. check(
  132. 'B1-unknown-status-not-guessed-as-normal',
  133. visualForStatus('MAINTENANCE').key === UNKNOWN_VISUAL.key &&
  134. visualForStatus('MAINTENANCE').key !== POINT_STATUS.NORMAL &&
  135. visualForStatus('MAINTENANCE').label !== BACKEND_LABELS.NORMAL,
  136. `MAINTENANCE → ${visualForStatus('MAINTENANCE').key}「${visualForStatus('MAINTENANCE').label}」`,
  137. )
  138. check(
  139. 'B2-missing-status-not-guessed',
  140. visualForStatus('').key === UNKNOWN_VISUAL.key && visualForStatus(undefined).key === UNKNOWN_VISUAL.key,
  141. '没给状态时如实说「状态未发布」,不落成正常',
  142. )
  143. check(
  144. 'B3-lowercase-not-coerced',
  145. visualForStatus('normal').key === UNKNOWN_VISUAL.key,
  146. '后端发的是大写枚举;小写不当成同一个状态(不猜、不归一化)',
  147. )
  148. check(
  149. 'B4-unassociated-is-its-own-row',
  150. pointVisual({ kind: 'unassociated-equipment', status: '' }).key === UNASSOCIATED_VISUAL.key &&
  151. pointVisual({ kind: 'unassociated-equipment', status: 'NORMAL' }).key === UNASSOCIATED_VISUAL.key,
  152. '未关联设备不借用在线态(票 05 同一立场:0/NORMAL 都算伪造)',
  153. )
  154. check(
  155. 'B5-status-passed-through-not-derived',
  156. pointVisual(object('MP-1', 'ALARM')).key === 'ALARM' && pointVisual(object('MP-2', 'OFFLINE')).key === 'OFFLINE',
  157. '状态原样取自后端字段,前端不做任何推导',
  158. )
  159. }
  160. // ================================================================ C 标记计划
  161. {
  162. const objects = [
  163. object('MP-1', 'ALARM'),
  164. object('MP-2', 'NORMAL'),
  165. object('MP-3', 'WARNING'),
  166. { ...object('EQ-1', '', { kind: 'unassociated-equipment' }) },
  167. blockedObject('MP-4', 'ALARM'),
  168. blockedObject('EQ-2', '', { kind: 'unassociated-equipment' }),
  169. ]
  170. const plan = planPointMarkers(objects)
  171. check(
  172. 'C1-only-renderable-becomes-marker',
  173. plan.total === 4 && plan.markers.length === 4 && plan.blocked.length === 2,
  174. `标记 ${plan.total} · 拦下 ${plan.blocked.length}(共 ${objects.length} 个对象)`,
  175. )
  176. check(
  177. 'C2-blocked-kept-not-dropped',
  178. plan.blocked.map((item) => item.code).join(',') === 'MP-4,EQ-2',
  179. `拦下的仍在 blocked 里:${plan.blocked.map((item) => item.code).join(', ')}(不上图 ≠ 不存在)`,
  180. )
  181. check(
  182. 'C3-marker-carries-backend-status',
  183. plan.markers.every((marker) => marker.status === (objects.find((o) => o.code === marker.code) || {}).status) &&
  184. plan.markers.find((m) => m.code === 'MP-1').status === 'ALARM',
  185. '标记带后端原始状态串,供排查与后续票消费',
  186. )
  187. check(
  188. 'C4-by-status-counts',
  189. plan.byStatus.ALARM === 1 && plan.byStatus.NORMAL === 1 && plan.byStatus.WARNING === 1 && plan.byStatus.UNASSOCIATED === 1,
  190. JSON.stringify(plan.byStatus),
  191. )
  192. check(
  193. 'C5-marker-visual-matches-table',
  194. plan.markers.every((marker) => {
  195. const visual = legendVisuals().find((item) => item.key === marker.visualKey)
  196. return visual && visual.tone === marker.tone && visual.shape === marker.shape && visual.label === marker.label
  197. }),
  198. '每个标记的色键/形状/文字都能在词表里逐字对上(同一张表)',
  199. )
  200. check(
  201. 'C6-coordinate-criteria-not-reinvented',
  202. planPointMarkers(null).total === 0 && planPointMarkers(undefined).markers.length === 0 && planPointMarkers([]).byStatus && Object.keys(planPointMarkers([]).byStatus).length === 0,
  203. '空输入安全,且准入只走 domain/coordinate.js 的 isRenderable()',
  204. )
  205. const unknownObject = object('MP-9', 'MAINTENANCE')
  206. const withUnknown = planPointMarkers([unknownObject])
  207. check(
  208. 'C7-unknown-status-becomes-own-row',
  209. withUnknown.byStatus.UNKNOWN === 1 && withUnknown.markers[0].shape === UNKNOWN_VISUAL.shape,
  210. `认不出的状态自成一行,不落到真实状态上(${JSON.stringify(withUnknown.byStatus)})`,
  211. )
  212. }
  213. // ================================================================ D 图例与标记一致
  214. {
  215. const objects = [
  216. object('MP-1', 'ALARM'),
  217. object('MP-2', 'ALARM'),
  218. object('MP-3', 'WORK_ORDER'),
  219. object('MP-4', 'OFFLINE'),
  220. object('MP-5', 'EMPTY'),
  221. blockedObject('MP-6', 'NORMAL'),
  222. ]
  223. const plan = planPointMarkers(objects)
  224. const legend = pointLegend(plan.byStatus)
  225. const verdict = legendMatchesMarkers(legend, plan.markers)
  226. check('D1-legend-matches-markers', verdict.ok, verdict.problems.length ? verdict.problems.join(' | ') : '图例与标记逐行一致')
  227. check(
  228. 'D2-legend-counts-equal-markers',
  229. legend.reduce((sum, row) => sum + row.count, 0) === plan.markers.length && legend.length === 8,
  230. `图例合计 ${legend.reduce((sum, row) => sum + row.count, 0)} = 标记 ${plan.markers.length}`,
  231. )
  232. check(
  233. 'D3-legend-row-for-every-used-status',
  234. ['ALARM', 'WORK_ORDER', 'OFFLINE', 'EMPTY'].every((key) => legend.find((row) => row.key === key).count === plan.byStatus[key]),
  235. legend
  236. .filter((row) => row.count > 0)
  237. .map((row) => `${row.label} ${row.count}`)
  238. .join(' · '),
  239. )
  240. check(
  241. 'D4-zero-count-rows-still-listed',
  242. legend.find((row) => row.key === 'WARNING').count === 0 && legend.find((row) => row.key === 'WARNING').shape === 'diamond',
  243. '没出现的状态也留在图例里(形状的含义要能查到),只是计数为 0',
  244. )
  245. // 对抗式:故意把图例改坏,判据必须变红 —— 否则 D1 就是恒真的假绿。
  246. const wrongCount = legend.map((row) => (row.key === 'ALARM' ? { ...row, count: 99 } : row))
  247. const wrongShape = legend.map((row) => (row.key === 'ALARM' ? { ...row, shape: 'circle' } : row))
  248. const missingRow = legend.filter((row) => row.key !== 'ALARM')
  249. const verdicts = [wrongCount, wrongShape, missingRow].map((broken) => legendMatchesMarkers(broken, plan.markers))
  250. check(
  251. 'D5-inconsistency-detected',
  252. verdicts.every((item) => item.ok === false),
  253. `计数错/形状错/缺行 三类篡改都被抓到:${verdicts.map((item) => item.problems.length).join(', ')} 条问题`,
  254. )
  255. check(
  256. 'D6-summary-matches-plan',
  257. JSON.stringify(summarizeMarkerStatuses(plan.markers)) === JSON.stringify(plan.byStatus),
  258. 'byStatus 就是按标记算出来的,不是另算一份',
  259. )
  260. }
  261. // ================================================================ E 标记图片
  262. {
  263. const red = markerSvg('triangle', '#ff4d5e')
  264. const blue = markerSvg('triangle', '#4fd8ff')
  265. const circle = markerSvg('circle', '#ff4d5e')
  266. check('E1-svg-data-url', red.startsWith('data:image/svg+xml;charset=utf-8,'), red.slice(0, 40))
  267. const decoded = decodeURIComponent(red.slice('data:image/svg+xml;charset=utf-8,'.length))
  268. check(
  269. 'E2-color-baked-in',
  270. decoded.includes('#ff4d5e') && decoded.includes('viewBox="0 0 12 12"'),
  271. '颜色烘焙进图片(billboard 读不到页面 CSS 变量,必须由服务层传色进来)',
  272. )
  273. check('E3-color-is-parameter', red !== blue && red !== circle, '换颜色/换形状得到不同图片')
  274. check('E4-shape-markup-included', decoded.includes('<path d="M6 1.5 11.2 10.5H0.8z"/>'), '形状片段来自 SHAPES,不另画一套')
  275. }
  276. // ================================================================ F 不混用事件等级色
  277. {
  278. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  279. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  280. /**
  281. * 去掉注释只留代码。
  282. *
  283. * 为什么必须这样做:本文件的注释**故意**提到 `LEVEL_TAGS` 与 `#ff4d5e`
  284. * (解释「事件四级图例归票 13/19」「颜色由调用方传」),直接对全文做正则会把
  285. * 「讲解」判成「违规」——那就成了拿假阳性换通过率。断言只该管代码。
  286. */
  287. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  288. const pointStatusCode = code(read('domain/pointStatus.js'))
  289. const mapCanvasCode = code(read('components/MapCanvas.vue'))
  290. const engineCode = code(read('services/cesiumEngine.js'))
  291. // 事件四级的词汇(1级..4级)与 LEVEL_TAGS 属于票 13/19 的图例,不得进入点位状态词表。
  292. check(
  293. 'F1-no-event-level-words-in-point-status',
  294. !/[1-4]级/.test(pointStatusCode) &&
  295. !/LEVEL_TAGS/.test(pointStatusCode) &&
  296. !/from '\.\/statusTones\.js'/.test(pointStatusCode),
  297. 'pointStatus.js 的代码里没有事件等级词汇,也没引用 LEVEL_TAGS',
  298. )
  299. check(
  300. 'F2-map-legend-is-point-status-not-event-level',
  301. !/LEVEL_TAGS/.test(mapCanvasCode) &&
  302. /getPointMarkerState|legendRows/.test(mapCanvasCode) &&
  303. /data-map-legend/.test(mapCanvasCode),
  304. '地图图例改成点位状态图例(不再是事件四级),带 data-map-legend 供走查断言',
  305. )
  306. // 票 06 的单一口径:语义色只在 styles/tokens.css 声明。这里断言 domain 层
  307. // **不出现调色板的值、也不声明这些变量** —— 白色兜底不是调色板成员,故不在禁令内。
  308. const PALETTE = {
  309. '--lv1': '#ff4d5e',
  310. '--lv2': '#ffa63d',
  311. '--lv3': '#4fd8ff',
  312. '--lv4': '#35e39a',
  313. '--neutral': '#6d92ae',
  314. }
  315. const offenders = []
  316. for (const [file, text] of [
  317. ['domain/pointStatus.js', pointStatusCode],
  318. ['components/StatusTag.vue', code(read('components/StatusTag.vue'))],
  319. ]) {
  320. for (const [name, value] of Object.entries(PALETTE)) {
  321. // 允许 `var(--lv1, #ff4d5e)` 这种「读 token、带同值兜底」的写法(票 06 既有约定),
  322. // 只禁止**另起一份**调色板:裸色值出现且不在 var() 兜底位里。
  323. const bare = new RegExp(`(?<!var\\([^)]{0,40})${value.replace('#', '#')}`, 'i')
  324. if (bare.test(text) && !new RegExp(`var\\(${name},\\s*${value}`, 'i').test(text)) {
  325. offenders.push(`${file} 出现调色板裸色值 ${value}(${name})`)
  326. }
  327. if (new RegExp(`${name}\\s*:`).test(text)) offenders.push(`${file} 重复声明 ${name}`)
  328. }
  329. }
  330. check('F3-no-second-palette-declaration', offenders.length === 0, offenders.length ? offenders.join(' | ') : 'domain/组件层没有第二份语义色调色板')
  331. check(
  332. 'F3b-domain-has-no-palette-at-all',
  333. !Object.values(PALETTE).some((value) => pointStatusCode.includes(value)),
  334. 'pointStatus.js 的代码里连调色板色值都不出现(颜色全靠调用方传)',
  335. )
  336. // 服务层读 token 而不是写第二份色表。
  337. const mapServiceCode = code(read('services/mapService.js'))
  338. check(
  339. 'F4-colors-read-from-tokens',
  340. /getComputedStyle/.test(mapServiceCode) && /--\$\{tone\}/.test(mapServiceCode),
  341. '地图标记的颜色从 .cockpit-shell 的计算样式读 --lv1..--neutral',
  342. )
  343. check(
  344. 'F4b-engine-and-domain-never-read-css',
  345. !/getComputedStyle/.test(engineCode) && !/getComputedStyle/.test(pointStatusCode),
  346. '读样式只发生在服务层一处(引擎与 domain 都不碰 DOM)',
  347. )
  348. // 引擎层不许认识业务状态词(状态→视觉的映射只有 domain 一份)。
  349. check(
  350. 'F5-engine-knows-shape-not-status',
  351. !/ALARM|WORK_ORDER|报警/.test(engineCode) && /marker\.shape/.test(engineCode) && /marker\.tone/.test(engineCode),
  352. 'cesiumEngine 只认 shape/tone,不认识业务状态名',
  353. )
  354. }
  355. const failed = checks.filter((item) => !item.pass)
  356. console.log('')
  357. console.log(`T09 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  358. if (failed.length) {
  359. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  360. process.exit(1)
  361. }