| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744 |
- /**
- * T17 基础设施模式面板 纯逻辑检查(票 17)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t17-infrastructure-panels.mjs
- *
- * 七段:
- * A 面板映射:四块面板齐、各自对应一个端点、与目录的 panelId 一致
- * B 契约适配:四份真形状载荷 → 领域对象(含 BigDecimal 字符串、三类维度共用的 Distribution)
- * C 视图层:验收点名的画面元素都在(设施规模/行业分类/区域分布/档案摘要、三类能力、
- * 管理单位/权属单位、危险源/防护目标/应急资源),且缺值显示占位符
- * D 分区独立:一块失败不遮盖其它块;后端 UNAVAILABLE 与 EMPTY 分得开
- * E 行业筛选:前端键 → 后端码;无契约位的键不传参(不拿脏值去换 400)
- * F 地图联动:三种"点不动"分得开;坐标准入只认后端 renderable(不重算)
- * G 静态守卫:组件不拼路径、不认后端裸字段、不泄原因码;domain 不发请求、不 import service
- *
- * 契约依据:`CockpitInfrastructure{BaseArchives,OperatingInformation,ManagementInformation,
- * EmergencySupport}Response` 与 `docs/cockpit/infrastructure-*-demo.html`(逐类读过)。
- */
- import { readFileSync } from 'node:fs'
- import { createPinia, setActivePinia } from 'pinia'
- import {
- INFRASTRUCTURE_PANEL_IDS,
- MANAGEMENT_REGION_GAP,
- archivePanel,
- codeText,
- emergencyPanel,
- managementPanel,
- numberish,
- numberText,
- operationPanel,
- panelLocateEntries,
- ratioText,
- regionText,
- sectionHasContent,
- sectionPending,
- sectionPlaceholder,
- toBaseArchives,
- toEmergencySupport,
- toManagementInformation,
- toOperatingInformation,
- } from '../../../src/modules/cockpit/domain/infrastructurePanels.js'
- import { SECTION_STATE, section, sectionFromResult } from '../../../src/modules/cockpit/domain/sectionState.js'
- import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
- import { INDUSTRY_CODES, industryQuery } from '../../../src/modules/cockpit/domain/filterOptions.js'
- import { LOCATE_RESULT, findMapObject, locateCameraTarget, planLocate } from '../../../src/modules/cockpit/domain/mapLocate.js'
- import { panelsForMode } from '../../../src/modules/cockpit/domain/panelCatalog.js'
- import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
- import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
- import { useInfrastructureStore } from '../../../src/modules/cockpit/stores/infrastructureStore.js'
- const checks = []
- /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
- function check(name, pass, evidence) {
- checks.push({ name, pass })
- console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
- }
- const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
- /** 去掉注释只留代码(注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率)。 */
- const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
- const readCode = (relative) => code(read(relative))
- const readVueCode = (relative) => codeVue(read(relative))
- setActivePinia(createPinia())
- // ================================================================ A 面板映射
- {
- const infra = panelsForMode('infrastructure')
- check(
- 'A1-four-panels-match-catalog',
- INFRASTRUCTURE_PANEL_IDS.join(',') === 'archive,operation,management,emergency' &&
- infra.slice().sort().join(',') === INFRASTRUCTURE_PANEL_IDS.slice().sort().join(','),
- `${INFRASTRUCTURE_PANEL_IDS.length} 块:${INFRASTRUCTURE_PANEL_IDS.join(' / ')}(与 ADR 0003 的目录一致)`,
- )
- check(
- 'A2-panels-are-infrastructure-mode-only',
- panelsForMode('monitoring').every((id) => !INFRASTRUCTURE_PANEL_IDS.includes(id)) &&
- panelsForMode('warningResponse').every((id) => !INFRASTRUCTURE_PANEL_IDS.includes(id)),
- '本票只接基础设施模式的四块,不越界接其它模式的面板',
- )
- }
- // ================================================================ B 契约适配
- {
- // B1 BigDecimal 的线上形状是**字符串**(照抄 demo 载荷),必须认。
- check(
- 'B1-numberish-accepts-string-and-number',
- numberish('0.32') === 0.32 && numberish(1200.5) === 1200.5 && numberish('') === null && numberish(null) === null,
- `"0.32" → ${numberish('0.32')};1200.5 → ${numberish(1200.5)};"" → ${numberish('')}`,
- )
- // B2 真 0 是有效读数(票 16 的验收原句),认不出才回占位符。
- check(
- 'B2-zero-is-a-value-not-missing',
- numberish('0') === 0 && numberText('0', 'm3/h') === '0 m3/h' && numberText(null, 'm3/h') === DETAIL_PLACEHOLDER,
- `"0" → ${numberText('0', 'm3/h')};null → ${numberText(null, 'm3/h')}(不拼出半截读数)`,
- )
- check(
- 'B2b-ratio-text',
- ratioText('0.8000') === '80.0%' && ratioText('1.2500') === '125%' && ratioText(null) === DETAIL_PLACEHOLDER,
- `0.8 → ${ratioText('0.8000')};1.25 → ${ratioText('1.2500')}(超载超过 100% 如实显示)`,
- )
- // B3 基础档案:设施规模 / 行业分类 / 区域分布 / 档案摘要。
- const archives = toBaseArchives({
- updatedAt: 'T',
- range: { type: 'CURRENT_BASELINE' },
- totals: { facilityCount: 5, lengthKm: '12.40', scale: 'medium' },
- industryDistribution: [{ code: 'GAS', count: 2 }],
- facilityTypeDistribution: [{ code: 'WATER_PLANT', name: '水厂', count: 1 }],
- ageDistribution: [{ code: 'UNDER_5_YEARS', count: 1 }],
- ageReference: { asOfDate: '2026-09-02', commissionedCount: 3, builtCount: 2, unknownCount: 0 },
- regionDistribution: [{ code: 'CN', count: 3 }],
- selectedFilters: { industry: null },
- excluded: { unknownTypeCount: 0 },
- })
- check(
- 'B3-base-archives-four-dimensions',
- archives.facilityCount === 5 &&
- archives.lengthKm === 12.4 &&
- archives.industries[0].count === 2 &&
- archives.regions[0].count === 3 &&
- archives.ages[0].count === 1 &&
- archives.ageReference.commissionedCount === 3,
- `设施 ${archives.facilityCount} / 里程 ${archives.lengthKm} / 行业 ${archives.industries.length} 类 / 区域 ${archives.regions.length} 类 / 年限 ${archives.ages.length} 桶`,
- )
- // B4 行业码翻人话(后端不给中文标签,本层直译);认不出的**回原码**而不是留白。
- check(
- 'B4-industry-code-text',
- archives.industries[0].label === '燃气' && codeText('SOMETHING_NEW') === 'SOMETHING_NEW' && regionText(null) === '未分区',
- `GAS → ${archives.industries[0].label};未知码 → ${codeText('SOMETHING_NEW')}(照原样带出,不猜也不留白;区域空值 → ${regionText(null)})`,
- )
- // 真机走查逮到的泄漏:`scale` 是后端**透传**的自由字符串(契约里没有中文口径),
- // 漏译它界面就会印出英文枚举(当时是「规模=medium」)。这里把**已知全量取值**逐条钉住 ——
- // 只查"有没有翻译"不够,必须逐值确认,否则漏一个词就又漏到大屏上。
- check(
- 'B4b-scale-codes-translated',
- ['large', 'medium', 'small'].every((code) => codeText(code) !== code) &&
- codeText('medium') === '中型' &&
- codeText('small') === '小型' &&
- codeText('large') === '大型',
- `large/medium/small → ${codeText('large')}/${codeText('medium')}/${codeText('small')}(三个已知取值全部翻成中文)`,
- )
- // 归纳版守卫:**凡是字典里登记过的码,翻译结果都不能等于原码**(防止将来加词时漏一半)。
- // 认不出的自由值仍允许回原码(那是数据值,见 CODE_TEXT 的说明)。
- const registered = ['GAS', 'WATER_SUPPLY', 'DRAINAGE', 'SUPPLY', 'FLOW', 'PRESSURE', 'QUALITY', 'CAPACITY', 'NORMAL', 'WATCH', 'OVERLOAD', 'CURRENT', 'STALE', 'EXPIRED', 'OWNED', 'LEASED', 'ENTRUSTED', 'EMERGENCY_RESOURCE', 'HAZARD_SOURCE', 'PROTECTION_TARGET', 'UNKNOWN', 'EMPTY', 'large', 'medium', 'small']
- const untranslated = registered.filter((code) => codeText(code) === code)
- check(
- 'B4c-no-registered-code-passes-through',
- untranslated.length === 0 && codeText('SOMETHING_NEW') === 'SOMETHING_NEW',
- untranslated.length
- ? `这些登记过的码没有被翻译:${untranslated.join(', ')}`
- : `${registered.length} 个登记码全部翻成中文;未登记的自由值仍回原码(${codeText('SOMETHING_NEW')})`,
- )
- // B5 运行信息:三类能力(SUPPLY/FLOW/CAPACITY)+ 指标占位。
- const operation = toOperatingInformation({
- updatedAt: 'T',
- range: { type: 'LAST_24_HOURS', start: 'S', end: 'E' },
- selectedIndustry: { code: 'GAS', label: '燃气' },
- industries: [
- {
- industry: { code: 'GAS', key: 'gas', label: '燃气' },
- state: 'READY',
- stateReason: 'READY',
- abnormalMetricCount: 1,
- metrics: [
- { metricCode: 'p', metricName: '压力', category: 'SUPPLY', unit: 'MPa', value: '0.32', capacityValue: '0.40', loadRatio: '0.8000', loadState: 'WATCH', pointCodes: ['MP-001'] },
- { metricCode: 'f', metricName: '流量', category: 'FLOW', unit: 'm3/h', value: '1200.5', capacityValue: null, loadRatio: null, loadState: 'UNKNOWN', pointCodes: [] },
- { metricCode: 'c', metricName: '承载', category: 'CAPACITY', unit: 'm3', value: '800', capacityValue: '2000', loadRatio: '0.4000', loadState: 'NORMAL', pointCodes: [] },
- ],
- },
- ],
- excluded: {},
- })
- check(
- 'B5-operating-three-capabilities',
- operation.industries.length === 1 &&
- operation.industries[0].metrics.length === 3 &&
- operation.industries[0].metrics.map((m) => m.category).join(',') === 'SUPPLY,FLOW,CAPACITY',
- `三类能力:${operation.industries[0].metrics.map((m) => m.category).join(' / ')}`,
- )
- check(
- 'B5b-operating-keeps-point-codes',
- operation.industries[0].metrics[0].pointCodes[0] === 'MP-001',
- '指标带关联点位编码(坐标要靠地图回联,见 F 段)',
- )
- // B6 管理信息:管理单位 + 权属单位;**责任区域在后端契约里不存在**,如实为缺口。
- const management = toManagementInformation({
- updatedAt: 'T',
- totals: { organizationCount: 3, facilityCount: 3, equipmentCount: 2, unmaintainedCount: 1 },
- organizations: [
- { organizationCode: 'ORG-WS-A', organizationName: '供水一公司', industry: 'WATER_SUPPLY', facilityCount: 1, equipmentCount: 1, unmaintained: false, ownershipDistribution: [{ ownershipType: 'OWNED', facilityCount: 1, equipmentCount: 0, total: 1 }] },
- { organizationCode: 'UNMAINTAINED', organizationName: '未维护', industry: 'DRAINAGE', facilityCount: 1, equipmentCount: 0, unmaintained: true, ownershipDistribution: [] },
- ],
- ownershipDistribution: [{ ownershipType: 'OWNED', facilityCount: 2, equipmentCount: 1, total: 3 }],
- excluded: {},
- })
- check(
- 'B6-management-units-and-ownership',
- management.organizationCount === 3 &&
- management.organizations.length === 2 &&
- management.organizations[1].unmaintained === true &&
- management.ownership[0].total === 3,
- `${management.organizations.length} 家管理单位(含"无管理关系"显式桶);权属 ${management.ownership.length} 类`,
- )
- check(
- 'B6b-management-region-is-a-declared-gap',
- MANAGEMENT_REGION_GAP.includes('责任区域') && MANAGEMENT_REGION_GAP.includes('没有字段') &&
- managementPanel(management, section(SECTION_STATE.SUCCESS)).gap === MANAGEMENT_REGION_GAP,
- '「责任区域」如实标为契约缺口,不拿行业冒充区域',
- )
- // B7 应急保障:三个分区 + **三类维度共用的 Distribution**(值分别在 industry / area / category 上)。
- const emergency = toEmergencySupport({
- updatedAt: 'T',
- totals: { emergencyResourceCount: 2, hazardSourceCount: 1, protectionTargetCount: 1, totalObjectCount: 4 },
- sections: {
- hazardSources: { state: 'READY', stateReason: 'READY', items: [{ objectCode: 'HS-1', objectName: '危险源', category: 'HAZARD_SOURCE', industry: 'WATER_SUPPLY', area: 'TC', pointCodes: ['MP-003'], eventAssociations: { warningCount: 1, alarmCount: 1, workOrderCount: 1 } }] },
- protectionTargets: { state: 'UNAVAILABLE', stateReason: 'SOURCE_UNAVAILABLE', items: [] },
- emergencyResources: { state: 'READY', stateReason: 'READY', items: [{ objectCode: 'ER-1', objectName: '物资库', category: 'EMERGENCY_RESOURCE', industry: 'GAS', area: null, pointCodes: [], eventAssociations: { warningCount: 0, alarmCount: 0, workOrderCount: 0 } }] },
- },
- eventSummary: { warningCount: 1, alarmCount: 1, workOrderCount: 1 },
- // 照抄后端:三类维度共用结构,`code` 一律 null。
- industryDistribution: [{ category: null, industry: 'WATER_SUPPLY', area: null, count: 2 }],
- areaDistribution: [{ category: null, industry: null, area: 'TC', count: 1 }],
- categoryDistribution: [{ category: 'HAZARD_SOURCE', industry: null, area: null, count: 1 }],
- excluded: {},
- })
- check(
- 'B7-emergency-three-sections',
- emergency.totals.hazardSourceCount === 1 &&
- emergency.sections.hazardSources.items.length === 1 &&
- emergency.sections.protectionTargets.items.length === 0 &&
- emergency.sections.emergencyResources.items.length === 1,
- `危险源 ${emergency.sections.hazardSources.items.length} / 防护目标 ${emergency.sections.protectionTargets.items.length} / 应急资源 ${emergency.sections.emergencyResources.items.length}`,
- )
- check(
- 'B7b-shared-distribution-reads-per-dimension-key',
- emergency.industries[0].code === 'WATER_SUPPLY' &&
- emergency.industries[0].count === 2 &&
- emergency.areas[0].code === 'TC' &&
- emergency.areas[0].label === 'TC',
- `行业分布取值在 industry 上(${emergency.industries[0].label})、区域分布在 area 上(${emergency.areas[0].label})—— 一律读 code 会全部落空`,
- )
- check(
- 'B7c-unknown-region-code-survives',
- regionText('CN') === 'CN' && regionText('TC') === 'TC' && regionText(null) === '未分区',
- `无字典的区域码照原样带出(${regionText('CN')} / ${regionText('TC')})—— 空白会被读成"没有数据";空值则明说「${regionText(null)}」`,
- )
- // 但**哨兵值**不是数据值:`UNKNOWN`/`EMPTY` 是后端表示"这一维没有值"的占位,
- // 原样印到大屏上就是把接口词汇漏给值班人员(票 05 的真机走查逮到过同类泄漏)。
- check(
- 'B7d-sentinel-codes-translated-not-leaked',
- codeText('UNKNOWN') === '未知' && codeText('EMPTY') === '无数据' && codeText('CURRENT') === '新鲜',
- `哨兵值翻人话:UNKNOWN → ${codeText('UNKNOWN')};EMPTY → ${codeText('EMPTY')};CURRENT → ${codeText('CURRENT')}`,
- )
- }
- // ================================================================ C 视图层(验收点名的画面元素)
- {
- const archives = archivePanel(
- toBaseArchives({
- totals: { facilityCount: 5, lengthKm: '12.40', scale: 'medium' },
- industryDistribution: [{ code: 'GAS', count: 2 }],
- regionDistribution: [{ code: 'CN', count: 3 }],
- ageDistribution: [{ code: 'UNDER_5_YEARS', count: 1 }],
- ageReference: { asOfDate: 'D', commissionedCount: 3, builtCount: 2, unknownCount: 0 },
- facilityTypeDistribution: [],
- selectedFilters: {},
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- )
- const archiveBars = archives.blocks.filter((b) => b.kind === 'bars').map((b) => b.label)
- check(
- 'C1-archive-shows-scale-industry-region-age',
- archives.blocks[0].items.some((i) => i.label === '设施总数') &&
- archiveBars.includes('行业分类') &&
- archiveBars.includes('区域分布') &&
- archiveBars.some((label) => label.includes('建成年限')),
- `验收四项都在:${['设施规模', ...archiveBars].join(' / ')}`,
- )
- check(
- 'C1b-archive-has-no-locate-targets',
- archives.locate.length === 0 && archives.locateNote.includes('没有可定位对象'),
- '基础档案是聚合分布口径(后端不返回逐设施坐标),如实说没有可定位对象',
- )
- const operation = operationPanel(
- toOperatingInformation({
- industries: [
- {
- industry: { code: 'GAS', label: '燃气' },
- state: 'READY',
- metrics: [
- { metricCode: 'p', metricName: '压力', category: 'SUPPLY', unit: 'MPa', value: '0.32', capacityValue: '0.40', loadRatio: '0.8000', loadState: 'WATCH', pointCodes: ['MP-001'] },
- { metricCode: 'f', metricName: '流量', category: 'FLOW', unit: 'm3/h', value: '1', capacityValue: null, loadRatio: null, loadState: 'UNKNOWN', pointCodes: [] },
- { metricCode: 'c', metricName: '承载', category: 'CAPACITY', unit: 'm3', value: '1', capacityValue: '2', loadRatio: '0.5', loadState: 'NORMAL', pointCodes: [] },
- ],
- },
- ],
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- )
- const kpiLabels = operation.blocks[0].items.map((i) => i.label)
- check(
- 'C2-operation-shows-supply-flow-capacity',
- kpiLabels.join(',') === '供应,输送,承载' && operation.blocks[0].items[0].value === '0.32 MPa',
- `验收三类能力:${kpiLabels.join(' / ')}(供应 = ${operation.blocks[0].items[0].value})`,
- )
- check(
- 'C2b-operation-missing-capacity-shows-placeholder',
- operation.blocks[0].items[1].hint.includes('无能力基准') && numberText(null, 'm3/h') === DETAIL_PLACEHOLDER,
- '能力基准缺失时说"无能力基准",不显示虚假 0 负荷',
- )
- check(
- 'C2c-operation-locates-metric-points',
- operation.locate.length === 1 && operation.locate[0].pointCode === 'MP-001',
- `指标关联点位可定位:${operation.locate.map((e) => e.pointCode).join(', ')}`,
- )
- // 色键必须**成对给文字**(票 06:状态标签不能只靠颜色,灰度与色盲下要读得出)。
- // 缺了 toneLabel,组件会画出一个只有颜色的空标签 —— 那条纪律就在这一层被悄悄破掉。
- const tonedRows = [
- ...operation.blocks.filter((b) => b.kind === 'list').flatMap((b) => b.rows),
- ...managementPanel(
- toManagementInformation({
- totals: {},
- organizations: [{ organizationCode: 'O', organizationName: 'N', industry: 'GAS', unmaintained: true, ownershipDistribution: [] }],
- ownershipDistribution: [],
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- ).blocks.filter((b) => b.kind === 'list').flatMap((b) => b.rows),
- ].filter((row) => row.tone)
- check(
- 'C2d-every-tone-carries-a-text-label',
- tonedRows.length >= 2 && tonedRows.every((row) => !!row.toneLabel && row.toneLabel.trim().length > 0),
- `带色键的条目 ${tonedRows.length} 条,每条都带文字(${tonedRows.map((r) => `${r.tone}:${r.toneLabel}`).join(' / ')})`,
- )
- const management = managementPanel(
- toManagementInformation({
- totals: { organizationCount: 1 },
- organizations: [{ organizationCode: 'ORG-1', organizationName: '供水一公司', industry: 'WATER_SUPPLY', facilityCount: 1, equipmentCount: 1, unmaintained: false, ownershipDistribution: [] }],
- ownershipDistribution: [{ ownershipType: 'OWNED', facilityCount: 1, equipmentCount: 0, total: 1 }],
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- )
- check(
- 'C3-management-shows-units-and-ownership-plus-gap',
- management.blocks.some((b) => b.kind === 'bars' && b.label.includes('权属')) &&
- management.blocks.some((b) => b.kind === 'list' && b.label === '管理单位') &&
- !!management.gap,
- '管理单位 / 权属单位都画出;责任区域以缺口说明呈现(后端无该字段)',
- )
- const emergency = emergencyPanel(
- toEmergencySupport({
- totals: { hazardSourceCount: 1, protectionTargetCount: 1, emergencyResourceCount: 1, totalObjectCount: 3 },
- sections: {
- hazardSources: { state: 'READY', items: [{ objectCode: 'HS-1', objectName: '危险源', category: 'HAZARD_SOURCE', industry: 'WATER_SUPPLY', area: 'TC', pointCodes: ['MP-003'], eventAssociations: {} }] },
- protectionTargets: { state: 'READY', items: [{ objectCode: 'PT-1', objectName: '防护目标', category: 'PROTECTION_TARGET', industry: 'DRAINAGE', area: null, pointCodes: [], eventAssociations: {} }] },
- emergencyResources: { state: 'READY', items: [{ objectCode: 'ER-1', objectName: '应急资源', category: 'EMERGENCY_RESOURCE', industry: 'GAS', area: 'CN', pointCodes: ['MP-001'], eventAssociations: {} }] },
- },
- eventSummary: { warningCount: 1, alarmCount: 2, workOrderCount: 3 },
- industryDistribution: [],
- areaDistribution: [],
- excluded: {},
- }),
- section(SECTION_STATE.SUCCESS),
- )
- const listLabels = emergency.blocks.filter((b) => b.kind === 'list').map((b) => b.label)
- check(
- 'C4-emergency-shows-hazard-target-resource',
- listLabels.includes('危险源') && listLabels.includes('防护目标') && listLabels.includes('应急资源'),
- `验收三项:${listLabels.join(' / ')}`,
- )
- check(
- 'C4b-emergency-locates-linked-points',
- emergency.locate.length === 2 && panelLocateEntries(emergency).length >= 2,
- `危险源与应急资源的关联点位可定位:${emergency.locate.map((e) => e.pointCode).join(', ')}`,
- )
- check(
- 'C5-placeholder-text-per-state',
- sectionPlaceholder(section(SECTION_STATE.EMPTY)).includes('没有数据') &&
- sectionPlaceholder(section(SECTION_STATE.FAILED)).includes('加载失败') &&
- sectionPlaceholder(section(SECTION_STATE.UNAVAILABLE)).includes('不可用') &&
- sectionPlaceholder(section(SECTION_STATE.FAILED)).includes('其它面板') &&
- sectionPlaceholder(section(SECTION_STATE.STALE)) === '',
- '空 / 失败 / 不可用各有明确措辞;过期是有值(不回占位说明)',
- )
- check(
- 'C6-content-states',
- sectionHasContent(section(SECTION_STATE.SUCCESS)) &&
- sectionHasContent(section(SECTION_STATE.STALE)) &&
- !sectionHasContent(section(SECTION_STATE.EMPTY)) &&
- !sectionHasContent(section(SECTION_STATE.FAILED)),
- '过期算有值(内容照画),空与失败才算没内容',
- )
- // 还没取过数时说"正在取数",**不能说"没有数据"** —— 那是在报告一件还没问过的事。
- // (首帧就是这个状态:配置还没落地、请求还没发出。)
- check(
- 'C7-pending-says-loading-not-empty',
- sectionPlaceholder({ state: SECTION_STATE.EMPTY, loaded: false }) === '正在取数…' &&
- sectionPending({ state: SECTION_STATE.EMPTY, loaded: false }) === true &&
- sectionPending({ state: SECTION_STATE.EMPTY, loaded: true }) === false &&
- sectionPlaceholder({ state: SECTION_STATE.EMPTY, loaded: true }) === '该面板当前没有数据',
- '未取数 → 正在取数;取过且为空 → 该面板当前没有数据(两种状态分得开)',
- )
- // 整段缺失(null)也是"还没有",**不是**"加载失败":
- // "这个分区还没有位置"与"取了但坏了"是两件事,只有后者才有资格说"加载失败"。
- check(
- 'C7b-missing-section-is-pending-not-failed',
- sectionPending(null) === true &&
- sectionPlaceholder(null) === '正在取数…' &&
- sectionPlaceholder({ state: SECTION_STATE.FAILED, loaded: true }) === '该面板加载失败(其它面板不受影响)',
- `null → 「${sectionPlaceholder(null)}」;真失败 → 「${sectionPlaceholder({ state: SECTION_STATE.FAILED, loaded: true })}」`,
- )
- }
- // ================================================================ D 分区独立(四块各自成败)
- {
- const store = useInfrastructureStore()
- store.reset()
- store.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await store.loadPanels({ industry: 'all' })
- const all = store.sections
- check(
- 'D1-all-four-panels-load',
- INFRASTRUCTURE_PANEL_IDS.every((id) => all[id].state === SECTION_STATE.SUCCESS && all[id].loaded === true),
- INFRASTRUCTURE_PANEL_IDS.map((id) => `${id}=${all[id].state}`).join(' '),
- )
- // 只让**一个**端点失败:认证类失败只影响那一块(票 05 验收第 6 条的同一条纪律)。
- const isolated = useInfrastructureStore()
- isolated.reset()
- isolated.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({
- scenario: 'failed',
- onlyPath: '/infrastructure/management-information',
- handlers: { registerMockTransport, resetMockTransportsForTest },
- })
- await isolated.loadPanels({ industry: 'all' })
- const sections = isolated.sections
- check(
- 'D2-one-panel-failure-does-not-cover-others',
- sections.management.state !== SECTION_STATE.SUCCESS &&
- sections.archive.state === SECTION_STATE.SUCCESS &&
- sections.operation.state === SECTION_STATE.SUCCESS &&
- sections.emergency.state === SECTION_STATE.SUCCESS,
- `管理块=${sections.management.state},其余三块仍是 ${sections.archive.state}/${sections.operation.state}/${sections.emergency.state}`,
- )
- // 对抗式反证:同样的判据在全成功场景下必须全绿,否则上面那条可能是"全都失败了"而恒真。
- check(
- 'D3-guard-has-teeth',
- INFRASTRUCTURE_PANEL_IDS.every((id) => all[id].state === SECTION_STATE.SUCCESS),
- '全成功场景四块全绿(因此 D2 的"一块坏"是有信息量的)',
- )
- // 空场景:四块都说"没有数据",不伪造 0。
- const emptyStore = useInfrastructureStore()
- emptyStore.reset()
- emptyStore.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await emptyStore.loadPanels({ industry: 'all' })
- const emptyViews = INFRASTRUCTURE_PANEL_IDS.map((id) =>
- require_placeholder(id, emptyStore.sections[id]),
- )
- check(
- 'D4-empty-scenario-all-placeholders',
- emptyViews.every((text) => text === '该面板当前没有数据'),
- `空场景四块都是明确占位说明:${emptyViews.join(' / ')}`,
- )
- // 过期场景:内容照画 + 保留标识(票 16 的立场),不是"没有数据"。
- const staleStore = useInfrastructureStore()
- staleStore.reset()
- staleStore.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'stale', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await staleStore.loadPanels({ industry: 'all' })
- const staleArchive = staleStore.sections.archive
- check(
- 'D5-stale-is-value-not-empty',
- staleArchive.state === SECTION_STATE.STALE &&
- staleArchive.retained === true &&
- sectionHasContent(staleArchive) &&
- !!staleArchive.data &&
- staleArchive.data.facilityCount === 5,
- `过期分区仍带着数据(设施 ${staleArchive.data.facilityCount}),界面照画并标过期`,
- )
- // 后端 `UNAVAILABLE`(没取到)与 `EMPTY`(确实没有)必须分得开。
- const partialStore = useInfrastructureStore()
- partialStore.reset()
- partialStore.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'infrastructure-partial', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await partialStore.loadPanels({ industry: 'all' })
- const emergency = emergencyPanel(partialStore.sections.emergency.data, partialStore.sections.emergency)
- const targetBlock = emergency.blocks.find((b) => b.label === '防护目标')
- const hazardBlock = emergency.blocks.find((b) => b.label === '危险源')
- check(
- 'D6-unavailable-vs-empty-in-child-sections',
- targetBlock.stateText === '该段数据未取到' &&
- targetBlock.rows.length === 0 &&
- hazardBlock.rows.length === 1,
- `防护目标段=${targetBlock.stateText}(没取到、0 条),危险源段照常画出 ${hazardBlock.rows.length} 条`,
- )
- // 段状态与空列表提示**互斥**:有 stateText 就必然没有 rows(组件据此把 stateText 放前面)。
- // 钉住这条,免得将来有人让两者同时成立而使组件里的 v-if/v-else-if 顺序失去意义。
- check(
- 'D6b-section-state-and-empty-list-are-mutually-exclusive',
- emergency.blocks
- .filter((b) => b.kind === 'list')
- .every((b) => !(b.stateText && b.rows.length > 0)),
- '有段状态说明的段一定没有条目(所以组件可以安全地用 stateText 优先于空列表提示)',
- )
- }
- /** 取一个面板在空场景下的占位说明(复用视图层,不读组件)。 */
- function require_placeholder(panelId, sectionValue) {
- const builders = { archive: archivePanel, operation: operationPanel, management: managementPanel, emergency: emergencyPanel }
- const view = builders[panelId](sectionValue.data, sectionValue)
- return view.placeholder
- }
- // ================================================================ E 行业筛选
- {
- check(
- 'E1-industry-key-maps-to-backend-code',
- INDUSTRY_CODES.gas === 'GAS' &&
- INDUSTRY_CODES.water === 'WATER_SUPPLY' &&
- INDUSTRY_CODES.drainage === 'DRAINAGE' &&
- // 票 18 更正(2026-09-28):`MANHOLE` 现已是后端第四个正式行业(全部 11 个 cockpit
- // 服务都收,含 `map-snapshot`),井盖**要下发**。本断言原来守的
- // 「井盖无契约位」已被后端推翻 —— 那是票 17 立、票 25 第八轮记的旧事实。
- INDUSTRY_CODES.manhole === 'MANHOLE',
- `gas → ${INDUSTRY_CODES.gas};water → ${INDUSTRY_CODES.water};drainage → ${INDUSTRY_CODES.drainage};manhole → ${INDUSTRY_CODES.manhole}(四个正式行业全部下发)`,
- )
- check(
- 'E2-only-all-and-unmapped-keys-send-nothing',
- industryQuery('all') === '' && industryQuery('nope') === '',
- '「全部」不传参;**认不出的键**也不传参(兜底:将来新增的前端选项若后端还没有这个码,不拿脏值去换 400)',
- )
- // 端到端:传行业后请求串里带上后端码;传 all 时不带。
- let seenPath = ''
- registerMockTransport('/infrastructure/base-archives', (context) => {
- seenPath = context.path
- return { totals: { facilityCount: 1 }, industryDistribution: [{ code: 'GAS', count: 1 }], regionDistribution: [], ageDistribution: [], ageReference: {}, facilityTypeDistribution: [], selectedFilters: {}, excluded: {} }
- })
- const store = useInfrastructureStore()
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ industry: 'gas' })
- const withIndustry = seenPath
- await store.loadPanels({ industry: 'all' })
- const withoutIndustry = seenPath
- check(
- 'E3-industry-reaches-the-request',
- withIndustry.includes('industry=GAS') && !withoutIndustry.includes('industry='),
- `按行业取数时:${withIndustry};全部行业时:${withoutIndustry}`,
- )
- }
- // ================================================================ F 地图联动
- {
- const objects = [
- { kind: 'monitoring-point', code: 'MP-001', name: '供水干管监测点', longitude: 110.141, latitude: 28.452, coordinate: { renderable: true, validated: true } },
- { kind: 'monitoring-point', code: 'MP-003', name: '污水排口监测点', longitude: 110.143, latitude: 28.454, coordinate: { renderable: false, validated: true, reason: 'UNVERIFIED_COORDINATE' } },
- ]
- check(
- 'F1-find-object-by-point-code',
- findMapObject(objects, 'MP-001').name === '供水干管监测点' && findMapObject(objects, 'NOPE') === null && findMapObject(objects, '') === null,
- '按点位编码回联已加载的地图对象;找不到回 null(不猜)',
- )
- check(
- 'F2-locate-ok-path',
- planLocate({ objects, pointCode: 'MP-001', mapReady: true }).result === LOCATE_RESULT.OK &&
- locateCameraTarget(planLocate({ objects, pointCode: 'MP-001' }).object).lon === 110.141,
- '可上图且地图就绪 → 给出相机目标',
- )
- check(
- 'F3-locate-missing-vs-blocked-vs-unavailable',
- planLocate({ objects, pointCode: 'NOPE', mapReady: true }).result === LOCATE_RESULT.MISSING &&
- planLocate({ objects, pointCode: 'MP-003', mapReady: true }).result === LOCATE_RESULT.BLOCKED &&
- planLocate({ objects, pointCode: 'MP-001', mapReady: false }).result === LOCATE_RESULT.UNAVAILABLE,
- '三种"点不动"分得开:不在已加载范围 / 后端判定不可上图 / 地图未就绪',
- )
- check(
- 'F4-blocked-carries-readable-reason',
- planLocate({ objects, pointCode: 'MP-003' }).reasonText === '坐标未经校验',
- `不可上图的文案来自票 07 的同一张词表:${planLocate({ objects, pointCode: 'MP-003' }).reasonText}`,
- )
- // 坐标准入**只认后端 renderable**:前端不重算经纬度(票 07 的验收原句)。
- check(
- 'F5-admission-only-reads-backend-renderable',
- planLocate({ objects: [{ kind: 'monitoring-point', code: 'X', longitude: 999, latitude: 999, coordinate: { renderable: false, validated: true } }], pointCode: 'X' }).result === LOCATE_RESULT.BLOCKED &&
- planLocate({ objects: [{ kind: 'monitoring-point', code: 'Y', longitude: 999, latitude: 999, coordinate: { renderable: true, validated: true } }], pointCode: 'Y' }).result === LOCATE_RESULT.OK,
- '越界坐标只要后端说可上图就放行,合法坐标只要后端说不可上图就拦下 —— 前端不重算',
- )
- // store 的定位联动:三种结果各走一遍,只有成功那一次才动相机并开详情。
- const store = useInfrastructureStore()
- store.reset()
- const focusCalls = []
- const openCalls = []
- const deps = {
- objects,
- mapReady: true,
- focus: (target) => {
- focusCalls.push(target)
- return true
- },
- openPoint: (code) => {
- openCalls.push(code)
- },
- }
- const ok = await store.locatePoint('MP-001', deps)
- const blocked = await store.locatePoint('MP-003', deps)
- const missing = await store.locatePoint('NOPE', deps)
- check(
- 'F6-store-locate-only-flies-on-success',
- ok.result === LOCATE_RESULT.OK &&
- blocked.result === LOCATE_RESULT.BLOCKED &&
- missing.result === LOCATE_RESULT.MISSING &&
- focusCalls.length === 1 &&
- openCalls.length === 1 &&
- openCalls[0] === 'MP-001',
- `相机只被下发 ${focusCalls.length} 次、详情只被打开 ${openCalls.length} 次(不可上图与找不到都不动相机)`,
- )
- check(
- 'F7-store-reports-unavailable-when-camera-refuses',
- (await store.locatePoint('MP-001', { ...deps, focus: () => false })).result === LOCATE_RESULT.UNAVAILABLE,
- '相机没吃下指令时如实降级成"地图尚未就绪",不假装定位成功',
- )
- }
- // ================================================================ G 静态守卫
- {
- // 票 18:共享渲染层改名 `InfrastructurePanel.vue` → `PanelBlocks.vue`(解除模式耦合、
- // 吃算好的 view),分发改由 `domain/panelViews.js` 的注册表承担。票 17 要守的三条
- // 契约(组件不拼路径 / 零后端裸字段 / 零原因码)一字不变,只是文件换了名字。
- const panelVue = readVueCode('components/PanelBlocks.vue')
- const shellVue = readVueCode('CockpitShell.vue')
- const viewCode = readCode('domain/infrastructurePanels.js')
- const locateCode = readCode('domain/mapLocate.js')
- const storeCode = readCode('stores/infrastructureStore.js')
- const apiCode = readCode('services/infrastructureApi.js')
- check(
- 'G1-component-does-not-build-api-paths',
- !/\/infrastructure\//.test(panelVue) && !/fetch\(/.test(panelVue),
- '接口路径只在 services/infrastructureApi.js,组件不自己拼',
- )
- check(
- 'G2-component-has-no-backend-field-names',
- !/facilityCount|organizationCode|ownershipDistribution|pointCodes|metricCode|stateReason|emergencyResourceCount|hazardSourceCount|protectionTargetCount|industryDistribution|areaDistribution/.test(panelVue) &&
- !/facilityCount|organizationCode|ownershipDistribution|pointCodes|metricCode|emergencyResourceCount/.test(shellVue),
- '组件只读视图对象字段(后端裸字段名只出现在 domain 适配层)',
- )
- // 后端原因码不许出现在界面上(票 05/11 的真机走查各逮到过一次)。
- const reasonCodes = ['READY', 'NO_OBJECTS', 'NO_METRICS', 'SAMPLE_CURRENT', 'SOURCE_UNAVAILABLE', 'TWO_UPDATE_CYCLES_MISSED', 'SUCCESS', 'FAILED', 'EMPTY', 'UNAVAILABLE']
- const leaked = reasonCodes.filter((token) => new RegExp(`\\b${token}\\b`).test(panelVue))
- check(
- 'G3-no-backend-reason-codes-in-ui',
- leaked.length === 0,
- leaked.length ? `组件里出现后端代号:${leaked.join(', ')}` : '组件零后端代号(都是 domain 翻好的人话)',
- )
- check(
- 'G4-domain-does-not-fetch-or-import-service',
- !/\bfetch\s*\(|XMLHttpRequest/.test(viewCode) &&
- !/from '\.\.\/services\//.test(viewCode) &&
- !/\bfetch\s*\(|XMLHttpRequest/.test(locateCode) &&
- !/from '\.\.\/services\//.test(locateCode),
- 'domain 纯函数层不发请求、不 import service(层次不倒挂)',
- )
- check(
- 'G5-store-has-no-raw-fields-or-paths',
- !/\/infrastructure\//.test(storeCode) && !/facilityCount|organizations|hazardSources/.test(storeCode),
- 'store 只转发分区,不改名也不拼路径',
- )
- check(
- 'G6-api-has-no-credential',
- !/Authorization|Bearer|api\.token/.test(apiCode),
- '凭据只在 services/cockpitApi.js 附加(票 04 的接缝未被绕过)',
- )
- check(
- 'G7-no-write-operations',
- !/method:\s*'(POST|PUT|PATCH|DELETE)'/.test(apiCode) && !/@click="[^"]*(save|submit|update|delete)/i.test(panelVue),
- '驾驶舱只读:本票不引入任何写操作',
- )
- check(
- 'G8-shell-routes-four-panels-through-one-seam',
- /panelViewOf\(panelId\)/.test(shellVue) &&
- /PanelBlocks/.test(shellVue) &&
- /PanelPlaceholder v-else/.test(shellVue),
- '四块走共享的面板渲染层(分发归 domain/panelViews.js),其余面板仍是占位(占位文案是"哪几块还没接数据"的信号)',
- )
- check(
- 'G9-locate-seam-is-shared-not-per-panel',
- /planLocate/.test(locateCode) && /isRenderable/.test(locateCode) && /blockReason/.test(locateCode),
- '定位判定收在 domain/mapLocate.js 一处(复用语义来自票 07 的坐标准入)',
- )
- // 全局行业筛选必须**真的作用到面板数据上**(CONTEXT.md:它是作用于整个驾驶舱的全局条件),
- // 而不只是记在 store 里。跟随的判据是「industry 变了就按新值重取」,
- // 且只在 mock 取数就绪后跟随(抢跑会带着空配置发请求,票 04 的纪律)。
- check(
- 'G10-shell-follows-global-industry-filter',
- /watch\(industry,/.test(shellVue) &&
- /infrastructureStore\.loadPanels\(\{ industry: next \}\)/.test(shellVue) &&
- /mockReady/.test(shellVue),
- '行业筛选变化 → 四块面板按新行业重取(配置就绪后才跟随)',
- )
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T17 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:' + failed.map((item) => item.name).join(', '))
- process.exit(1)
- }
|