/** * T11 设备详情聚合与分区状态 纯逻辑检查(票 11)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail.mjs * * 六段: * A 分区映射:后端四态 → 前端分区词汇;每个分区各带自己的 state/reason/message * B 视图层:六块面板计划、缺值占位符、空/失败/不可用的措辞、字段缺失清单 * C store:按**设备编码**取数、换设备竞态收口、已答过的同一台不重复取、失败可重试 * D 分区独立:一块失败不遮盖其它成功块(含对抗式反证:真的只坏了一块) * E 最新图片:只展示后端指定的那一张(结构化验证:前端没有"挑一张"的路径) * F 静态守卫:组件不拼路径、不认后端裸字段、不排序、不抢票 12/13 的活 * * 契约依据:`CockpitEquipmentDetailAggregateResponse`(后端演示页 * `equipment-detail-aggregate-demo.html`)—— 每个分区自带 `state/reason/message/data`。 */ import { readFileSync } from 'node:fs' import { createPinia, setActivePinia } from 'pinia' import { EQUIPMENT_SECTIONS, toEquipmentDetailAggregate, toDeviceImage, toSection, } from '../../../src/modules/cockpit/domain/equipmentDetail.js' import { SECTION_STATE } from '../../../src/modules/cockpit/domain/sectionState.js' import { DEFERRED_SECTIONS, EQUIPMENT_SECTION_PANELS, archiveRows, collectMissing, emptyListText, aggregateDeviceCode, aggregateUnavailable, equipmentDetailMatches, equipmentPanels, eventRows, imageView, metricRows, metricsSourceText, missingNote, quantityText, sectionHasContent, sectionPlaceholder, valueText, workOrderRows, } from '../../../src/modules/cockpit/domain/equipmentDetailView.js' import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js' import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js' import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js' import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.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 readCode = (relative) => code(read(relative)) /** * `.vue` 的注释是 ``(HTML),上面那个只剥 JS 注释的 `code()` 剥不掉 —— * 组件的抬头注释里**故意**写着 `FAILED / STALE / EMPTY` 这些后端词来解释语义, * 不剥就会把"解释"当成"泄漏"(本守卫第一版正是这么假红的)。 */ const codeVue = (text) => code(text.replace(//g, '')) const readVueCode = (relative) => codeVue(read(relative)) setActivePinia(createPinia()) /** 造一份后端分区(`CockpitSectionResult`)。 */ const rawSection = (state, { reason = 'SUCCESS', message = null, data = null } = {}) => ({ state, reason, message, data }) /** 造一份后端聚合载荷;只给想给的分区(其余留空=后端没给,前端应算失败)。 */ const rawAggregate = (deviceCode, sections = {}) => ({ deviceCode, aggregateState: 'READY', serverTime: '2026-09-02T10:00:00+08:00', ...sections, }) // ================================================================ A 分区映射 { const aggregate = toEquipmentDetailAggregate({ deviceCode: 'EQ-A', aggregateState: 'PARTIAL', archive: rawSection('FAILED', { reason: 'READ_FAILURE', message: '档案读不到' }), metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-A', state: 'CURRENT', metrics: [] } }), warnings: rawSection('SUCCESS', { data: [] }), alarms: rawSection('EMPTY', { reason: 'NO_EVENTS' }), workOrders: rawSection('STALE', { reason: 'SAMPLE_OUTDATED', data: [{ workOrderCode: 'WO-1', stateCode: 4 }] }), latestImage: rawSection('SUCCESS', { data: { url: '/cockpit/x.webp', capturedAt: '2026-08-30T10:20:00+08:00' } }), }) check( 'A1-all-eight-sections-present', EQUIPMENT_SECTIONS.length === 8 && EQUIPMENT_SECTIONS.every((name) => !!aggregate.sections[name]), `${EQUIPMENT_SECTIONS.length} 个分区都有位置(后端没给的也留位)`, ) check( 'A2-backend-state-mapped-1to1', aggregate.sections.archive.state === SECTION_STATE.FAILED && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS && aggregate.sections.alarms.state === SECTION_STATE.EMPTY && aggregate.sections.workOrders.state === SECTION_STATE.STALE, JSON.stringify({ archive: aggregate.sections.archive.state, metrics: aggregate.sections.metrics.state, alarms: aggregate.sections.alarms.state, workOrders: aggregate.sections.workOrders.state, }), ) check( 'A3-failed-section-keeps-backend-message', aggregate.sections.archive.reason === '档案读不到', `界面文案取后端 message:「${aggregate.sections.archive.reason}」`, ) check( 'A4-stale-keeps-value-and-flag', aggregate.sections.workOrders.retained === true && aggregate.sections.workOrders.data[0].code === 'WO-1', '过期分区保留最后已知值,并带 retained 标识(票 16:不得伪装成实时)', ) check( 'A5-empty-has-no-data', aggregate.sections.alarms.data === null, '空分区不伪造空数组冒充数据', ) check( 'A6-missing-section-counts-as-failed', aggregate.sections.timelines.state === SECTION_STATE.FAILED, '后端整段缺失的分区算失败,不算「成功但没内容」', ) check( 'A7-aggregate-state-not-authoritative', aggregate.aggregateState === 'PARTIAL' && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS, '界面分区状态以各分区自己为准,整体态漏标也不会拖垮成功分区', ) check( 'A8-unknown-state-falls-to-failed', toSection('archive', { state: 'SOMETHING_NEW', data: { deviceCode: 'X' } }).state === SECTION_STATE.FAILED, '认不出的后端状态不静默混进「已就绪」', ) } // ================================================================ B 视图层 { // 缺值 vs 真 0:这是票 16 与票 11 验收第 4 条的交汇点。 check( 'B1-zero-is-a-value-not-missing', valueText(0) === '0' && valueText(18.2) === '18.2' && quantityText(0, 'm³/h') === '0 m³/h', '后端真给的 0 是有效读数,照原样显示', ) check( 'B2-missing-shows-placeholder', valueText(null) === DETAIL_PLACEHOLDER && valueText(undefined) === DETAIL_PLACEHOLDER && valueText('') === DETAIL_PLACEHOLDER && quantityText(null, 'mg/L') === DETAIL_PLACEHOLDER, `缺值显示占位符(${DETAIL_PLACEHOLDER}),且不拼出「— mg/L」这种半截读数`, ) check( 'B3-placeholder-text-per-state', sectionPlaceholder({ state: SECTION_STATE.EMPTY }).includes('没有数据') && sectionPlaceholder({ state: SECTION_STATE.FAILED }).includes('加载失败') && sectionPlaceholder({ state: SECTION_STATE.UNAVAILABLE }).includes('不可用') && sectionPlaceholder({ state: SECTION_STATE.EMPTY }).includes('其它分区') === false && sectionPlaceholder({ state: SECTION_STATE.FAILED }).includes('其它分区') === true, '空 / 失败 / 不可用各有明确且不同的措辞(失败态明确说「不影响其它分区」)', ) check( 'B4-stale-and-success-have-content', sectionHasContent({ state: SECTION_STATE.SUCCESS }) === true && sectionHasContent({ state: SECTION_STATE.STALE }) === true && sectionHasContent({ state: SECTION_STATE.EMPTY }) === false && sectionHasContent({ state: SECTION_STATE.FAILED }) === false, '过期是有值(内容照画),空与失败才是没内容', ) // 档案行:字段缺失显示占位符,不是空白、也不是 0。 const archive = archiveRows({ code: 'EQ-1', name: '污水设备', assetValue: 128000, maintainer: '' }) check( 'B5-archive-rows-placeholder', archive.find((row) => row.key === 'assetValue').value === '128000' && archive.find((row) => row.key === 'maintainer').value === DETAIL_PLACEHOLDER && archive.find((row) => row.key === 'model').value === DETAIL_PLACEHOLDER, '有值的照原样、缺值的占位符', ) // 指标行:真 0 与缺值必须区分开(同一张表里)。 const metrics = metricRows({ metrics: [ { code: 'FLOW', name: '流量', value: 0, unit: 'm³/h' }, { code: 'COD', name: 'COD' }, ], }) check( 'B6-metric-zero-vs-missing', metrics[0].value === '0' && metrics[1].value === DETAIL_PLACEHOLDER, `真 0 → ${metrics[0].value};缺 value → ${metrics[1].value}`, ) // 工单行:状态名缺失时按状态字典(后端 0–7)补;未结案的真实 null 显示占位符。 const orders = workOrderRows([{ workOrderCode: 'WO-1', stateCode: 3, realFinishTime: null }, { workOrderCode: 'WO-2', stateCode: 99 }]) check( 'B7-work-order-state-from-dictionary', orders[0].stateName === '处理中' && orders[1].stateName === DETAIL_PLACEHOLDER, `码 3 → ${orders[0].stateName};认不出的码 99 → ${orders[1].stateName}(不猜)`, ) check( 'B8-unclosed-order-placeholder', orders[0].realFinishTime === DETAIL_PLACEHOLDER, '未结案的真实 null 与字段缺失一样显示占位符', ) // 事件行:等级只出文本,配色原样带出后端值(配色归票 13/19)。 const events = eventRows([ { warningNo: 'WRN-1', warningName: 'COD 预警', level: 2, levelColor: 'YELLOW', value: 18.2, unit: 'mg/L', eventTime: 'T' }, { warningNo: 'WRN-2', warningName: '缺等级预警' }, ]) check( 'B9-event-level-and-color', events[0].levelText === '2 级' && events[0].levelColor === 'YELLOW' && events[0].quantity === '18.2 mg/L' && events[1].levelText === DETAIL_PLACEHOLDER, '等级出文本、配色如实带后端值、缺等级占位符', ) // 字段缺失清单:要能穿透到嵌套层(单个指标自己记的 missing)。 const missing = collectMissing({ code: 'EQ-1', missing: ['archive.deviceName'], metrics: [{ code: 'COD', missing: ['metric.value'] }], }) check( 'B10-missing-collected-recursively', missing.includes('archive.deviceName') && missing.includes('metric.value') && missing.length === 2, `嵌套缺失也收上来:${missing.join(', ')}`, ) check( 'B11-missing-collector-survives-cycles', collectMissing((() => { const node = { missing: ['self'] } node.self = node return node })())[0] === 'self', '自引用形状不炸(契约缺口造出的怪数据也不能让界面挂掉)', ) // 给人读的缺失说明:只报条数,字段路径进 tooltip。 // 票 05 的真机走查逮到过「界面漏出内部代号」,所以这条是**回归**,不是新要求。 const note = missingNote({ missing: ['archive.deviceName', 'metric.value'] }) check( 'B11b-missing-note-hides-field-paths', note.count === 2 && note.text === '后端未提供 2 项字段' && !note.text.includes('archive.') && note.tooltip.includes('archive.deviceName'), `界面文案「${note.text}」不含字段路径;路径只在 tooltip:「${note.tooltip}」`, ) // 指标来源文案:认得出的翻人话,认不出的**回空串**(绝不把 SAMPLE_OUTDATED 这类代号画上去)。 check( 'B11c-source-reason-never-leaks-codes', metricsSourceText({ stateReason: 'READY' }) === '' && metricsSourceText({ stateReason: 'SAMPLE_OUTDATED' }) === '采样已超过配置周期' && metricsSourceText({ stateReason: 'SOMETHING_NEW' }) === '' && !metricsSourceText({ stateReason: 'SOMETHING_NEW' }).includes('SOMETHING_NEW'), `SAMPLE_OUTDATED → 「${metricsSourceText({ stateReason: 'SAMPLE_OUTDATED' })}」;未知码 → 空串(不泄漏)`, ) // 空列表提示:SUCCESS 但列表为空,仍是明确 UI 而不是一片空白。 check( 'B12-empty-list-is-explicit', emptyListText('工单') === '工单当前没有记录', `「${emptyListText('工单')}」`, ) } // ================================================================ C store 取数与竞态 { const store = useDetailStore() store.reset() store.configure({ config: mockConfig }) let aggregateRequests = 0 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) // 计数用:精确路径优先于 `/equipments/*/detail-aggregate` 模板。 registerMockTransport('/equipments/EQ-COUNT/detail-aggregate', () => { aggregateRequests += 1 return rawAggregate('EQ-COUNT', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-COUNT', deviceName: '计数设备' } }), metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-COUNT', state: 'CURRENT', metrics: [] } }), }) }) const selection = await store.selectDevice('EQ-COUNT') check( 'C1-select-device-loads-by-code', selection.level === 'equipment' && selection.deviceCode === 'EQ-COUNT' && store.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS && store.sections.equipmentAggregate.loaded === true && aggregateRequests === 1, `层级 ${selection.level}、请求 ${aggregateRequests} 次`, ) check( 'C2-aggregate-carries-requested-code', store.sections.equipmentAggregate.data.deviceCode === 'EQ-COUNT', '按设备编码取数(传错编码能看出来)', ) // 已答过的同一台设备:不再重复取数。 await store.selectDevice('EQ-COUNT') check( 'C3-same-device-does-not-refetch', aggregateRequests === 1, `再选同一台后请求数仍为 ${aggregateRequests}`, ) // 返回点位级再点回来:仍不重复取数(已加载且是同一台)。 store.selectDevice('EQ-COUNT') store.backToPoint() await store.selectDevice('EQ-COUNT') check( 'C4-back-and-forth-does-not-refetch', aggregateRequests === 1 && store.sections.equipmentAggregate.data.deviceCode === 'EQ-COUNT', `来回一次后请求数仍为 ${aggregateRequests}`, ) // 换设备:A 慢、B 快 —— B 必须赢,A 迟到不得覆盖。 let releaseA = null registerMockTransport('/equipments/EQ-SLOW-A/detail-aggregate', () => new Promise((resolve) => { releaseA = () => resolve(rawAggregate('EQ-SLOW-A', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-SLOW-A', deviceName: 'A 设备' } }) })) })) registerMockTransport('/equipments/EQ-FAST-B/detail-aggregate', () => rawAggregate('EQ-FAST-B', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FAST-B', deviceName: 'B 设备' } }) }), ) const pendingA = store.selectDevice('EQ-SLOW-A') await store.selectDevice('EQ-FAST-B') const afterB = store.sections.equipmentAggregate.data.deviceCode releaseA() await pendingA await new Promise((resolve) => setTimeout(resolve, 0)) check( 'C5-race-latest-wins', afterB === 'EQ-FAST-B' && store.selection.deviceCode === 'EQ-FAST-B' && store.sections.equipmentAggregate.data.deviceCode === 'EQ-FAST-B', `B 落地后聚合里是 ${store.sections.equipmentAggregate.data.deviceCode}(A 迟到被丢弃)`, ) check( 'C6-stale-aggregate-not-shown-for-new-device', equipmentDetailMatches(store.sections.equipmentAggregate, 'EQ-SLOW-A') === false && equipmentDetailMatches(store.sections.equipmentAggregate, 'EQ-FAST-B') === true, 'interface 的匹配判据挡住了「把 A 的载荷挂在 B 表头下」', ) // 失败留恢复路径:第一次抛错、第二次成功(同一个入口重试)。 let flakyAttempts = 0 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) registerMockTransport('/equipments/EQ-FLAKY/detail-aggregate', () => { flakyAttempts += 1 if (flakyAttempts === 1) throw new Error('mock: 第一次读取失败') return rawAggregate('EQ-FLAKY', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FLAKY', deviceName: '重试设备' } }) }) }) const retryStore = useDetailStore() retryStore.reset() retryStore.configure({ config: mockConfig }) await retryStore.selectDevice('EQ-FLAKY') const firstState = retryStore.sections.equipmentAggregate.state await retryStore.selectDevice('EQ-FLAKY') check( 'C7-failed-device-can-be-retried', firstState === SECTION_STATE.FAILED && flakyAttempts === 2 && retryStore.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS, `第一次 ${firstState} → 再选一次(第 ${flakyAttempts} 次请求)后 ${retryStore.sections.equipmentAggregate.state}`, ) // 关闭详情:在途请求作废,选择回空。 const closeStore = useDetailStore() closeStore.reset() closeStore.configure({ config: mockConfig }) const inFlight = closeStore.selectDevice('EQ-SLOW-A') const closed = closeStore.closeDetail() await inFlight check( 'C8-close-invalidates-in-flight', closed.level === 'none' && closeStore.selection.deviceCode === '' && closeStore.loading === false && closeStore.deviceLoading === false, JSON.stringify({ level: closed.level, deviceLoading: closeStore.deviceLoading }), ) // 未关联设备直接进设备级:同样按编码取数(没有点位摘要这一层)。 const deviceStore = useDetailStore() deviceStore.reset() deviceStore.configure({ config: mockConfig }) registerMockTransport('/equipments/EQ-FREE-9/detail-aggregate', () => rawAggregate('EQ-FREE-9', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FREE-9', deviceName: '未关联设备' } }) }), ) await deviceStore.openDevice('EQ-FREE-9') check( 'C9-unassociated-device-loads-aggregate', deviceStore.selection.pointCode === '' && deviceStore.selection.deviceCode === 'EQ-FREE-9' && deviceStore.sections.equipmentAggregate.loaded === true, JSON.stringify(deviceStore.selection), ) // 没有编码:不发请求,也不把分区卡在加载中。 const blankStore = useDetailStore() blankStore.reset() blankStore.configure({ config: mockConfig }) await blankStore.selectDevice('') check( 'C10-blank-code-does-not-load', blankStore.sections.equipmentAggregate.loaded === false && blankStore.deviceLoading === false, '缺编码不取数、不卡加载态', ) // 预载(loadSharedSections)与用户进级抢同一个分区:慢的那份不得覆盖快的那份。 // 场景是外壳挂载时预载演示设备,用户抢先点了另一台 —— 预载的迟到响应必须被丢弃。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) let releasePreload = null registerMockTransport('/equipments/TC-PS-WS-202609020012/detail-aggregate', () => new Promise((resolve) => { releasePreload = () => resolve(rawAggregate('TC-PS-WS-202609020012', { archive: rawSection('SUCCESS', { data: { deviceCode: 'TC-PS-WS-202609020012', deviceName: '预载演示设备' } }) })) })) const raceStore = useDetailStore() raceStore.reset() raceStore.configure({ config: mockConfig }) const preloading = raceStore.loadSharedSections({ config: mockConfig }) await raceStore.selectDevice('EQ-USER') const userCode = raceStore.sections.equipmentAggregate.data.deviceCode releasePreload() await preloading await new Promise((resolve) => setTimeout(resolve, 0)) check( 'C11-preload-does-not-clobber-user-device', userCode === 'EQ-USER' && raceStore.sections.equipmentAggregate.data.deviceCode === 'EQ-USER', `用户选的 ${userCode} 没被预载的演示设备覆盖(预载迟到被丢弃)`, ) // 整包失败时的界面判据:它要说"这台没取到",而不是逐块说"其它分区不受影响"。 // 用真 store 走一遍失败,断言判据在这一刻成立、且重试入口能恢复。 let downAttempts = 0 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) registerMockTransport('/equipments/EQ-DOWN/detail-aggregate', () => { downAttempts += 1 if (downAttempts === 1) throw new Error('mock: 整包读取失败') return rawAggregate('EQ-DOWN', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-DOWN', deviceName: '恢复设备' } }) }) }) const downStore = useDetailStore() downStore.reset() downStore.configure({ config: mockConfig }) await downStore.selectDevice('EQ-DOWN') const downSection = downStore.sections.equipmentAggregate check( 'C12-whole-failure-shows-single-truthful-state', downSection.state === SECTION_STATE.FAILED && aggregateUnavailable(downSection) === true && downSection.data === null, '整包失败:判据成立(界面据此只说一句实话,不逐块谎报"其它分区不受影响")', ) // 重试入口:再点一次同一台即可恢复(失败不算"已答过")。 await downStore.selectDevice('EQ-DOWN') check( 'C13-whole-failure-retry-recovers', downAttempts === 2 && downStore.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS && aggregateUnavailable(downStore.sections.equipmentAggregate) === false, `重试后第 ${downAttempts} 次请求 → ${downStore.sections.equipmentAggregate.state}(界面回到六块分区)`, ) } // ================================================================ D 分区独立(核心验收) { // 真机走同一份契约:`equipment-partial` 场景里有三块分区各自失败、三块照常成功。 const store = useDetailStore() store.reset() store.configure({ config: mockConfig }) resetMockTransportsForTest() registerCockpitMocks({ scenario: 'equipment-partial', handlers: { registerMockTransport, resetMockTransportsForTest } }) await store.selectDevice('TC-PS-WS-202609020012') const panels = equipmentPanels(store.sections.equipmentAggregate) const byName = Object.fromEntries(panels.map((panel) => [panel.name, panel])) check( 'D1-six-panels-in-order', panels.length === 6 && panels.map((panel) => panel.name).join(',') === 'archive,metrics,warnings,alarms,workOrders,latestImage', `${panels.length} 块:${panels.map((panel) => panel.label).join(' / ')}`, ) check( 'D2-one-failure-does-not-cover-others', byName.archive.section.state === SECTION_STATE.FAILED && byName.metrics.section.state === SECTION_STATE.SUCCESS && byName.warnings.section.state === SECTION_STATE.SUCCESS && byName.alarms.section.state === SECTION_STATE.SUCCESS && byName.workOrders.section.state === SECTION_STATE.FAILED && byName.latestImage.section.state === SECTION_STATE.FAILED, `档案=${byName.archive.section.state} 指标=${byName.metrics.section.state} 预警=${byName.warnings.section.state} 报警=${byName.alarms.section.state} 工单=${byName.workOrders.section.state} 图片=${byName.latestImage.section.state}`, ) check( 'D3-successful-panels-still-render-content', byName.metrics.hasContent === true && byName.metrics.section.data.metrics.length > 0 && byName.warnings.hasContent === true && byName.warnings.section.data.length > 0, `指标 ${byName.metrics.section.data.metrics.length} 条、预警 ${byName.warnings.section.data.length} 条仍然画得出来`, ) check( 'D4-failed-panels-say-why', byName.archive.hasContent === false && byName.archive.placeholder.includes('加载失败') && byName.archive.section.reason === '设备档案数据暂不可用', `失败块显示「${byName.archive.placeholder}」并带上后端原因`, ) // 对抗式反证:换一个**整体成功**的场景,同样的判据必须全绿 —— // 否则上面那条 D2 可能只是因为"所有块都失败了"而恒真。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) const allGoodStore = useDetailStore() allGoodStore.reset() allGoodStore.configure({ config: mockConfig }) await allGoodStore.selectDevice('TC-PS-WS-202609020012') const allGood = equipmentPanels(allGoodStore.sections.equipmentAggregate) check( 'D5-guard-has-teeth', allGood.every((panel) => panel.section.state === SECTION_STATE.SUCCESS) && allGood.every((panel) => panel.hasContent === true) && allGood.length === 6, '成功场景下六块全绿(因此 D2 的"部分失败"是有信息量的,不是恒真)', ) // 空场景:六块都该说「没有数据」,而不是显示虚假零值。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } }) const emptyStore = useDetailStore() emptyStore.reset() emptyStore.configure({ config: mockConfig }) await emptyStore.selectDevice('TC-PS-WS-202609020012') const emptyPanels = equipmentPanels(emptyStore.sections.equipmentAggregate) check( 'D6-empty-scenario-all-placeholders', emptyPanels.every((panel) => panel.section.state === SECTION_STATE.EMPTY && panel.hasContent === false) && emptyPanels.every((panel) => panel.placeholder === '该分区当前没有数据'), '空场景六块都是明确占位说明,没有一块冒充有数据', ) // 过期场景:指标块过期但有值,其它块照常成功(过期不牵连)。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'stale', handlers: { registerMockTransport, resetMockTransportsForTest } }) const staleStore = useDetailStore() staleStore.reset() staleStore.configure({ config: mockConfig }) await staleStore.selectDevice('TC-PS-WS-202609020012') const stalePanels = Object.fromEntries(equipmentPanels(staleStore.sections.equipmentAggregate).map((panel) => [panel.name, panel])) check( 'D7-stale-is-value-not-empty', stalePanels.metrics.section.state === SECTION_STATE.STALE && stalePanels.metrics.section.retained === true && stalePanels.metrics.hasContent === true && stalePanels.metrics.section.data.metrics.length > 0 && stalePanels.archive.section.state === SECTION_STATE.SUCCESS, `指标块 ${stalePanels.metrics.section.state} 但仍画出 ${stalePanels.metrics.section.data.metrics.length} 条读数;档案块照常 ${stalePanels.archive.section.state}`, ) } // ================================================================ D2 整包失败 ≠ 分区级失败 { // 这两件事必须分开,否则界面会对值班人员撒谎: // · 整包失败(端点读不到,data 为 null)→ 六块一起没数据,「其它分区不受影响」是假话; // · 分区级失败(data 在、各分区自带 state)→ 真正的部分失败,六块照常渲染。 const wholeFailed = { state: SECTION_STATE.FAILED, data: null, loaded: true, reason: 'unreachable' } const partialData = toEquipmentDetailAggregate({ deviceCode: 'EQ-P', aggregateState: 'PARTIAL', archive: rawSection('FAILED', { reason: 'READ_FAILURE', message: '档案读不到' }), metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-P', state: 'CURRENT', metrics: [{ metricCode: 'COD', value: 1 }] } }), }) const partial = { state: SECTION_STATE.SUCCESS, data: partialData, loaded: true } check( 'D8-whole-failure-detected', aggregateUnavailable(wholeFailed) === true && aggregateUnavailable(partial) === false, '整包失败与分区级失败分得开', ) check( 'D9-partial-failure-still-renders-panels', aggregateUnavailable(partial) === false && equipmentPanels(partial).length === 6, `分区级失败仍渲染六块(档案=${equipmentPanels(partial)[0].section.state}、指标=${equipmentPanels(partial)[1].section.state})`, ) // 未加载(还没请求)既不是失败也不是就绪:不该显示"这台设备详情没取到"。 check( 'D10-not-loaded-is-not-a-failure', aggregateUnavailable({ state: SECTION_STATE.EMPTY, data: null, loaded: false }) === false && aggregateUnavailable(null) === false, '还没取数时不报"没取到"', ) // 整包失败**无法**从载荷里认出是哪台设备(data 为 null、带不出编码)—— // 所以判定刻意不比对编码,而靠 store 的 token 纪律保证「失败态必定属于当前这台」。 // 这条断言把这个前提钉住:一旦有人让失败分区带上编码,就该重新审视上面的推理。 check( 'D11-whole-failure-carries-no-device-code', aggregateDeviceCode(wholeFailed) === '' && equipmentDetailMatches(wholeFailed, 'EQ-P') === false, '失败分区带不出设备编码(故判定不依赖编码;靠 token 纪律保证归属当前设备)', ) } // ================================================================ E 最新图片只展示一张 { const one = imageView({ url: '/cockpit/a.webp', capturedAt: 'T' }) check( 'E1-image-shows-backend-designated-one', one.hasImage === true && one.url === '/cockpit/a.webp', `画的是后端给的那一张:${one.url}`, ) check( 'E2-image-empty-and-missing-safe', imageView(null).hasImage === false && imageView({}).hasImage === false && imageView({ url: ' ' }).hasImage === false, '后端没给 / 给了空串都不画空图', ) check( 'E3-image-never-indexes-a-list', imageView([{ url: '/a.webp' }, { url: '/b.webp' }]).hasImage === false, '后端若给数组,前端**不**挑第一张(挑图=替后端做业务判断)', ) check( 'E4-adapter-image-is-single-value', toDeviceImage({ url: '/cockpit/a.webp' }).url === '/cockpit/a.webp' && (() => { const archive = toSection('archive', rawSection('SUCCESS', { data: { deviceCode: 'EQ-1', latestImage: { url: '/cockpit/a.webp' } } })) return !!archive.data.image && !Array.isArray(archive.data.image) && archive.data.image.url === '/cockpit/a.webp' })(), '档案里的 latestImage 是单值对象,不是列表', ) // 静态守卫:渲染层不该出现任何"从多张里挑一张"的代码。 const viewCode = readCode('domain/equipmentDetailView.js') const componentCode = readCode('components/EquipmentDetailSections.vue') check( 'E5-no-picking-among-images', !/\.find\(|\.filter\(|\.sort\(|\.slice\(/.test(viewCode.split('export function imageView')[1].split('export function')[0] || '') && !/latestImage\s*\[|\bimages\b/.test(componentCode), '图片路径里没有 find/filter/sort/slice,组件也不认数组', ) } // ================================================================ F 静态守卫 { const componentCode = readVueCode('components/EquipmentDetailSections.vue') const cardCode = readVueCode('components/PointDetailCard.vue') const viewCode = readCode('domain/equipmentDetailView.js') const storeCode = readCode('stores/detailStore.js') check( 'F1-component-does-not-build-api-paths', !/\/equipments\/|\/monitoring-points\//.test(componentCode) && !/fetch\(/.test(componentCode), '接口路径只在 services/detailApi.js,组件不自己拼', ) check( 'F2-component-has-no-backend-field-names', !/deviceModel|deviceSpec|ownershipUnit|maintainerPhone|expectedServiceYears|metricCode|warningNo|alarmId|workOrderCode|latestImage|aggregateState|stateReason/.test(componentCode), '组件只读视图对象字段(后端裸字段名只在 domain 适配层出现)', ) check( 'F3-component-does-not-sort', !/\.sort\(/.test(componentCode) && !/\.sort\(/.test(viewCode), '展示顺序由计划与后端顺序决定,前端不重排', ) check( 'F4-section-state-comes-from-section-state-module', /sectionView/.test(readCode('components/SectionStateTag.vue')) && !/is-lv1|#ff4d5e/.test(componentCode), '分区状态色键与文案只来自 domain/sectionState.js(组件不写第二套)', ) // 票 05 的真机走查逮到过「界面漏出内部原因码」(`unavailable` 那一次)。 // 设备详情多了一个同类风险面:后端 `stateReason` 与字段缺失清单都是接口词汇, // 直接铺到大屏上就是给值班人员看噪音。所以两类词汇都必须在 domain 层翻掉, // 组件里**不许**出现任何后端代号(当作词匹配,不要求带引号 —— // 真正会漏到界面上的形态恰恰是模板里的裸文本 `
SAMPLE_OUTDATED
`, // 只查引号字面量会放过它;这条守卫第一版就是这么假绿的)。 const reasonCodeLiterals = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'READ_FAILURE', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY'] const leaked = reasonCodeLiterals.filter((code) => new RegExp(`\\b${code}\\b`).test(componentCode)) check( 'F4b-no-backend-reason-codes-in-ui', leaked.length === 0, leaked.length ? `组件里出现后端代号:${leaked.join(', ')}` : '组件零后端代号(都是 domain 翻好的人话)', ) // 字段缺失清单同理:组件只画条数,字段路径不许出现在可见文案里。 check( 'F4c-no-field-paths-in-visible-text', !/\{\{\s*panel\.missing\.fields/.test(componentCode) && !/\{\{\s*panel\.missing\.join/.test(componentCode) && /:title="panel\.missing\.tooltip"/.test(componentCode), '缺失只报条数,字段路径只进 title(排查拿得到,值班人员看不到)', ) check( 'F5-deferred-sections-not-rendered', DEFERRED_SECTIONS.every((name) => !EQUIPMENT_SECTION_PANELS.some((panel) => panel.name === name)) && DEFERRED_SECTIONS.every((name) => !new RegExp(name).test(componentCode)), // 票 12 已接手 `timelines`(它现在是工单面板内的一部分,不再延后), // 所以这里只剩票 13 的预警-报警关联。 `预警-报警关联(票 13)不在本票渲染计划内:${DEFERRED_SECTIONS.join(', ')}`, ) check( 'F6-store-loads-aggregate-on-level-entry', /export function fetchEquipmentDetailAggregate/.test(readCode('services/detailApi.js')) && /fetchEquipmentDetailAggregate\(deviceCode/.test(storeCode) && /async function enterDeviceLevel/.test(storeCode), '设备级入口(selectDevice / openDevice)自己负责取数,调用方不必记得多调一次', ) check( 'F7-card-does-not-hold-its-own-aggregate-state', /useDetailStore/.test(cardCode) && /EquipmentDetailSections/.test(cardCode) && /storeToRefs\(detailStore\)/.test(cardCode) && /const aggregateSection = computed\(\(\) => \(aggregateMatches\.value \? sections\.value\.equipmentAggregate : null\)\)/.test(cardCode) && !/(?:aggregateSection|equipmentAggregate|sections)\s*=\s*(?:reactive|ref)\(/.test(cardCode.split('