/** * 票 67 变异测试:证明 t67 闸门的每条关键断言**抓得住它要防的缺陷**(t59/t60/t63/t64/t68 同一纪律)。 * * 做法:对源文件做**单点**变异注入 → 跑 t67 → 断言**指定断言**转红 → 按字节还原并校验一致。 * 「随便红一条就算」是不允许的:每条变异都点名它必须打红的那个断言名。 * * 十四条变异对应本票的真实风险: * M1 多维分析(唯一真收片区的端点)不再下发 district → A1 红(片区静默失效,用户以为筛过了) * M2 当前事件被塞上 district(伪造一次后端不认的筛选) → A4 红(票面「不得伪造」) * M3 scope 不进条件键(换片区页码不复位) → B2 红(条文 5/6) * M4 watch(scope) 丢掉 eventStore(切片区四块面板不刷新) → C1 红(票 67 缺口 2 复发) * M5 历史分析那块不再声明片区缺口 → D-gap:historyAnalysis 红(如实说明缺失) * M6 缺口句里塞接口字段名 district → D5 红(票 05 文案纪律) * M7 districtApplied 恒 false(管线明明按片区筛了却说没筛)→ E3 红(语义退回半成品) * M8 管线说明写回「不作用于管线与构件图层」(旧失实结论) → E5 红(把能做的说成做不到) * M9 定时刷新漏传 scope(预警分析悄悄退回全域) → C2 红 * M10 工单列表的行业缺口说明被摘掉 → D3b 红(同屏两数各说各话) * M11 当前预警面板的缺口说明被摘掉 → D-gap:currentEvents 红 * M12 注册表出口绕过 withFilterCaliber(接缝被旁路) → D3 红(正向普查兜住) * M13 前缀映射错位(TC→0,太常的管线被当成城南) → E1/E2 红(推导正确率与筛选结果同时崩) * M14 推不出片区的对象被静默保留在具体片区结果里 → E4 红(既不硬塞也不该混进来) * * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t67-mutations.mjs */ import assert from "node:assert/strict" import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { spawnSync } from "node:child_process" import { tmpdir } from "node:os" import { join } from "node:path" import { fileURLToPath } from "node:url" const repoRoot = fileURLToPath(new URL("../../../", import.meta.url)) const GATE = fileURLToPath(new URL("t67-filter-caliber.mjs", import.meta.url)) const srcPath = (rel) => repoRoot + "src/modules/cockpit/" + rel /** 用文件重定向取子进程输出(Windows 沙箱下管道捕获是文档化边界,见 t60/t63 注释)。 */ const reportDir = mkdtempSync(join(tmpdir(), "t67-mutations-")) function runGate() { const outPath = join(reportDir, "stdout.txt") const errPath = join(reportDir, "stderr.txt") const out = openSync(outPath, "w") const err = openSync(errPath, "w") let status = 0 try { status = spawnSync(process.execPath, [GATE], { stdio: ["ignore", out, err], timeout: 300000 }).status } finally { closeSync(out) closeSync(err) } return { output: readFileSync(outPath, "utf8") + readFileSync(errPath, "utf8"), status } } /** 断言名形如 "D-gap:historyAnalysis"(含冒号),所以按「非空白」切,不按冒号切。 */ function failingNames(output) { return [...output.matchAll(/^FAIL (\S+)/gm)].map((match) => match[1]) } let injected = 0 let proved = 0 /** 文字替换式变异:from 找不到就报「源未命中」(文件形状变了,变异脚本要跟着更新)。 */ function mutate(label, file, from, to, expectRed) { return mutateWith(label, file, expectRed, (text) => { const candidates = [from, from.replace(/\n/g, "\r\n")] const needle = candidates.find((candidate) => text.includes(candidate)) if (!needle) return null return text.replace(needle, to) }) } /** 通用的单点变异:transform 回 null 表示变异源未命中。 */ function mutateWith(label, file, expectRed, transform) { const path = srcPath(file) const original = readFileSync(path, "utf8") const mutated = transform(original) injected += 1 if (mutated === null || mutated === original) { console.error("FAIL " + label + ": 变异源未命中(文件形状已变,需人工更新变异脚本)") return } writeFileSync(path, mutated, "utf8") let caught = false let how = "" try { const result = runGate() const red = failingNames(result.output) const expected = Array.isArray(expectRed) ? expectRed : [expectRed] const hit = red.find((name) => expected.some((want) => name === want || name.startsWith(want))) if (hit) { caught = true how = expected.join(" 或 ") + " 转红(实际命中 " + hit + ")" } else if (result.status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(result.output)) { caught = true how = "闸门整体加载失败(导出缺失/语法错)— 崩溃式抓住" } else if (result.status !== 0) { how = "退出码 " + result.status + " 但目标断言未红(红项:" + (red.join(", ") || "无") + ")— 判据不明,按未抓住记" } else { how = "未转红(假绿!闸门仍全绿)" } console.log((caught ? "PASS " : "FAIL ") + label + " → " + how) if (caught) proved += 1 } finally { writeFileSync(path, original, "utf8") assert.equal(readFileSync(path, "utf8"), original, label + ": 还原失败") } } const WARNING_RESPONSE = "services/warningResponseApi.js" const EVENT_API = "services/eventApi.js" const EVENT_STORE = "stores/eventStore.js" const SHELL = "CockpitShell.vue" const CALIBER = "domain/filterCaliber.js" const PANEL_VIEWS = "domain/panelViews.js" const PANELS = "domain/warningResponsePanels.js" const PIPES = "domain/pipeNetwork.js" const EVENT_LIST = "domain/eventList.js" // M1 多维分析不再下发 district(事件族的片区接线整条消失,界面却仍写着「当前范围」) mutate( "M1-analysis-drops-district", WARNING_RESPONSE, " if (hooks.acceptsDistrict === true) base.district = districtQuery(options.scope)", " if (hooks.acceptsDistrict === true) base.district = ''", "A1", ) // M2 当前事件被塞上 district(后端实测忽略该参数 —— 传了就是伪造一次筛选,票面明令禁止) mutateWith("M2-current-events-fakes-district", EVENT_API, "A4", (text) => { const anchor = " industry: industryQuery(options.industry)," if (!text.includes(anchor)) return null return text.replace(anchor, anchor + "\n district: 0,") }) // M3 scope 不进条件键(换片区页码不回第 1 页,用户停在第 3 页看另一片区的第 3 页) mutateWith("M3-scope-not-a-condition-key", EVENT_STORE, "B2", (text) => { const matched = /(const CONDITION_KEYS = \[)([^\]]*)(\])/.exec(text) if (!matched) return null const inner = matched[2] .split(",") .map((item) => item.trim()) .filter((item) => item && item !== "'scope'" && item !== '"scope"') .join(", ") return text.replace(matched[0], matched[1] + inner + matched[3]) }) // M4 watch(scope) 丢掉 eventStore(票 67 缺口 2 原样复发:切片区预警四块面板不刷新) mutateWith("M4-scope-watch-drops-event-store", SHELL, "C1", (text) => { const start = text.indexOf("watch(scope") if (start < 0) return null const open = text.indexOf("{", start) let depth = 0 let end = -1 for (let index = open; index < text.length; index += 1) { if (text[index] === "{") depth += 1 else if (text[index] === "}") { depth -= 1 if (depth === 0) { end = index; break } } } if (end < 0) return null const block = text.slice(start, end) const stripped = block.replace(/^[ \t]*eventStore\.loadPanels\([^)]*\)[ \t]*\r?\n/m, "") if (stripped === block) return null return text.slice(0, start) + stripped + text.slice(end) }) // M5 历史分析那块不再声明片区缺口(读者只会以为「切了片区、数字没变」是数据问题) mutateWith("M5-history-gap-removed", CALIBER, "D-gap:historyAnalysis", (text) => { const stripped = text.replace(/^[ \t]*historyAnalysis: .*\r?\n/m, "") return stripped === text ? null : stripped }) // M6 缺口句里塞进接口字段名(票 05 纪律:接口词汇不上大屏) mutate( "M6-gap-leaks-api-field-name", CALIBER, "const NO_DISTRICT_PARAM = '后端该端点未提供片区入参'", "const NO_DISTRICT_PARAM = '后端该端点未提供 district 入参'", "D5", ) // M7 districtApplied 恒 false(管线明明已经按编码前缀筛过了,界面却退回「没筛」的说法) mutate( "M7-district-applied-forced-false", PIPES, " districtApplied: district !== '',", " districtApplied: false,", "E3", ) // M8 管线片区说明被写回旧结论「不作用于管线与构件图层」(把已经能做到的又说成做不到) mutateWith("M8-pipe-note-back-to-not-applied", CALIBER, "E5", (text) => { const start = text.indexOf("export const PIPE_DISTRICT_NOTE =") if (start < 0) return null // 不写反引号字面量(本脚本自身用模板串拼装):用字符码定位模板串的收尾引号。 const quote = String.fromCharCode(96) const firstQuote = text.indexOf(quote, start) const lastQuote = firstQuote < 0 ? -1 : text.indexOf(quote, firstQuote + 1) if (lastQuote < 0) return null const lineEnd = text.indexOf("\n", lastQuote) if (lineEnd < 0) return null const replacement = "export const PIPE_DISTRICT_NOTE = '「当前范围」不作用于管线与构件图层:几何数据里没有片区字段'" return text.slice(0, start) + replacement + text.slice(lineEnd) }) // M9 定时刷新漏传 scope(预警分析悄悄退回全域,而筛选条仍显示某个片区) mutate( "M9-refresh-drops-scope", SHELL, " await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value, scope: scope.value })", " await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value })", "C2", ) // M10 工单列表的行业缺口说明被摘掉(同屏上方分布随行业变、下方列表不变且不说原因) mutate( "M10-work-order-industry-gap-removed", PANELS, " const industryNote = industryGap('workOrderList')", " const industryNote = ''", "D3b", ) // M11 当前预警面板的缺口说明被摘掉(字段没了=屏上那句也没了) mutateWith("M11-current-events-gap-removed", EVENT_LIST, "D-gap:currentEvents", (text) => { const stripped = text.replace(/^[ \t]*gap: districtGap\('currentEvents'\),\r?\n/m, "") return stripped === text ? null : stripped }) // M12 注册表出口绕过 withFilterCaliber(接缝被旁路:面板自己没写缺口,缺口就整体消失) mutate( "M12-registry-bypasses-filter-caliber", PANEL_VIEWS, " return withFilterCaliber(build(data, section, extra), panelId)", " return build(data, section, extra)", "D3", ) // M13 前缀映射错位(TC→城南 0:太常的 4116 条管线会被当成城南,推导与筛选同时崩) mutate( "M13-prefix-map-misaligned", PIPES, " TC: DISTRICT_CODES.taichang,", " TC: DISTRICT_CODES.south,", ["E1", "E2"], ) // M14 推不出片区的对象被静默保留在具体片区结果里(既不硬塞进某片区,也不该混进来) mutate( "M14-unresolved-kept-under-concrete-district", PIPES, " return row.district === district", " return true", "E4", ) rmSync(reportDir, { recursive: true, force: true }) console.log("") console.log("T67 变异:" + proved + "/" + injected + " 条被闸门抓住。") if (proved !== injected) { console.error("有变异未被抓住(闸门有假绿风险)。") process.exit(1) } console.log("T67 变异全部打红并按字节还原。")