/** * 票 38 变异闸:把本票要防的错法逐一注入,确认 t38 真的转红;每次变异后按字节还原并校验哈希。 * * 判据只看退出码(不用管道捕获子进程输出——那是本环境的已知边界,票 37 记过)。 * 用法:先单跑 t38 看绿,再跑本脚本看每条变异都被抓住。 * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t38-mutations.mjs */ import { spawnSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' const ROOT = fileURLToPath(new URL('../../../', import.meta.url)) const GATE = '.scratch/cockpit-real-data-wayfinding/verify/t38-infrastructure-panel-adjustments.mjs' const MUTATIONS = [ { id: 'M1', why: '规模 KPI 回流到基础档案(第 2 条被撤销)→ A2/A6/D1 必须红', file: 'src/modules/cockpit/domain/infrastructurePanels.js', from: " { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n ]),", to: " { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n { key: 'scale', label: '规模', value: current.scale ? codeText(current.scale) : DETAIL_PLACEHOLDER },\n ]),", }, { id: 'M2', why: '规模 KPI 回流到总体概览基础设施面板 → A5 必须红', file: 'src/modules/cockpit/domain/overviewPanels.js', from: " { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km', wide: true },\n ]),", to: " { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km', wide: true },\n { key: 'scale', label: '规模', value: '中型' },\n ]),", }, { id: 'M3', why: 'lengthKm 也被顺手删掉(票面明说保留)→ A1b/A2b/A5/D3b 必须红', file: 'src/modules/cockpit/domain/infrastructurePanels.js', from: " { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },\n { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n ]),", to: " { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },\n ]),", }, { id: 'M4', why: '监测点定位退回只换中心的 setCameraFocus(「没反应」的相机根因复发)→ E9/F1 必须红', file: 'src/modules/cockpit/stores/infrastructureStore.js', from: ' const applied = plan.object.kind === MAP_OBJECT_KIND.MONITORING_POINT\n ? await flyPoint(plan.object)\n : focus(target)', to: ' const applied = focus(target)', }, { id: 'M5', why: '相机下发结果不核对(把没飞说成飞了)→ F4/F5 必须红', file: 'src/modules/cockpit/stores/infrastructureStore.js', from: ' if (applied !== true) {\n locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }\n return locate.value\n }', to: ' if (false) {\n locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }\n return locate.value\n }', }, { id: 'M6', why: '管理信息主图退回普通 bars(可切换维度的图表块被撤,第 6 条不成立)→ C1/C2 必须红', file: 'src/modules/cockpit/domain/infrastructurePanels.js', from: " dimensionBarsBlock('片区 × 行业', rows, {", to: " barsBlock('片区 × 行业', rows, {", }, { id: 'M7', why: '维度聚合退回逐行直画(城南两行不合并)→ C3/C4 必须红', file: 'src/modules/cockpit/domain/panelBlocks.js', 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)', 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()]', }, { id: 'M8', why: 'lengthKm 整行独占**退回按 key 特判**(决定权应从 domain 的 wide 滑回渲染层)→ A3b 必须红', file: 'src/modules/cockpit/components/PanelBlocks.vue', from: " :class=\"{ 'panel-kpi--wide': item.wide === true }\"", to: " :class=\"{ 'panel-kpi--wide': item.key === 'lengthKm' }\"", }, { id: 'M9', why: '浮层被重新门控在折叠状态上(「点了没反应」换写法复发)→ E6c 必须红', file: 'src/modules/cockpit/CockpitShell.vue', from: '
', to: '
', }, { id: 'M10', why: '浮层不自动收起(常驻噪音 + 定时器纪律丢失)→ E8 必须红', // 锚点含驻留毫秒数(票 38 走查定为 8s):以后若调时长,这里要同步改。 file: 'src/modules/cockpit/CockpitShell.vue', from: ' locateDismissTimer = window.setTimeout(() => {\n locateDismissTimer = null\n infrastructureStore.clearLocate()\n }, 8000)', to: ' locateDismissTimer = null', }, { id: 'M11', why: '第二轮:关联设备回联被撤(`21119740` 又报「不在已加载范围」)→ B5/B5d 必须红', file: 'src/modules/cockpit/domain/mapLocate.js', from: ' found = findDeviceOwner(objects, code)', to: ' found = null', }, { id: 'M12', why: '第二轮:概览区域分布又被硬写 null(管线里程再次对不上)→ E13 必须红', file: 'src/modules/cockpit/stores/overviewStore.js', from: ' regions: regionsLoaded ? regions.data : null,', to: ' regions: null,', }, { id: 'M13', why: '第二轮:wide 标志从 domain 撤掉(独占整行的呈现意图丢失)→ A3c 必须红', file: 'src/modules/cockpit/domain/overviewPanels.js', from: "unit: 'km', wide: true },", to: "unit: 'km' },", }, ] const hash = (path) => createHash('sha256').update(readFileSync(path)).digest('hex') /** 变异源文本按 CRLF 归一后再注入(票 37/43 的教训:PowerShell 写盘是 CRLF,锚点含 \\n 会假未命中)。 */ const normalizeFor = (text, path) => (readFileSync(path, 'utf8').includes('\r\n') ? text.replace(/\n/g, '\r\n') : text) let failures = 0 for (const m of MUTATIONS) { const path = ROOT + m.file.replaceAll('/', process.platform === 'win32' ? '\\' : '/') const before = hash(path) const source = readFileSync(path, 'utf8') const needle = normalizeFor(m.from, path) if (!source.includes(needle)) { console.log(`BAD ${m.id}: 变异锚点未命中(源码已变,需更新脚本)`) failures += 1 continue } writeFileSync(path, source.replace(needle, normalizeFor(m.to, path))) const status = spawnSync(process.execPath, [GATE], { cwd: ROOT, stdio: 'ignore' }).status writeFileSync(path, source) const restored = hash(path) === before const caught = status !== 0 console.log(`${caught && restored ? 'OK ' : 'BAD '} ${m.id} — 闸门${caught ? '转红' : '**没抓住**'};还原${restored ? '按字节一致' : '**失败(哈希不符)**'}。${m.why}`) if (!caught || !restored) failures += 1 } console.log('') console.log(`T38 mutations: ${MUTATIONS.length - failures}/${MUTATIONS.length} caught`) if (failures) process.exit(1)