| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752 |
- /**
- * 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(/<!--[\s\S]*?-->/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}`,
- )
- // 工单行:状态名缺失时按七态字典补;未结案的真实 null 显示占位符。
- const orders = workOrderRows([{ workOrderCode: 'WO-1', stateCode: 4, realFinishTime: null }, { workOrderCode: 'WO-2', stateCode: 99 }])
- check(
- 'B7-work-order-state-from-dictionary',
- orders[0].stateName === '处理中' && orders[1].stateName === DETAIL_PLACEHOLDER,
- `码 4 → ${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 层翻掉,
- // 组件里**不许**出现任何后端代号(当作词匹配,不要求带引号 ——
- // 真正会漏到界面上的形态恰恰是模板里的裸文本 `<p>SAMPLE_OUTDATED</p>`,
- // 只查引号字面量会放过它;这条守卫第一版就是这么假绿的)。
- 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)与预警-报警关联(票 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) && !/reactive\(|ref\(/.test(cardCode.split('<script setup>')[1] || ''),
- '组件把状态交给 store,自己不存一份(票 10 的 F6 立场延续)',
- )
- check(
- 'F8-no-write-operations',
- !/method:\s*'(POST|PUT|PATCH|DELETE)'|method:\s*"(POST|PUT|PATCH|DELETE)"/.test(readCode('services/detailApi.js')) &&
- !/@click="[^"]*(save|submit|update|delete)/i.test(componentCode),
- '驾驶舱只读:本票不引入任何写操作(spec 验收第 6 条)',
- )
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T11 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:' + failed.map((item) => item.name).join(', '))
- process.exit(1)
- }
|