| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727 |
- /**
- * T20 总体概览模式面板 纯逻辑检查(票 20)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t20-overview-panels.mjs
- *
- * 六段:
- * A 面板映射与注册:四块与 ADR 0003 目录一致、不越界接其它模式、注册表覆盖四个模式共 16 块
- * B 契约适配:三份载荷翻领域对象、BigDecimal 字符串、缺失记账、工单状态 0–7 全铺
- * C 视图层:验收点名的画面元素逐项在场;分母为 0 显示占位符而不是 0.0%;缺口如实说明
- * D 分区独立:三块各自成败、一块坏不遮其它两块 + 对抗式反证;态势总览由三块合成
- * E 联动:指标点击 → 切模式;行业行 → 写全局行业筛选(都走 store 的唯一入口)
- * F 静态守卫:domain 纯函数、渲染层不认识 panelId、不拼路径、外壳走同一接缝
- *
- * 契约依据:后端 `CockpitOverview{Infrastructure,Monitoring,WarningSettings}Response`
- * 与三个 Service(`E:\pipe-ner-server\...\cockpit\overview\`)。
- */
- import { readFileSync } from 'node:fs'
- import { createPinia, setActivePinia } from 'pinia'
- import { columnsForMode, panelsForMode } from '../../../src/modules/cockpit/domain/panelCatalog.js'
- import {
- OVERVIEW_PANEL_IDS,
- MONITORING_FAULT_GAP,
- toOverviewInfrastructure,
- toOverviewMonitoring,
- toOverviewWarningSettings,
- toOverviewRegions,
- situationSummaryPanel,
- infrastructureSummaryPanel,
- monitoringSummaryPanel,
- alertSettingsPanel,
- OVERVIEW_PANEL_BUILDERS,
- } from '../../../src/modules/cockpit/domain/overviewPanels.js'
- import { hasPanelView, implementedPanelIds, panelView } from '../../../src/modules/cockpit/domain/panelViews.js'
- import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
- import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
- import { OVERVIEW_PATHS, OVERVIEW_RANGE } from '../../../src/modules/cockpit/services/overviewApi.js'
- import { WORK_ORDER_STATES } from '../../../src/modules/cockpit/domain/workOrder.js'
- import { useOverviewStore } from '../../../src/modules/cockpit/stores/overviewStore.js'
- import { useUiStore } from '../../../src/modules/cockpit/stores/uiStore.js'
- import { useFilterStore } from '../../../src/modules/cockpit/stores/filterStore.js'
- const checks = []
- function check(name, pass, evidence = '') {
- checks.push(pass)
- console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
- }
- const ready = (data) => ({ ...section(SECTION_STATE.SUCCESS, { data }), loaded: true })
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (path) => readFileSync(new URL(path, moduleRoot), 'utf8')
- // ================================================================ A 面板映射与注册
- {
- check(
- 'A1-four-overview-panels-match-catalog',
- panelsForMode('overview').join(',') === 'situationSummary,infrastructureSummary,monitoringSummary,alertSettings',
- `目录:${panelsForMode('overview').join(' / ')}`,
- )
- const columns = columnsForMode('overview')
- check(
- 'A2-columns-are-two-and-two',
- columns.left.join(',') === 'situationSummary,infrastructureSummary' && columns.right.join(',') === 'monitoringSummary,alertSettings',
- `左栏 ${columns.left.join('/')} · 右栏 ${columns.right.join('/')}`,
- )
- check(
- 'A3-builders-cover-exactly-the-four',
- OVERVIEW_PANEL_IDS.join(',') === 'situationSummary,infrastructureSummary,monitoringSummary,alertSettings' &&
- Object.keys(OVERVIEW_PANEL_BUILDERS).join(',') === OVERVIEW_PANEL_IDS.join(','),
- `构造器与目录逐项一致(${Object.keys(OVERVIEW_PANEL_BUILDERS).length} 个)`,
- )
- check(
- 'A4-registry-covers-all-four-modes',
- OVERVIEW_PANEL_IDS.every(hasPanelView) &&
- ['archive', 'operation', 'management', 'emergency'].every(hasPanelView) &&
- ['monitoringOverview', 'monitoringDistribution', 'operationOverview', 'monitoringPointLedger'].every(hasPanelView) &&
- ['historyAnalysis', 'responseEfficiency', 'eventAnalysis'].every(hasPanelView),
- `已接面板共 ${implementedPanelIds().length} 块(本票把四个模式都接上了)`,
- )
- check(
- 'A5-unknown-panel-still-returns-null',
- panelView('notAPanel', {}, ready({})) === null && hasPanelView('notAPanel') === false,
- '未注册的 panelId 仍回 null(调用方画占位),注册表不会假装认识它',
- )
- check(
- 'A6-overview-does-not-claim-other-modes-panels',
- !OVERVIEW_PANEL_IDS.includes('archive') && !OVERVIEW_PANEL_IDS.includes('currentEvents') && !OVERVIEW_PANEL_IDS.includes('monitoringOverview'),
- '本票只接总体概览那四块,不越界接其它模式的面板',
- )
- }
- // ================================================================ B 契约适配
- {
- const infra = toOverviewInfrastructure({
- updatedAt: '2026-09-29T10:00:00+08:00',
- totals: { facilityCount: 5, smartDeviceCount: 9 },
- industries: [
- { code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4, facilityTypes: [{ code: 'PIPE', name: '燃气管线', count: 2, lengthKm: '12.4', scale: 'medium' }] },
- { code: 'DRAINAGE', key: 'drainage', facilityCount: 1, smartDeviceCount: 2 },
- ],
- excluded: { unknownTypeCount: 1, typeKindMismatchCount: 0 },
- })
- check(
- 'B1-infrastructure-totals-and-industries',
- infra.facilityCount === 5 && infra.smartDeviceCount === 9 && infra.industries.length === 2 && infra.excluded.unknownTypeCount === 1,
- `设施 ${infra.facilityCount} · 智能设备 ${infra.smartDeviceCount} · 行业 ${infra.industries.length}`,
- )
- // BigDecimal 在线上是字符串(`@JsonFormat(shape=STRING)`):必须认字符串,不能变假 0。
- // 票 38(第 2 条):`facilityTypes[].scale` 不再透传——真实值是 "km"(长度单位)不是规模语义,
- // 长度已由 lengthKm 承担,界面零「规模」展示位;留字段只会诱导出第二次误用。
- check(
- 'B1b-bigdecimal-string-is-read',
- infra.industries[0].facilityTypes[0].lengthKm === 12.4 && !('scale' in infra.industries[0].facilityTypes[0]),
- `lengthKm "12.4" → ${infra.industries[0].facilityTypes[0].lengthKm}(字符串被认下,不是 0;规模已随票 38 退役)`,
- )
- check(
- 'B1c-industry-label-falls-back-to-dictionary',
- infra.industries[1].label === '排水',
- `缺 label 的行业按码翻:DRAINAGE → ${infra.industries[1].label}(不显示裸码)`,
- )
- // 本端点**没有** regionDistribution:这是契约现状,不是漏读。
- check(
- 'B1d-overview-infrastructure-has-no-regions',
- infra.regions === undefined,
- '`/overview/infrastructure` 不含区域分布(区域走票 17 的 base-archives,见文件头第 ② 条)',
- )
- const monitoring = toOverviewMonitoring({
- counts: {
- deviceCount: 9,
- monitoringPointCount: 7,
- monitoringFacilityCount: 3,
- infrastructureFacilityCount: 5,
- status: { online: 6, offline: 1, stale: 1, unknownOrMissing: 1, denominator: 8 },
- onlineRate: { value: '0.7500', onlineCount: 6, denominator: 8, state: 'CURRENT' },
- },
- metricFreshness: { status: 'CURRENT', updatedAt: '2026-09-29T09:59:00+08:00' },
- formalScope: { industries: ['GAS', 'WATER_SUPPLY'], keys: ['gas', 'water'] },
- typeDistribution: [{ industry: { code: 'GAS', label: '燃气' }, deviceType: { code: 'PIPE', name: '燃气管线' }, deviceCount: 4 }],
- excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 },
- })
- check(
- 'B2-monitoring-counts-and-four-status-buckets',
- monitoring.deviceCount === 9 && monitoring.onlineRate === 0.75 && monitoring.deviceStatus.online === 6 &&
- monitoring.deviceStatus.offline === 1 && monitoring.deviceStatus.stale === 1 && monitoring.deviceStatus.unknownOrMissing === 1,
- `四态桶:在线 ${monitoring.deviceStatus.online} / 离线 ${monitoring.deviceStatus.offline} / 滞后 ${monitoring.deviceStatus.stale} / 未知 ${monitoring.deviceStatus.unknownOrMissing}`,
- )
- // 验收第 3 条点名的「故障统计」在契约里**不存在**独立桶 —— 适配层不得凭空造一个。
- check(
- 'B2b-no-fault-bucket-is-invented',
- monitoring.deviceStatus.fault === undefined && !('fault' in monitoring.deviceStatus),
- '不发明「故障」桶(后端只有 online/offline/stale/unknownOrMissing 四态)',
- )
- check(
- 'B2c-metric-freshness-is-kept',
- monitoring.metricFreshness.updatedAt === '2026-09-29T09:59:00+08:00' && monitoring.metricFreshness.status === 'CURRENT',
- '逐设备最近采集时间在本端点上(票 18 的 I2/BT-8 缺口在这里有值)',
- )
- check(
- 'B2d-formal-scope-is-kept',
- monitoring.formalIndustries.join(',') === 'GAS,WATER_SUPPLY',
- `正式行业范围回显:${monitoring.formalIndustries.join(',')}`,
- )
- // 票 42 起两族口径分叉:预警族只给自身生命周期(工单口径六字段 null),
- // 报警族保留工单口径并存 alarm_status 生命周期(后端 b047a60d)。
- const warnings = toOverviewWarningSettings({
- updatedAt: '2026-09-29T10:00:00+08:00',
- displayName: '预警设置',
- selectedMode: 'WARNING',
- range: { type: 'LAST_7_DAYS', start: '2026-09-23T00:00:00+08:00', end: '2026-09-30T00:00:00+08:00' },
- warning: {
- eventFamily: 'WARNING', state: 'READY', stateReason: 'WARNINGS_RESOLVED', total: 8,
- handled: null, pending: null, cancelledCount: null, unlinkedEventCount: null, invalidWorkOrderStateCount: null,
- handlingEfficiency: { numerator: 5, denominator: 8, value: '0.6250', state: 'AVAILABLE', reason: null },
- workOrderStates: null,
- statusDistribution: [
- { code: 'DRAFT', label: '草稿', count: 0 }, { code: 'PENDING', label: '待办', count: 1 },
- { code: 'RECEIVED', label: '已接收', count: 0 }, { code: 'PROCESSING', label: '处置中', count: 1 },
- { code: 'HANDLED', label: '已处置', count: 1 }, { code: 'CLOSED', label: '已解除', count: 5 },
- ],
- levelDistribution: [
- { code: '1', label: '1级', count: 2 }, { code: '2', label: '2级', count: 3 },
- { code: '3', label: '3级', count: 2 }, { code: '4', label: '4级', count: 1 },
- ],
- industryDistribution: [
- { code: 'GAS', label: '燃气', count: 3 }, { code: 'WATER_SUPPLY', label: '供水', count: 2 },
- { code: 'DRAINAGE', label: '排水', count: 2 }, { code: 'MANHOLE', label: '窨井', count: 0 },
- { code: 'UNKNOWN', label: '未知', count: 1 },
- ],
- districtDistribution: [
- { code: '0', label: '城南', count: 3 }, { code: '1', label: '城北', count: 2 },
- { code: '2', label: '太常', count: 1 }, { code: 'UNASSIGNED', label: '未分区', count: 2 },
- ],
- trend: [{ bucket: '2026-09-28', total: 3, handled: 2 }],
- },
- alarm: {
- eventFamily: 'ALARM', state: 'READY', stateReason: 'EVENTS_RESOLVED', total: 9, handled: 6, pending: 2, cancelledCount: 1, unlinkedEventCount: 0,
- handlingEfficiency: { numerator: 6, denominator: 9, value: '0.6667', state: 'AVAILABLE', reason: null },
- workOrderStates: { 0: 1, 5: 2 },
- statusDistribution: [
- { code: 'UNHANDLED', label: '未处理', count: 3 }, { code: 'HANDLED', label: '已处理', count: 6 },
- ],
- levelDistribution: [], industryDistribution: [], districtDistribution: [],
- trend: [{ bucket: '2026-09-28', total: 4, handled: 3 }],
- },
- })
- check(
- 'B3-both-families-come-from-one-payload',
- warnings.warning.total === 8 && warnings.alarm.total === 9 && warnings.warning.family === 'WARNING' && warnings.alarm.family === 'ALARM',
- `一次取数同时给两族:预警 ${warnings.warning.total} 条 / 报警 ${warnings.alarm.total} 条(故不需要切换)`,
- )
- check(
- 'B3b-efficiency-carries-state-alongside-value',
- warnings.warning.efficiency.handlingRate === 0.625 && warnings.warning.efficiency.handlingState === 'AVAILABLE',
- '处置率连 `state` 一起带出("分母为 0"与"真的是 0%"必须分得开)',
- )
- // 工单状态分布(票 42 起只在**报警族**):0–7 全铺,缺的态补 0(0 是真实计数,不是"没有数据")。
- check(
- 'B3c-work-order-states-cover-all-eight-codes',
- warnings.alarm.workOrderStates.length === 8 &&
- warnings.alarm.workOrderStates.map((row) => row.code).join(',') === '0,1,2,3,4,5,6,7' &&
- warnings.alarm.workOrderStates.find((row) => row.code === '5').count === 2 &&
- warnings.alarm.workOrderStates.find((row) => row.code === '1').count === 0,
- `0–7 全铺(8 个码):${warnings.alarm.workOrderStates.map((row) => `${row.code}:${row.count}`).join(' ')}`,
- )
- // 票 42:预警族工单口径字段原样 null —— 「不适用」绝不 numberish 成 0、也不补假零条。
- const warningStatusRows = warnings.warning.statusDistribution || []
- check(
- 'B3e-warning-family-carries-null-not-zeros',
- warnings.warning.workOrderStates === null && warnings.warning.handled === null && warnings.warning.pending === null &&
- warningStatusRows.length === 6 && warningStatusRows.reduce((total, row) => total + row.count, 0) === warnings.warning.total,
- `预警族工单口径全 null、状态六桶自洽(和=总数 ${warnings.warning.total})`,
- )
- // 票 42:分布桶直读不翻码 —— 后端给的 label 原样带到领域层(含 UNKNOWN 这类显式桶)。
- check(
- 'B3f-lifecycle-buckets-pass-through-with-labels',
- (warnings.warning.industryDistribution || []).some((row) => row.code === 'UNKNOWN' && row.label === '未知') &&
- (warnings.warning.districtDistribution || []).some((row) => row.code === 'UNASSIGNED' && row.label === '未分区'),
- '行业/片区分布含显式 UNKNOWN/UNASSIGNED 桶且 label 原样透传',
- )
- check(
- 'B3d-range-type-is-echoed',
- warnings.rangeType === 'LAST_7_DAYS',
- `窗口回显 ${warnings.rangeType}(验收第 1 条的「近 7 日」靠它声明)`,
- )
- const regions = toOverviewRegions({
- totals: { facilityCount: 5, lengthKm: '12.4', scale: 'medium' },
- // 票 38 第二轮:灌**真实后端形状**(决策 06 后是 `districtDistribution[{district,districtName,count}]`,
- // 不是早期的 `regionDistribution[{code,count}]`)。片区编码走 `normalizeDistrict`/`districtLabel`。
- districtDistribution: [{ district: 0, districtName: '城南', count: 3 }, { district: null, districtName: '未分区', count: 2 }],
- })
- check(
- 'B4-regions-come-from-the-sibling-endpoint',
- regions.regions.length === 2 && regions.regions[0].label === '城南' && regions.lengthKm === 12.4,
- `片区分布借票 17 的端点:${regions.regions.map((row) => `${row.label}${row.count}`).join(' ')}`,
- )
- }
- // ================================================================ C 视图层
- {
- const data = {
- infrastructure: { facilityCount: 5, smartDeviceCount: 9, industries: [{ code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4 }], excluded: {} },
- regions: { regions: [{ code: '城区A', label: '城区A', count: 3 }], lengthKm: 12.4, scale: 'medium' },
- monitoring: {
- deviceCount: 9, monitoringPointCount: 7, monitoringFacilityCount: 3, infrastructureFacilityCount: 5,
- onlineRate: 0.75, onlineCount: 6, onlineDenominator: 8, onlineState: 'CURRENT',
- deviceStatus: { online: 6, offline: 1, stale: 1, unknownOrMissing: 1, denominator: 8 },
- formalIndustries: ['GAS'], metricFreshness: { status: 'CURRENT', updatedAt: 'T' },
- typeDistribution: [{ industryLabel: '燃气', typeName: '燃气管线', deviceCount: 4 }],
- },
- warningSettings: {
- rangeType: 'LAST_7_DAYS',
- // 票 42 新契约领域对象:预警族工单口径全 null + 四组生命周期分布;报警族保留工单口径。
- warning: {
- family: 'WARNING', total: 8, handled: null, pending: null, cancelledCount: null, unlinkedEventCount: null,
- efficiency: { handlingRate: 0.625, handlingState: 'AVAILABLE', numerator: 5, denominator: 8 },
- workOrderStates: null,
- statusDistribution: [
- { code: 'DRAFT', label: '草稿', count: 0 }, { code: 'PENDING', label: '待办', count: 1 },
- { code: 'RECEIVED', label: '已接收', count: 0 }, { code: 'PROCESSING', label: '处置中', count: 1 },
- { code: 'HANDLED', label: '已处置', count: 1 }, { code: 'CLOSED', label: '已解除', count: 5 },
- ],
- levelDistribution: [{ code: '1', label: '1级', count: 2 }, { code: '2', label: '2级', count: 3 }, { code: '3', label: '3级', count: 2 }, { code: '4', label: '4级', count: 1 }],
- industryDistribution: [{ code: 'GAS', label: '燃气', count: 3 }, { code: 'WATER_SUPPLY', label: '供水', count: 2 }, { code: 'DRAINAGE', label: '排水', count: 2 }, { code: 'MANHOLE', label: '窨井', count: 0 }, { code: 'UNKNOWN', label: '未知', count: 1 }],
- districtDistribution: [{ code: '0', label: '城南', count: 3 }, { code: '1', label: '城北', count: 2 }, { code: '2', label: '太常', count: 1 }, { code: 'UNASSIGNED', label: '未分区', count: 2 }],
- trend: [],
- },
- alarm: {
- family: 'ALARM', total: 9, handled: 6, pending: 2, cancelledCount: 1, unlinkedEventCount: 0,
- efficiency: { handlingRate: 0.667, handlingState: 'AVAILABLE', numerator: 6, denominator: 9 },
- workOrderStates: WORK_ORDER_STATES.map((s) => ({ code: String(s.code), label: s.name, count: 0 })),
- statusDistribution: [{ code: 'UNHANDLED', label: '未处理', count: 3 }, { code: 'HANDLED', label: '已处理', count: 6 }],
- levelDistribution: [], industryDistribution: [], districtDistribution: [],
- trend: [],
- },
- },
- }
- const situation = situationSummaryPanel(data, ready(data))
- const kpiKeys = situation.blocks[0].items.map((item) => item.key)
- check(
- 'C1-situation-kpis-cover-the-acceptance-list',
- ['facilityCount', 'smartDeviceCount', 'deviceCount', 'monitoringPointCount', 'onlineRate', 'warningTotal', 'alarmTotal', 'handlingRate'].every((key) => kpiKeys.includes(key)),
- `跨行业 KPI:${kpiKeys.join(' / ')}`,
- )
- check(
- 'C1b-situation-has-both-trend-series',
- situation.blocks.filter((block) => block.kind === 'trend').length === 2 &&
- situation.rangeNote.includes('近 7 日'),
- `预警与报警两条趋势序列(${situation.blocks.filter((b) => b.kind === 'trend').map((b) => b.label).join(' / ')})`,
- )
- check(
- 'C1c-situation-handling-rate-is-a-link',
- situation.blocks[0].items.find((item) => item.key === 'handlingRate').link === 'EFFICIENCY' &&
- situation.blocks[0].items.find((item) => item.key === 'warningTotal').link === 'WARNING',
- '当前预警/报警/处置率三个读数带联动出口(验收第 5 条)',
- )
- // **分母为 0 不是 0%**:后端回 value="0.0000" + state=EMPTY 时界面必须显示占位符。
- const zeroRate = situationSummaryPanel(
- { ...data, monitoring: { ...data.monitoring, onlineRate: 0, onlineDenominator: 0, onlineState: 'EMPTY' } },
- ready(data),
- )
- check(
- 'C1d-zero-denominator-is-not-zero-percent',
- zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').value === DETAIL_PLACEHOLDER &&
- zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').hint.includes('不适用'),
- `分母为 0 → ${zeroRate.blocks[0].items.find((item) => item.key === 'onlineRate').value}(不是 0.0%)`,
- )
- const infra = infrastructureSummaryPanel(data, ready(data))
- check(
- 'C2-infrastructure-has-industry-and-region',
- infra.blocks.some((block) => block.label === '行业构成 · 设施') &&
- infra.blocks.some((block) => block.label === '片区分布') &&
- infra.blocks.find((block) => block.label === '片区分布').rows.length === 1,
- `设施底数 + 行业构成 + 片区分布(验收第 2 条三项齐;决策 06 后统一叫「片区分布」)`,
- )
- check(
- 'C2b-industry-rows-are-actionable',
- infra.blocks.find((block) => block.label === '行业构成 · 设施').action === 'industry' &&
- infra.blocks.find((block) => block.label === '行业构成 · 设施').rows[0].actionKey === 'gas',
- '行业行带联动动作与前端筛选键(点它写全局行业筛选)',
- )
- check(
- 'C2c-region-gap-is-honest-when-missing',
- infrastructureSummaryPanel({ infrastructure: data.infrastructure, regions: null }, ready(data)).gap.includes('base-archives'),
- '区域取不到时如实说明它来自哪个端点,不假装有',
- )
- check(
- 'C2d-length-comes-from-the-sibling-endpoint',
- infra.blocks[0].items.find((item) => item.key === 'lengthKm').value === '12.4' &&
- !infra.blocks[0].items.some((item) => item.key === 'scale'),
- '里程取自票 17 的档案端点(本概览端点不给);票 38 起「规模」读数已删除(真实后端 totals.scale 恒 null)',
- )
- const monitoring = monitoringSummaryPanel(data.monitoring, ready(data.monitoring))
- const monitoringKpis = monitoring.blocks[0].items.map((item) => item.key)
- check(
- 'C3-monitoring-shows-online-rate-and-four-buckets',
- ['onlineRate', 'online', 'offline', 'stale', 'unknownOrMissing'].every((key) => monitoringKpis.includes(key)),
- `在线率 + 四态桶:${monitoringKpis.join(' / ')}(验收第 3 条)`,
- )
- // ⚠️ 真机走查逮到的真缺陷,这条守卫就是它的护栏:
- // `monitoringSummaryPanel` 原先读 `data.monitoring`(以为拿到包装),而外壳传的是
- // **裸领域对象** `section.data` —— 于是整块读数全是 `—`,而当时逻辑闸门全绿,
- // 因为**测试喂的正是那个错误的包装形状**(测试与实现犯了同一个错)。
- // 所以这里必须断言"喂真实形状时**真的画出数字**",而不只是"块在不在"。
- check(
- 'C3-contract-panel-reads-its-own-section-data',
- monitoringKpis.includes('onlineRate') &&
- monitoring.blocks[0].items.find((item) => item.key === 'onlineRate').value === '75.0%' &&
- monitoring.blocks[0].items.find((item) => item.key === 'online').value === '6' &&
- monitoring.blocks.some((block) => block.label === '监测覆盖'),
- `按外壳真实的调用形状(裸 section.data)喂进去,读数真的是数字而不是占位符:在线率 ${monitoring.blocks[0].items.find((i) => i.key === 'onlineRate').value} · 在线 ${monitoring.blocks[0].items.find((i) => i.key === 'online').value}`,
- )
- check(
- 'C3b-monitoring-has-coverage-and-type-distribution',
- monitoring.blocks.some((block) => block.label === '监测覆盖') && monitoring.blocks.some((block) => block.label === '类型分布 · 智能设备'),
- '监测覆盖 + 类型分布',
- )
- // 验收第 3 条点名「故障统计」,而契约没有该桶 —— 必须如实说明,不静默略过。
- check(
- 'C3c-fault-gap-is-declared',
- monitoring.gap === MONITORING_FAULT_GAP && monitoring.gap.includes('没有独立的「故障」桶'),
- '「故障」缺口如实写在面板上(不发明一个故障数)',
- )
- check(
- 'C3d-metric-freshness-row-is-present',
- monitoring.blocks.some((block) => block.label === '最近采集时间'),
- '逐设备最近采集时间有值时才画那一行',
- )
- const alert = alertSettingsPanel(data.warningSettings, ready(data.warningSettings))
- // 只有分布块有 `label`(KPI 块的 label 在 items 上),所以先滤掉没有 label 的块。
- const alertLabels = alert.blocks.map((block) => block.label).filter(Boolean)
- // 票 42:两族口径不同、块结构也不同 —— 预警族生命周期四分布,报警族工单分布 + 生命周期分布并存。
- const alarmKpiItems = alert.blocks.flatMap((block) => block.items || [])
- check(
- 'C4-alert-settings-shows-both-families',
- alertLabels.some((label) => label.includes('预警')) && alertLabels.some((label) => label.includes('报警')) &&
- alertLabels.some((label) => label.includes('生命周期状态')) && alertLabels.some((label) => label.includes('工单状态分布')),
- `两族统计同屏且口径分叉可见(票 42):${alertLabels.join(' / ')}`,
- )
- // 与 C3 同一族护栏:喂**裸领域对象**时 KPI 必须是真的数字。
- // (票 42 起块数量按族不同,改按 key 找,不按块下标。)
- check(
- 'C4-contract-alert-panel-reads-its-own-section-data',
- alert.blocks[0].items.find((item) => item.key === 'WARNING-total').value === '8' &&
- alarmKpiItems.find((item) => item.key === 'ALARM-total').value === '9',
- `按外壳真实的调用形状喂进去,两族总数真的画出来:预警 ${alert.blocks[0].items.find((i) => i.key === 'WARNING-total').value} 条 / 报警 ${alarmKpiItems.find((i) => i.key === 'ALARM-total').value} 条`,
- )
- check(
- 'C4b-alert-work-order-distribution-is-eight-codes',
- (alert.blocks.find((block) => block.label === '报警 · 工单状态分布') || { rows: [] }).rows.length === 8 &&
- (alert.blocks.find((block) => block.label === '预警 · 生命周期状态') || { rows: [] }).rows.length === 6,
- '报警工单分布 0–7 全铺(8 个码);预警生命周期状态六态全铺(票 42)',
- )
- // 票 42 验收第 4 条:预警族不再出现工单口径读数 —— KPI 只剩 总数 + 已解除率。
- const warningKpiKeys = alert.blocks[0].items.map((item) => item.key)
- check(
- 'C4d-warning-family-has-no-work-order-vocabulary',
- warningKpiKeys.join(',') === 'WARNING-total,WARNING-rate' &&
- !alertLabels.some((label) => label.startsWith('预警 · 工单')) &&
- alert.blocks[0].items.find((item) => item.key === 'WARNING-rate').label === '已解除率',
- `预警族只有 总数/已解除率(${warningKpiKeys.join(' / ')}),工单口径块只在报警族`,
- )
- check(
- 'C4c-alert-keeps-the-contract-name',
- alert.name === 'alertSettings' && alert.label === '预警设置',
- '面板名保持合同名「预警设置」(验收第 4 条原句)',
- )
- // 失败/空时仍返回结构完整的视图(面板标题不能跟着数据一起消失)。
- const failed = section(SECTION_STATE.FAILED, { data: null })
- check(
- 'C5-failed-section-still-returns-a-complete-view',
- ['situationSummary', 'infrastructureSummary', 'monitoringSummary', 'alertSettings'].every((panelId) => {
- const view = panelView(panelId, null, { ...failed, loaded: true })
- return view && Array.isArray(view.blocks) && view.hasContent === false && typeof view.placeholder === 'string'
- }),
- '四块在失败态都返回结构完整的视图(块在、占位说明在)',
- )
- }
- // ================================================================ D 分区独立(真契约 mock 走一遍)
- {
- setActivePinia(createPinia())
- const { registerCockpitMocks } = await import('../../../src/modules/cockpit/mocks/contractMocks.js')
- const { registerMockTransport, resetMockTransportsForTest } = await import('../../../src/modules/cockpit/services/cockpitApi.js')
- const { builtinConfig } = await import('../../../src/modules/cockpit/services/cockpitConfig.js')
- const store = useOverviewStore()
- store.reset()
- store.configure({ config: builtinConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await store.loadPanels({ industry: '' })
- const sections = store.sections
- check(
- 'D1-three-panels-load-independently',
- sections.infrastructureSummary.state === SECTION_STATE.SUCCESS &&
- sections.monitoringSummary.state === SECTION_STATE.SUCCESS &&
- sections.alertSettings.state === SECTION_STATE.SUCCESS,
- `三块各自成功:${['infrastructureSummary', 'monitoringSummary', 'alertSettings'].map((id) => sections[id].state).join(' / ')}`,
- )
- check(
- 'D2-situation-is-composed-not-fetched',
- store.situationSection().state === SECTION_STATE.SUCCESS &&
- store.situationData().warningSettings !== null &&
- store.situationData().monitoring !== null,
- '态势总览由另外三块合成(它不自己发请求),故与预警设置同源',
- )
- // 同源的结构性证明:两处读的是**同一个对象引用**,不是"两次取数期望一致"。
- check(
- 'D2b-situation-and-alert-share-the-same-payload',
- store.situationData().warningSettings === sections.alertSettings.data,
- '态势总览与预警设置读同一份预警载荷(引用相同)—— 两处数字不可能对不上',
- )
- check(
- 'D3-regions-are-merged-into-infrastructure',
- store.infrastructureData().regions !== null &&
- Array.isArray(store.infrastructureData().regions.regions) &&
- store.infrastructureData().regions.regions.length > 0,
- `区域分布并进基础设施分区(${((store.infrastructureData().regions || {}).regions || []).length} 行)`,
- )
- // ⚠️ **端到端**:从 store 取数 → 经 `panelView()`(外壳用的那个入口)→ 断言读数是真的数字。
- //
- // 为什么这条必须存在(票 20 真机走查的教训):C 段那几条是**手喂 data** 的单元断言,
- // 而真机那次恰恰是"单元断言与实现犯了同一个错"(都以为 data 是包装对象)→ 全绿而界面全 `—`。
- // 只有走**外壳真实的调用路径**才拦得住这类错。
- //
- // 注意每块的 data 来源**不一样**,这里必须与 `CockpitShell.vue` 的 `panelViewOf()` 逐块一致:
- // 态势总览与基础设施面板吃的是 store 的**合成**数据,另外两块吃 `section.data`。
- // (这个"逐块不同"本身就是易错点,F6c 那条守卫专门钉它。)
- const shellDataSource = {
- situationSummary: () => store.situationData(),
- infrastructureSummary: () => store.infrastructureData(),
- monitoringSummary: () => store.sections.monitoringSummary.data,
- alertSettings: () => store.sections.alertSettings.data,
- }
- const endToEnd = [
- ['monitoringSummary', 'onlineRate', '75.0%'],
- ['alertSettings', 'WARNING-total', '8'],
- ['infrastructureSummary', 'facilityCount', '5'],
- ['situationSummary', 'warningTotal', '8'],
- ].map(([panelId, kpiKey, expected]) => {
- const section = panelId === 'situationSummary' ? store.situationSection() : store.sections[panelId]
- const view = panelView(panelId, shellDataSource[panelId](), section)
- const found = (view.blocks.find((block) => block.kind === 'kpi') || { items: [] }).items.find((item) => item.key === kpiKey)
- return { panelId, kpiKey, expected, actual: found ? found.value : '(无此 KPI)' }
- })
- check(
- 'D8-end-to-end-panels-really-draw-numbers',
- endToEnd.every((row) => row.actual === row.expected),
- `经外壳真实入口逐块读数:${endToEnd.map((r) => `${r.panelId}.${r.kpiKey}=${r.actual}`).join(' · ')}`,
- )
- // 只坏一块:另外两块照常成功(验收:任一分区失败不遮盖其它)。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'failed', onlyPath: '/overview/monitoring', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const partial = useOverviewStore()
- partial.reset()
- partial.configure({ config: builtinConfig })
- await partial.loadPanels({ industry: '' })
- check(
- 'D4-one-broken-panel-does-not-cover-others',
- partial.sections.monitoringSummary.state !== SECTION_STATE.SUCCESS &&
- partial.sections.infrastructureSummary.state === SECTION_STATE.SUCCESS &&
- partial.sections.alertSettings.state === SECTION_STATE.SUCCESS,
- `只坏监测概览:监测 ${partial.sections.monitoringSummary.state},基础设施 ${partial.sections.infrastructureSummary.state},预警设置 ${partial.sections.alertSettings.state}`,
- )
- // 对抗式反证:全成功场景下三块都是 success,所以上面那条"一块坏"确实有信息量。
- check(
- 'D4b-control-all-success',
- sections.monitoringSummary.state === SECTION_STATE.SUCCESS,
- '对照组:同一份代码在 success 场景下监测概览是 success(故 D4 的判红有牙)',
- )
- // 空场景:不伪造 0,也不把"没有数据"画成"加载失败"。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const empty = useOverviewStore()
- empty.reset()
- empty.configure({ config: builtinConfig })
- await empty.loadPanels({ industry: '' })
- check(
- 'D5-empty-is-not-failure',
- [empty.sections.infrastructureSummary.state, empty.sections.monitoringSummary.state, empty.sections.alertSettings.state].every((state) => state === SECTION_STATE.EMPTY),
- `空场景三块都是 ${SECTION_STATE.EMPTY}("没有数据"与"加载失败"分得开)`,
- )
- // 行业筛选:只有 `/overview/monitoring` 收该入参,所以它必须**真的**换内容。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const filtered = useOverviewStore()
- filtered.reset()
- filtered.configure({ config: builtinConfig })
- await filtered.loadPanels({ industry: '' })
- const allDevices = filtered.sections.monitoringSummary.data.deviceCount
- await filtered.loadPanels({ industry: 'gas' })
- const gasDevices = filtered.sections.monitoringSummary.data.deviceCount
- check(
- 'D6-industry-filter-really-changes-the-numbers',
- gasDevices < allDevices && filtered.sections.monitoringSummary.data.formalIndustries.join(',') === 'GAS',
- `行业筛选真的重算:设备 ${allDevices} → ${gasDevices},正式范围 ${filtered.sections.monitoringSummary.data.formalIndustries.join(',')}`,
- )
- check(
- 'D6b-industry-filter-does-not-refetch-unfilterable-panels',
- filtered.sections.infrastructureSummary.data.facilityCount === 5,
- '另两个端点不收行业入参,故其读数不随筛选变化(不假装生效)',
- )
- // 趋势窗口:本票固定声明 LAST_7_DAYS,后端据此过滤桶。
- check(
- 'D7-range-is-declared-and-echoed',
- OVERVIEW_RANGE === 'LAST_7_DAYS' && filtered.sections.alertSettings.data.rangeType === 'LAST_7_DAYS',
- `窗口 ${filtered.sections.alertSettings.data.rangeType}(不声明时后端是无界 CURRENT_BASELINE,"近 7 日"就只是句空话)`,
- )
- }
- // ================================================================ E 联动
- {
- setActivePinia(createPinia())
- const store = useOverviewStore()
- const uiStore = useUiStore()
- const filterStore = useFilterStore()
- uiStore.setMode('overview')
- filterStore.setIndustry('all')
- const switched = store.linkToMode('warningResponse')
- check(
- 'E1-metric-link-switches-mode',
- switched === true && uiStore.mode === 'warningResponse',
- `点指标 → 一级模式切到 ${uiStore.mode}(落到真实事件列表,那里的事件带设备编码可再跳点位)`,
- )
- check(
- 'E1b-link-does-not-fake-a-map-locate',
- typeof store.locatePoint === 'undefined' && typeof store.locateCode === 'undefined',
- '概览 store 不提供任何地图定位入口(本模式端点不含点位编码,定位只能伪造)',
- )
- uiStore.setMode('overview')
- const linked = store.linkToIndustry('gas')
- check(
- 'E2-industry-link-writes-the-global-filter',
- linked === true && filterStore.industry === 'gas',
- `点行业行 → 全局行业筛选变成 ${filterStore.industry}(真的驱动一张图与各面板)`,
- )
- check(
- 'E2b-empty-action-is-refused',
- store.linkToIndustry('') === false && store.linkToMode('') === false,
- '空动作被拒(不静默写一个空筛选或切到未定义模式)',
- )
- filterStore.setIndustry('all')
- uiStore.setMode('overview')
- }
- // ================================================================ F 静态守卫
- {
- const domain = read('domain/overviewPanels.js')
- const service = read('services/overviewApi.js')
- const storeCode = read('stores/overviewStore.js')
- const renderer = read('components/PanelBlocks.vue')
- const shell = read('CockpitShell.vue')
- // 注释会解释被禁的东西,直接对全文正则会拿假阳性换通过率(t18 的教训)。
- const stripComments = (text) =>
- text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1').replace(/<!--[\s\S]*?-->/g, '')
- const domainCode = stripComments(domain)
- const storeClean = stripComments(storeCode)
- check(
- 'F1-domain-is-pure',
- !/\bfetch\s*\(|XMLHttpRequest/.test(domainCode) && !/from '\.\.\/services\//.test(domainCode),
- 'domain 不自己发请求、不反向依赖 service(层次不倒挂)',
- )
- check(
- 'F2-service-does-not-build-absolute-urls',
- !/https?:\/\//.test(stripComments(service)) && !/Authorization|Bearer/.test(stripComments(service)),
- 'service 只给根相对路径,地址与凭据归部署层(票 04 的唯一出口)',
- )
- // 守卫面与 t05 的 D1 **同宽**(票 20 的教训:我第一版只列了自己用到的几个词,
- // 于是 `rawDeviceStatistics` 这种"加了前缀的裸字段"漏过去了 ——
- // 变异 M12 实测全绿才发现。守卫比它要守的东西窄,就等于没守)。
- // 注意用 `\w*` 前后缀:裸字段名带前缀/后缀同样是泄漏(`rawDeviceStatistics`)。
- const RAW_FIELD = /\w*(monitoringPoints|unassociatedEquipment|coordinateValidation|pointStatusCode|deviceStatistics|aggregateState|timelineEntries|alarmWarningRelations|warningNo|alarmId|stateDictionary|stateReason|missingCycles|dataAgeSeconds|calibrationPeriodSeconds|staleReason|workOrders|deviceStatus|metricFreshness|workOrderStates|formalScope|typeDistribution)\w*/
- check(
- 'F3-store-has-no-raw-backend-fields',
- !RAW_FIELD.test(storeClean),
- 'store 里零后端裸字段名(字段改名归 domain)—— 与 t05 的 D1 守卫面同宽',
- )
- check(
- 'F4-store-has-no-write-operations',
- !/\b(POST|PUT|PATCH|DELETE)\b/.test(storeClean),
- '驾驶舱只读:本票不引入任何写操作',
- )
- // 渲染层**不认识任何 panelId**(票 18 解除的耦合,本票不得长回来)。
- check(
- 'F5-renderer-does-not-know-panel-ids',
- !/panelId/.test(stripComments(renderer)),
- '共享渲染层不认识 panelId(它只按 block.kind 与 block.action 分派)',
- )
- check(
- 'F5b-renderer-has-no-api-paths-or-fetch',
- !/\/overview\//.test(stripComments(renderer)) && !/fetch\(/.test(stripComments(renderer)),
- '渲染层不拼接口路径、不发请求',
- )
- // 外壳走同一条接缝:overview 四块必须走 PanelBlocks,而不是再长一套分支。
- check(
- 'F6-shell-routes-overview-through-the-shared-seam',
- (shell.match(/v-if="hasPanelView\(panelId/g) || []).length === 2 &&
- /!hasPanelView\(panelId\)\) return '面板占位'/.test(shell) &&
- /return '正在取数'/.test(shell),
- '外壳保持两处 hasPanelView 分支与既有的三态措辞(t18 的 H9 断言面)',
- )
- // 这一条守的是**接线本身**,不是"某个词出现过"。
- //
- // 票 20 的教训(变异 M13 实测全绿才发现):第一版只查 `linkToMode` / `linkToIndustry`
- // 在文件里**出现过** —— 而它们在 `handlePanelAction` 的定义里当然出现,
- // 于是"把 `@panel-action` 绑定删掉"(联动出口实际断掉)根本测不出来。
- // 现在改成断言**两处模板绑定**都在(左右栏各一处),以及取数入口在挂载里被调用。
- check(
- 'F6b-shell-wires-overview-store-and-links',
- (shell.match(/@panel-action="handlePanelAction"/g) || []).length === 2 &&
- /overviewStore\.loadPanels\(/.test(shell) &&
- /useOverviewStore/.test(shell) &&
- /overviewStore\.linkToMode\(/.test(shell) &&
- /overviewStore\.linkToIndustry\(/.test(shell),
- '外壳把概览接进共享接缝:左右栏各一处 @panel-action 绑定 + 取数入口 + 两条联动出口都真的接上',
- )
- // 这一条钉住"哪块面板吃哪份数据"—— 真机走查逮到的那类错正是**这一层错位**:
- // 视图函数以为 data 是包装对象、而外壳传的是裸 `section.data`,于是界面全是 `—` 而闸门全绿。
- // 所以把外壳里的分派逐块钉住:合成数据的两块必须显式传合成结果,另两块走 `section.data`。
- check(
- 'F6c-shell-per-panel-data-sources-are-pinned',
- /panelView\(panelId, overviewStore\.situationData\(\), section, extra\)/.test(shell) &&
- /panelView\(panelId, overviewStore\.infrastructureData\(\), section, extra\)/.test(shell) &&
- /return panelView\(panelId, section\.data, section, extra\)/.test(shell),
- '外壳逐块分派数据源:态势总览→situationData()、基础设施→infrastructureData()、其余→section.data(与各视图函数的期望一一对应)',
- )
- check(
- 'F7-domain-declares-its-own-paths-nowhere',
- !/\/overview\//.test(domainCode),
- 'domain 里不出现接口路径(路径归 services)',
- )
- check(
- 'F8-paths-live-in-one-place',
- OVERVIEW_PATHS.infrastructure === '/overview/infrastructure' &&
- OVERVIEW_PATHS.monitoring === '/overview/monitoring' &&
- OVERVIEW_PATHS.warningSettings === '/overview/warning-settings' &&
- OVERVIEW_PATHS.regions === '/infrastructure/base-archives',
- `四个端点路径只有一处声明:${Object.values(OVERVIEW_PATHS).join(' / ')}`,
- )
- }
- const passed = checks.filter(Boolean).length
- console.log(`\nT20 overview-panels: ${passed}/${checks.length}`)
- if (passed !== checks.length) process.exitCode = 1
|