t59-mutations.mjs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. /**
  2. * 票 59 闸门变异测试:证明 t59 的每条关键断言**抓得住它要防的缺陷**(与 t46-mutations 同一纪律)。
  3. *
  4. * 做法:对源文件做**单点**变异注入 → 跑 t59 → 断言指定断言转红 → 按字节还原并校验一致。
  5. * 变异只在本脚本运行期间存活几秒,跑完即还原。
  6. *
  7. * 十三条变异对应票 59 的真实风险:
  8. * M1 窗口全列阈值被改成 5 → W2 必须红(7 页就收缩=条文 2 的「≤7 全列」失守)
  9. * M2 右侧省略号不推了 → W3 必须红(收缩窗口缺省略号=「恒含省略号」失守)
  10. * M3 跳页夹回放 NaN 过 → J3 必须红(页码状态被 NaN 污染,控件画成「第 NaN 页」)
  11. * M4 跳页只夹下不夹上 → J2 必须红(超界页跳进空页)
  12. * M5 一页也画控件 → S1 必须红(条文 4「一页不画」失守)
  13. * M6 翻页出口改名出第二个事件 → S2 必须红(「共用同一翻页出口」失守)
  14. * M7 到边按钮不再禁用 → S3 必须红(票 46 既有语义回退)
  15. * M8 当前页 aria-current 丢了 → S4 必须红(条文 9 失守)
  16. * M9 跳页绕过 clampPage → S6 必须红(夹回判据名存实亡)
  17. * M10 兼容再导出被改名 → t59 具名 import 加载失败=崩溃式抓住(条文 11 的单源锚)
  18. * M11 组件里重新长出翻页算术 → C4 必须红(条文 11「禁止组件重写算术」)
  19. * M12 工单回退成全量直渲 → C3 必须红(本票「工单状态新接入分页」交付被吃掉)
  20. * M13 换分类不再回第 1 页 → C5 必须红(条文 6 复位规则失守)
  21. *
  22. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t59-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('t59-shared-pager.mjs', import.meta.url))
  32. const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
  33. /**
  34. * 跑闸门并取回输出。
  35. * 刻意用**文件重定向**而不是管道捕获(t46 实测:Windows 沙箱下捕获子进程 stdio 的命名管道
  36. * 是文档化的边界(EPERM),管道版拿回空输出 → 变异全部"未转红"的假信号)。
  37. */
  38. const reportDir = mkdtempSync(join(tmpdir(), 't59-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. let caught = false
  72. let how = ''
  73. try {
  74. const { output, status } = runGate()
  75. const red = failingNames(output)
  76. if (red.includes(expectRed)) {
  77. caught = true
  78. how = `${expectRed} 转红`
  79. } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) {
  80. // 崩溃也算抓住(导出改名后闸门具名 import 加载失败),单列不与"断言转红"混淆。
  81. caught = true
  82. how = '闸门整体加载失败(导出缺失)— 崩溃式抓住'
  83. } else if (status !== 0) {
  84. how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记`
  85. } else {
  86. how = '未转红(假绿!闸门仍全绿)'
  87. }
  88. console.log(`${caught ? 'PASS' : 'FAIL'} ${label} → ${how}`)
  89. if (caught) proved += 1
  90. } finally {
  91. writeFileSync(path, original, 'utf8')
  92. assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`)
  93. }
  94. }
  95. // ---- pagination.js:窗口与夹回(纯函数闸门) ----
  96. mutate(
  97. 'M1-window-full-list-threshold-shrunk',
  98. 'domain/pagination.js',
  99. ' if (total <= 7) {',
  100. ' if (total <= 5) {',
  101. 'W2-seven-pages-still-full',
  102. )
  103. mutate(
  104. 'M2-shrink-drops-right-ellipsis',
  105. 'domain/pagination.js',
  106. ' if (windowEnd < total - 1) items.push(PAGE_ELLIPSIS)',
  107. ' if (false) items.push(PAGE_ELLIPSIS)',
  108. 'W3-eight-pages-shrinks',
  109. )
  110. mutate(
  111. 'M3-clampPage-lets-NaN-through',
  112. 'domain/pagination.js',
  113. ' if (!Number.isFinite(num)) return 1',
  114. ' if (!Number.isFinite(num)) return num',
  115. 'J3-non-numeric-clamps-to-one',
  116. )
  117. mutate(
  118. 'M4-clampPage-loses-upper-bound',
  119. 'domain/pagination.js',
  120. ' return Math.min(Math.max(1, Math.trunc(num)), limit)',
  121. ' return Math.max(1, Math.trunc(num))',
  122. 'J2-clamp-to-range',
  123. )
  124. // ---- PagerNav.vue:共享控件的行为契约 ----
  125. mutate(
  126. 'M5-single-page-still-renders-pager',
  127. 'components/PagerNav.vue',
  128. 'const visible = computed(() => props.pageCount > 1)',
  129. 'const visible = computed(() => props.pageCount >= 1)',
  130. 'S1-one-page-renders-nothing',
  131. )
  132. mutate(
  133. 'M6-second-page-outlet',
  134. 'components/PagerNav.vue',
  135. " emit('page', target)",
  136. " emit('turn', target)",
  137. 'S2-single-page-outlet',
  138. )
  139. mutate(
  140. 'M7-edge-disable-gone',
  141. 'components/PagerNav.vue',
  142. ':disabled="page <= 1" data-page-prev',
  143. ':disabled="false" data-page-prev',
  144. 'S3-edge-disabled-with-placeholder',
  145. )
  146. mutate(
  147. 'M8-aria-current-dropped',
  148. 'components/PagerNav.vue',
  149. `:aria-current="item === page ? 'page' : undefined"`,
  150. ':aria-current="undefined"',
  151. 'S4-aria-group-current',
  152. )
  153. mutate(
  154. 'M9-jump-skips-clamp',
  155. 'components/PagerNav.vue',
  156. ' const target = clampPage(jumpValue.value, props.pageCount)',
  157. ' const target = Number(jumpValue.value)',
  158. 'S6-jump-goes-through-clamp-and-same-outlet',
  159. )
  160. // ---- 兼容再导出(条文 11 的单源锚):改名后闸门加载失败=崩溃式抓住 ----
  161. mutate(
  162. 'M10-compat-export-renamed',
  163. 'domain/equipmentDetailView.js',
  164. "export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows } from './pagination.js'",
  165. "export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows as pagedRowsV2 } from './pagination.js'",
  166. 'P2-compat-export-is-same-function',
  167. )
  168. // ---- 消费方:设备详情三清单 ----
  169. mutate(
  170. 'M11-second-arithmetic-in-component',
  171. 'components/EquipmentDetailSections.vue',
  172. ' return pagedRows(visibleRows(panel), pages.value[panel.name] || 1)',
  173. ' const list = visibleRows(panel); const p = pages.value[panel.name] || 1; return { rows: list.slice((p - 1) * 10, p * 10), page: p, pageCount: Math.ceil(list.length / 10), total: list.length }',
  174. 'C4-no-local-pagination-arithmetic',
  175. )
  176. mutate(
  177. 'M12-orders-revert-to-full-render',
  178. 'components/EquipmentDetailSections.vue',
  179. 'v-for="(order, index) in pagedOf(panel).rows"',
  180. 'v-for="(order, index) in visibleRows(panel)"',
  181. 'C3-orders-paginated',
  182. )
  183. mutate(
  184. 'M13-category-keeps-page',
  185. 'components/EquipmentDetailSections.vue',
  186. ' pages.value = { ...pages.value, [panelName]: 1 }',
  187. ' pages.value = pages.value',
  188. 'C5-reset-rules-intact',
  189. )
  190. console.log('')
  191. console.log(`T59 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
  192. rmSync(reportDir, { recursive: true, force: true })
  193. if (proved < injected) process.exit(1)