t38-mutations.mjs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. /**
  2. * 票 38 变异闸:把本票要防的错法逐一注入,确认 t38 真的转红;每次变异后按字节还原并校验哈希。
  3. *
  4. * 判据只看退出码(不用管道捕获子进程输出——那是本环境的已知边界,票 37 记过)。
  5. * 用法:先单跑 t38 看绿,再跑本脚本看每条变异都被抓住。
  6. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t38-mutations.mjs
  7. */
  8. import { spawnSync } from 'node:child_process'
  9. import { readFileSync, writeFileSync } from 'node:fs'
  10. import { createHash } from 'node:crypto'
  11. import { fileURLToPath } from 'node:url'
  12. const ROOT = fileURLToPath(new URL('../../../', import.meta.url))
  13. const GATE = '.scratch/cockpit-real-data-wayfinding/verify/t38-infrastructure-panel-adjustments.mjs'
  14. const MUTATIONS = [
  15. {
  16. id: 'M1',
  17. why: '规模 KPI 回流到基础档案(第 2 条被撤销)→ A2/A6/D1 必须红',
  18. file: 'src/modules/cockpit/domain/infrastructurePanels.js',
  19. from: " { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n ]),",
  20. to: " { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n { key: 'scale', label: '规模', value: current.scale ? codeText(current.scale) : DETAIL_PLACEHOLDER },\n ]),",
  21. },
  22. {
  23. id: 'M2',
  24. why: '规模 KPI 回流到总体概览基础设施面板 → A5 必须红',
  25. file: 'src/modules/cockpit/domain/overviewPanels.js',
  26. from: " { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km', wide: true },\n ]),",
  27. to: " { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km', wide: true },\n { key: 'scale', label: '规模', value: '中型' },\n ]),",
  28. },
  29. {
  30. id: 'M3',
  31. why: 'lengthKm 也被顺手删掉(票面明说保留)→ A1b/A2b/A5/D3b 必须红',
  32. file: 'src/modules/cockpit/domain/infrastructurePanels.js',
  33. from: " { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },\n { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n ]),",
  34. to: " { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },\n ]),",
  35. },
  36. {
  37. id: 'M4',
  38. why: '监测点定位退回只换中心的 setCameraFocus(「没反应」的相机根因复发)→ E9/F1 必须红',
  39. file: 'src/modules/cockpit/stores/infrastructureStore.js',
  40. from: ' const applied = plan.object.kind === MAP_OBJECT_KIND.MONITORING_POINT\n ? await flyPoint(plan.object)\n : focus(target)',
  41. to: ' const applied = focus(target)',
  42. },
  43. {
  44. id: 'M5',
  45. why: '相机下发结果不核对(把没飞说成飞了)→ F4/F5 必须红',
  46. file: 'src/modules/cockpit/stores/infrastructureStore.js',
  47. from: ' if (applied !== true) {\n locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }\n return locate.value\n }',
  48. to: ' if (false) {\n locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }\n return locate.value\n }',
  49. },
  50. {
  51. id: 'M6',
  52. why: '管理信息主图退回普通 bars(可切换维度的图表块被撤,第 6 条不成立)→ C1/C2 必须红',
  53. file: 'src/modules/cockpit/domain/infrastructurePanels.js',
  54. from: " dimensionBarsBlock('片区 × 行业', rows, {",
  55. to: " barsBlock('片区 × 行业', rows, {",
  56. },
  57. {
  58. id: 'M7',
  59. why: '维度聚合退回逐行直画(城南两行不合并)→ C3/C4 必须红',
  60. file: 'src/modules/cockpit/domain/panelBlocks.js',
  61. from: ' entry.count += Number(countOf(row)) || 0\n for (const [name, source] of Object.entries(extras)) entry[name] += Number(row[source]) || 0\n }\n const sorted = sortByCountDesc([...byValue.values()], (row) => row.count)',
  62. to: ' entry.count = Number(countOf(row)) || 0\n for (const [name, source] of Object.entries(extras)) entry[name] = Number(row[source]) || 0\n byValue.set(key, { ...entry })\n }\n const sorted = [...byValue.values()]',
  63. },
  64. {
  65. id: 'M8',
  66. why: 'lengthKm 整行独占**退回按 key 特判**(决定权应从 domain 的 wide 滑回渲染层)→ A3b 必须红',
  67. file: 'src/modules/cockpit/components/PanelBlocks.vue',
  68. from: " :class=\"{ 'panel-kpi--wide': item.wide === true }\"",
  69. to: " :class=\"{ 'panel-kpi--wide': item.key === 'lengthKm' }\"",
  70. },
  71. {
  72. id: 'M9',
  73. why: '浮层被重新门控在折叠状态上(「点了没反应」换写法复发)→ E6c 必须红',
  74. file: 'src/modules/cockpit/CockpitShell.vue',
  75. from: '<div v-if="locateFeedback" class="cockpit-shell__notice" data-locate-notice>',
  76. to: '<div v-if="locateFeedback" v-show="statusPanelExpanded" class="cockpit-shell__notice" data-locate-notice>',
  77. },
  78. {
  79. id: 'M10',
  80. why: '浮层不自动收起(常驻噪音 + 定时器纪律丢失)→ E8 必须红',
  81. // 锚点含驻留毫秒数(票 38 走查定为 8s):以后若调时长,这里要同步改。
  82. file: 'src/modules/cockpit/CockpitShell.vue',
  83. from: ' locateDismissTimer = window.setTimeout(() => {\n locateDismissTimer = null\n infrastructureStore.clearLocate()\n }, 8000)',
  84. to: ' locateDismissTimer = null',
  85. },
  86. {
  87. id: 'M11',
  88. why: '第二轮:关联设备回联被撤(`21119740` 又报「不在已加载范围」)→ B5/B5d 必须红',
  89. file: 'src/modules/cockpit/domain/mapLocate.js',
  90. from: ' found = findDeviceOwner(objects, code)',
  91. to: ' found = null',
  92. },
  93. {
  94. id: 'M12',
  95. why: '第二轮:概览区域分布又被硬写 null(管线里程再次对不上)→ E13 必须红',
  96. file: 'src/modules/cockpit/stores/overviewStore.js',
  97. from: ' regions: regionsLoaded ? regions.data : null,',
  98. to: ' regions: null,',
  99. },
  100. {
  101. id: 'M13',
  102. why: '第二轮:wide 标志从 domain 撤掉(独占整行的呈现意图丢失)→ A3c 必须红',
  103. file: 'src/modules/cockpit/domain/overviewPanels.js',
  104. from: "unit: 'km', wide: true },",
  105. to: "unit: 'km' },",
  106. },
  107. ]
  108. const hash = (path) => createHash('sha256').update(readFileSync(path)).digest('hex')
  109. /** 变异源文本按 CRLF 归一后再注入(票 37/43 的教训:PowerShell 写盘是 CRLF,锚点含 \\n 会假未命中)。 */
  110. const normalizeFor = (text, path) => (readFileSync(path, 'utf8').includes('\r\n') ? text.replace(/\n/g, '\r\n') : text)
  111. let failures = 0
  112. for (const m of MUTATIONS) {
  113. const path = ROOT + m.file.replaceAll('/', process.platform === 'win32' ? '\\' : '/')
  114. const before = hash(path)
  115. const source = readFileSync(path, 'utf8')
  116. const needle = normalizeFor(m.from, path)
  117. if (!source.includes(needle)) {
  118. console.log(`BAD ${m.id}: 变异锚点未命中(源码已变,需更新脚本)`)
  119. failures += 1
  120. continue
  121. }
  122. writeFileSync(path, source.replace(needle, normalizeFor(m.to, path)))
  123. const status = spawnSync(process.execPath, [GATE], { cwd: ROOT, stdio: 'ignore' }).status
  124. writeFileSync(path, source)
  125. const restored = hash(path) === before
  126. const caught = status !== 0
  127. console.log(`${caught && restored ? 'OK ' : 'BAD '} ${m.id} — 闸门${caught ? '转红' : '**没抓住**'};还原${restored ? '按字节一致' : '**失败(哈希不符)**'}。${m.why}`)
  128. if (!caught || !restored) failures += 1
  129. }
  130. console.log('')
  131. console.log(`T38 mutations: ${MUTATIONS.length - failures}/${MUTATIONS.length} caught`)
  132. if (failures) process.exit(1)