| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396 |
- /**
- * T09 点位状态图例与标记 纯逻辑检查(票 09)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t09-point-status.mjs
- *
- * 六段:
- * A 状态词表:后端 `CockpitMonitoringPointStatus` 六态 + 未关联设备 + 认不出的状态;
- * 每个词条必须同时有**形状 + 色键 + 文字**(验收第 3 条:不只靠颜色)。
- * B 认不出的状态不得被猜成正常(前端不重算点位状态)。
- * C 标记计划:只收坐标准入通过的对象,被拦下的仍留在 `blocked`(不上图 ≠ 不存在)。
- * D 图例与标记一致(验收第 4 条):同一份 `byStatus` 生成,计数与形状/色键逐个对齐;
- * 并**故意注入不一致**,证明这条判据真的会红(不然它就是恒真的假绿)。
- * E 标记图片:形状 + 颜色烘焙进 SVG;色值由调用方传(本文件零硬编码颜色)。
- * F 静态断言:不把事件等级色混用为点位状态色(票 09 正文的硬约束)。
- *
- * 状态与中文标签的依据是**后端源码**而不是猜:
- * `E:\pipe-ner-server\...\cockpit\point\CockpitMonitoringPointStatus.java`
- * (ALARM 报警 1 / WARNING 预警 2 / WORK_ORDER 工单处置 3 / NORMAL 正常 4 / OFFLINE 离线 5 / EMPTY 无设备 6)。
- * 本脚本不复读该文件(跨仓库路径不是本仓库的契约),所以把六个取值与优先级写死在断言里 ——
- * 后端改词表时这里会红,那正是它该提醒的事。
- */
- import assert from 'node:assert/strict'
- import { readFileSync } from 'node:fs'
- import {
- POINT_STATUS,
- POINT_STATUS_VISUALS,
- SHAPES,
- UNASSOCIATED_VISUAL,
- UNKNOWN_VISUAL,
- isKnownShape,
- legendMatchesMarkers,
- legendVisuals,
- markerSvg,
- planPointMarkers,
- pointLegend,
- pointVisual,
- shapeSvg,
- summarizeMarkerStatuses,
- visualForStatus,
- } from '../../../src/modules/cockpit/domain/pointStatus.js'
- import { TONES, isKnownTone } from '../../../src/modules/cockpit/domain/statusTones.js'
- const checks = []
- function check(name, pass, evidence) {
- checks.push({ name, pass })
- console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
- }
- /** 造一个地图对象(形状照抄 `domain/mapSummary.js` 的领域对象)。 */
- const object = (code, status, extras = {}) => ({
- kind: 'monitoring-point',
- code,
- name: `${code} 点位`,
- status,
- statusReasons: [],
- longitude: 110.141,
- latitude: 28.452,
- coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
- ...extras,
- })
- /** 造一个不上图的对象(renderable=false)。 */
- const blockedObject = (code, status, reason = 'ZERO_COORDINATE') => ({
- ...object(code, status),
- longitude: 0,
- latitude: 0,
- coordinate: { state: 'INVALID', reason, crs: 'WGS84', renderable: false, validated: true },
- })
- // ================================================================ A 状态词表
- const BACKEND_STATUSES = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY']
- /** 后端 `CockpitMonitoringPointStatus` 的中文标签,逐字对照。 */
- const BACKEND_LABELS = {
- ALARM: '报警',
- WARNING: '预警',
- WORK_ORDER: '工单处置',
- NORMAL: '正常',
- OFFLINE: '离线',
- EMPTY: '无设备',
- }
- {
- check(
- 'A1-six-backend-statuses',
- BACKEND_STATUSES.every((key) => POINT_STATUS[key] === key && POINT_STATUS_VISUALS[key]),
- `六态 ${BACKEND_STATUSES.join(', ')} 都有词条`,
- )
- const mismatched = BACKEND_STATUSES.filter((key) => POINT_STATUS_VISUALS[key].label !== BACKEND_LABELS[key])
- check('A2-labels-match-backend', mismatched.length === 0, mismatched.length ? `不符:${mismatched.join(', ')}` : '中文标签与后端枚举逐字一致')
- // 图例按后端优先级排序:ALARM 最先。前端不自己定序。
- const priorities = BACKEND_STATUSES.map((key) => POINT_STATUS_VISUALS[key].priority)
- check(
- 'A3-priority-from-backend',
- priorities.join(',') === '1,2,3,4,5,6' &&
- POINT_STATUS_VISUALS.ALARM.priority === 1 &&
- POINT_STATUS_VISUALS.EMPTY.priority === 6,
- `优先级 ${priorities.join(',')}`,
- )
- // 验收第 3 条:每种颜色都有文字或形状辅助。
- const lacking = legendVisuals().filter(
- (visual) => !visual.label || !visual.shape || !isKnownShape(visual.shape) || !isKnownTone(visual.tone),
- )
- check(
- 'A4-every-visual-has-shape-tone-label',
- lacking.length === 0,
- lacking.length ? `缺件:${lacking.map((v) => v.key).join(', ')}` : `${legendVisuals().length} 条词条都是 形状+色键+文字`,
- )
- // 形状必须两两不同:形状是「不只靠颜色」里真正与颜色无关的那一半。
- const shapes = legendVisuals().map((visual) => visual.shape)
- check(
- 'A5-shapes-pairwise-distinct',
- new Set(shapes).size === shapes.length,
- `${shapes.length} 条词条用到 ${new Set(shapes).size} 种形状:${shapes.join(', ')}`,
- )
- // 同一个色键下的多个状态靠形状区分(OFFLINE / EMPTY / UNKNOWN / UNASSOCIATED 都是中性灰)。
- const neutralShapes = legendVisuals()
- .filter((visual) => visual.tone === 'neutral')
- .map((visual) => visual.shape)
- check(
- 'A6-neutral-group-split-by-shape',
- neutralShapes.length >= 2 && new Set(neutralShapes).size === neutralShapes.length,
- `中性灰 ${neutralShapes.length} 个状态用 ${new Set(neutralShapes).size} 种形状`,
- )
- const badTones = legendVisuals().filter((visual) => !TONES.includes(visual.tone))
- check('A7-tones-are-known-keys', badTones.length === 0, badTones.length ? badTones.map((v) => v.tone).join(', ') : `色键都取自 ${TONES.join('/')}`)
- check('A8-legend-covers-eight-rows', legendVisuals().length === 8, `图例 ${legendVisuals().length} 行(六态 + 状态未发布 + 无关联设备)`)
- check(
- 'A9-unknown-shape-safe',
- isKnownShape('nope') === false && shapeSvg('nope') === SHAPES.circle,
- '认不出的形状退回实心圆,不抛错也不留空',
- )
- }
- // ================================================================ B 前端不重算
- {
- check(
- 'B1-unknown-status-not-guessed-as-normal',
- visualForStatus('MAINTENANCE').key === UNKNOWN_VISUAL.key &&
- visualForStatus('MAINTENANCE').key !== POINT_STATUS.NORMAL &&
- visualForStatus('MAINTENANCE').label !== BACKEND_LABELS.NORMAL,
- `MAINTENANCE → ${visualForStatus('MAINTENANCE').key}「${visualForStatus('MAINTENANCE').label}」`,
- )
- check(
- 'B2-missing-status-not-guessed',
- visualForStatus('').key === UNKNOWN_VISUAL.key && visualForStatus(undefined).key === UNKNOWN_VISUAL.key,
- '没给状态时如实说「状态未发布」,不落成正常',
- )
- check(
- 'B3-lowercase-not-coerced',
- visualForStatus('normal').key === UNKNOWN_VISUAL.key,
- '后端发的是大写枚举;小写不当成同一个状态(不猜、不归一化)',
- )
- check(
- 'B4-unassociated-is-its-own-row',
- pointVisual({ kind: 'unassociated-equipment', status: '' }).key === UNASSOCIATED_VISUAL.key &&
- pointVisual({ kind: 'unassociated-equipment', status: 'NORMAL' }).key === UNASSOCIATED_VISUAL.key,
- '未关联设备不借用在线态(票 05 同一立场:0/NORMAL 都算伪造)',
- )
- check(
- 'B5-status-passed-through-not-derived',
- pointVisual(object('MP-1', 'ALARM')).key === 'ALARM' && pointVisual(object('MP-2', 'OFFLINE')).key === 'OFFLINE',
- '状态原样取自后端字段,前端不做任何推导',
- )
- }
- // ================================================================ C 标记计划
- {
- const objects = [
- object('MP-1', 'ALARM'),
- object('MP-2', 'NORMAL'),
- object('MP-3', 'WARNING'),
- { ...object('EQ-1', '', { kind: 'unassociated-equipment' }) },
- blockedObject('MP-4', 'ALARM'),
- blockedObject('EQ-2', '', { kind: 'unassociated-equipment' }),
- ]
- const plan = planPointMarkers(objects)
- check(
- 'C1-only-renderable-becomes-marker',
- plan.total === 4 && plan.markers.length === 4 && plan.blocked.length === 2,
- `标记 ${plan.total} · 拦下 ${plan.blocked.length}(共 ${objects.length} 个对象)`,
- )
- check(
- 'C2-blocked-kept-not-dropped',
- plan.blocked.map((item) => item.code).join(',') === 'MP-4,EQ-2',
- `拦下的仍在 blocked 里:${plan.blocked.map((item) => item.code).join(', ')}(不上图 ≠ 不存在)`,
- )
- check(
- 'C3-marker-carries-backend-status',
- plan.markers.every((marker) => marker.status === (objects.find((o) => o.code === marker.code) || {}).status) &&
- plan.markers.find((m) => m.code === 'MP-1').status === 'ALARM',
- '标记带后端原始状态串,供排查与后续票消费',
- )
- check(
- 'C4-by-status-counts',
- plan.byStatus.ALARM === 1 && plan.byStatus.NORMAL === 1 && plan.byStatus.WARNING === 1 && plan.byStatus.UNASSOCIATED === 1,
- JSON.stringify(plan.byStatus),
- )
- check(
- 'C5-marker-visual-matches-table',
- plan.markers.every((marker) => {
- const visual = legendVisuals().find((item) => item.key === marker.visualKey)
- return visual && visual.tone === marker.tone && visual.shape === marker.shape && visual.label === marker.label
- }),
- '每个标记的色键/形状/文字都能在词表里逐字对上(同一张表)',
- )
- check(
- 'C6-coordinate-criteria-not-reinvented',
- planPointMarkers(null).total === 0 && planPointMarkers(undefined).markers.length === 0 && planPointMarkers([]).byStatus && Object.keys(planPointMarkers([]).byStatus).length === 0,
- '空输入安全,且准入只走 domain/coordinate.js 的 isRenderable()',
- )
- const unknownObject = object('MP-9', 'MAINTENANCE')
- const withUnknown = planPointMarkers([unknownObject])
- check(
- 'C7-unknown-status-becomes-own-row',
- withUnknown.byStatus.UNKNOWN === 1 && withUnknown.markers[0].shape === UNKNOWN_VISUAL.shape,
- `认不出的状态自成一行,不落到真实状态上(${JSON.stringify(withUnknown.byStatus)})`,
- )
- }
- // ================================================================ D 图例与标记一致
- {
- const objects = [
- object('MP-1', 'ALARM'),
- object('MP-2', 'ALARM'),
- object('MP-3', 'WORK_ORDER'),
- object('MP-4', 'OFFLINE'),
- object('MP-5', 'EMPTY'),
- blockedObject('MP-6', 'NORMAL'),
- ]
- const plan = planPointMarkers(objects)
- const legend = pointLegend(plan.byStatus)
- const verdict = legendMatchesMarkers(legend, plan.markers)
- check('D1-legend-matches-markers', verdict.ok, verdict.problems.length ? verdict.problems.join(' | ') : '图例与标记逐行一致')
- check(
- 'D2-legend-counts-equal-markers',
- legend.reduce((sum, row) => sum + row.count, 0) === plan.markers.length && legend.length === 8,
- `图例合计 ${legend.reduce((sum, row) => sum + row.count, 0)} = 标记 ${plan.markers.length}`,
- )
- check(
- 'D3-legend-row-for-every-used-status',
- ['ALARM', 'WORK_ORDER', 'OFFLINE', 'EMPTY'].every((key) => legend.find((row) => row.key === key).count === plan.byStatus[key]),
- legend
- .filter((row) => row.count > 0)
- .map((row) => `${row.label} ${row.count}`)
- .join(' · '),
- )
- check(
- 'D4-zero-count-rows-still-listed',
- legend.find((row) => row.key === 'WARNING').count === 0 && legend.find((row) => row.key === 'WARNING').shape === 'diamond',
- '没出现的状态也留在图例里(形状的含义要能查到),只是计数为 0',
- )
- // 对抗式:故意把图例改坏,判据必须变红 —— 否则 D1 就是恒真的假绿。
- const wrongCount = legend.map((row) => (row.key === 'ALARM' ? { ...row, count: 99 } : row))
- const wrongShape = legend.map((row) => (row.key === 'ALARM' ? { ...row, shape: 'circle' } : row))
- const missingRow = legend.filter((row) => row.key !== 'ALARM')
- const verdicts = [wrongCount, wrongShape, missingRow].map((broken) => legendMatchesMarkers(broken, plan.markers))
- check(
- 'D5-inconsistency-detected',
- verdicts.every((item) => item.ok === false),
- `计数错/形状错/缺行 三类篡改都被抓到:${verdicts.map((item) => item.problems.length).join(', ')} 条问题`,
- )
- check(
- 'D6-summary-matches-plan',
- JSON.stringify(summarizeMarkerStatuses(plan.markers)) === JSON.stringify(plan.byStatus),
- 'byStatus 就是按标记算出来的,不是另算一份',
- )
- }
- // ================================================================ E 标记图片
- {
- const red = markerSvg('triangle', '#ff4d5e')
- const blue = markerSvg('triangle', '#4fd8ff')
- const circle = markerSvg('circle', '#ff4d5e')
- check('E1-svg-data-url', red.startsWith('data:image/svg+xml;charset=utf-8,'), red.slice(0, 40))
- const decoded = decodeURIComponent(red.slice('data:image/svg+xml;charset=utf-8,'.length))
- check(
- 'E2-color-baked-in',
- decoded.includes('#ff4d5e') && decoded.includes('viewBox="0 0 12 12"'),
- '颜色烘焙进图片(billboard 读不到页面 CSS 变量,必须由服务层传色进来)',
- )
- check('E3-color-is-parameter', red !== blue && red !== circle, '换颜色/换形状得到不同图片')
- check('E4-shape-markup-included', decoded.includes('<path d="M6 1.5 11.2 10.5H0.8z"/>'), '形状片段来自 SHAPES,不另画一套')
- }
- // ================================================================ F 不混用事件等级色
- {
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
- /**
- * 去掉注释只留代码。
- *
- * 为什么必须这样做:本文件的注释**故意**提到 `LEVEL_TAGS` 与 `#ff4d5e`
- * (解释「事件四级图例归票 13/19」「颜色由调用方传」),直接对全文做正则会把
- * 「讲解」判成「违规」——那就成了拿假阳性换通过率。断言只该管代码。
- */
- const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const pointStatusCode = code(read('domain/pointStatus.js'))
- const mapCanvasCode = code(read('components/MapCanvas.vue'))
- const engineCode = code(read('services/cesiumEngine.js'))
- // 事件四级的词汇(1级..4级)与 LEVEL_TAGS 属于票 13/19 的图例,不得进入点位状态词表。
- check(
- 'F1-no-event-level-words-in-point-status',
- !/[1-4]级/.test(pointStatusCode) &&
- !/LEVEL_TAGS/.test(pointStatusCode) &&
- !/from '\.\/statusTones\.js'/.test(pointStatusCode),
- 'pointStatus.js 的代码里没有事件等级词汇,也没引用 LEVEL_TAGS',
- )
- check(
- 'F2-map-legend-is-point-status-not-event-level',
- !/LEVEL_TAGS/.test(mapCanvasCode) &&
- /getPointMarkerState|legendRows/.test(mapCanvasCode) &&
- /data-map-legend/.test(mapCanvasCode),
- '地图图例改成点位状态图例(不再是事件四级),带 data-map-legend 供走查断言',
- )
- // 票 06 的单一口径:语义色只在 styles/tokens.css 声明。这里断言 domain 层
- // **不出现调色板的值、也不声明这些变量** —— 白色兜底不是调色板成员,故不在禁令内。
- const PALETTE = {
- '--lv1': '#ff4d5e',
- '--lv2': '#ffa63d',
- '--lv3': '#4fd8ff',
- '--lv4': '#35e39a',
- '--neutral': '#6d92ae',
- }
- const offenders = []
- for (const [file, text] of [
- ['domain/pointStatus.js', pointStatusCode],
- ['components/StatusTag.vue', code(read('components/StatusTag.vue'))],
- ]) {
- for (const [name, value] of Object.entries(PALETTE)) {
- // 允许 `var(--lv1, #ff4d5e)` 这种「读 token、带同值兜底」的写法(票 06 既有约定),
- // 只禁止**另起一份**调色板:裸色值出现且不在 var() 兜底位里。
- const bare = new RegExp(`(?<!var\\([^)]{0,40})${value.replace('#', '#')}`, 'i')
- if (bare.test(text) && !new RegExp(`var\\(${name},\\s*${value}`, 'i').test(text)) {
- offenders.push(`${file} 出现调色板裸色值 ${value}(${name})`)
- }
- if (new RegExp(`${name}\\s*:`).test(text)) offenders.push(`${file} 重复声明 ${name}`)
- }
- }
- check('F3-no-second-palette-declaration', offenders.length === 0, offenders.length ? offenders.join(' | ') : 'domain/组件层没有第二份语义色调色板')
- check(
- 'F3b-domain-has-no-palette-at-all',
- !Object.values(PALETTE).some((value) => pointStatusCode.includes(value)),
- 'pointStatus.js 的代码里连调色板色值都不出现(颜色全靠调用方传)',
- )
- // 服务层读 token 而不是写第二份色表。
- const mapServiceCode = code(read('services/mapService.js'))
- check(
- 'F4-colors-read-from-tokens',
- /getComputedStyle/.test(mapServiceCode) && /--\$\{tone\}/.test(mapServiceCode),
- '地图标记的颜色从 .cockpit-shell 的计算样式读 --lv1..--neutral',
- )
- check(
- 'F4b-engine-and-domain-never-read-css',
- !/getComputedStyle/.test(engineCode) && !/getComputedStyle/.test(pointStatusCode),
- '读样式只发生在服务层一处(引擎与 domain 都不碰 DOM)',
- )
- // 引擎层不许认识业务状态词(状态→视觉的映射只有 domain 一份)。
- check(
- 'F5-engine-knows-shape-not-status',
- !/ALARM|WORK_ORDER|报警/.test(engineCode) && /marker\.shape/.test(engineCode) && /marker\.tone/.test(engineCode),
- 'cesiumEngine 只认 shape/tone,不认识业务状态名',
- )
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T09 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:' + failed.map((item) => item.name).join(', '))
- process.exit(1)
- }
|