t60-mutations.mjs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /**
  2. * 票 60 闸门变异测试:证明 t60 的每条关键断言**抓得住它要防的缺陷**(t59/t46 同一纪律)。
  3. *
  4. * 做法:对源文件做**单点**变异注入 → 跑 t60 → 断言指定断言转红 → 按字节还原并校验一致。
  5. *
  6. * 十条变异对应本票的真实风险:
  7. * M1 卡片下标退回页内 0 起 → R8 红(走查/探针按第 N 台取数会串位)
  8. * M2 关联清单退回全量直渲 → R2 红(本票交付本身被吃掉)
  9. * M3 未命中子清单退回全量展开 → T2 红(维护者裁定 1 被吃掉)
  10. * M4 差额读数改读当前窗口 → T5 红(票 14「另有 N 台」不再可追溯)
  11. * M5 标题总数改读窗口条数 → K2 红(票 42 口径:总数只认当前快照)
  12. * M6 计数行开关默认改 false → S1 红(批次一既有消费方静默丢计数行)
  13. * M7 组件里重新长翻页算术 → S6 红(条文 11 单源失守)
  14. * M8 翻页出口多开一个事件名 → S5 红(两套翻页通道)
  15. * M9 换点位不再复位页码 → Z4 红(条文 5/6)
  16. * M10 复位触发器退回数组 getter → Z3 红(票 43 教训:同值也触发=假复位)
  17. *
  18. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t60-mutations.mjs
  19. */
  20. import assert from 'node:assert/strict'
  21. import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  22. import { spawnSync } from 'node:child_process'
  23. import { tmpdir } from 'node:os'
  24. import { join } from 'node:path'
  25. import { fileURLToPath } from 'node:url'
  26. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  27. const GATE = fileURLToPath(new URL('t60-point-detail-lists.mjs', import.meta.url))
  28. const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
  29. /**
  30. * 跑闸门并取回输出。刻意用**文件重定向**而不是管道捕获(t46/t59 实测:
  31. * Windows 沙箱下捕获子进程 stdio 的命名管道是文档化边界(EPERM),管道版拿回空输出
  32. * → 变异全部"未转红"的假信号)。
  33. */
  34. const reportDir = mkdtempSync(join(tmpdir(), 't60-mutations-'))
  35. function runGate() {
  36. const outPath = join(reportDir, 'stdout.txt')
  37. const errPath = join(reportDir, 'stderr.txt')
  38. const out = openSync(outPath, 'w')
  39. const err = openSync(errPath, 'w')
  40. let status = 0
  41. try {
  42. status = spawnSync(process.execPath, [GATE], { stdio: ['ignore', out, err] }).status
  43. } finally {
  44. closeSync(out)
  45. closeSync(err)
  46. }
  47. return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status }
  48. }
  49. function failingNames(output) {
  50. return [...output.matchAll(/^FAIL ([^ ]+)/gm)].map((match) => match[1])
  51. }
  52. let injected = 0
  53. let proved = 0
  54. function mutate(label, file, from, to, expectRed) {
  55. const path = srcPath(file)
  56. const original = readFileSync(path, 'utf8')
  57. // 变异源文本按 CRLF 归一后再匹配(票 37 实测踩过:行尾不一致时 replace 静默不命中)。
  58. const candidates = [from, from.replace(/\n/g, '\r\n')]
  59. const needle = candidates.find((candidate) => original.includes(candidate))
  60. if (!needle) {
  61. console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`)
  62. injected += 1
  63. return
  64. }
  65. injected += 1
  66. writeFileSync(path, original.replace(needle, to), 'utf8')
  67. let caught = false
  68. let how = ''
  69. try {
  70. const { output, status } = runGate()
  71. const red = failingNames(output)
  72. if (red.includes(expectRed)) {
  73. caught = true
  74. how = `${expectRed} 转红`
  75. } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) {
  76. caught = true
  77. how = '闸门整体加载失败(导出缺失)— 崩溃式抓住'
  78. } else if (status !== 0) {
  79. how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记`
  80. } else {
  81. how = '未转红(假绿!闸门仍全绿)'
  82. }
  83. console.log(`${caught ? 'PASS' : 'FAIL'} ${label} → ${how}`)
  84. if (caught) proved += 1
  85. } finally {
  86. writeFileSync(path, original, 'utf8')
  87. assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`)
  88. }
  89. }
  90. const CARD = 'components/PointDetailCard.vue'
  91. const PAGER = 'components/PagerNav.vue'
  92. // ---- 窗口与断言面(本票交付本身) ----
  93. mutate(
  94. 'M1-card-index-back-to-page-local',
  95. CARD,
  96. ':data-device-card="pagedCards.from - 1 + index"',
  97. ':data-device-card="index"',
  98. 'R8',
  99. )
  100. mutate(
  101. 'M2-layer-list-reverts-to-full-render',
  102. CARD,
  103. 'v-for="row in pagedLayerRows.rows"',
  104. 'v-for="row in hitDeviceRows"',
  105. 'R2',
  106. )
  107. mutate(
  108. 'M3-unmatched-list-reverts-to-full-expand',
  109. CARD,
  110. 'v-for="row in pagedUnmatchedRows.rows"',
  111. 'v-for="row in unmatchedDeviceRows"',
  112. 'T2',
  113. )
  114. mutate(
  115. 'M4-more-count-reads-current-window',
  116. CARD,
  117. ':data-device-more-count="unmatchedDeviceRows.length"',
  118. ':data-device-more-count="pagedUnmatchedRows.rows.length"',
  119. 'T5',
  120. )
  121. mutate(
  122. 'M5-layer-total-reads-window-size',
  123. CARD,
  124. 'data-device-layer-total>{{ deviceLayerTotal }}</b>',
  125. 'data-device-layer-total>{{ pagedLayerRows.total }}</b>',
  126. 'K2',
  127. )
  128. // ---- 共享层的默认形态(批次一既有消费方不得被本票静默改掉) ----
  129. mutate(
  130. 'M6-show-count-default-false',
  131. PAGER,
  132. 'showCount: { type: Boolean, default: true }',
  133. 'showCount: { type: Boolean, default: false }',
  134. 'S1',
  135. )
  136. mutate(
  137. 'M7-second-arithmetic-in-component',
  138. CARD,
  139. ' return pagedRows(rows, pages.value[key])',
  140. ' const p = Number(pages.value[key]) || 1; const pc = Math.max(1, Math.ceil(rows.length / 10)); const c = Math.min(p, pc); return { rows: rows.slice((c - 1) * 10, c * 10), page: c, pageCount: pc, total: rows.length, from: rows.length ? (c - 1) * 10 + 1 : 0 }',
  141. 'S6',
  142. )
  143. mutate(
  144. 'M8-second-page-outlet',
  145. CARD,
  146. "@page=\"(target) => gotoPage('cards', target)\"",
  147. "@turnPage=\"(target) => gotoPage('cards', target)\"",
  148. 'S5',
  149. )
  150. // ---- 复位规则(条文 5/6 + 票 43 的稳定标量键教训) ----
  151. mutate(
  152. 'M9-point-change-keeps-page',
  153. CARD,
  154. ' pages.value = { cards: 1, layer: 1, unmatched: 1 }',
  155. ' pages.value = pages.value',
  156. 'Z5',
  157. )
  158. mutate(
  159. 'M10-reset-trigger-back-to-array-getter',
  160. CARD,
  161. ' () => `${selection.value.pointCode}|${selection.value.deviceCode}`,',
  162. ' () => [selection.value.pointCode, selection.value.deviceCode],',
  163. 'Z3',
  164. )
  165. console.log('')
  166. console.log(`T60 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
  167. rmSync(reportDir, { recursive: true, force: true })
  168. if (proved < injected) process.exit(1)