/** * 票 65 离线闸门:预警效率面板按预警/报警分流 —— 预警族展示预警流程而非工单口径。 * * 背景(票面):维护者 2026-10-19 反馈第 6 条 —— 预警处置「预警效率」面板没有做 * 预警/报警模式的数据区分;预警模式下没有关联工单数据,应展示**预警流程**的信息。 * 实测(2026-10-21 复验,`192.168.110.96:8301`):`?eventFamily=WARNING` 与 `=ALARM` * 返回**同一套工单口径字段**;预警族 `totalEvents=5` 而 `linkedWorkOrderCount=0`、 * `missingWorkOrderCount=5` —— 预警不挂工单,照画工单口径就是「用一个不存在的对象统计预警」。 * * 维护者裁定(方案 A):本票只做**诚实的语义分流**,不假装后端已有预警流程字段; * 预警族只画该端点里真实属于预警的读数(条数、逐事件等级、逐桶发生量、工单关联实况), * 工单处置率/工单状态分布/结案时长在预警模式下**不上屏**;契约缺口写进界面说明并另开后端票。 * * 断言分组(都不启浏览器、不联网;渲染层用 Vue SSR 跑真实组件): * A 真实载荷两族:预警族读数全是真实计数、视图**不含**工单口径;报警族保留工单口径。 * B 标题/口径说明/KPI 提示随切换一起换,词表复用 `eventListSwitchView` 单源。 * C 分流依据:`data.family`(后端回显)优先于 `extra.mode`(切换参数),都不给才落报警口径。 * D 渲染层 SSR:预警模式 DOM 无处置率/工单状态分布;报警模式有;口径说明在 DOM 里。 * E 接线:面板组件把 `eventStore.mode` 交给 domain;外壳标题走 domain 单源。 * F mock 同构:mock 两族与真实同形(含工单字段,不把缺口藏起来),过同一 domain 得同一结论。 * G 缺失即占位:读数缺失画 `—`,不把「没有」画成 0(票 22/42 同一纪律)。 * * 变异有牙见 t65-mutations.mjs。 * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t65-warning-efficiency-family-split.mjs */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { registerHooks } from 'node:module' import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' import { createSSRApp, h } from 'vue' import { renderToString } from '@vue/server-renderer' const SRC = '../../../src/modules/cockpit/' const ROOT = new URL('../../../', import.meta.url) const read = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8') const readVerifier = (rel) => readFileSync(new URL(rel, import.meta.url), 'utf8') // .vue 一律走 SSR 编译钩子(t64 成例:共享子组件按 parentURL 传播,否则子组件只有 render)。 registerHooks({ resolve(specifier, context, nextResolve) { const resolved = nextResolve(specifier, context) const parent = context.parentURL || '' if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) { return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true } } return resolved }, load(url, context, nextLoad) { if (url.endsWith('.json')) { return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` } } const isClient = url.endsWith('.vue?client') if (url.endsWith('.vue') || isClient) { const path = isClient ? url.slice(0, -'?client'.length) : url const source = readFileSync(new URL(path), 'utf8') const { descriptor } = parse(source, { filename: path }) const script = compileScript(descriptor, { id: 't65', genDefaultAs: '__sfc__' }) const template = compileTemplate({ source: descriptor.template.content, filename: path, id: 't65', ssr: !isClient, compilerOptions: { bindingMetadata: script.bindings }, }) assert.deepEqual(template.errors, [], `${path} template`) const tail = isClient ? '__sfc__.render = render; export default __sfc__;' : '__sfc__.ssrRender = ssrRender; export default __sfc__;' return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` } } return nextLoad(url, context) }, }) const { ALARM_EFFICIENCY_BASIS, WARNING_EFFICIENCY_GAP, responseEfficiencyPanel, responseEfficiencyTitle, toHandlingEfficiency, } = await import(`${SRC}domain/warningResponsePanels.js`) const { eventListSwitchView } = await import(`${SRC}domain/eventList.js`) const { SECTION_STATE, section } = await import(`${SRC}domain/sectionState.js`) const { default: PanelBlocks } = await import(`${SRC}components/PanelBlocks.vue`) let passed = 0 let failed = 0 function check(name, condition, detail = '') { if (condition) { passed += 1 console.log(`PASS ${name}`) } else { failed += 1 console.error(`FAIL ${name}: ${detail}`) } } const okSection = (data) => ({ ...section(SECTION_STATE.SUCCESS, { data }), loaded: true }) const viewOf = (data, extra) => responseEfficiencyPanel(data, okSection(data), extra) const kpiOf = (view) => (view.blocks.find((block) => block.kind === 'kpi') || { items: [] }).items const kpiKeys = (view) => kpiOf(view).map((item) => item.key) const blockLabels = (view) => view.blocks.map((block) => block.label).filter(Boolean) const renderPanel = async (view) => renderToString(createSSRApp({ render: () => h(PanelBlocks, { view }) })) const WORK_ORDER_ONLY_KEYS = ['handlingRate', 'responseDuration', 'closeDuration'] const WARNING_ONLY_KEYS = ['warningTotal', 'warningLinked', 'warningMissing'] // ================================================================ A. 真实载荷两族 // 载荷来自 2026-10-21 对 192.168.110.96:8301 的只读实测(同目录 artifacts/t65/)。 const liveWarning = JSON.parse(readVerifier('./artifacts/t65/handling-warning-live.json')).data const liveAlarm = JSON.parse(readVerifier('./artifacts/t65/handling-alarm-live.json')).data const warningData = toHandlingEfficiency(liveWarning) const alarmData = toHandlingEfficiency(liveAlarm) const warningView = viewOf(warningData, { mode: 'WARNING' }) const alarmView = viewOf(alarmData, { mode: 'ALARM' }) { check( 'A1 两族族别取自后端回显(同一响应类,靠 eventFamily 分流)', warningData.family === 'WARNING' && alarmData.family === 'ALARM', `warning=${warningData.family} alarm=${alarmData.family}`, ) check( 'A2 预警族工单关联实况是真实计数(预警不挂工单:0 关联 / 全部未关联)', warningData.totalEvents === liveWarning.summary.totalEvents && warningData.linkedWorkOrderCount === liveWarning.summary.linkedWorkOrderCount && warningData.missingWorkOrderCount === liveWarning.summary.missingWorkOrderCount && warningData.linkedWorkOrderCount === 0 && warningData.missingWorkOrderCount === warningData.totalEvents, `total=${warningData.totalEvents} linked=${warningData.linkedWorkOrderCount} missing=${warningData.missingWorkOrderCount}`, ) // A3 预警族 KPI 全是预警自身读数,读数取自真实载荷(不是编的、也不是工单口径) const keys = kpiKeys(warningView) const items = kpiOf(warningView) check( 'A3 预警族 KPI = 预警自身三项读数(条数/已关联/未关联)', JSON.stringify(keys) === JSON.stringify(WARNING_ONLY_KEYS) && items[0].value === String(warningData.totalEvents) && items[1].value === String(warningData.linkedWorkOrderCount) && items[2].value === String(warningData.missingWorkOrderCount), `keys=${keys.join('/')} values=${items.map((item) => item.value).join('/')}`, ) // A3b 「预警不挂工单」这句必须在 KPI 提示或口径说明里说出口(不能说了一半)。 const warningText = JSON.stringify(warningView) check('A3b 预警族如实说明「预警不挂工单」', warningText.includes('预警不挂工单'), 'KPI hint / 口径说明都没说这句') // A4 预警模式下**不得**画工单口径:KPI 键、块标题、块类型三重判据 const labels = blockLabels(warningView) check( 'A4 预警模式不画工单口径(处置率/工单状态分布/结案时长)', !keys.some((key) => WORK_ORDER_ONLY_KEYS.includes(key)) && !labels.some((label) => /工单状态分布|结案时长|处置率/.test(label)) && !warningView.blocks.some((block) => block.kind === 'stacked'), `keys=${keys.join('/')} labels=${labels.join('/')} kinds=${warningView.blocks.map((b) => b.kind).join('/')}`, ) // A4b 更硬的一条:把**画出来的块**(KPI 项/分布行/趋势点,即 view.blocks 本体) // 序列化后,工单口径的基线与词汇一概不出现 —— 防止「字段名换成中文但数值还是工单口径」 // 这类改字不改数的假分流。范围**只含块**:口径说明(`view.note`)按设计要点名 // 「工单处置率/工单状态分布」来说清它们为何不呈现,把它算进来是把「解释」当「读数」。 check( 'A4b 预警模式画出来的块里零工单口径词汇与基线', !/CLOSED_WORK_ORDERS|可计入工单|已结案|工单状态/.test(JSON.stringify(warningView.blocks)), `blocks=${JSON.stringify(warningView.blocks).slice(0, 300)}`, ) // A4c 反过来:预警族薪资的**真实**读数必须在(分流的另一半:不是把面板清空)。 check( 'A4c 预警模式确有预警流程读数(等级分布 + 发生趋势)', warningView.blocks.some((block) => block.kind === 'bars' && block.label === '预警等级分布') && warningView.blocks.some((block) => block.kind === 'trend'), `kinds=${warningView.blocks.map((b) => b.kind).join('/')}`, ) // A5 等级分布逐级对真实事件行(4 行事件:1 级 4 条、2 级 1 条) const realLevels = [1, 2, 3, 4].map((level) => liveWarning.events.filter((item) => Number(item.level) === level).length) const barsBlockOfWarning = warningView.blocks.find((block) => block.kind === 'bars') check( 'A5 预警等级分布逐级等于真实事件行统计', JSON.stringify((barsBlockOfWarning.rows || []).map((row) => row.count)) === JSON.stringify(realLevels) && (barsBlockOfWarning.rows || []).length === 4, `rows=${(barsBlockOfWarning.rows || []).map((row) => row.count).join('/')} want=${realLevels.join('/')}`, ) // A6 发生趋势按日合并(真实 169 个小时桶 → 每自然日一个点;不重采样、不补点)。 // 期望值由载荷自己算:桶数 = 覆盖的自然日数,非零点数 = 有事件的自然日数, // 求和 = 载荷逐桶 `eventCount` 的总和(受日合并后不丢量)。 const trendBlockOfWarning = warningView.blocks.find((block) => block.kind === 'trend') const liveBuckets = liveWarning.durationTrend const dayOf = (bucket) => String(bucket).slice(0, 10) const expectDays = new Set(liveBuckets.map((row) => dayOf(row.timeBucket))).size const activeBuckets = liveBuckets.filter((row) => row.eventCount > 0) const expectEventDays = new Set(activeBuckets.map((row) => dayOf(row.timeBucket))).size const expectEventSum = activeBuckets.reduce((sum, row) => sum + row.eventCount, 0) const nonzeroTrend = (trendBlockOfWarning.points || []).filter((point) => point.count > 0) check( 'A6 预警发生趋势按日合并(小时桶→天,不重采样、不补点)', liveBuckets.length > 48 && (trendBlockOfWarning.points || []).length === expectDays && nonzeroTrend.length === expectEventDays && nonzeroTrend.reduce((sum, point) => sum + point.count, 0) === expectEventSum, `buckets=${liveBuckets.length} points=${(trendBlockOfWarning.points || []).length}/${expectDays} nonzero=${nonzeroTrend.length}/${expectEventDays}`, ) // A7 报警族保留工单口径(既有裁定 41/42/52 不重开) const alarmKeys = kpiKeys(alarmView) const alarmLabels = blockLabels(alarmView) check( 'A7 报警族保留工单口径(处置率/响应与关闭时长/工单状态分布)', JSON.stringify(alarmKeys) === JSON.stringify(WORK_ORDER_ONLY_KEYS) && alarmLabels.includes('工单状态分布') && alarmLabels.includes('平均结案时长趋势') && alarmView.blocks.some((block) => block.kind === 'stacked'), `keys=${alarmKeys.join('/')} labels=${alarmLabels.join('/')}`, ) // A7b 报警族的处置率读数与真实载荷一致(0/1 可计入 → 0.0%,不是「不适用」) const alarmRate = kpiOf(alarmView)[0] check( 'A7b 报警族处置率读数取自真实工单口径(0/1 → 0.0%)', alarmRate.value === '0.0%' && liveAlarm.summary.handlingEfficiency.denominator === 1 && liveAlarm.summary.handlingEfficiency.numerator === 0, `value=${alarmRate.value}`, ) // A7c 报警模式不画预警自身读数(分流是对称的,不是只给预警加口径) check( 'A7c 报警模式不出现预警自身 KPI', !alarmKeys.some((key) => WARNING_ONLY_KEYS.includes(key)), `keys=${alarmKeys.join('/')}`, ) } // ================================================================ B. 标题 / 口径说明 / KPI 提示随切换一起换 { // B1 标题的词根来自**同一张切换词表**(改词表则标题跟着变 —— 行为断言,不是比对字面量) const warningSwitch = eventListSwitchView('WARNING') const alarmSwitch = eventListSwitchView('ALARM') check( 'B1 面板标题复用事件族单一词表(预警/报警 + 「效率」)', responseEfficiencyTitle('WARNING') === `${warningSwitch.label}效率` && responseEfficiencyTitle('ALARM') === `${alarmSwitch.label}效率` && responseEfficiencyTitle('WARNING') !== responseEfficiencyTitle('ALARM'), `warning=${responseEfficiencyTitle('WARNING')} alarm=${responseEfficiencyTitle('ALARM')}`, ) check( 'B2 两族视图的面板标题跟着族换', warningView.label === responseEfficiencyTitle('WARNING') && alarmView.label === responseEfficiencyTitle('ALARM'), `warning=${warningView.label} alarm=${alarmView.label}`, ) // B3 口径说明随族换,且各自说清本族口径(预警:不挂工单 + 缺口;报警:工单是处置手段) check( 'B3 口径说明随族换且各自如实', warningView.note === WARNING_EFFICIENCY_GAP && alarmView.note === ALARM_EFFICIENCY_BASIS && /预警不挂工单|没有预警自身的处置流程统计/.test(warningView.note) && /工单为处置手段/.test(alarmView.note), `warningNote=${warningView.note}`, ) // B4 两族窗口读数的落点不同:预警给条数/关联实况,报警给可计入工单/已结案 check( 'B4 窗口读数随族换(预警=条数与关联实况;报警=工单张数)', /本窗口预警 5 条/.test(warningView.rangeNote) && /已关联工单 0 条/.test(warningView.rangeNote) && /可计入工单 1 张/.test(alarmView.rangeNote) && warningView.rangeNote !== alarmView.rangeNote, `warning=${warningView.rangeNote} | alarm=${alarmView.rangeNote}`, ) } // ================================================================ C. 分流依据优先级 { // C1 后端回显优先:数据是预警族的,切换参数传错也不得画成报警口径。 const echoed = viewOf(warningData, { mode: 'ALARM' }) check( 'C1 data.family(后端回显)优先于 extra.mode(切换参数)', echoed.label === responseEfficiencyTitle('WARNING') && !kpiKeys(echoed).some((key) => WORK_ORDER_ONLY_KEYS.includes(key)), `label=${echoed.label} keys=${kpiKeys(echoed).join('/')}`, ) // C2 回显缺失时靠切换参数分流(mock / 旧载荷可能不给 eventFamily) const noFamily = { ...warningData, family: '' } const byMode = viewOf(noFamily, { mode: 'WARNING' }) check( 'C2 回显缺失时按切换参数分流(extra.mode=WARNING → 预警口径)', byMode.label === responseEfficiencyTitle('WARNING') && JSON.stringify(kpiKeys(byMode)) === JSON.stringify(WARNING_ONLY_KEYS), `label=${byMode.label} keys=${kpiKeys(byMode).join('/')}`, ) // C3 两者都没有时落报警口径(工单口径是既有形态;旧调用点(t19/t16/t44)不因本票改签名而变空壳) const fallback = viewOf({ ...alarmData, family: '' }, undefined) check( 'C3 回显与切换参数都没有时保持既有报警口径(旧调用点不退化)', fallback.label === responseEfficiencyTitle('ALARM') && JSON.stringify(kpiKeys(fallback)) === JSON.stringify(WORK_ORDER_ONLY_KEYS), `label=${fallback.label} keys=${kpiKeys(fallback).join('/')}`, ) } // ================================================================ D. 渲染层 SSR { const warningHtml = await renderPanel(warningView) const alarmHtml = await renderPanel(alarmView) check( 'D1 预警模式 DOM 有预警读数、无处置率与工单状态分布', /data-kpi="warningTotal"/.test(warningHtml) && /data-kpi="warningLinked"/.test(warningHtml) && !/data-kpi="handlingRate"/.test(warningHtml) && !/data-panel-block="stacked"/.test(warningHtml), '预警 DOM 里仍出现工单口径块', ) check( 'D2 报警模式 DOM 有处置率与工单状态分布、无预警读数', /data-kpi="handlingRate"/.test(alarmHtml) && /data-panel-block="stacked"/.test(alarmHtml) && !/data-kpi="warningTotal"/.test(alarmHtml), '报警 DOM 与工单口径不一致', ) // D3 口径说明走票 56 的折叠摘要:文本在 DOM 里(v-show 折叠不丢信息),断言面是 data-panel-note。 check( 'D3 预警口径缺口说明在 DOM 里(折叠不丢文本)', /data-panel-note/.test(warningHtml) && warningHtml.includes('预警不挂工单'), '说明字段没进折叠摘要(会在界面上隐形 —— 票 56 记过的同类缺陷)', ) // D4 预警模式面板标题在 DOM 里随族换 check( 'D4 预警模式 DOM 标题为「预警效率」,报警模式为「报警效率」', warningHtml.includes('预警效率摘要') && alarmHtml.includes('报警效率摘要'), '标题没随族换', ) } // ================================================================ E. 接线(源码级判据) { const pane = read('components/WarningResponsePane.vue') const shell = read('CockpitShell.vue') check( 'E1 面板组件把当前切换族交给 domain(否则预警接线永远落回报警口径)', // 票 65 追加需求后 `extra` 多了 `workOrders`(报警模式的工单列表数据源), // 所以这里不再钉字面量写法——只钉**行为**:panelView 的第四参里出现 // `mode: eventStore.mode`(顺序、同行与否都不影响语义,避免"改格式就误报")。 /panelView\(\s*props\.panelId,\s*current\.data,\s*current,\s*\{[\s\S]*?mode:\s*eventStore\.mode[\s\S]*?\}\s*\)/.test(pane), 'WarningResponsePane 没传 extra.mode ⇒ 预警模式仍画工单口径', ) check( 'E2 外壳面板标题走 domain 单源(responseEfficiencyTitle),不另起一张词表', /function panelTitle\(panelId\)/.test(shell) && /responseEfficiencyTitle\(eventStore\.mode\)/.test(shell) && /:title="panelTitle\(panelId\)"/.test(shell), '外壳标题没接 domain 单源', ) // E3 词表只有一份:面板标题不得另写一份 `预警效率`/`报警效率` 字面量(domain 之外零字面量) const shellWithoutImport = shell.replace(/import \{[^}]*responseEfficiencyTitle[^}]*\} from '\.\/domain\/warningResponsePanels\.js'/, '') check( 'E3 预警/报警效率标题字面量只存在于 domain(外壳零第二份)', !/'预警效率'|'报警效率'|`预警效率`|`报警效率`/.test(shellWithoutImport) && !/'报警效率'/.test(pane), '外壳/组件里又写了一份标题字面量(早晚漂移)', ) } // ================================================================ F. mock 同构 { const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`) const transports = {} registerCockpitMocks({ handlers: { registerMockTransport: (path, handler) => { transports[path] = handler } } }) // mock 处理器交出的是**业务载荷**本身(信封由 transport 层包),与真实 `data` 同形。 const mockOf = async (query) => transports['/events/handling-efficiency']({ path: `/events/handling-efficiency${query}` }) const rawWarning = await mockOf('?eventFamily=WARNING') const rawAlarm = await mockOf('?eventFamily=ALARM') const mockWarningData = toHandlingEfficiency(rawWarning) const mockAlarmData = toHandlingEfficiency(rawAlarm) const mockWarningView = viewOf(mockWarningData, { mode: 'WARNING' }) const mockAlarmView = viewOf(mockAlarmData, { mode: 'ALARM' }) check( 'F1 mock 预警族与真实同形(不挂工单:0 关联 / 全部未关联)', rawWarning.eventFamily === 'WARNING' && mockWarningData.linkedWorkOrderCount === 0 && mockWarningData.missingWorkOrderCount === mockWarningData.totalEvents && mockWarningData.totalEvents > 0, `total=${mockWarningData.totalEvents} linked=${mockWarningData.linkedWorkOrderCount}`, ) // F2 mock **不**替后端把工单字段藏掉:契约缺口必须在 mock 上也看得见(否则真机才会炸)。 check( 'F2 mock 两族仍保留工单口径字段(契约现状,不伪装成已分流)', !!rawWarning.summary.handlingEfficiency && Array.isArray(rawWarning.workOrderStateCounts) && rawWarning.workOrderStateCounts.length === 8 && !!rawAlarm.summary.handlingEfficiency, 'mock 把工单字段藏起来了 —— 缺口被 mock 掩盖', ) // F3 同一 domain 消费 mock → 与真实同一个结论(预警不画工单口径;报警保留) check( 'F3 mock 走同一 domain 得同一结论(预警口径 / 报警口径)', mockWarningView.label === responseEfficiencyTitle('WARNING') && !kpiKeys(mockWarningView).some((key) => WORK_ORDER_ONLY_KEYS.includes(key)) && mockAlarmView.label === responseEfficiencyTitle('ALARM') && JSON.stringify(kpiKeys(mockAlarmView)) === JSON.stringify(WORK_ORDER_ONLY_KEYS), `mockWarning=${kpiKeys(mockWarningView).join('/')} mockAlarm=${kpiKeys(mockAlarmView).join('/')}`, ) // F4 mock 报警族有真实的关联工单读数(与预警族的 0 形成对照,证明分流不是「两族都画 0」) check( 'F4 mock 报警族有承接工单(与预警族 0 关联形成对照)', mockAlarmData.linkedWorkOrderCount > 0 && mockWarningData.linkedWorkOrderCount === 0, `alarm linked=${mockAlarmData.linkedWorkOrderCount} warning linked=${mockWarningData.linkedWorkOrderCount}`, ) } // ================================================================ G. 缺失即占位(不把「没有」画成 0) { const bare = toHandlingEfficiency({ eventFamily: 'WARNING', durationTrend: [], events: [] }) const view = viewOf(bare, { mode: 'WARNING' }) const items = kpiOf(view) check( 'G1 预警族读数缺失画占位符(不把「没有」画成 0 条)', bare.totalEvents === null && items.every((item) => item.value === '—'), `values=${items.map((item) => item.value).join('/')}`, ) check( 'G2 缺失时不编造窗口读数(rangeNote 为空串而不是「0 条」)', view.rangeNote === '', `rangeNote=${view.rangeNote}`, ) } // ================================================================ H. 报警族「当前工单列表」(2026-10-21 追加需求 1) /** * 背景:维护者追加要求「报警效率页面的报警模式,除了报警数据外还需直接展示当前工单列表 * (卡片形式,可点开查看工单详情)」。 * * 先说清数据来源,这是本组所有断言的前提:`handling-efficiency` **不返回工单实体**, * 只有状态计数(实测算术:报警族八态合计 0,但同一台设备 `/equipments/…/work-orders` * 能查到 7 张工单)。所以列表必须来自另一个端点,本组按 store 真实传入的形状喂它。 */ { const listOrders = [ { code: 'WO-A', stateCode: 0, stateName: '待派单', description: '监测报警,请现场核实', createTime: '2026-10-14T13:43:50+08:00', orderTypeName: '故障维修', associatedWithCurrentEvent: true }, { code: 'WO-B', stateCode: 5, stateName: '已结案', description: '已处理', createTime: '2026-10-12T11:03:50+08:00', orderTypeName: '', associatedWithCurrentEvent: false }, ] const loadedList = { loaded: true, state: 'success', data: { orders: listOrders, pagination: { page: 1, pageSize: 5, total: 7, totalPages: 2 } }, } const alarmWithList = viewOf(alarmData, { mode: 'ALARM', workOrderList: loadedList }) const listBlockOf = (view) => view.blocks.find((block) => block.kind === 'list') || null const block = listBlockOf(alarmWithList) check( 'H1 报警模式出现「当前工单列表」块,且排在统计图之后(先结论后明细)', !!block && block.label === '当前工单列表' && alarmWithList.blocks.indexOf(block) === alarmWithList.blocks.length - 1, `blocks=${blockLabels(alarmWithList).join('/')}`, ) check( 'H2 卡片行带可点契约与状态词表(code 触发渲染层 open-card;tone 来自既有色键)', !!block && block.rows.length === 2 && block.rows[0].code === 'WO-A' && block.rows[0].toneLabel === '待派单' && // 色键必须是 StatusTag 认识的封闭词表成员,否则标签会退回中性灰而不报错 ['lv1', 'lv2', 'lv3', 'lv4', 'neutral'].includes(block.rows[0].tone), `row0=${JSON.stringify(block && block.rows[0])}`, ) check( 'H3 分页读后端回显(共 7 张 / 2 页),不由本页 2 条反推', !!block && block.serverPage.page === 1 && block.serverPage.pageCount === 2 && block.serverPage.total === 7, `serverPage=${JSON.stringify(block && block.serverPage)}`, ) check( 'H4 口径说明点明「与工单状态分布是同一批工单」(时间窗对齐后不再说"两个粒度")', !!block && /同一批工单/.test(block.pageNote) && /共 7 张/.test(block.pageNote) && /创建时间/.test(block.pageNote), `pageNote=${block && block.pageNote}`, ) // H5–H7:三态如实——「正在取数」「未取到」「确实没有」是三句不同的话(票 11/18 分区纪律) const pendingView = viewOf(alarmData, { mode: 'ALARM', workOrderList: { loaded: false, state: 'empty', data: null } }) check( 'H5 未取到时不画假空列表(说「正在取数」而不是「当前没有工单」)', /正在取数/.test((listBlockOf(pendingView) || {}).emptyText || ''), `emptyText=${(listBlockOf(pendingView) || {}).emptyText}`, ) const failedView = viewOf(alarmData, { mode: 'ALARM', workOrderList: { loaded: true, state: 'failed', data: null } }) check( 'H6 取数失败说「未取到」,与「确实没有」分开(不把缺数据画成 0 张)', /未取到/.test((listBlockOf(failedView) || {}).emptyText || ''), `emptyText=${(listBlockOf(failedView) || {}).emptyText}`, ) const emptyView = viewOf(alarmData, { mode: 'ALARM', workOrderList: { loaded: true, state: 'success', data: { orders: [], pagination: { page: 1, pageSize: 5, total: 0, totalPages: 1 } } } }) check( 'H7 真的 0 张时说「当前没有工单」(真实业务结论,不是加载失败)', (listBlockOf(emptyView) || {}).emptyText === '当前没有工单', `emptyText=${(listBlockOf(emptyView) || {}).emptyText}`, ) // H8:预警族**不得**出现工单列表 —— 预警不挂工单,列出来就是上一轮同样要防的假读数 const warningWithList = viewOf(warningData, { mode: 'WARNING', workOrderList: loadedList }) check( 'H8 预警模式不出现工单列表(预警不挂工单,与「不画工单口径」同一条纪律)', !listBlockOf(warningWithList) && !blockLabels(warningWithList).includes('当前工单列表'), `blocks=${blockLabels(warningWithList).join('/')}`, ) // H9:状态色调覆盖全码表且不新增词表 —— 8 个码都要落在 StatusTag 认识的色键上 const { workOrderStateTone } = await import(`${SRC}domain/warningResponsePanels.js`) const tones = [0, 1, 2, 3, 4, 5, 6, 7, null, 99].map((code) => workOrderStateTone(code)) check( 'H9 工单状态色调对 8 个码 + 缺失/未知都给既有色键(不新增第二张词表、不返回 undefined)', tones.every((tone) => ['lv1', 'lv2', 'lv3', 'lv4', 'neutral'].includes(tone)), `tones=${tones.join('/')}`, ) check( 'H9b 色调随状态语义分档(待办≠在办≠已结案≠已取消)', workOrderStateTone(0) === 'lv1' && workOrderStateTone(3) === 'lv3' && workOrderStateTone(5) === 'lv4' && workOrderStateTone(6) === 'lv2' && workOrderStateTone(7) === 'neutral', `0=${workOrderStateTone(0)} 3=${workOrderStateTone(3)} 5=${workOrderStateTone(5)} 6=${workOrderStateTone(6)} 7=${workOrderStateTone(7)}`, ) } // ================================================================ I. 预警口径说明文案(2026-10-21 追加需求 2) /** * 背景:维护者看到预警模式摘要仍写着「本端点对预警族仍只返回工单口径字段…」, * 要求「确认预警数据是否正确」。复核结论是**数据没变、文案该改**: * 后端源码 `CockpitHandlingEfficiencyService` 的 `stateCounts` 来自 `scopeWorkOrders(...)`, * 注释原句「范围内全部工单(不要求关联报警/事件)」;实测预警族 `linkedWorkOrderCount=0`、 * 八态合计 0 —— 预警族**确实没有**预警自身的处置流程统计。 * * 但旧文案有三处毛病:① 「本端点」是接口词汇(项目纪律:接口词汇不上大屏); * ② 先讲"没有字段",读者要读两遍才知道面板上的数能不能信; * ③ 「工单关联实况」这类内部说法对值班人员无意义。本组钉新文案的三条底线。 */ { check( 'I1 预警口径说明不含接口词汇(「本端点」「该端点」「/events/」等不上大屏)', !/本端点|该端点|接口返回|\/events\//.test(WARNING_EFFICIENCY_GAP), `gap=${WARNING_EFFICIENCY_GAP}`, ) check( 'I2 前两句先说清「这些数是什么、能不能信」(先讲读数再讲缺口)', /预警不挂工单/.test(WARNING_EFFICIENCY_GAP) && /真实读数/.test(WARNING_EFFICIENCY_GAP), `gap=${WARNING_EFFICIENCY_GAP}`, ) check( 'I3 如实说明预警自身的处置流程统计尚未提供,并指向需求池(不假装已有)', /处置流程统计/.test(WARNING_EFFICIENCY_GAP) && /尚未提供/.test(WARNING_EFFICIENCY_GAP) && /需求池/.test(WARNING_EFFICIENCY_GAP), `gap=${WARNING_EFFICIENCY_GAP}`, ) check( 'I4 两族口径说明都不含接口词汇(报警那句同一纪律)', !/端点|接口返回/.test(ALARM_EFFICIENCY_BASIS), `basis=${ALARM_EFFICIENCY_BASIS}`, ) } // ================================================================ J. 全量工单端点契约(2026-10-21 追加需求 1) /** * 背景:报警模式的工单列表来自**新端点** `GET /api/cockpit/v1/work-orders` * (后端 `CockpitWorkOrderReadController.workOrderList`,与契约测试同源)。 * 本组钉住**前端消费侧**对那份响应的解析:字段名、分页四件套、关联标记。 * * 为什么必须有这一组:后端代码今天已在 checkout 里改好并通过 31 条契约测试, * 但**尚未部署**(实测该路径仍 404)。若不在这里钉住前端解析, * 等后端上线那天才发现字段对不上,就又要一轮返工。 */ { const { fetchWorkOrderList } = await import(`${SRC}services/detailApi.js`) const contractPayload = JSON.parse(readVerifier('./artifacts/t65b/work-order-list-contract.json')) let parsed = null let parseError = null try { parsed = await fetchWorkOrderList({ page: 1, pageSize: 5 }, { // REAL 模式才走 fetchImpl(mock 模式走 transport),这样能直接喂真实形状载荷; // 只要 AJAX 外壳,不需要真网络。 config: { dataSource: 'real', api: { baseUrl: 'http://contract.invalid' } }, fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({ code: 200, msg: 'ok', data: contractPayload }) }), }) } catch (error) { parseError = error } check( 'J1 前端能解析新端点的真实载荷(state/工单行/分页四件套都不丢)', !parseError && parsed && parsed.state === 'success' && parsed.data.orders.length === 1 && parsed.data.orders[0].code === contractPayload.workOrders[0].workOrderCode, parseError ? `解析抛错:${parseError.message}` : `pagination=${JSON.stringify(parsed && parsed.data && parsed.data.pagination)}`, ) check( 'J2 分页四件套逐项读后端回显(page/pageSize/total/totalPages,不由行数反推)', !!parsed && parsed.data.pagination.page === 1 && parsed.data.pagination.pageSize === 5 && parsed.data.pagination.total === 7 && parsed.data.pagination.totalPages === 2, `pagination=${JSON.stringify(parsed && parsed.data && parsed.data.pagination)}`, ) check( 'J3 工单的关联标记如实透传(列表里能分辨「关联了报警」与「纯设备工单」)', !!parsed && parsed.data.orders[0].associatedWithCurrentEvent === true, `assoc=${parsed && parsed.data && parsed.data.orders[0] && parsed.data.orders[0].associatedWithCurrentEvent}`, ) check( 'J4 长描述完整保留(截断责任在渲染层,不在解析层丢信息)', !!parsed && parsed.data.orders[0].description === contractPayload.workOrders[0].orderDesc, `desc=${parsed && parsed.data && parsed.data.orders[0] && parsed.data.orders[0].description}`, ) // J5:mock 与真实**同形**——mock 若没有 pagination,前端翻页与「共 N 张」永远测不到。 const { registerMockTransport, resetMockTransportsForTest } = await import(`${SRC}services/cockpitApi.js`) const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`) resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) const mockPage1 = await fetchWorkOrderList({ page: 1, pageSize: 5 }) const mockPage2 = await fetchWorkOrderList({ page: 2, pageSize: 2 }) check( 'J5 mock 全量工单与真实同形(有 pagination;换 pageSize 后翻页真的切窗口,不是永远回全量)', mockPage1.state === 'success' && !!mockPage1.data.pagination && mockPage1.data.pagination.total === 4 && mockPage1.data.orders.length === 4 && mockPage2.state === 'success' && mockPage2.data.orders.length === 2, `p1=${JSON.stringify(mockPage1.data && mockPage1.data.pagination)} rows=${mockPage1.data && mockPage1.data.orders.length} p2rows=${mockPage2.data && mockPage2.data.orders.length}`, ) resetMockTransportsForTest() } console.log('') console.log(`T65 预警效率两族语义分流:${passed}/${passed + failed} 项通过。`) if (failed) { console.log('未通过项见上方 FAIL。') process.exit(1) } console.log('T65 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')