/** * 票 52 变异闸:证明契约测试对「单设备工单处置优先」这条规则**有牙**。 * * 每条变异:按字节把源码改成**规则失效**的形态 → 跑 * BusinessCockpitDeviceFactResolverTest → 必须转红 → 再按字节还原。 * 全部转红、且还原后复绿,闸门才算通过。 * * 两个必须踩住的坑(首版都踩了,留档): * 1. 用 `mvn -pl zksy-admin test` 会把 zksy-system 从**本地仓库 jar** 解析, * 源码变异根本进不了测试 —— 必须 `-pl zksy-admin -am` 走 reactor 重新编译。 * 2. Windows 上 `mvn` 是 `mvn.cmd`,`execFileSync('mvn', ...)` 直接 ENOENT; * 那样每次"失败"都会被误判成"打红"。这里显式用 shell + mvn.cmd 路径, * 并把「构建失败」与「测试计数含非零失败」分开判定。 * * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t52-mutations.mjs */ import { execSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' const BACKEND = 'E:/pipe-ner-server' const MODULE = 'pipe-network-service' /** * 变异闸覆盖**三个测试类**:判定单源(resolver/facts)、事实透传接缝(分布适配器)、 * 以及服务层重建设备时的透传(分布控制器)。少了最后一层, * 「effectiveDevice 丢事实」这类变异抓不住 —— 首版就是这么漏掉 M7 的。 */ const TEST = 'BusinessCockpitDeviceFactResolverTest,BusinessCockpitMonitoringDistributionAdapterTest,CockpitMonitoringDistributionControllerTest' const MVN = 'D:\\Maven\\apache-maven-3.9.15\\bin\\mvn.cmd' const RESOLVER = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointStatusResolver.java` const FACTS = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java` const DIST_ADAPTER = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java` const DIST_SERVICE = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java` const read = (p) => readFileSync(p, 'utf8') const write = (p, s) => writeFileSync(p, s) const toLf = (s) => s.replace(/\r\n/g, '\n') const mutations = [ { id: 'M1', name: 'deviceStatus 退回「报警恒压工单」(删掉承接分支)', file: RESOLVER, from: ` return device.isAlarmsCoveredByOpenWorkOrders() ? CockpitMonitoringPointStatus.WORK_ORDER : CockpitMonitoringPointStatus.ALARM;`, to: ` return CockpitMonitoringPointStatus.ALARM;`, }, { id: 'M2', name: '承接判据放宽为「至少一条」(逐条比对改为只看非空)', file: FACTS, from: ` if (orderIds.size() != unhandled.size()) { return false; }`, to: ` if (orderIds.isEmpty()) { return false; }`, }, { id: 'M3', name: '把已结案/已取消也当成承接(isOpenOrderStatus 恒真)', file: FACTS, from: ` return (orderStatus >= 0 && orderStatus <= 4) || orderStatus == 6;`, to: ` return true;`, }, { id: 'M4', name: '空集视为「全部都有工单」(unhandled.isEmpty 返回 true)', file: FACTS, from: ` if (unhandled.isEmpty()) { return false; }`, to: ` if (unhandled.isEmpty()) { return true; }`, }, { id: 'M5', name: '报警范围放宽为全部历史流水(去掉 alarm_status 过滤)', file: FACTS, from: ` .eq(AlarmData::getDeviceCode, deviceCode.trim()) .eq(AlarmData::getAlarmStatus, UNHANDLED_ALARM_STATUS)));`, to: ` .eq(AlarmData::getDeviceCode, deviceCode.trim())));`, }, { id: 'M6', name: '分布适配器不再透传承接事实(快照丢事实 → 出口永远判 ALARM)', file: DIST_ADAPTER, from: ` CockpitCreatedAt.format(device.getCreateTime()), fact.isAlarmsCoveredByOpenWorkOrders()));`, to: ` CockpitCreatedAt.format(device.getCreateTime()), false));`, }, { id: 'M7', name: '分布服务 effectiveDevice 重建时丢承接事实', file: DIST_SERVICE, from: ` : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState), source.isAlarmsCoveredByOpenWorkOrders());`, to: ` : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState), false);`, }, ] /** 跑一次测试;返回 { ran, green } —— ran=false 表示构建没跑到测试(不能算证据)。 */ const runTest = () => { let out = '' try { out = execSync( `"${MVN}" -o -pl zksy-admin -am test -Dtest=${TEST} -DfailIfNoTests=false`, { cwd: `${BACKEND}/${MODULE}`, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) } catch (error) { out = `${error.stdout ?? ''}${error.stderr ?? ''}` } const summary = out.match(/Tests run: (\d+), Failures: (\d+), Errors: (\d+)/g) if (!summary || summary.length === 0) { return { ran: false, green: false, out } } const last = summary[summary.length - 1] const [, total, failures, errors] = last.match(/Tests run: (\d+), Failures: (\d+), Errors: (\d+)/) return { ran: true, green: Number(failures) === 0 && Number(errors) === 0 && Number(total) > 0, out } } const baseline = runTest() if (!baseline.ran || !baseline.green) { console.log('基线不绿,闸门无法判定:先修好源码/测试再跑变异。') process.exit(1) } console.log('基线绿(未变异时测试全过),开始变异…\n') let red = 0 let broken = 0 const originals = new Map() for (const m of mutations) { if (!originals.has(m.file)) originals.set(m.file, read(m.file)) const original = originals.get(m.file) const crlf = original.includes('\r\n') const norm = toLf(original) if (!norm.includes(m.from)) { console.log(`SKIP ${m.id} — anchor 未命中(源码已变,需同步变异脚本):${m.name}`) broken += 1 continue } write(m.file, crlf ? norm.replace(m.from, m.to).replace(/\n/g, '\r\n') : norm.replace(m.from, m.to)) const result = runTest() if (!result.ran) { console.log(`FAIL ${m.id} — 构建没跑到测试,不能算打红:${m.name}`) broken += 1 } else if (!result.green) { red += 1 console.log(`PASS ${m.id} — 变异被打红:${m.name}`) } else { console.log(`FAIL ${m.id} — 变异未被抓住(测试无牙):${m.name}`) } write(m.file, original) if (read(m.file) !== original) { console.log(`FAIL ${m.id} — 还原后字节不一致`) broken += 1 } } const final = runTest() console.log(`\n变异打红 ${red}/${mutations.length};还原后复绿:${final.green ? '是' : '否'};异常 ${broken}`) process.exit(red === mutations.length && final.green && broken === 0 ? 0 : 1)