| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632 |
- /**
- * T12 工单状态时间线 纯逻辑检查(票 12)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t12-work-order-timeline.mjs
- *
- * 六段:
- * A 状态字典:0–7 共 8 个码齐全、顺序即业务顺序、只有已结案(5)/已取消(7)是终态
- * B 动作判定:推进 / 回退 / 取消 / 旁路(延期中) / 同态 / 认不出的码(**回退是动作,不是终态**)
- * C 轨迹组装:按 operatedAt 升序、走到过的最远处、回退计数、每张工单一条
- * D 状态路径视图:码表一个不少、当前态与"走到过"分开、已取消与延期中都是路径外
- * E 分区独立:时间线分区失败 ≠ 没有动作记录(界面必须说两件不同的事)
- * F 静态守卫:没有伪状态、没有写操作、组件不认后端裸字段
- *
- * 契约依据:后端 `WorkOrderStatusEnum`(`com.zksy.ops.enums`,0–7 是唯一真理)、
- * `CockpitWorkOrderStateDictionary`、`CockpitWorkOrderTimelineResponse/Entry`
- * (`E:\pipe-ner-server\...\cockpit\workorder\`)。
- *
- * ⚠️ 票 20 修正:本脚本原先按旧的 1–7「七态」断言,并有一条 `B6-no-delay-action-exists`
- * 要求"没有延期档"。但后端 0–7 里 **6 就是「延期中」**(处理旁路,`ACTIVE_CODES` 含它),
- * 所以该断言守的是一个已被后端推翻的假设。现按 0–7 重写,并新增旁路(BYPASS)覆盖。
- */
- import { readFileSync } from 'node:fs'
- import { createPinia, setActivePinia } from 'pinia'
- import { WORK_ORDER_STATES, workOrderStateName } from '../../../src/modules/cockpit/domain/workOrder.js'
- import {
- TIMELINE_ACTION,
- WORK_ORDER_CANCELLED_CODE,
- WORK_ORDER_PATH,
- isOnWorkOrderPath,
- isWorkOrderStateCode,
- pathOnlyTrack,
- pathView,
- stepAction,
- trackOf,
- tracksByOrder,
- } from '../../../src/modules/cockpit/domain/workOrderTimeline.js'
- import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
- import { timelineSource, workOrderRows } from '../../../src/modules/cockpit/domain/equipmentDetailView.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)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
- 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()` 剥不掉。 */
- const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
- const readVueCode = (relative) => codeVue(read(relative))
- setActivePinia(createPinia())
- /** 造一条后端轨迹记录(字段名照抄 `CockpitWorkOrderTimelineEntry`)。 */
- const entry = (logId, operationName, operatedAt, stateCode, stateName, extra = {}) => ({
- logId,
- operationName,
- operationDesc: workOrderStateName(stateCode),
- operatorName: '系统',
- operatedAt,
- stateCode,
- stateName,
- ...extra,
- })
- /**
- * E9b/E9c 的输入:一份**领域工单对象**(`toWorkOrder()` 的形状),走 `workOrderRows()` 成行视图,
- * 再喂给 `pathOnlyTrack()` —— 这条链正是界面在"只有轨迹分区失败"时走的路径。
- */
- const TIMELINE_ORDER_ROWS_FOR_TEST = [{ code: 'WO-PATH-ONLY', stateCode: 4, stateName: '待验收' }]
- /** 造一份 `toWorkOrderTimeline()` 形状的领域轨迹(本段只测纯函数,不经过适配层)。 */
- const track = (code, entries) =>
- trackOf({
- workOrder: { code, stateCode: entries.length ? entries[entries.length - 1].stateCode : null, stateName: '' },
- entries,
- })
- // ================================================================ A 状态字典(后端 0–7)
- {
- check(
- 'A1-seven-states-complete',
- WORK_ORDER_STATES.length === 8 &&
- WORK_ORDER_STATES.map((state) => state.code).join('') === '01234567' &&
- WORK_ORDER_STATES.map((state) => state.name).join(',') === '待派单,待接单,已接单,处理中,待验收,已结案,延期中,已取消',
- `状态码表:${WORK_ORDER_STATES.map((state) => `${state.code}${state.name}`).join(' / ')}`,
- )
- check(
- 'A2-only-closed-and-cancelled-are-terminal',
- WORK_ORDER_STATES.filter((state) => state.terminal).map((state) => state.code).join(',') === '5,7',
- '只有已结案(5)/已取消(7)是终态;**6 延期中不是终态而是处理旁路**(后端 ACTIVE_CODES 含它)',
- )
- check(
- 'A2b-delay-is-bypass-not-terminal',
- WORK_ORDER_STATES.filter((state) => state.bypass).map((state) => state.code).join(',') === '6' &&
- WORK_ORDER_STATES.find((state) => state.code === 6).name === '延期中',
- '票 20:6 延期中是正式状态(后端枚举旁路),既非终态也不在推进主线上',
- )
- check(
- 'A3-path-excludes-cancelled-and-delay',
- WORK_ORDER_PATH.join(',') === '0,1,2,3,4,5' &&
- !isOnWorkOrderPath(WORK_ORDER_CANCELLED_CODE) &&
- !isOnWorkOrderPath(6),
- `推进主线 ${WORK_ORDER_PATH.join('→')};已取消(${WORK_ORDER_CANCELLED_CODE}) 与延期中(6) 都在主线外`,
- )
- check(
- 'A4-unknown-code-not-a-state',
- isWorkOrderStateCode(8) === false && isWorkOrderStateCode(null) === false && isWorkOrderStateCode(3) === true,
- '认不出的码不算码表之一(不猜、不冒充状态名)',
- )
- }
- // ================================================================ B 动作判定
- {
- check(
- 'B1-forward',
- stepAction(0, 1) === TIMELINE_ACTION.FORWARD && stepAction(3, 4) === TIMELINE_ACTION.FORWARD && stepAction(4, 5) === TIMELINE_ACTION.FORWARD,
- '码变大 = 流转',
- )
- // 本票的核心验收:验收驳回 = 4→3 的**回退动作**,不是多出来的一个状态。
- check(
- 'B2-reject-is-rollback-not-terminal',
- stepAction(4, 3) === TIMELINE_ACTION.ROLLBACK &&
- stepAction(3, 2) === TIMELINE_ACTION.ROLLBACK &&
- TIMELINE_ACTION.ROLLBACK !== TIMELINE_ACTION.CANCEL,
- '待验收(4) → 处理中(3) 判成回退动作(不是终态、也不是取消)',
- )
- check(
- 'B3-first-step-is-start',
- stepAction(null, 0) === TIMELINE_ACTION.START && stepAction(undefined, 4) === TIMELINE_ACTION.START,
- '轨迹第一条是"从无到有",不算回退',
- )
- check(
- 'B4-cancel-and-after-cancel',
- stepAction(1, 7) === TIMELINE_ACTION.CANCEL && stepAction(7, 3) === TIMELINE_ACTION.UNKNOWN,
- '取消是路径外动作;取消之后又出现动作是认不出的形状(不猜)',
- )
- check(
- 'B5-hold-and-unknown-code',
- stepAction(3, 3) === TIMELINE_ACTION.HOLD && stepAction(3, 99) === TIMELINE_ACTION.UNKNOWN && stepAction(3, null) === TIMELINE_ACTION.UNKNOWN,
- '同态不是回退;认不出的码既不往前算也不当回退',
- )
- // 票 20 改写了本条:旧断言要求"没有延期档",但后端 0–7 里 6 就是延期中。
- // 现在要守的是"延期是**旁路**,既不冒充主线前进、也不算回退/取消"。
- check(
- 'B6-delay-is-bypass-action',
- stepAction(3, 6) === TIMELINE_ACTION.BYPASS &&
- stepAction(6, 3) === TIMELINE_ACTION.BYPASS &&
- stepAction(3, 6) !== TIMELINE_ACTION.FORWARD &&
- stepAction(3, 6) !== TIMELINE_ACTION.ROLLBACK,
- `进入/离开延期中都判成旁路动作(${TIMELINE_ACTION.BYPASS}),不冒充主线前进`,
- )
- }
- // ================================================================ C 轨迹组装
- {
- const rollback = track('WO-RB', [
- entry(1, '创建工单', '2026-09-02T09:00:00+08:00', 0, '待派单'),
- entry(2, '派单', '2026-09-02T09:10:00+08:00', 1, '待接单'),
- entry(3, '接单', '2026-09-02T09:20:00+08:00', 2, '已接单'),
- entry(4, '开始处置', '2026-09-02T09:40:00+08:00', 3, '处理中'),
- entry(5, '提交验收', '2026-09-02T11:30:00+08:00', 4, '待验收'),
- entry(6, '验收驳回', '2026-09-02T12:00:00+08:00', 3, '处理中'),
- ])
- check(
- 'C1-steps-in-order',
- rollback.steps.map((step) => step.operationName).join(',') === '创建工单,派单,接单,开始处置,提交验收,验收驳回',
- `六步:${rollback.steps.map((step) => step.operationName).join(' → ')}`,
- )
- check(
- 'C2-rollback-counted-and-flagged',
- rollback.rollbackCount === 1 && rollback.hasRollback === true && rollback.steps[5].isRollback === true,
- '回退恰好一次,且回退那一步带 isRollback 标记',
- )
- check(
- 'C3-rollback-carries-from-state',
- rollback.steps[5].fromStateName === '待验收' && rollback.steps[5].stateName === '处理中' && rollback.steps[5].actionLabel === '回退',
- `回退记录如实说「自${rollback.steps[5].fromStateName}」→「${rollback.steps[5].stateName}」`,
- )
- // 回退之后"当前态"是 3,但"走到过的最远处"是 4 —— 两者必须分开,否则回退看不见。
- check(
- 'C4-reached-is-furthest-not-current',
- rollback.stateCode === 3 && rollback.reachedCode === 4,
- `当前 ${rollback.stateCode} 处理中,但走到过 ${rollback.reachedCode} 待验收(回退正是靠这个差表现)`,
- )
- const cancelled = track('WO-CX', [
- entry(1, '创建工单', '2026-09-02T07:00:00+08:00', 0, '待派单'),
- entry(2, '派单', '2026-09-02T07:10:00+08:00', 1, '待接单'),
- entry(3, '取消工单', '2026-09-02T07:30:00+08:00', 7, '已取消'),
- ])
- check(
- 'C5-cancel-is-not-a-path-step',
- cancelled.reachedCode === 1 && cancelled.steps[2].action === TIMELINE_ACTION.CANCEL && cancelled.rollbackCount === 0,
- `取消轨迹:走到过 ${cancelled.reachedCode},取消不算回退、也不算推进到 7`,
- )
- const unknown = track('WO-XX', [entry(1, '状态不明的动作', '2026-09-02T09:00:00+08:00', 99, '已驳回')])
- check(
- 'C6-unknown-code-does-not-become-a-state',
- unknown.steps[0].action === TIMELINE_ACTION.UNKNOWN && unknown.steps[0].stateCode === null && unknown.steps[0].stateName === '',
- '后端给了码表外的码:如实留空动作与状态名,**不**把「已驳回」当多出来的状态接受',
- )
- const empty = trackOf({ workOrder: { code: 'WO-E', stateCode: 5 }, entries: [] })
- check(
- 'C7-empty-track-is-honest',
- empty.entryCount === 0 && empty.steps.length === 0 && empty.stateCode === 5 && empty.stateName === '已结案',
- '没有轨迹记录时条数为 0(界面说"没有动作记录",而当前态仍来自工单自己)',
- )
- // 同一编码重复出现:保留第一条,但**不静默丢弃**多出来的那条。
- const grouped = tracksByOrder([
- { workOrder: { code: 'WO-1', stateCode: 3 }, entries: [entry(1, '创建工单', 'T1', 0, '待派单')] },
- { workOrder: { code: 'WO-1', stateCode: 4 }, entries: [entry(2, '提交验收', 'T2', 4, '待验收')] },
- { workOrder: { code: '', stateCode: 3 }, entries: [] },
- ])
- check(
- 'C8-grouping-keeps-first-and-reports-duplicates',
- !!grouped.byCode['WO-1'] &&
- grouped.byCode['WO-1'].entryCount === 1 &&
- grouped.duplicates.join(',') === 'WO-1' &&
- grouped.orphans.length === 1,
- `每张工单一条:保留第一条、重复的进 duplicates(${grouped.duplicates.join(',')})、无编码的进 orphans`,
- )
- }
- // ================================================================ D 状态路径视图
- {
- const rollback = track('WO-RB', [
- entry(1, '创建工单', '2026-09-02T09:00:00+08:00', 0, '待派单'),
- entry(2, '派单', '2026-09-02T09:10:00+08:00', 1, '待接单'),
- entry(3, '接单', '2026-09-02T09:20:00+08:00', 2, '已接单'),
- entry(4, '开始处置', '2026-09-02T09:40:00+08:00', 3, '处理中'),
- entry(5, '提交验收', '2026-09-02T11:30:00+08:00', 4, '待验收'),
- entry(6, '验收驳回', '2026-09-02T12:00:00+08:00', 3, '处理中'),
- ])
- const nodes = pathView(rollback)
- check(
- 'D1-path-always-has-seven-nodes',
- nodes.length === 8 && nodes.map((node) => node.code).join('') === '01234567',
- `路径永远这八个节点:${nodes.map((node) => node.name).join('→')}(驳回**不**多出节点)`,
- )
- check(
- 'D2-current-vs-reached',
- nodes.find((node) => node.code === 3).current === true &&
- nodes.find((node) => node.code === 4).reached === true &&
- nodes.find((node) => node.code === 4).current === false &&
- nodes.find((node) => node.code === 5).reached === false,
- '当前态=处理中(3);待验收(4) 标"走到过";已结案(5) 没到过',
- )
- check(
- 'D3-cancelled-node-is-off-path',
- nodes.find((node) => node.code === 7).offPath === true &&
- nodes.filter((node) => node.onPath).length === 6,
- '已取消是**路径外**节点(不接在已结案后面冒充下一步)',
- )
- // 票 20 新增:6 延期中也是路径外,且必须与"路径外终态"分开标注。
- check(
- 'D3b-delay-node-is-off-path-bypass',
- nodes.find((node) => node.code === 6).offPath === true &&
- nodes.find((node) => node.code === 6).bypass === true &&
- nodes.find((node) => node.code === 7).bypass === false,
- '延期中(6) 是路径外的**旁路**(bypass),与已取消(7) 的"路径外终态"不是一回事',
- )
- const cancelledNodes = pathView(track('WO-CX', [entry(1, '取消工单', 'T', 7, '已取消')]))
- check(
- 'D4-cancelled-current-is-off-path-only',
- cancelledNodes.find((node) => node.code === 7).current === true &&
- cancelledNodes.filter((node) => node.reached).length === 1,
- '已取消工单:只有路径外的那个节点是亮的,推进主线六个节点都不算"走到过"',
- )
- }
- // ================================================================ E 分区独立(真契约 mock 走一遍)
- {
- // 工单列表成功 + 时间线成功:每张工单一条独立轨迹(验收第 2 条)。
- const store = useDetailStore()
- store.reset()
- store.configure({ config: mockConfig })
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'work-order-timeline', handlers: { registerMockTransport, resetMockTransportsForTest } })
- await store.selectDevice('TC-PS-WS-202609020012')
- const source = timelineSource(store.sections.equipmentAggregate)
- const codes = Object.keys(source.tracks).sort()
- check(
- 'E1-every-order-has-its-own-track',
- codes.join(',') === 'WO-TL-CANCELLED,WO-TL-DELAYED,WO-TL-FORWARD,WO-TL-ROLLBACK',
- `四张工单各自一条:${codes.join(' / ')}`,
- )
- check(
- 'E2-tracks-differ-per-order',
- source.tracks['WO-TL-FORWARD'].entryCount === 5 &&
- source.tracks['WO-TL-ROLLBACK'].entryCount === 6 &&
- source.tracks['WO-TL-DELAYED'].entryCount === 6 &&
- source.tracks['WO-TL-CANCELLED'].entryCount === 3,
- `条数各不相同(${['WO-TL-FORWARD', 'WO-TL-ROLLBACK', 'WO-TL-DELAYED', 'WO-TL-CANCELLED'].map((c) => source.tracks[c].entryCount).join('/')})—— 不是同一份轨迹抄四遍`,
- )
- check(
- 'E3-rollback-order-is-the-rollback-shape',
- source.tracks['WO-TL-ROLLBACK'].hasRollback === true &&
- source.tracks['WO-TL-ROLLBACK'].stateCode === 3 &&
- source.tracks['WO-TL-ROLLBACK'].reachedCode === 4 &&
- source.tracks['WO-TL-FORWARD'].hasRollback === false,
- '回退那张:当前处理中、走到过待验收、恰一次回退;推进那张零回退(对照,证明判据有牙)',
- )
- // 票 20 新增:延期中那张的形态 —— 当前态是旁路 6,且**不算回退**(旁路不是倒退)。
- check(
- 'E3b-delay-order-is-the-bypass-shape',
- source.tracks['WO-TL-DELAYED'].stateCode === 6 &&
- source.tracks['WO-TL-DELAYED'].hasRollback === false &&
- source.tracks['WO-TL-DELAYED'].steps[5].action === TIMELINE_ACTION.BYPASS &&
- source.tracks['WO-TL-DELAYED'].reachedCode === 3,
- `延期中那张:当前态 6(旁路)、零回退、最后一步是旁路动作、走到过的最远处仍是主线 3`,
- )
- check(
- 'E4-timeline-section-state-is-success',
- source.state === SECTION_STATE.SUCCESS && source.hasContent === true && source.placeholder === '',
- `时间线分区 ${source.state},没有占位说明`,
- )
- // 只有时间线分区失败:界面必须说「轨迹没取到」,而不是「这张工单没有动作记录」。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'work-order-timeline-failed', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const downStore = useDetailStore()
- downStore.reset()
- downStore.configure({ config: mockConfig })
- await downStore.selectDevice('TC-PS-WS-202609020012')
- const downSource = timelineSource(downStore.sections.equipmentAggregate)
- const downPanels = downStore.sections.equipmentAggregate.data.sections
- check(
- 'E5-timeline-failure-is-its-own-state',
- downSource.state === SECTION_STATE.FAILED &&
- downSource.hasContent === false &&
- Object.keys(downSource.tracks).length === 0 &&
- downSource.placeholder.includes('加载失败') &&
- downSource.placeholder.includes('工单动作轨迹'),
- `轨迹分区失败时:占位说明「${downSource.placeholder}」,且**不**留下空轨迹表冒充有数据`,
- )
- check(
- 'E6-order-list-still-succeeds-alongside',
- downPanels.workOrders.state === SECTION_STATE.SUCCESS &&
- downPanels.timelines.state === SECTION_STATE.FAILED &&
- downPanels.archive.state === SECTION_STATE.SUCCESS,
- `工单列表照常 ${downPanels.workOrders.state},只有轨迹分区 ${downPanels.timelines.state}(分区独立,不互相遮盖)`,
- )
- check(
- 'E7-failure-text-differs-from-no-records',
- downSource.placeholder !== '' && downSource.placeholder !== '这张工单没有动作记录' && !/没有动作记录/.test(downSource.placeholder),
- `「${downSource.placeholder}」(没取到)与「这张工单没有动作记录」(确实没有)是两句不同的话`,
- )
- // 空场景:时间线分区为空时也不画空轨迹。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const emptyStore = useDetailStore()
- emptyStore.reset()
- emptyStore.configure({ config: mockConfig })
- await emptyStore.selectDevice('TC-PS-WS-202609020012')
- const emptySource = timelineSource(emptyStore.sections.equipmentAggregate)
- check(
- 'E8-empty-timeline-draws-nothing',
- emptySource.state === SECTION_STATE.EMPTY &&
- emptySource.hasContent === false &&
- Object.keys(emptySource.tracks).length === 0 &&
- emptySource.placeholder.includes('该分区当前没有数据'),
- `空场景:没有轨迹可画,说的是「${emptySource.placeholder}」`,
- )
- // 未加载:`timelineSource(null)` 不能抛(组件在设备还没选时也会拿到它)。
- const unloaded = timelineSource(null)
- check(
- 'E9-unloaded-source-is-safe',
- unloaded.state === SECTION_STATE.FAILED && unloaded.tracks && typeof unloaded.tracks === 'object' && unloaded.hasContent === false,
- '未加载时回一条失败分区、轨迹表是空对象(组件不必写 ?. 兜底)',
- )
- // E5 的反面:轨迹分区没拿到时,状态路径**仍要**铺得出来(验收第 1 条不因轨迹失败而丢)。
- // 当前状态码来自工单列表分区(它照常成功),所以"这张工单现在在哪一态"本来就拿得到。
- const orderRows = workOrderRows(TIMELINE_ORDER_ROWS_FOR_TEST)
- const pathOnly = pathOnlyTrack(orderRows[0])
- const pathOnlyNodes = pathView(pathOnly)
- check(
- 'E9b-path-survives-when-only-timeline-failed',
- pathOnlyNodes.length === 8 &&
- pathOnlyNodes.find((node) => node.code === 4).current === true &&
- pathOnly.steps.length === 0 &&
- pathOnly.entryCount === 0 &&
- pathOnly.pathOnly === true,
- `轨迹没拿到时仍铺状态路径(当前态=${(pathOnlyNodes.find((n) => n.current) || {}).name}),只是没有动作流水`,
- )
- check(
- 'E9c-path-only-does-not-fake-reached',
- pathOnly.reachedCode === null &&
- // 走到过哪里根本无从知道,所以**没有**任何节点该被标成"走到过"(当前态单独用 current 标)。
- pathOnlyNodes.filter((node) => node.reached).length === 0 &&
- pathOnlyNodes.filter((node) => node.current).length === 1 &&
- pathOnly.hasRollback === false,
- '只有路径时 reachedCode 为 null、零个节点标"走到过"(不拿当前态冒充"走到过最远处"),回退也无从谈起',
- )
- // 单张工单的时间线端点也按编码回显(真接口语义:认不出的编码回 null)。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const { fetchWorkOrderTimeline } = await import('../../../src/modules/cockpit/services/detailApi.js')
- const oneFailed = await fetchWorkOrderTimeline('WO-UNKNOWN', { config: mockConfig })
- const oneForward = await fetchWorkOrderTimeline('WO-TL-FORWARD', { config: mockConfig })
- check(
- 'E10-timeline-endpoint-echoes-code',
- oneForward.state === SECTION_STATE.SUCCESS &&
- oneForward.data.workOrder.code === 'WO-TL-FORWARD' &&
- oneForward.data.entries.length === 5,
- `按编码取回 ${oneForward.data.workOrder.code} 自己的 5 条轨迹(不是永远同一张工单)`,
- )
- check(
- 'E11-unknown-order-is-not-empty-success',
- oneFailed.state !== SECTION_STATE.SUCCESS,
- `认不出的工单编码不算成功(${oneFailed.state})—— 缺数据不能画成"没有动作"`,
- )
- }
- // ================================================================ F 静态守卫(对抗式)
- {
- const timelineCode = readCode('domain/workOrderTimeline.js')
- const viewCode = readCode('domain/equipmentDetailView.js')
- const componentCode = readVueCode('components/WorkOrderTimeline.vue')
- const sectionsCode = readVueCode('components/EquipmentDetailSections.vue')
- const apiCode = readCode('services/detailApi.js')
- // 第八态与延期档:票 12 的两条否定式验收,用"全仓不许出现这些词"来守。
- check(
- 'F1-no-eighth-state-vocabulary',
- !/已驳回|驳回态|第八态|REJECTED_STATE/.test(timelineCode + viewCode + componentCode + sectionsCode),
- '「已驳回」不作为状态词出现(它只能以**动作名**出现在样例数据里)',
- )
- check(
- 'F2-no-delay-vocabulary',
- !/延期|逾期|超期|OVERDUE|DELAYED/.test(timelineCode + viewCode + componentCode + sectionsCode),
- '时间线里没有延期这一档(后端契约也没有该字段)',
- )
- check(
- 'F3-timeline-module-is-pure',
- !/\bfetch\s*\(|XMLHttpRequest/.test(timelineCode) && !/from '\.\.\/services\//.test(timelineCode),
- '时间线域模块是纯函数:不发请求、不 import service',
- )
- // 组件不许认后端裸字段(票 05 立下的守卫面,新组件必须一起守)。
- check(
- 'F4-component-has-no-backend-field-names',
- !/timelineEntries|stateDictionary|workOrderCode|aggregateState|stateReason|deviceCode/.test(componentCode),
- '时间线组件只读视图对象字段(后端裸字段名只在 domain 适配层出现)',
- )
- check(
- 'F5-component-does-not-build-api-paths',
- !/\/work-orders\/|\/equipments\//.test(componentCode + sectionsCode) && !/fetch\(/.test(componentCode),
- '接口路径只在 services/detailApi.js,组件不自己拼',
- )
- check(
- 'F6-no-write-operations',
- !/method:\s*['"](POST|PUT|PATCH|DELETE)['"]/.test(apiCode) &&
- !/@click="[^"]*(save|submit|update|delete|cancel|dispatch)/i.test(componentCode + sectionsCode),
- '驾驶舱只读:时间线不引入任何工单写操作(spec 验收第 6 条)',
- )
- // 排序归 domain(后端顺序不保证时序):组件不许自己重排。
- check(
- 'F7-component-does-not-sort',
- !/\.sort\(/.test(componentCode) && !/\.sort\(/.test(readVueCode('components/EquipmentDetailSections.vue')),
- '轨迹顺序由 domain 按 operatedAt 定,组件不重排',
- )
- check(
- 'F8-timeline-section-is-no-longer-deferred',
- !/DEFERRED_SECTIONS\s*=\s*\[[^\]]*timelines/.test(viewCode) && /timelineSource/.test(viewCode),
- '`timelines` 已从延后清单移出并接到工单面板(票 11 写明归票 12)',
- )
- // 「没取到」与「没有动作记录」在**模板上**必须是两条不同的分支 ——
- // 逻辑层的 E7 只能证明两句措辞不同,证明不了界面不会同时/错误地画它们。
- // 这条守卫钉住:逐张工单的"没有动作记录"被 `timeline.hasContent` 包住,
- // 分区失败时的占位说明走另一条分支。
- check(
- 'F9-no-records-note-is-gated-by-section-content',
- /v-if="trackOfOrder\(order\)"/.test(sectionsCode) &&
- /v-else-if="timeline\.hasContent"/.test(sectionsCode) &&
- /:path-only="!timeline\.hasContent"/.test(sectionsCode),
- '分区失败时仍铺状态路径(path-only),「这张工单没有动作记录」只在分区有内容时才可能渲染',
- )
- check(
- 'F10-path-only-blocks-no-records-claim',
- // 必须是 `v-else-if="!pathOnly"`:裸 `v-else`(不带 pathOnly 守卫)就是无条件说「没有动作记录」。
- // 正则要排除 `v-else-if` 本身,否则会把自己的守卫当成违规(第一版正是这么假红的)。
- /v-else-if="!pathOnly"/.test(componentCode) && !/v-else(?!-if)[\s\S]{0,120}data-wo-steps-empty/.test(componentCode),
- '时间线自己也不许在 path-only 时说「没有动作记录」(把"没取到"说成"没有")',
- )
- /**
- * **模板里用到的每个本地标识符都必须有定义**(import / const / props)。
- *
- * 为什么需要这一条:真机走查第一轮就是被这类错误判红的 ——
- * `EquipmentDetailSections.vue` 用了 `pathOnlyTrack(order)` 却**忘了 import**,
- * 静态守卫全绿(它只查"有没有出现被禁的东西",不查"用的东西在不在"),
- * 逻辑闸门也全绿(它测的是 domain 层,压根不加载 .vue),
- * 只有真机渲染时才炸成 `pathOnlyTrack is not defined`,把整块工单面板打没。
- * 所以在这里补一条**引用完整性**检查:模板表达式里出现的裸标识符,
- * 都要能在 `<script setup>` 里找到同名绑定(或它是 v-for 引入的局部名)。
- *
- * 只从**表达式上下文**取词(`{{ }}` 与 `v-*` / `:` / `@` 属性的值),
- * 不碰标签名与属性名 —— 否则 `div` / `class` / `data-wo-step` 会全部误报(第一版正是这么假红的)。
- */
- for (const file of ['components/EquipmentDetailSections.vue', 'components/WorkOrderTimeline.vue']) {
- const source = read(file)
- const template = (source.match(/<template>([\s\S]*)<\/template>/) || [])[1] || ''
- /**
- * 脚本体先剥注释与字符串字面量再扫。
- * 不剥会双向出错,两个方向都被实测踩过:
- * · 假红 —— 注释里的 `O(n²)` 被当成调用 `O(`、JSDoc 里的 `timelineSource()` 被当成未绑定调用;
- * · 假绿 —— 注释掉的 `// const foo = ...` 会被算成"有绑定",掩盖真的未定义。
- */
- const stripLiterals = (text) => text.replace(/'[^'\n]*'/g, "''").replace(/"[^"\n]*"/g, '""').replace(/`[^`]*`/g, '``')
- const script = stripLiterals(code((source.match(/<script setup>([\s\S]*?)<\/script>/) || [])[1] || ''))
- // 1) 收集表达式文本:插值 + 指令/绑定的属性值。
- const expressions = []
- for (const match of template.matchAll(/\{\{([\s\S]*?)\}\}/g)) expressions.push(match[1])
- for (const match of template.matchAll(/(?:^|\s)(?:v-[a-z-]+|[:@][a-zA-Z-]+)="([^"]*)"/g)) expressions.push(match[1])
- // 2) v-for 引入的局部名(`x in y`、`(a, b) in y`),以及内联箭头函数的形参
- // (`@page="(target) => gotoPage(panel, target)"` —— 票 59 实测:扫描器不认
- // 形参就把事件处理器里的合法参数误报成未绑定,与 v-for 那版假红同一形状)。
- const locals = new Set()
- for (const match of template.matchAll(/v-for="\(?([^)"]*?)\)?\s+in\s/g)) {
- for (const piece of match[1].split(',')) locals.add(piece.trim())
- }
- for (const match of template.matchAll(/\(([^)]*)\)\s*=>/g)) {
- for (const piece of match[1].split(',')) {
- const name = piece.trim().split(/[\s:=]+/)[0]
- if (name) locals.add(name)
- }
- }
- // 3) script 里的顶层绑定:import(默认/具名)、const/let/var/function、defineProps 的 prop 名。
- const bound = new Set(locals)
- for (const match of script.matchAll(/import\s+\{([^}]*)\}\s+from/g)) {
- for (const piece of match[1].split(',')) {
- const name = piece.trim().split(/\s+as\s+/).pop().trim()
- if (name) bound.add(name)
- }
- }
- for (const match of script.matchAll(/import\s+([A-Za-z0-9_$]+)\s+from/g)) bound.add(match[1])
- for (const match of script.matchAll(/(?:const|let|var|function)\s+([A-Za-z0-9_$]+)/g)) bound.add(match[1])
- const propsBlock = (script.match(/defineProps\(\{([\s\S]*?)\n\}\)/) || [])[1] || ''
- for (const match of propsBlock.matchAll(/^\s*([A-Za-z0-9_$]+)\s*:/gm)) bound.add(match[1])
- // 4) 表达式里取裸标识符:排除属性访问(`.` 之后)与对象字面量的键(`key:`)。
- const allowed = new Set(['if', 'else', 'for', 'in', 'of', 'return', 'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'new', 'Number', 'String', 'Boolean', 'Math', 'Date', 'Object', 'Array', 'JSON'])
- const used = new Set()
- for (const expression of expressions) {
- // 去掉字符串字面量,避免把文案里的词当成标识符。
- const bare = expression.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""').replace(/`[^`]*`/g, '``')
- for (const match of bare.matchAll(/(^|[^.\w$])([A-Za-z_$][A-Za-z0-9_$]*)\s*(:)?/g)) {
- // `foo:` 是对象字面量的键,不是引用。
- if (match[3] === ':') continue
- used.add(match[2])
- }
- }
- const missing = [...used].filter((name) => !bound.has(name) && !allowed.has(name))
- check(
- `F11-${file.split('/').pop()}-template-references-are-bound`,
- missing.length === 0,
- missing.length ? `模板里用了但没定义的标识符:${missing.join(', ')}` : `模板表达式引用的 ${used.size} 个标识符都有 import/const/props/v-for 定义`,
- )
- /**
- * **`<script setup>` 里调用的每个函数名都必须有定义。**
- *
- * 这条才是真机走查逮到的那个 bug 的形状:`EquipmentDetailSections.vue` 在
- * **脚本体**里写了 `return full || pathOnlyTrack(order)`,却没 import `pathOnlyTrack`
- * —— 模板检查(上面那条)看不见脚本体,静态守卫也只看"有没有出现被禁的东西",
- * 于是三处全绿,只有真机渲染才炸 `pathOnlyTrack is not defined`。
- * 本仓零依赖、不打包 .vue,所以要靠"调用点名字必须有绑定"这条来守。
- * **变异测试确认过它有牙**:去掉该 import 后本断言必须变红。
- */
- const callables = new Set()
- for (const match of script.matchAll(/(^|[^.\w$])([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) callables.add(match[2])
- // 显式声明过的局部函数、以及宏与 JS 内置(它们不 import 也能调)。
- const callableAllowed = new Set([
- 'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'function',
- ...allowed,
- 'defineProps', 'defineEmits', 'defineExpose', 'withDefaults',
- 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'setTimeout', 'clearTimeout',
- 'setInterval', 'clearInterval', 'encodeURIComponent', 'decodeURIComponent',
- 'Promise', 'Error', 'RegExp', 'Map', 'Set', 'WeakMap', 'Symbol', 'BigInt',
- 'queueMicrotask', 'structuredClone', 'requestAnimationFrame', 'String', 'Number',
- ])
- const unboundCalls = [...callables].filter((name) => !bound.has(name) && !callableAllowed.has(name))
- check(
- `F12-${file.split('/').pop()}-script-calls-are-bound`,
- unboundCalls.length === 0,
- unboundCalls.length
- ? `脚本里调用了但没定义的函数:${unboundCalls.join(', ')}`
- : `脚本体 ${callables.size} 个调用点(${[...callables].join(', ')})都解析得到`,
- )
- }
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T12 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:' + failed.map((item) => item.name).join(', '))
- process.exit(1)
- }
|