/** * 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(''), '形状片段来自 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(`(? 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) }