t46-mutations.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. /**
  2. * 票 46 闸门变异测试:证明 t46 的每条关键断言**抓得住它要防的缺陷**。
  3. *
  4. * 做法(与 t44-mutations 同一纪律):对源文件做**单点**变异注入 → 跑 t46 → 断言指定断言转红
  5. * → 按字节还原并校验一致。变异只在本脚本运行期间存活几秒,跑完即还原。
  6. *
  7. * 四条变异对应票 46 的四个真实风险:
  8. * M1 filterByCategory 认不出的键回**全量**("筛了个寂寞"的假绿)→ A4c 必须红
  9. * M2 统计整段缺失时画**一排 0**(虚假零值,票 16 明令禁止)→ D1 必须红
  10. * M3 展开详情的必选项没值就**丢掉**(数据时间/状态/来源/关联对象消失)→ C1 必须红
  11. * M4 认不出的等级**并进 level:1**(把未知猜成真实等级)→ B2 必须红
  12. *
  13. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t46-mutations.mjs
  14. */
  15. import assert from 'node:assert/strict'
  16. import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  17. import { spawnSync } from 'node:child_process'
  18. import { tmpdir } from 'node:os'
  19. import { join } from 'node:path'
  20. import { fileURLToPath } from 'node:url'
  21. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  22. const GATE = fileURLToPath(new URL('t46-detail-cards-history.mjs', import.meta.url))
  23. const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
  24. /**
  25. * 跑闸门并取回输出。
  26. * 刻意用**文件重定向**而不是管道捕获:Windows 沙箱下捕获子进程 stdio 的命名管道
  27. * 是文档化的边界(EPERM),管道版实测拿回空输出 → 变异全部"未转红"的假信号。
  28. */
  29. const reportDir = mkdtempSync(join(tmpdir(), 't46-mutations-'))
  30. function runGate() {
  31. const outPath = join(reportDir, 'stdout.txt')
  32. const errPath = join(reportDir, 'stderr.txt')
  33. const out = openSync(outPath, 'w')
  34. const err = openSync(errPath, 'w')
  35. let status = 0
  36. try {
  37. status = spawnSync(process.execPath, [GATE], { stdio: ['ignore', out, err] }).status
  38. } finally {
  39. closeSync(out)
  40. closeSync(err)
  41. }
  42. return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status }
  43. }
  44. function failingNames(output) {
  45. return [...output.matchAll(/^FAIL ([^:]+):/gm)].map((match) => match[1])
  46. }
  47. let injected = 0
  48. let proved = 0
  49. function mutate(label, file, from, to, expectRed) {
  50. const path = srcPath(file)
  51. const original = readFileSync(path, 'utf8')
  52. // 变异源文本按 CRLF 归一后再匹配(票 37 实测踩过:行尾不一致时 replace 静默不命中)。
  53. const candidates = [from, from.replace(/\n/g, '\r\n')]
  54. const needle = candidates.find((candidate) => original.includes(candidate))
  55. if (!needle) {
  56. console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`)
  57. injected += 1
  58. return
  59. }
  60. injected += 1
  61. writeFileSync(path, original.replace(needle, to), 'utf8')
  62. let caught = false
  63. let how = ''
  64. try {
  65. const { output, status } = runGate()
  66. const red = failingNames(output)
  67. if (red.includes(expectRed)) {
  68. caught = true
  69. how = `${expectRed} 转红`
  70. } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) {
  71. // **崩溃也算抓住**,而且是最硬的一种:变异改掉了 domain 的真实导出,
  72. // 闸门自己的具名 import 就加载失败(一条 PASS 都印不出来)。
  73. // 单列成 CAUGHT-BY-CRASH,不与"某条断言转红"混为一谈,也不误记成假绿。
  74. caught = true
  75. how = `闸门整体加载失败(导出缺失)— 崩溃式抓住`
  76. } else if (status !== 0) {
  77. how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记`
  78. } else {
  79. how = `未转红(假绿!闸门仍全绿)`
  80. }
  81. console.log(`${caught ? 'PASS' : 'FAIL'} ${label} → ${how}`)
  82. if (caught) proved += 1
  83. } finally {
  84. writeFileSync(path, original, 'utf8')
  85. assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`)
  86. }
  87. }
  88. mutate(
  89. 'M1-unknown-key-returns-all',
  90. 'domain/equipmentDetailView.js',
  91. ' if (!key) return list\n return list.filter((row) => row.categoryKey === key)',
  92. ' if (!key) return list\n const hits = list.filter((row) => row.categoryKey === key)\n return hits.length ? hits : list',
  93. 'A4c-unknown-key-is-empty',
  94. )
  95. mutate(
  96. 'M2-missing-statistics-draws-zeros',
  97. 'domain/detailFlow.js',
  98. ' if (!statistics || !Array.isArray(statistics.missing) || statistics.missing.length > 0) return null',
  99. ' if (!statistics || !Array.isArray(statistics.missing)) return null',
  100. 'D1-missing-statistics-hides-kpi',
  101. )
  102. mutate(
  103. 'M3-mandatory-detail-rows-dropped',
  104. 'domain/equipmentDetailView.js',
  105. ' if (!keepEmpty && text === DETAIL_PLACEHOLDER) return null',
  106. ' if (text === DETAIL_PLACEHOLDER) return null',
  107. 'C1-mandatory-detail-rows',
  108. )
  109. mutate(
  110. 'M4-unknown-level-guessed-as-level1',
  111. 'domain/equipmentDetailView.js',
  112. " categoryKey: levelNumber === null ? 'level:unknown' : `level:${levelNumber}`,",
  113. ' categoryKey: `level:${levelNumber === null ? 1 : levelNumber}`,',
  114. 'B2-unknown-level-not-guessed',
  115. )
  116. // ---- 返工(2026-10-15 维护者第 1/2 条)新增的三条变异 ----
  117. mutate(
  118. 'M5-pager-does-not-clamp-out-of-range',
  119. 'domain/equipmentDetailView.js',
  120. ' const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)',
  121. ' const current = Math.max(1, Math.trunc(Number(page) || 1))',
  122. 'G3-out-of-range-clamped',
  123. )
  124. mutate(
  125. 'M6-image-tab-dropped-from-archive',
  126. 'domain/equipmentDetailView.js',
  127. " { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },",
  128. " { name: 'archive', label: '设备档案', sections: ['archive'] },",
  129. 'F1-tab-plan-shape',
  130. )
  131. mutate(
  132. 'M7-icon-registry-accepts-inherited-keys',
  133. 'domain/pointStatus.js',
  134. ' return Object.prototype.hasOwnProperty.call(LEGEND_ICON_BY_KEY, key) ? LEGEND_ICON_BY_KEY[key] : \'\'',
  135. ' return LEGEND_ICON_BY_KEY[key] || \'\'',
  136. 'H5-blank-key-no-icon',
  137. )
  138. // E11 的牙:把 domain 导出改名(组件 import 的名字就落空)→ 必须被"按真实导出判"抓住。
  139. // 这条改的是 t46 **自己也 import** 的名字,所以闸门直接加载失败(崩溃式抓住,最硬的一种)。
  140. mutate(
  141. 'M8-domain-export-renamed',
  142. 'domain/equipmentDetailView.js',
  143. 'export function pagedRows(rows, page, pageSize = HISTORY_PAGE_SIZE) {',
  144. 'export function pagedRowsRenamed(rows, page, pageSize = HISTORY_PAGE_SIZE) {',
  145. 'E11-imported-names-are-real-exports',
  146. )
  147. // M8b 才是 E11 判据本身的牙:改一个**只有组件 import、t46 自己不 import** 的名字
  148. // (`archiveRows`)—— 闸门仍能加载,此时必须由 E11 那条"按真实导出判"转红。
  149. // 只有崩溃没有断言红,说明判据没被单独验证过(本仓"守卫比它要守的东西宽"的同一类坑)。
  150. mutate(
  151. 'M8b-component-only-export-renamed',
  152. 'domain/equipmentDetailView.js',
  153. 'export function archiveRows(data) {',
  154. 'export function archiveRowsRenamed(data) {',
  155. 'E11-imported-names-are-real-exports',
  156. )
  157. // 标签页牙:页签顺序被打乱(默认页不再第一)→ F1 必须红。
  158. mutate(
  159. 'M9-tabs-order-shuffled',
  160. 'domain/equipmentDetailView.js',
  161. " { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },\n { name: 'metrics', label: '最新指标', sections: ['metrics'] },",
  162. " { name: 'metrics', label: '最新指标', sections: ['metrics'] },\n { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },",
  163. 'F1-tab-plan-shape',
  164. )
  165. // 图例对齐牙:详情状态视图丢掉图标 → H2 必须红。
  166. mutate(
  167. 'M10-status-view-drops-icon',
  168. 'domain/detailFlow.js',
  169. ' label: labelOverride || visual.label,\n icon: legendIconFor(visual.key),\n }\n}\n\n/**\n * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。',
  170. ' label: labelOverride || visual.label,\n }\n}\n\n/**\n * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。',
  171. 'H2-point-status-matches-top-legend',
  172. )
  173. console.log('')
  174. console.log(`T46 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
  175. rmSync(reportDir, { recursive: true, force: true })
  176. if (proved < injected) process.exit(1)