/** * 票 54 变异闸:把本票要防的错法逐一注入,确认 t54 真的一致红; * 每条变异后按字节还原并校验哈希。判据只看退出码(不管道捕获子进程输出,票 37 的沙箱教训)。 * * node .scratch/cockpit-real-data-wayfinding/verify/t54-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 T54 = '.scratch/cockpit-real-data-wayfinding/verify/t54-monitoring-overview-grid.mjs' const PANELS = 'src/modules/cockpit/domain/monitoringPanels.js' const RENDERER = 'src/modules/cockpit/components/PanelBlocks.vue' const MUTATIONS = [ { id: 'M1', file: PANELS, from: ' return { key: \'createdAt\', label: \'数据时间\', value: shortEventTime(value) || full, full }', to: ' return { key: \'createdAt\', label: \'数据时间\', value: full, full }', why: '屏上退回 ISO 全串(2×2 半格里必然省略成半截读数,验收第 1 条的原始诉求落空)→ G1a 必须红', }, { id: 'M2', file: PANELS, from: ' return { key: \'createdAt\', label: \'数据时间\', value: shortEventTime(value) || full, full }', to: ' return { key: \'createdAt\', label: \'数据时间\', value: shortEventTime(value) || full }', why: '完整原文字段丢失(title 只剩短读法,「语义全部保持」被破坏)→ G1b/G1c 必须红', }, { id: 'M3', file: PANELS, from: " if (full === DETAIL_PLACEHOLDER) return { key: 'createdAt', label: '数据时间', value: full, full }", to: " if (full === DETAIL_PLACEHOLDER) return { key: 'createdAt', label: '数据时间', value: full, full: '' }", why: '缺值分支的完整原文置空(占位符的 title 契约破坏)→ G1d 必须红', }, { id: 'M4', file: PANELS, from: ' ], { layout: \'two-by-two\' }),', to: ' ]),', why: 'domain 不再声明 2×2(退回 auto-fit,宽面板又展开成一行)→ G2a 必须红', }, { id: 'M5', file: PANELS, from: " createdAtKpi(current.createdAt),\n { key: 'offlineCount'", to: " { key: 'createdAt', label: '数据时间', value: valueText(current.createdAt) },\n { key: 'offlineCount'", why: '运行概况另起一套写法(两处数据时间早晚漂移)→ G1f 必须红', }, { id: 'M6', file: RENDERER, from: ' return `${item.full || item.value}${unit}`', to: ' return `${item.value}${unit}`', why: 'title 退回只显示值(domain 的 full 没人消费,hover 读不到完整 ISO)→ G4c 必须红', }, { id: 'M7', file: RENDERER, from: ":class=\"{ 'is-muted': isPlaceholder(item.value) }\"", to: ":class=\"{ 'is-muted': isPlaceholder(item.value), 'panel-kpi__value--time': item.key === 'createdAt' }\"", why: '渲染层重新按 item.key 特判(票 38 明令反对的第二份布局语义)→ G2d/G3c 必须红', }, { id: 'M8', file: RENDERER, from: '.panel-kpi__value {\n min-width: 0;', to: '.panel-kpi__value--time {\n font-size: var(--fs-caption, 10.5px);\n}\n\n.panel-kpi__value {\n min-width: 0;', why: '时间降档规则被复活(数据时间又与四卡不同档)→ G3a/G3c 必须红', }, { id: 'M9', file: RENDERER, from: ' grid-template-columns: repeat(2, minmax(0, 1fr));\n container-type: inline-size;', to: ' grid-template-columns: repeat(2, 1fr);\n container-type: inline-size;', why: '列宽去掉 minmax(0,·)(auto 最小尺寸把长读数顶出横向溢出,验收第 2 条)→ G2e 必须红', }, { id: 'M10', file: RENDERER, from: ' grid-template-columns: repeat(2, minmax(0, 1fr));\n container-type: inline-size;', to: ' grid-template-columns: repeat(auto-fit, minmax(calc(78 * var(--u, 1px)), 1fr));\n container-type: inline-size;', why: '两列改回 auto-fit(宽面板不固定 2×2)→ G2e 必须红', }, { id: 'M11', file: RENDERER, from: '@container panel-kpi-grid (max-width: 260px) {', to: '@media (max-width: 260px) {', why: '降级退回视口 media query(面板栏宽固定 280–460px,这条规则在大屏永不生效=假完成)→ G2f/G2g 必须红', }, { id: 'M12', file: RENDERER, from: ' .panel-kpis--two-by-two .panel-kpi {\n grid-column: 1 / -1;\n }', to: ' .panel-kpis--two-by-two {\n grid-template-columns: minmax(0, 1fr);\n }', why: '把降级写在容器自身上(CSS containment 规定容器查不了自己的尺寸,规则永不命中)→ G2h 必须红', }, { id: 'M13', file: RENDERER, from: '.panel-blocks {\n /* 图表 hover 详情浮层', to: '.panel-blocks {\n container-type: inline-size;\n /* 图表 hover 详情浮层', why: '给 .panel-blocks 根加 containment(contain:layout 让它成为绝对定位后代的包含块,.chart-tip 基准被换掉)→ G4f 必须红', }, ] const hash = (path) => createHash('sha256').update(readFileSync(path)).digest('hex') /** 变异源文本按 CRLF 归一后再注入(票 37 的教训:锚点含 \n 而源文件是 CRLF 会假未命中)。 */ 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 caught = spawnSync(process.execPath, [T54], { cwd: ROOT, stdio: 'ignore' }).status !== 0 writeFileSync(path, source) const restored = hash(path) === before if (!caught) { console.log(`MISS ${m.id}: 注入后闸门仍全绿 → 守卫没有牙(${m.why})`) failures += 1 } else if (!restored) { console.log(`BAD ${m.id}: 还原后哈希不一致,文件状态可疑,立刻人工核对`) failures += 1 } else { console.log(`OK ${m.id} ${m.why} → 闸门转红;还原按字节一致`) } } console.log(`\nT54 MUTATIONS: ${MUTATIONS.length - failures}/${MUTATIONS.length} 全部被抓住`) process.exitCode = failures ? 1 : 0