t12-work-order-timeline.mjs 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632
  1. /**
  2. * T12 工单状态时间线 纯逻辑检查(票 12)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t12-work-order-timeline.mjs
  6. *
  7. * 六段:
  8. * A 状态字典:0–7 共 8 个码齐全、顺序即业务顺序、只有已结案(5)/已取消(7)是终态
  9. * B 动作判定:推进 / 回退 / 取消 / 旁路(延期中) / 同态 / 认不出的码(**回退是动作,不是终态**)
  10. * C 轨迹组装:按 operatedAt 升序、走到过的最远处、回退计数、每张工单一条
  11. * D 状态路径视图:码表一个不少、当前态与"走到过"分开、已取消与延期中都是路径外
  12. * E 分区独立:时间线分区失败 ≠ 没有动作记录(界面必须说两件不同的事)
  13. * F 静态守卫:没有伪状态、没有写操作、组件不认后端裸字段
  14. *
  15. * 契约依据:后端 `WorkOrderStatusEnum`(`com.zksy.ops.enums`,0–7 是唯一真理)、
  16. * `CockpitWorkOrderStateDictionary`、`CockpitWorkOrderTimelineResponse/Entry`
  17. * (`E:\pipe-ner-server\...\cockpit\workorder\`)。
  18. *
  19. * ⚠️ 票 20 修正:本脚本原先按旧的 1–7「七态」断言,并有一条 `B6-no-delay-action-exists`
  20. * 要求"没有延期档"。但后端 0–7 里 **6 就是「延期中」**(处理旁路,`ACTIVE_CODES` 含它),
  21. * 所以该断言守的是一个已被后端推翻的假设。现按 0–7 重写,并新增旁路(BYPASS)覆盖。
  22. */
  23. import { readFileSync } from 'node:fs'
  24. import { createPinia, setActivePinia } from 'pinia'
  25. import { WORK_ORDER_STATES, workOrderStateName } from '../../../src/modules/cockpit/domain/workOrder.js'
  26. import {
  27. TIMELINE_ACTION,
  28. WORK_ORDER_CANCELLED_CODE,
  29. WORK_ORDER_PATH,
  30. isOnWorkOrderPath,
  31. isWorkOrderStateCode,
  32. pathOnlyTrack,
  33. pathView,
  34. stepAction,
  35. trackOf,
  36. tracksByOrder,
  37. } from '../../../src/modules/cockpit/domain/workOrderTimeline.js'
  38. import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
  39. import { timelineSource, workOrderRows } from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
  40. import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  41. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  42. import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
  43. const checks = []
  44. /** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
  45. function check(name, pass, evidence) {
  46. checks.push({ name, pass })
  47. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  48. }
  49. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  50. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  51. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  52. /** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */
  53. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  54. const readCode = (relative) => code(read(relative))
  55. /** `.vue` 的注释是 `<!-- -->`(HTML),上面那个只剥 JS 注释的 `code()` 剥不掉。 */
  56. const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
  57. const readVueCode = (relative) => codeVue(read(relative))
  58. setActivePinia(createPinia())
  59. /** 造一条后端轨迹记录(字段名照抄 `CockpitWorkOrderTimelineEntry`)。 */
  60. const entry = (logId, operationName, operatedAt, stateCode, stateName, extra = {}) => ({
  61. logId,
  62. operationName,
  63. operationDesc: workOrderStateName(stateCode),
  64. operatorName: '系统',
  65. operatedAt,
  66. stateCode,
  67. stateName,
  68. ...extra,
  69. })
  70. /**
  71. * E9b/E9c 的输入:一份**领域工单对象**(`toWorkOrder()` 的形状),走 `workOrderRows()` 成行视图,
  72. * 再喂给 `pathOnlyTrack()` —— 这条链正是界面在"只有轨迹分区失败"时走的路径。
  73. */
  74. const TIMELINE_ORDER_ROWS_FOR_TEST = [{ code: 'WO-PATH-ONLY', stateCode: 4, stateName: '待验收' }]
  75. /** 造一份 `toWorkOrderTimeline()` 形状的领域轨迹(本段只测纯函数,不经过适配层)。 */
  76. const track = (code, entries) =>
  77. trackOf({
  78. workOrder: { code, stateCode: entries.length ? entries[entries.length - 1].stateCode : null, stateName: '' },
  79. entries,
  80. })
  81. // ================================================================ A 状态字典(后端 0–7)
  82. {
  83. check(
  84. 'A1-seven-states-complete',
  85. WORK_ORDER_STATES.length === 8 &&
  86. WORK_ORDER_STATES.map((state) => state.code).join('') === '01234567' &&
  87. WORK_ORDER_STATES.map((state) => state.name).join(',') === '待派单,待接单,已接单,处理中,待验收,已结案,延期中,已取消',
  88. `状态码表:${WORK_ORDER_STATES.map((state) => `${state.code}${state.name}`).join(' / ')}`,
  89. )
  90. check(
  91. 'A2-only-closed-and-cancelled-are-terminal',
  92. WORK_ORDER_STATES.filter((state) => state.terminal).map((state) => state.code).join(',') === '5,7',
  93. '只有已结案(5)/已取消(7)是终态;**6 延期中不是终态而是处理旁路**(后端 ACTIVE_CODES 含它)',
  94. )
  95. check(
  96. 'A2b-delay-is-bypass-not-terminal',
  97. WORK_ORDER_STATES.filter((state) => state.bypass).map((state) => state.code).join(',') === '6' &&
  98. WORK_ORDER_STATES.find((state) => state.code === 6).name === '延期中',
  99. '票 20:6 延期中是正式状态(后端枚举旁路),既非终态也不在推进主线上',
  100. )
  101. check(
  102. 'A3-path-excludes-cancelled-and-delay',
  103. WORK_ORDER_PATH.join(',') === '0,1,2,3,4,5' &&
  104. !isOnWorkOrderPath(WORK_ORDER_CANCELLED_CODE) &&
  105. !isOnWorkOrderPath(6),
  106. `推进主线 ${WORK_ORDER_PATH.join('→')};已取消(${WORK_ORDER_CANCELLED_CODE}) 与延期中(6) 都在主线外`,
  107. )
  108. check(
  109. 'A4-unknown-code-not-a-state',
  110. isWorkOrderStateCode(8) === false && isWorkOrderStateCode(null) === false && isWorkOrderStateCode(3) === true,
  111. '认不出的码不算码表之一(不猜、不冒充状态名)',
  112. )
  113. }
  114. // ================================================================ B 动作判定
  115. {
  116. check(
  117. 'B1-forward',
  118. stepAction(0, 1) === TIMELINE_ACTION.FORWARD && stepAction(3, 4) === TIMELINE_ACTION.FORWARD && stepAction(4, 5) === TIMELINE_ACTION.FORWARD,
  119. '码变大 = 流转',
  120. )
  121. // 本票的核心验收:验收驳回 = 4→3 的**回退动作**,不是多出来的一个状态。
  122. check(
  123. 'B2-reject-is-rollback-not-terminal',
  124. stepAction(4, 3) === TIMELINE_ACTION.ROLLBACK &&
  125. stepAction(3, 2) === TIMELINE_ACTION.ROLLBACK &&
  126. TIMELINE_ACTION.ROLLBACK !== TIMELINE_ACTION.CANCEL,
  127. '待验收(4) → 处理中(3) 判成回退动作(不是终态、也不是取消)',
  128. )
  129. check(
  130. 'B3-first-step-is-start',
  131. stepAction(null, 0) === TIMELINE_ACTION.START && stepAction(undefined, 4) === TIMELINE_ACTION.START,
  132. '轨迹第一条是"从无到有",不算回退',
  133. )
  134. check(
  135. 'B4-cancel-and-after-cancel',
  136. stepAction(1, 7) === TIMELINE_ACTION.CANCEL && stepAction(7, 3) === TIMELINE_ACTION.UNKNOWN,
  137. '取消是路径外动作;取消之后又出现动作是认不出的形状(不猜)',
  138. )
  139. check(
  140. 'B5-hold-and-unknown-code',
  141. stepAction(3, 3) === TIMELINE_ACTION.HOLD && stepAction(3, 99) === TIMELINE_ACTION.UNKNOWN && stepAction(3, null) === TIMELINE_ACTION.UNKNOWN,
  142. '同态不是回退;认不出的码既不往前算也不当回退',
  143. )
  144. // 票 20 改写了本条:旧断言要求"没有延期档",但后端 0–7 里 6 就是延期中。
  145. // 现在要守的是"延期是**旁路**,既不冒充主线前进、也不算回退/取消"。
  146. check(
  147. 'B6-delay-is-bypass-action',
  148. stepAction(3, 6) === TIMELINE_ACTION.BYPASS &&
  149. stepAction(6, 3) === TIMELINE_ACTION.BYPASS &&
  150. stepAction(3, 6) !== TIMELINE_ACTION.FORWARD &&
  151. stepAction(3, 6) !== TIMELINE_ACTION.ROLLBACK,
  152. `进入/离开延期中都判成旁路动作(${TIMELINE_ACTION.BYPASS}),不冒充主线前进`,
  153. )
  154. }
  155. // ================================================================ C 轨迹组装
  156. {
  157. const rollback = track('WO-RB', [
  158. entry(1, '创建工单', '2026-09-02T09:00:00+08:00', 0, '待派单'),
  159. entry(2, '派单', '2026-09-02T09:10:00+08:00', 1, '待接单'),
  160. entry(3, '接单', '2026-09-02T09:20:00+08:00', 2, '已接单'),
  161. entry(4, '开始处置', '2026-09-02T09:40:00+08:00', 3, '处理中'),
  162. entry(5, '提交验收', '2026-09-02T11:30:00+08:00', 4, '待验收'),
  163. entry(6, '验收驳回', '2026-09-02T12:00:00+08:00', 3, '处理中'),
  164. ])
  165. check(
  166. 'C1-steps-in-order',
  167. rollback.steps.map((step) => step.operationName).join(',') === '创建工单,派单,接单,开始处置,提交验收,验收驳回',
  168. `六步:${rollback.steps.map((step) => step.operationName).join(' → ')}`,
  169. )
  170. check(
  171. 'C2-rollback-counted-and-flagged',
  172. rollback.rollbackCount === 1 && rollback.hasRollback === true && rollback.steps[5].isRollback === true,
  173. '回退恰好一次,且回退那一步带 isRollback 标记',
  174. )
  175. check(
  176. 'C3-rollback-carries-from-state',
  177. rollback.steps[5].fromStateName === '待验收' && rollback.steps[5].stateName === '处理中' && rollback.steps[5].actionLabel === '回退',
  178. `回退记录如实说「自${rollback.steps[5].fromStateName}」→「${rollback.steps[5].stateName}」`,
  179. )
  180. // 回退之后"当前态"是 3,但"走到过的最远处"是 4 —— 两者必须分开,否则回退看不见。
  181. check(
  182. 'C4-reached-is-furthest-not-current',
  183. rollback.stateCode === 3 && rollback.reachedCode === 4,
  184. `当前 ${rollback.stateCode} 处理中,但走到过 ${rollback.reachedCode} 待验收(回退正是靠这个差表现)`,
  185. )
  186. const cancelled = track('WO-CX', [
  187. entry(1, '创建工单', '2026-09-02T07:00:00+08:00', 0, '待派单'),
  188. entry(2, '派单', '2026-09-02T07:10:00+08:00', 1, '待接单'),
  189. entry(3, '取消工单', '2026-09-02T07:30:00+08:00', 7, '已取消'),
  190. ])
  191. check(
  192. 'C5-cancel-is-not-a-path-step',
  193. cancelled.reachedCode === 1 && cancelled.steps[2].action === TIMELINE_ACTION.CANCEL && cancelled.rollbackCount === 0,
  194. `取消轨迹:走到过 ${cancelled.reachedCode},取消不算回退、也不算推进到 7`,
  195. )
  196. const unknown = track('WO-XX', [entry(1, '状态不明的动作', '2026-09-02T09:00:00+08:00', 99, '已驳回')])
  197. check(
  198. 'C6-unknown-code-does-not-become-a-state',
  199. unknown.steps[0].action === TIMELINE_ACTION.UNKNOWN && unknown.steps[0].stateCode === null && unknown.steps[0].stateName === '',
  200. '后端给了码表外的码:如实留空动作与状态名,**不**把「已驳回」当多出来的状态接受',
  201. )
  202. const empty = trackOf({ workOrder: { code: 'WO-E', stateCode: 5 }, entries: [] })
  203. check(
  204. 'C7-empty-track-is-honest',
  205. empty.entryCount === 0 && empty.steps.length === 0 && empty.stateCode === 5 && empty.stateName === '已结案',
  206. '没有轨迹记录时条数为 0(界面说"没有动作记录",而当前态仍来自工单自己)',
  207. )
  208. // 同一编码重复出现:保留第一条,但**不静默丢弃**多出来的那条。
  209. const grouped = tracksByOrder([
  210. { workOrder: { code: 'WO-1', stateCode: 3 }, entries: [entry(1, '创建工单', 'T1', 0, '待派单')] },
  211. { workOrder: { code: 'WO-1', stateCode: 4 }, entries: [entry(2, '提交验收', 'T2', 4, '待验收')] },
  212. { workOrder: { code: '', stateCode: 3 }, entries: [] },
  213. ])
  214. check(
  215. 'C8-grouping-keeps-first-and-reports-duplicates',
  216. !!grouped.byCode['WO-1'] &&
  217. grouped.byCode['WO-1'].entryCount === 1 &&
  218. grouped.duplicates.join(',') === 'WO-1' &&
  219. grouped.orphans.length === 1,
  220. `每张工单一条:保留第一条、重复的进 duplicates(${grouped.duplicates.join(',')})、无编码的进 orphans`,
  221. )
  222. }
  223. // ================================================================ D 状态路径视图
  224. {
  225. const rollback = track('WO-RB', [
  226. entry(1, '创建工单', '2026-09-02T09:00:00+08:00', 0, '待派单'),
  227. entry(2, '派单', '2026-09-02T09:10:00+08:00', 1, '待接单'),
  228. entry(3, '接单', '2026-09-02T09:20:00+08:00', 2, '已接单'),
  229. entry(4, '开始处置', '2026-09-02T09:40:00+08:00', 3, '处理中'),
  230. entry(5, '提交验收', '2026-09-02T11:30:00+08:00', 4, '待验收'),
  231. entry(6, '验收驳回', '2026-09-02T12:00:00+08:00', 3, '处理中'),
  232. ])
  233. const nodes = pathView(rollback)
  234. check(
  235. 'D1-path-always-has-seven-nodes',
  236. nodes.length === 8 && nodes.map((node) => node.code).join('') === '01234567',
  237. `路径永远这八个节点:${nodes.map((node) => node.name).join('→')}(驳回**不**多出节点)`,
  238. )
  239. check(
  240. 'D2-current-vs-reached',
  241. nodes.find((node) => node.code === 3).current === true &&
  242. nodes.find((node) => node.code === 4).reached === true &&
  243. nodes.find((node) => node.code === 4).current === false &&
  244. nodes.find((node) => node.code === 5).reached === false,
  245. '当前态=处理中(3);待验收(4) 标"走到过";已结案(5) 没到过',
  246. )
  247. check(
  248. 'D3-cancelled-node-is-off-path',
  249. nodes.find((node) => node.code === 7).offPath === true &&
  250. nodes.filter((node) => node.onPath).length === 6,
  251. '已取消是**路径外**节点(不接在已结案后面冒充下一步)',
  252. )
  253. // 票 20 新增:6 延期中也是路径外,且必须与"路径外终态"分开标注。
  254. check(
  255. 'D3b-delay-node-is-off-path-bypass',
  256. nodes.find((node) => node.code === 6).offPath === true &&
  257. nodes.find((node) => node.code === 6).bypass === true &&
  258. nodes.find((node) => node.code === 7).bypass === false,
  259. '延期中(6) 是路径外的**旁路**(bypass),与已取消(7) 的"路径外终态"不是一回事',
  260. )
  261. const cancelledNodes = pathView(track('WO-CX', [entry(1, '取消工单', 'T', 7, '已取消')]))
  262. check(
  263. 'D4-cancelled-current-is-off-path-only',
  264. cancelledNodes.find((node) => node.code === 7).current === true &&
  265. cancelledNodes.filter((node) => node.reached).length === 1,
  266. '已取消工单:只有路径外的那个节点是亮的,推进主线六个节点都不算"走到过"',
  267. )
  268. }
  269. // ================================================================ E 分区独立(真契约 mock 走一遍)
  270. {
  271. // 工单列表成功 + 时间线成功:每张工单一条独立轨迹(验收第 2 条)。
  272. const store = useDetailStore()
  273. store.reset()
  274. store.configure({ config: mockConfig })
  275. resetMockTransportsForTest()
  276. registerCockpitMocks({ scenario: 'work-order-timeline', handlers: { registerMockTransport, resetMockTransportsForTest } })
  277. await store.selectDevice('TC-PS-WS-202609020012')
  278. const source = timelineSource(store.sections.equipmentAggregate)
  279. const codes = Object.keys(source.tracks).sort()
  280. check(
  281. 'E1-every-order-has-its-own-track',
  282. codes.join(',') === 'WO-TL-CANCELLED,WO-TL-DELAYED,WO-TL-FORWARD,WO-TL-ROLLBACK',
  283. `四张工单各自一条:${codes.join(' / ')}`,
  284. )
  285. check(
  286. 'E2-tracks-differ-per-order',
  287. source.tracks['WO-TL-FORWARD'].entryCount === 5 &&
  288. source.tracks['WO-TL-ROLLBACK'].entryCount === 6 &&
  289. source.tracks['WO-TL-DELAYED'].entryCount === 6 &&
  290. source.tracks['WO-TL-CANCELLED'].entryCount === 3,
  291. `条数各不相同(${['WO-TL-FORWARD', 'WO-TL-ROLLBACK', 'WO-TL-DELAYED', 'WO-TL-CANCELLED'].map((c) => source.tracks[c].entryCount).join('/')})—— 不是同一份轨迹抄四遍`,
  292. )
  293. check(
  294. 'E3-rollback-order-is-the-rollback-shape',
  295. source.tracks['WO-TL-ROLLBACK'].hasRollback === true &&
  296. source.tracks['WO-TL-ROLLBACK'].stateCode === 3 &&
  297. source.tracks['WO-TL-ROLLBACK'].reachedCode === 4 &&
  298. source.tracks['WO-TL-FORWARD'].hasRollback === false,
  299. '回退那张:当前处理中、走到过待验收、恰一次回退;推进那张零回退(对照,证明判据有牙)',
  300. )
  301. // 票 20 新增:延期中那张的形态 —— 当前态是旁路 6,且**不算回退**(旁路不是倒退)。
  302. check(
  303. 'E3b-delay-order-is-the-bypass-shape',
  304. source.tracks['WO-TL-DELAYED'].stateCode === 6 &&
  305. source.tracks['WO-TL-DELAYED'].hasRollback === false &&
  306. source.tracks['WO-TL-DELAYED'].steps[5].action === TIMELINE_ACTION.BYPASS &&
  307. source.tracks['WO-TL-DELAYED'].reachedCode === 3,
  308. `延期中那张:当前态 6(旁路)、零回退、最后一步是旁路动作、走到过的最远处仍是主线 3`,
  309. )
  310. check(
  311. 'E4-timeline-section-state-is-success',
  312. source.state === SECTION_STATE.SUCCESS && source.hasContent === true && source.placeholder === '',
  313. `时间线分区 ${source.state},没有占位说明`,
  314. )
  315. // 只有时间线分区失败:界面必须说「轨迹没取到」,而不是「这张工单没有动作记录」。
  316. resetMockTransportsForTest()
  317. registerCockpitMocks({ scenario: 'work-order-timeline-failed', handlers: { registerMockTransport, resetMockTransportsForTest } })
  318. const downStore = useDetailStore()
  319. downStore.reset()
  320. downStore.configure({ config: mockConfig })
  321. await downStore.selectDevice('TC-PS-WS-202609020012')
  322. const downSource = timelineSource(downStore.sections.equipmentAggregate)
  323. const downPanels = downStore.sections.equipmentAggregate.data.sections
  324. check(
  325. 'E5-timeline-failure-is-its-own-state',
  326. downSource.state === SECTION_STATE.FAILED &&
  327. downSource.hasContent === false &&
  328. Object.keys(downSource.tracks).length === 0 &&
  329. downSource.placeholder.includes('加载失败') &&
  330. downSource.placeholder.includes('工单动作轨迹'),
  331. `轨迹分区失败时:占位说明「${downSource.placeholder}」,且**不**留下空轨迹表冒充有数据`,
  332. )
  333. check(
  334. 'E6-order-list-still-succeeds-alongside',
  335. downPanels.workOrders.state === SECTION_STATE.SUCCESS &&
  336. downPanels.timelines.state === SECTION_STATE.FAILED &&
  337. downPanels.archive.state === SECTION_STATE.SUCCESS,
  338. `工单列表照常 ${downPanels.workOrders.state},只有轨迹分区 ${downPanels.timelines.state}(分区独立,不互相遮盖)`,
  339. )
  340. check(
  341. 'E7-failure-text-differs-from-no-records',
  342. downSource.placeholder !== '' && downSource.placeholder !== '这张工单没有动作记录' && !/没有动作记录/.test(downSource.placeholder),
  343. `「${downSource.placeholder}」(没取到)与「这张工单没有动作记录」(确实没有)是两句不同的话`,
  344. )
  345. // 空场景:时间线分区为空时也不画空轨迹。
  346. resetMockTransportsForTest()
  347. registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
  348. const emptyStore = useDetailStore()
  349. emptyStore.reset()
  350. emptyStore.configure({ config: mockConfig })
  351. await emptyStore.selectDevice('TC-PS-WS-202609020012')
  352. const emptySource = timelineSource(emptyStore.sections.equipmentAggregate)
  353. check(
  354. 'E8-empty-timeline-draws-nothing',
  355. emptySource.state === SECTION_STATE.EMPTY &&
  356. emptySource.hasContent === false &&
  357. Object.keys(emptySource.tracks).length === 0 &&
  358. emptySource.placeholder.includes('该分区当前没有数据'),
  359. `空场景:没有轨迹可画,说的是「${emptySource.placeholder}」`,
  360. )
  361. // 未加载:`timelineSource(null)` 不能抛(组件在设备还没选时也会拿到它)。
  362. const unloaded = timelineSource(null)
  363. check(
  364. 'E9-unloaded-source-is-safe',
  365. unloaded.state === SECTION_STATE.FAILED && unloaded.tracks && typeof unloaded.tracks === 'object' && unloaded.hasContent === false,
  366. '未加载时回一条失败分区、轨迹表是空对象(组件不必写 ?. 兜底)',
  367. )
  368. // E5 的反面:轨迹分区没拿到时,状态路径**仍要**铺得出来(验收第 1 条不因轨迹失败而丢)。
  369. // 当前状态码来自工单列表分区(它照常成功),所以"这张工单现在在哪一态"本来就拿得到。
  370. const orderRows = workOrderRows(TIMELINE_ORDER_ROWS_FOR_TEST)
  371. const pathOnly = pathOnlyTrack(orderRows[0])
  372. const pathOnlyNodes = pathView(pathOnly)
  373. check(
  374. 'E9b-path-survives-when-only-timeline-failed',
  375. pathOnlyNodes.length === 8 &&
  376. pathOnlyNodes.find((node) => node.code === 4).current === true &&
  377. pathOnly.steps.length === 0 &&
  378. pathOnly.entryCount === 0 &&
  379. pathOnly.pathOnly === true,
  380. `轨迹没拿到时仍铺状态路径(当前态=${(pathOnlyNodes.find((n) => n.current) || {}).name}),只是没有动作流水`,
  381. )
  382. check(
  383. 'E9c-path-only-does-not-fake-reached',
  384. pathOnly.reachedCode === null &&
  385. // 走到过哪里根本无从知道,所以**没有**任何节点该被标成"走到过"(当前态单独用 current 标)。
  386. pathOnlyNodes.filter((node) => node.reached).length === 0 &&
  387. pathOnlyNodes.filter((node) => node.current).length === 1 &&
  388. pathOnly.hasRollback === false,
  389. '只有路径时 reachedCode 为 null、零个节点标"走到过"(不拿当前态冒充"走到过最远处"),回退也无从谈起',
  390. )
  391. // 单张工单的时间线端点也按编码回显(真接口语义:认不出的编码回 null)。
  392. resetMockTransportsForTest()
  393. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  394. const { fetchWorkOrderTimeline } = await import('../../../src/modules/cockpit/services/detailApi.js')
  395. const oneFailed = await fetchWorkOrderTimeline('WO-UNKNOWN', { config: mockConfig })
  396. const oneForward = await fetchWorkOrderTimeline('WO-TL-FORWARD', { config: mockConfig })
  397. check(
  398. 'E10-timeline-endpoint-echoes-code',
  399. oneForward.state === SECTION_STATE.SUCCESS &&
  400. oneForward.data.workOrder.code === 'WO-TL-FORWARD' &&
  401. oneForward.data.entries.length === 5,
  402. `按编码取回 ${oneForward.data.workOrder.code} 自己的 5 条轨迹(不是永远同一张工单)`,
  403. )
  404. check(
  405. 'E11-unknown-order-is-not-empty-success',
  406. oneFailed.state !== SECTION_STATE.SUCCESS,
  407. `认不出的工单编码不算成功(${oneFailed.state})—— 缺数据不能画成"没有动作"`,
  408. )
  409. }
  410. // ================================================================ F 静态守卫(对抗式)
  411. {
  412. const timelineCode = readCode('domain/workOrderTimeline.js')
  413. const viewCode = readCode('domain/equipmentDetailView.js')
  414. const componentCode = readVueCode('components/WorkOrderTimeline.vue')
  415. const sectionsCode = readVueCode('components/EquipmentDetailSections.vue')
  416. const apiCode = readCode('services/detailApi.js')
  417. // 第八态与延期档:票 12 的两条否定式验收,用"全仓不许出现这些词"来守。
  418. check(
  419. 'F1-no-eighth-state-vocabulary',
  420. !/已驳回|驳回态|第八态|REJECTED_STATE/.test(timelineCode + viewCode + componentCode + sectionsCode),
  421. '「已驳回」不作为状态词出现(它只能以**动作名**出现在样例数据里)',
  422. )
  423. check(
  424. 'F2-no-delay-vocabulary',
  425. !/延期|逾期|超期|OVERDUE|DELAYED/.test(timelineCode + viewCode + componentCode + sectionsCode),
  426. '时间线里没有延期这一档(后端契约也没有该字段)',
  427. )
  428. check(
  429. 'F3-timeline-module-is-pure',
  430. !/\bfetch\s*\(|XMLHttpRequest/.test(timelineCode) && !/from '\.\.\/services\//.test(timelineCode),
  431. '时间线域模块是纯函数:不发请求、不 import service',
  432. )
  433. // 组件不许认后端裸字段(票 05 立下的守卫面,新组件必须一起守)。
  434. check(
  435. 'F4-component-has-no-backend-field-names',
  436. !/timelineEntries|stateDictionary|workOrderCode|aggregateState|stateReason|deviceCode/.test(componentCode),
  437. '时间线组件只读视图对象字段(后端裸字段名只在 domain 适配层出现)',
  438. )
  439. check(
  440. 'F5-component-does-not-build-api-paths',
  441. !/\/work-orders\/|\/equipments\//.test(componentCode + sectionsCode) && !/fetch\(/.test(componentCode),
  442. '接口路径只在 services/detailApi.js,组件不自己拼',
  443. )
  444. check(
  445. 'F6-no-write-operations',
  446. !/method:\s*['"](POST|PUT|PATCH|DELETE)['"]/.test(apiCode) &&
  447. !/@click="[^"]*(save|submit|update|delete|cancel|dispatch)/i.test(componentCode + sectionsCode),
  448. '驾驶舱只读:时间线不引入任何工单写操作(spec 验收第 6 条)',
  449. )
  450. // 排序归 domain(后端顺序不保证时序):组件不许自己重排。
  451. check(
  452. 'F7-component-does-not-sort',
  453. !/\.sort\(/.test(componentCode) && !/\.sort\(/.test(readVueCode('components/EquipmentDetailSections.vue')),
  454. '轨迹顺序由 domain 按 operatedAt 定,组件不重排',
  455. )
  456. check(
  457. 'F8-timeline-section-is-no-longer-deferred',
  458. !/DEFERRED_SECTIONS\s*=\s*\[[^\]]*timelines/.test(viewCode) && /timelineSource/.test(viewCode),
  459. '`timelines` 已从延后清单移出并接到工单面板(票 11 写明归票 12)',
  460. )
  461. // 「没取到」与「没有动作记录」在**模板上**必须是两条不同的分支 ——
  462. // 逻辑层的 E7 只能证明两句措辞不同,证明不了界面不会同时/错误地画它们。
  463. // 这条守卫钉住:逐张工单的"没有动作记录"被 `timeline.hasContent` 包住,
  464. // 分区失败时的占位说明走另一条分支。
  465. check(
  466. 'F9-no-records-note-is-gated-by-section-content',
  467. /v-if="trackOfOrder\(order\)"/.test(sectionsCode) &&
  468. /v-else-if="timeline\.hasContent"/.test(sectionsCode) &&
  469. /:path-only="!timeline\.hasContent"/.test(sectionsCode),
  470. '分区失败时仍铺状态路径(path-only),「这张工单没有动作记录」只在分区有内容时才可能渲染',
  471. )
  472. check(
  473. 'F10-path-only-blocks-no-records-claim',
  474. // 必须是 `v-else-if="!pathOnly"`:裸 `v-else`(不带 pathOnly 守卫)就是无条件说「没有动作记录」。
  475. // 正则要排除 `v-else-if` 本身,否则会把自己的守卫当成违规(第一版正是这么假红的)。
  476. /v-else-if="!pathOnly"/.test(componentCode) && !/v-else(?!-if)[\s\S]{0,120}data-wo-steps-empty/.test(componentCode),
  477. '时间线自己也不许在 path-only 时说「没有动作记录」(把"没取到"说成"没有")',
  478. )
  479. /**
  480. * **模板里用到的每个本地标识符都必须有定义**(import / const / props)。
  481. *
  482. * 为什么需要这一条:真机走查第一轮就是被这类错误判红的 ——
  483. * `EquipmentDetailSections.vue` 用了 `pathOnlyTrack(order)` 却**忘了 import**,
  484. * 静态守卫全绿(它只查"有没有出现被禁的东西",不查"用的东西在不在"),
  485. * 逻辑闸门也全绿(它测的是 domain 层,压根不加载 .vue),
  486. * 只有真机渲染时才炸成 `pathOnlyTrack is not defined`,把整块工单面板打没。
  487. * 所以在这里补一条**引用完整性**检查:模板表达式里出现的裸标识符,
  488. * 都要能在 `<script setup>` 里找到同名绑定(或它是 v-for 引入的局部名)。
  489. *
  490. * 只从**表达式上下文**取词(`{{ }}` 与 `v-*` / `:` / `@` 属性的值),
  491. * 不碰标签名与属性名 —— 否则 `div` / `class` / `data-wo-step` 会全部误报(第一版正是这么假红的)。
  492. */
  493. for (const file of ['components/EquipmentDetailSections.vue', 'components/WorkOrderTimeline.vue']) {
  494. const source = read(file)
  495. const template = (source.match(/<template>([\s\S]*)<\/template>/) || [])[1] || ''
  496. /**
  497. * 脚本体先剥注释与字符串字面量再扫。
  498. * 不剥会双向出错,两个方向都被实测踩过:
  499. * · 假红 —— 注释里的 `O(n²)` 被当成调用 `O(`、JSDoc 里的 `timelineSource()` 被当成未绑定调用;
  500. * · 假绿 —— 注释掉的 `// const foo = ...` 会被算成"有绑定",掩盖真的未定义。
  501. */
  502. const stripLiterals = (text) => text.replace(/'[^'\n]*'/g, "''").replace(/"[^"\n]*"/g, '""').replace(/`[^`]*`/g, '``')
  503. const script = stripLiterals(code((source.match(/<script setup>([\s\S]*?)<\/script>/) || [])[1] || ''))
  504. // 1) 收集表达式文本:插值 + 指令/绑定的属性值。
  505. const expressions = []
  506. for (const match of template.matchAll(/\{\{([\s\S]*?)\}\}/g)) expressions.push(match[1])
  507. for (const match of template.matchAll(/(?:^|\s)(?:v-[a-z-]+|[:@][a-zA-Z-]+)="([^"]*)"/g)) expressions.push(match[1])
  508. // 2) v-for 引入的局部名(`x in y`、`(a, b) in y`),以及内联箭头函数的形参
  509. // (`@page="(target) => gotoPage(panel, target)"` —— 票 59 实测:扫描器不认
  510. // 形参就把事件处理器里的合法参数误报成未绑定,与 v-for 那版假红同一形状)。
  511. const locals = new Set()
  512. for (const match of template.matchAll(/v-for="\(?([^)"]*?)\)?\s+in\s/g)) {
  513. for (const piece of match[1].split(',')) locals.add(piece.trim())
  514. }
  515. for (const match of template.matchAll(/\(([^)]*)\)\s*=>/g)) {
  516. for (const piece of match[1].split(',')) {
  517. const name = piece.trim().split(/[\s:=]+/)[0]
  518. if (name) locals.add(name)
  519. }
  520. }
  521. // 3) script 里的顶层绑定:import(默认/具名)、const/let/var/function、defineProps 的 prop 名。
  522. const bound = new Set(locals)
  523. for (const match of script.matchAll(/import\s+\{([^}]*)\}\s+from/g)) {
  524. for (const piece of match[1].split(',')) {
  525. const name = piece.trim().split(/\s+as\s+/).pop().trim()
  526. if (name) bound.add(name)
  527. }
  528. }
  529. for (const match of script.matchAll(/import\s+([A-Za-z0-9_$]+)\s+from/g)) bound.add(match[1])
  530. for (const match of script.matchAll(/(?:const|let|var|function)\s+([A-Za-z0-9_$]+)/g)) bound.add(match[1])
  531. const propsBlock = (script.match(/defineProps\(\{([\s\S]*?)\n\}\)/) || [])[1] || ''
  532. for (const match of propsBlock.matchAll(/^\s*([A-Za-z0-9_$]+)\s*:/gm)) bound.add(match[1])
  533. // 4) 表达式里取裸标识符:排除属性访问(`.` 之后)与对象字面量的键(`key:`)。
  534. 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'])
  535. const used = new Set()
  536. for (const expression of expressions) {
  537. // 去掉字符串字面量,避免把文案里的词当成标识符。
  538. const bare = expression.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""').replace(/`[^`]*`/g, '``')
  539. for (const match of bare.matchAll(/(^|[^.\w$])([A-Za-z_$][A-Za-z0-9_$]*)\s*(:)?/g)) {
  540. // `foo:` 是对象字面量的键,不是引用。
  541. if (match[3] === ':') continue
  542. used.add(match[2])
  543. }
  544. }
  545. const missing = [...used].filter((name) => !bound.has(name) && !allowed.has(name))
  546. check(
  547. `F11-${file.split('/').pop()}-template-references-are-bound`,
  548. missing.length === 0,
  549. missing.length ? `模板里用了但没定义的标识符:${missing.join(', ')}` : `模板表达式引用的 ${used.size} 个标识符都有 import/const/props/v-for 定义`,
  550. )
  551. /**
  552. * **`<script setup>` 里调用的每个函数名都必须有定义。**
  553. *
  554. * 这条才是真机走查逮到的那个 bug 的形状:`EquipmentDetailSections.vue` 在
  555. * **脚本体**里写了 `return full || pathOnlyTrack(order)`,却没 import `pathOnlyTrack`
  556. * —— 模板检查(上面那条)看不见脚本体,静态守卫也只看"有没有出现被禁的东西",
  557. * 于是三处全绿,只有真机渲染才炸 `pathOnlyTrack is not defined`。
  558. * 本仓零依赖、不打包 .vue,所以要靠"调用点名字必须有绑定"这条来守。
  559. * **变异测试确认过它有牙**:去掉该 import 后本断言必须变红。
  560. */
  561. const callables = new Set()
  562. for (const match of script.matchAll(/(^|[^.\w$])([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) callables.add(match[2])
  563. // 显式声明过的局部函数、以及宏与 JS 内置(它们不 import 也能调)。
  564. const callableAllowed = new Set([
  565. 'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'function',
  566. ...allowed,
  567. 'defineProps', 'defineEmits', 'defineExpose', 'withDefaults',
  568. 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'setTimeout', 'clearTimeout',
  569. 'setInterval', 'clearInterval', 'encodeURIComponent', 'decodeURIComponent',
  570. 'Promise', 'Error', 'RegExp', 'Map', 'Set', 'WeakMap', 'Symbol', 'BigInt',
  571. 'queueMicrotask', 'structuredClone', 'requestAnimationFrame', 'String', 'Number',
  572. ])
  573. const unboundCalls = [...callables].filter((name) => !bound.has(name) && !callableAllowed.has(name))
  574. check(
  575. `F12-${file.split('/').pop()}-script-calls-are-bound`,
  576. unboundCalls.length === 0,
  577. unboundCalls.length
  578. ? `脚本里调用了但没定义的函数:${unboundCalls.join(', ')}`
  579. : `脚本体 ${callables.size} 个调用点(${[...callables].join(', ')})都解析得到`,
  580. )
  581. }
  582. }
  583. const failed = checks.filter((item) => !item.pass)
  584. console.log('')
  585. console.log(`T12 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  586. if (failed.length) {
  587. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  588. process.exit(1)
  589. }