t46-mutations.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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. // 票 59 升格:pagedRows 实现移到单源 domain/pagination.js(equipmentDetailView 只按原名再导出),
  118. // 夹页锚点随之改到 pagination.js;G 节测的仍是 equipmentDetailView 的兼容再导出(同一份实现)。
  119. mutate(
  120. 'M5-pager-does-not-clamp-out-of-range',
  121. 'domain/pagination.js',
  122. ' const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)',
  123. ' const current = Math.max(1, Math.trunc(Number(page) || 1))',
  124. 'G3-out-of-range-clamped',
  125. )
  126. mutate(
  127. 'M6-image-tab-dropped-from-archive',
  128. 'domain/equipmentDetailView.js',
  129. " { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },",
  130. " { name: 'archive', label: '设备档案', sections: ['archive'] },",
  131. 'F1-tab-plan-shape',
  132. )
  133. mutate(
  134. 'M7-icon-registry-accepts-inherited-keys',
  135. 'domain/pointStatus.js',
  136. ' return Object.prototype.hasOwnProperty.call(LEGEND_ICON_BY_KEY, key) ? LEGEND_ICON_BY_KEY[key] : \'\'',
  137. ' return LEGEND_ICON_BY_KEY[key] || \'\'',
  138. 'H5-blank-key-no-icon',
  139. )
  140. // E11 的牙:把 domain 导出改名(组件 import 的名字就落空)→ 必须被"按真实导出判"抓住。
  141. // 这条改的是 t46 **自己也 import** 的名字,所以闸门直接加载失败(崩溃式抓住,最硬的一种)。
  142. // 票 59 升格后:单源 pagination.js 里改名 pagedRows → equipmentDetailView 的原名兼容再导出落空,
  143. // 闸门具名 import 直接加载失败(崩溃式抓住,仍是 M8 要的"改名必被抓")。
  144. mutate(
  145. 'M8-domain-export-renamed',
  146. 'domain/pagination.js',
  147. 'export function pagedRows(rows, page, pageSize = PAGE_SIZE) {',
  148. 'export function pagedRowsRenamed(rows, page, pageSize = PAGE_SIZE) {',
  149. 'E11-imported-names-are-real-exports',
  150. )
  151. // M8b 才是 E11 判据本身的牙:改一个**只有组件 import、t46 自己不 import** 的名字
  152. // (`archiveRows`)—— 闸门仍能加载,此时必须由 E11 那条"按真实导出判"转红。
  153. // 只有崩溃没有断言红,说明判据没被单独验证过(本仓"守卫比它要守的东西宽"的同一类坑)。
  154. mutate(
  155. 'M8b-component-only-export-renamed',
  156. 'domain/equipmentDetailView.js',
  157. 'export function archiveRows(data) {',
  158. 'export function archiveRowsRenamed(data) {',
  159. 'E11-imported-names-are-real-exports',
  160. )
  161. // 标签页牙:页签顺序被打乱(默认页不再第一)→ F1 必须红。
  162. mutate(
  163. 'M9-tabs-order-shuffled',
  164. 'domain/equipmentDetailView.js',
  165. " { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },\n { name: 'metrics', label: '最新指标', sections: ['metrics'] },",
  166. " { name: 'metrics', label: '最新指标', sections: ['metrics'] },\n { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },",
  167. 'F1-tab-plan-shape',
  168. )
  169. // 图例对齐牙:详情状态视图丢掉图标 → H2 必须红。
  170. mutate(
  171. 'M10-status-view-drops-icon',
  172. 'domain/detailFlow.js',
  173. ' label: labelOverride || visual.label,\n icon: legendIconFor(visual.key),\n }\n}\n\n/**\n * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。',
  174. ' label: labelOverride || visual.label,\n }\n}\n\n/**\n * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。',
  175. 'H2-point-status-matches-top-legend',
  176. )
  177. // M11(收口后维护者复验补):把"形状与图标互斥"改回"形状照画" —— 即本次修掉的回归
  178. // (详情标签画出「□ ≡ 无设备」多一个顶部已废弃的形状)。E10c 必须转红。
  179. mutate(
  180. 'M11-shape-renders-alongside-icon',
  181. 'components/StatusTag.vue',
  182. ' <template v-else>',
  183. ' <template v-if="true">',
  184. 'E10c-icon-suppresses-deprecated-shape',
  185. )
  186. console.log('')
  187. console.log(`T46 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
  188. rmSync(reportDir, { recursive: true, force: true })
  189. if (proved < injected) process.exit(1)