| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- /**
- * 票 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 未自动执行)。')
|