t65-mutations.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. /**
  2. * 票 65 闸门变异测试:证明 t65 的每条关键断言**抓得住它要防的缺陷**(t46/t59/t64 同一纪律)。
  3. *
  4. * 做法:对源文件做**单点**变异注入 → 跑 t65 → 断言指定断言转红 → 按字节还原并校验一致。
  5. * 每处变异对应票 65 里的一个真实风险:
  6. * M1 预警族仍画工单口径 KPI → A3/A4/A7c 必须红(本票要防的**本体缺陷**)
  7. * M2 预警族仍画工单状态分布块 → A4 必须红(KPI 改了但图表没改 = 半分流)
  8. * M3 预警族用 `handlingCounts` 冒充预警读数 → A2/A3 必须红(把工单派生数画成预警数)
  9. * M4 等级分布改「全部铺 0」 → A5 必须红(真实计数被抹成 0)
  10. * M5 发生趋势不按日合并 → A6 必须红(169 个小时桶直接铺折线)
  11. * M6 报警族被一并改成预警口径 → A7 必须红(分流做成「两族都一样」)
  12. * M7 标题不吃切换词表 → B1/B2/D4 必须红(标题不随模式换)
  13. * M8 口径说明两族共用一张 → B3 必须红(预警族的缺口说明被报警口径顶掉)
  14. * M9 分流只看切换参数不看回显 → C1 必须红(回显优先级失守)
  15. * M10 分流只看回显不看切换参数 → C2 必须红(mock/旧载荷不分流)
  16. * M11 面板组件不传 extra.mode → E1/D1 必须红(接线断链:预警模式仍画工单口径)
  17. * M12 外壳标题回到静态 labelForPanel → E2/D4 必须红(外框标题不换)
  18. * M13 外壳另写一份标题字面量 → E3 必须红(第二张词表长出)
  19. * M14 mock 把预警族工单字段藏掉 → F2 必须红(契约缺口被 mock 掩盖)
  20. * M15 缺失读数补 0 → G1/G2 必须红(票 22/42 纪律失守)
  21. *
  22. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t65-mutations.mjs
  23. */
  24. import assert from 'node:assert/strict'
  25. import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  26. import { spawnSync } from 'node:child_process'
  27. import { tmpdir } from 'node:os'
  28. import { join } from 'node:path'
  29. import { fileURLToPath } from 'node:url'
  30. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  31. const GATE = fileURLToPath(new URL('t65-warning-efficiency-family-split.mjs', import.meta.url))
  32. const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
  33. /**
  34. * 跑闸门并取回输出。刻意用**文件重定向**而不是管道捕获(t46/t59/t64 实测:
  35. * Windows 沙箱下捕获子进程 stdio 的命名管道是文档化边界(EPERM),
  36. * 管道版拿回空输出 → 变异全部「未转红」的假信号)。
  37. */
  38. const reportDir = mkdtempSync(join(tmpdir(), 't65-mutations-'))
  39. function runGate() {
  40. const outPath = join(reportDir, 'stdout.txt')
  41. const errPath = join(reportDir, 'stderr.txt')
  42. const out = openSync(outPath, 'w')
  43. const err = openSync(errPath, 'w')
  44. let status = 0
  45. try {
  46. status = spawnSync(process.execPath, [GATE], { stdio: ['ignore', out, err] }).status
  47. } finally {
  48. closeSync(out)
  49. closeSync(err)
  50. }
  51. return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status }
  52. }
  53. function failingNames(output) {
  54. return [...output.matchAll(/^FAIL ([^:]+):/gm)].map((match) => match[1])
  55. }
  56. let injected = 0
  57. let proved = 0
  58. function mutate(label, file, from, to, expectRed) {
  59. const path = srcPath(file)
  60. const original = readFileSync(path, 'utf8')
  61. // 变异源文本按 CRLF 归一后再匹配(票 37 实测踩过:行尾不一致时 replace 静默不命中)。
  62. const candidates = [from, from.replace(/\n/g, '\r\n')]
  63. const needle = candidates.find((candidate) => original.includes(candidate))
  64. if (!needle) {
  65. console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`)
  66. injected += 1
  67. return
  68. }
  69. injected += 1
  70. writeFileSync(path, original.replace(needle, to), 'utf8')
  71. runAndJudge(label, original, path, expectRed)
  72. }
  73. function runAndJudge(label, original, path, expectRed) {
  74. let caught = false
  75. let how = ''
  76. try {
  77. const { output, status } = runGate()
  78. const red = failingNames(output)
  79. if (red.includes(expectRed)) {
  80. caught = true
  81. how = `${expectRed} 转红`
  82. } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) {
  83. caught = true
  84. how = '闸门整体加载失败(导出缺失/语法错)— 崩溃式抓住'
  85. } else if (status !== 0) {
  86. how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记`
  87. } else {
  88. how = '未转红(假绿!闸门仍全绿)'
  89. }
  90. console.log(`${caught ? 'PASS' : 'FAIL'} ${label} → ${how}`)
  91. if (caught) proved += 1
  92. } finally {
  93. writeFileSync(path, original, 'utf8')
  94. assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`)
  95. }
  96. }
  97. // ---- 本体:预警族必须不画工单口径(两面:KPI 与图表)----
  98. mutate(
  99. 'M1-warning-family-still-draws-work-order-kpis',
  100. 'domain/warningResponsePanels.js',
  101. ` if (family === EVENT_FAMILY.WARNING) {`,
  102. ` if (family === EVENT_FAMILY.WARNING && false) {`,
  103. 'A4 预警模式不画工单口径(处置率/工单状态分布/结案时长)',
  104. )
  105. // M1b 只改 KPI 不改图表 = 半分流(KPI 对了、堆叠条还在画工单状态)。
  106. // 与 M1 分开注入:M1 会把整支预警分支跳过,看不出「只改一半也算失守」。
  107. mutate(
  108. 'M1b-warning-family-hides-work-order-kpis-but-keeps-stacked',
  109. 'domain/warningResponsePanels.js',
  110. ` warningEfficiencyKpis(current),
  111. barsBlock('预警等级分布', current.levelDistribution || []),`,
  112. ` alarmEfficiencyKpis(current),
  113. barsBlock('预警等级分布', current.levelDistribution || []),`,
  114. 'A4 预警模式不画工单口径(处置率/工单状态分布/结案时长)',
  115. )
  116. mutate(
  117. 'M2-warning-family-keeps-work-order-stacked-block',
  118. 'domain/warningResponsePanels.js',
  119. ` barsBlock('预警等级分布', current.levelDistribution || []),
  120. trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }),`,
  121. ` barsBlock('预警等级分布', current.levelDistribution || []),
  122. stackedBlock('工单状态分布', current.workOrderStates || []),
  123. trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }),`,
  124. 'A4 预警模式不画工单口径(处置率/工单状态分布/结案时长)',
  125. )
  126. // M3 票面点名的「假读数」:预警族的关联计数改用**工单派生**字段(handled/cancelled),
  127. // 这正是票面 Acceptance 第 2 条禁止的「用一个不存在的对象统计预警」
  128. // (实测预警族 handled=cancelled=0,而真实 missing=全部 —— 一画就把 5 条预警说成 0 条未关联)。
  129. mutate(
  130. 'M3-work-order-derived-counts-painted-as-warning',
  131. 'domain/warningResponsePanels.js',
  132. ` linkedWorkOrderCount: numberish(summary.linkedWorkOrderCount === undefined ? reader.raw.linkedWorkOrderCount : summary.linkedWorkOrderCount),
  133. missingWorkOrderCount: numberish(summary.missingWorkOrderCount === undefined ? reader.raw.missingWorkOrderCount : summary.missingWorkOrderCount),`,
  134. ` linkedWorkOrderCount: numberish(summary.handledCount),
  135. missingWorkOrderCount: numberish(summary.cancelledCount),`,
  136. 'A2 预警族工单关联实况是真实计数(预警不挂工单:0 关联 / 全部未关联)',
  137. )
  138. // M4 等级分布退化成「四级全铺 0」:看上去像合规的空面板,实际把真实计数抹掉。
  139. mutate(
  140. 'M4-level-distribution-zeroed',
  141. 'domain/warningResponsePanels.js',
  142. ` count: events.filter((item) => Number(item && item.level) === level).length,`,
  143. ` count: 0,`,
  144. 'A5 预警等级分布逐级等于真实事件行统计',
  145. )
  146. // M5 发生趋势不按日合并:169 个小时桶直接铺到折线上(票 61 的历史趋势合并口径被绕过)。
  147. mutate(
  148. 'M5-event-trend-not-merged-by-day',
  149. 'domain/warningResponsePanels.js',
  150. ` const trend = historyTrendRows(current.eventTrend || [])`,
  151. ` const trend = trendRows(current.eventTrend || [])`,
  152. 'A6 预警发生趋势按日合并(小时桶→天,不重采样、不补点)',
  153. )
  154. // M6 分流做过头:报警族也被改成预警口径(「两族一样」= 另一种没分流)。
  155. mutate(
  156. 'M6-alarm-family-also-drawn-as-warning',
  157. 'domain/warningResponsePanels.js',
  158. ` return sectionView('responseEfficiency', section, [
  159. alarmEfficiencyKpis(current),`,
  160. ` return sectionView('responseEfficiency', section, [
  161. ...(family === EVENT_FAMILY.ALARM ? warningEfficiencyKpis(current).items.map((item) => item) : alarmEfficiencyKpis(current).items).length ? [] : [],`,
  162. 'A7 报警族保留工单口径(处置率/响应与关闭时长/工单状态分布)',
  163. )
  164. // ---- 词表与口径说明 ----
  165. mutate(
  166. 'M7-title-ignores-switch-word-table',
  167. 'domain/warningResponsePanels.js',
  168. ' return `${eventListSwitchView(mode).label}效率`',
  169. " return '预警效率'",
  170. 'B1 面板标题复用事件族单一词表(预警/报警 + 「效率」)',
  171. )
  172. mutate(
  173. 'M8-gap-note-shared-across-families',
  174. 'domain/warningResponsePanels.js',
  175. ` note: WARNING_EFFICIENCY_GAP,`,
  176. ` note: ALARM_EFFICIENCY_BASIS,`,
  177. 'B3 口径说明随族换且各自如实',
  178. )
  179. // ---- 分流依据:回显与切换参数的优先级 ----
  180. mutate(
  181. 'M9-split-ignores-backend-echo',
  182. 'domain/warningResponsePanels.js',
  183. ` const family = current.family || (extra && extra.mode) || EVENT_FAMILY.ALARM`,
  184. ` const family = (extra && extra.mode) || current.family || EVENT_FAMILY.ALARM`,
  185. 'C1 data.family(后端回显)优先于 extra.mode(切换参数)',
  186. )
  187. mutate(
  188. 'M10-split-ignores-switch-param',
  189. 'domain/warningResponsePanels.js',
  190. ` const family = current.family || (extra && extra.mode) || EVENT_FAMILY.ALARM`,
  191. ` const family = current.family || EVENT_FAMILY.ALARM`,
  192. 'C2 回显缺失时按切换参数分流(extra.mode=WARNING → 预警口径)',
  193. )
  194. // ---- 接线断链 ----
  195. mutate(
  196. 'M11-pane-does-not-pass-mode',
  197. 'components/WarningResponsePane.vue',
  198. // 票 65 追加需求后第四参扩成多行对象,变异点改为「整段 extra 不传」——
  199. // 这与原来的语义完全一致(接线断链),只是不再依赖单行字面量。
  200. ` return panelView(props.panelId, current.data, current, {
  201. mode: eventStore.mode,
  202. workOrderList: eventStore.sections.workOrderList,
  203. })`,
  204. ` return panelView(props.panelId, current.data, current)`,
  205. 'E1 面板组件把当前切换族交给 domain(否则预警接线永远落回报警口径)',
  206. )
  207. mutate(
  208. 'M12-shell-title-back-to-static-label',
  209. 'CockpitShell.vue',
  210. ` if (panelId === 'responseEfficiency') return responseEfficiencyTitle(eventStore.mode)`,
  211. ` if (panelId === 'responseEfficiency') return labelForPanel(panelId)`,
  212. 'E2 外壳面板标题走 domain 单源(responseEfficiencyTitle),不另起一张词表',
  213. )
  214. mutate(
  215. 'M13-shell-grows-second-title-literal',
  216. 'CockpitShell.vue',
  217. `function panelTitle(panelId) {`,
  218. `function responseEfficiencyTitle2(mode) { return mode === 'ALARM' ? '报警效率' : '预警效率' }\nfunction panelTitle(panelId) {`,
  219. 'E3 预警/报警效率标题字面量只存在于 domain(外壳零第二份)',
  220. )
  221. // ---- mock 同构:不许把契约缺口藏起来 ----
  222. mutate(
  223. 'M14-mock-hides-work-order-fields-on-warning',
  224. 'mocks/contractMocks.js',
  225. ` handlingEfficiency: { value: blank ? 0 : numerator / denominator, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' },`,
  226. ` handlingEfficiency: source.mode === 'WARNING' ? null : { value: blank ? 0 : numerator / denominator, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' },`,
  227. 'F2 mock 两族仍保留工单口径字段(契约现状,不伪装成已分流)',
  228. )
  229. // ---- 缺失即占位(票 22/42 纪律)----
  230. mutate(
  231. 'M15-missing-counts-painted-as-zero',
  232. 'domain/warningResponsePanels.js',
  233. ` { key: 'warningTotal', label: '预警条数', value: valueText(current.totalEvents), unit: '条' },`,
  234. ` { key: 'warningTotal', label: '预警条数', value: current.totalEvents === null || current.totalEvents === undefined ? '0' : valueText(current.totalEvents), unit: '条' },`,
  235. 'G1 预警族读数缺失画占位符(不把「没有」画成 0 条)',
  236. )
  237. // ---- 报警族「当前工单列表」(2026-10-21 追加需求 1)----
  238. mutate(
  239. 'M16-alarm-list-block-missing',
  240. 'domain/warningResponsePanels.js',
  241. ` workOrderListBlock(extra),`,
  242. ``,
  243. 'H1 报警模式出现「当前工单列表」块,且排在统计图之后(先结论后明细)',
  244. )
  245. mutate(
  246. 'M17-card-rows-lose-click-contract',
  247. 'domain/warningResponsePanels.js',
  248. ` code: row.code,`,
  249. ` code: '',`,
  250. 'H2 卡片行带可点契约与状态词表(code 触发渲染层 open-card;tone 来自既有色键)',
  251. )
  252. mutate(
  253. 'M18-pagination-from-page-length',
  254. 'domain/warningResponsePanels.js',
  255. ` serverPage: paging ? { page: paging.page, pageCount: paging.totalPages, total: paging.total } : undefined,`,
  256. ` serverPage: paging ? { page: paging.page, pageCount: 1, total: orders.length } : undefined,`,
  257. 'H3 分页读后端回显(共 7 张 / 2 页),不由本页 2 条反推',
  258. )
  259. mutate(
  260. 'M19-failed-list-drawn-as-empty',
  261. 'domain/warningResponsePanels.js',
  262. ` if (section.state !== 'success' && section.state !== 'stale' && orders.length === 0) {`,
  263. ` if (false) {`,
  264. 'H6 取数失败说「未取到」,与「确实没有」分开(不把缺数据画成 0 张)',
  265. )
  266. mutate(
  267. 'M20-warning-family-also-gets-work-order-list',
  268. 'domain/warningResponsePanels.js',
  269. ` barsBlock('预警等级分布', current.levelDistribution || []),`,
  270. ` barsBlock('预警等级分布', current.levelDistribution || []),
  271. workOrderListBlock(extra),`,
  272. 'H8 预警模式不出现工单列表(预警不挂工单,与「不画工单口径」同一条纪律)',
  273. )
  274. mutate(
  275. 'M21-state-tone-not-partitioned',
  276. 'domain/warningResponsePanels.js',
  277. ` if (state.code === 0 || state.code === 1) return 'lv1'`,
  278. ` if (state.code === 0 || state.code === 1) return 'neutral'`,
  279. 'H9b 色调随状态语义分档(待办≠在办≠已结案≠已取消)',
  280. )
  281. // ---- 预警口径说明文案(2026-10-21 追加需求 2)----
  282. mutate(
  283. 'M22-gap-note-back-to-endpoint-wording',
  284. 'domain/warningResponsePanels.js',
  285. ` '预警不挂工单,所以这里不画工单口径的处置率、工单状态分布与结案时长;' +`,
  286. ` '本端点对预警族仍只返回工单口径字段,预警不挂工单,所以这里不画处置率;' +`,
  287. 'I1 预警口径说明不含接口词汇(「本端点」「该端点」「/events/」等不上大屏)',
  288. )
  289. mutate(
  290. 'M23-gap-note-drops-readings-first',
  291. 'domain/warningResponsePanels.js',
  292. ` '下方的条数、等级分布与发生趋势都是本窗口内预警自身的真实读数。' +`,
  293. ``,
  294. 'I2 前两句先说清「这些数是什么、能不能信」(先讲读数再讲缺口)',
  295. )
  296. // ---- 全量工单端点契约(2026-10-21 追加需求 1 的数据面)----
  297. mutate(
  298. 'M24-list-pagination-dropped',
  299. 'services/detailApi.js',
  300. ` pagination: paging`,
  301. ` pagination: paging ? { page: 1, pageSize: 5, total: 1, totalPages: 1 } : null`,
  302. 'J2 分页四件套逐项读后端回显(page/pageSize/total/totalPages,不由行数反推)',
  303. )
  304. mutate(
  305. 'M25-list-association-flag-lost',
  306. 'services/detailApi.js',
  307. ` orders: Array.isArray(payload && payload.workOrders) ? payload.workOrders.map(toWorkOrder) : [],`,
  308. ` orders: Array.isArray(payload && payload.workOrders) ? payload.workOrders.map((row) => ({ ...toWorkOrder(row), associatedWithCurrentEvent: null })) : [],`,
  309. 'J3 工单的关联标记如实透传(列表里能分辨「关联了报警」与「纯设备工单」)',
  310. )
  311. mutate(
  312. 'M26-mock-list-always-returns-everything',
  313. 'mocks/contractMocks.js',
  314. ` workOrders: all.slice((safePage - 1) * pageSize, safePage * pageSize),`,
  315. ` workOrders: all,`,
  316. 'J5 mock 全量工单与真实同形(有 pagination;换 pageSize 后翻页真的切窗口,不是永远回全量)',
  317. )
  318. console.log('')
  319. console.log(`T65 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
  320. rmSync(reportDir, { recursive: true, force: true })
  321. if (proved < injected) process.exit(1)