/** * 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(//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) }