/** * 票 65 补口变异:证明工单列表翻页闸门抓得住这次真实缺陷形态。 * * M27 面板退回「只认 historyAnalysis」 → S1 必须红(**本次真机缺陷原形**) * M28 store 不再导出 setWorkOrderPage → S2 必须红(组件调不到) * M29 翻页共用全局面板令牌 → S3 必须红(会作废四块面板在途响应) * M30 条件变化不重置工单页码 → P6 必须红(切范围还停在第 2 页) * M31 翻页不落分区(写了不发) → P2 必须红(点了没反应) * M32 令牌检查去掉(迟到响应照落) → P7 必须红(连点落到旧页) * * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t65b-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('t65b-work-order-paging.mjs', import.meta.url)) const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}` const reportDir = mkdtempSync(join(tmpdir(), 't65b-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] }).status } finally { closeSync(out) closeSync(err) } return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status } } const failingNames = (output) => [...output.matchAll(/^FAIL ([^:]+):/gm)].map((match) => match[1]) let injected = 0 let proved = 0 function mutate(label, file, from, to, expectRed) { const path = srcPath(file) const original = readFileSync(path, 'utf8') const candidates = [from, from.replace(/\n/g, '\r\n')] const needle = candidates.find((candidate) => original.includes(candidate)) if (!needle) { console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`) injected += 1 return } injected += 1 writeFileSync(path, original.replace(needle, to), 'utf8') let caught = false let how = '' try { const { output, status } = runGate() const red = failingNames(output) if (red.includes(expectRed)) { caught = true how = `${expectRed} 转红` } else if (status !== 0 && /TypeError|is not a function|does not provide an export|SyntaxError/.test(output)) { // 删掉导出/方法这类变异会让闸门在「调到不存在的方法」处崩掉 —— 也算抓住(崩溃式)。 caught = true how = '闸门在缺导出/缺方法处崩溃 — 崩溃式抓住' } else if (status !== 0) { how = `退出码 ${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}: 还原失败`) } } // ---- 本次缺陷原形:面板只认 historyAnalysis ---- mutate( 'M27-pane-only-routes-history-analysis', 'components/WarningResponsePane.vue', ` const target = payload && payload.page if (props.panelId === 'historyAnalysis') { eventStore.setHistoryPage(target) return } if (props.panelId === 'responseEfficiency') { eventStore.setWorkOrderPage(target) }`, ` if (props.panelId !== 'historyAnalysis') return eventStore.setHistoryPage(payload && payload.page)`, 'S1 面板把报警效率的翻页事件交给 store(不再只认 historyAnalysis)', ) mutate( 'M28-store-does-not-export-action', 'stores/eventStore.js', ` setWorkOrderPage,`, ``, 'S2 store 暴露 setWorkOrderPage(组件才调得到)', ) mutate( 'M29-paging-shares-global-token', 'stores/eventStore.js', ` workOrderToken += 1 const mine = workOrderToken`, ` token += 1 const mine = token`, 'S3 工单页码用独立令牌(翻工单页不作废四块面板的在途响应)', ) mutate( 'M30-condition-change-keeps-work-order-page', 'stores/eventStore.js', ` // 票 65:条件变了,工单列表也回第 1 页(条文 5;定时刷新不带条件 ⇒ 页码保持)。 workOrderPage.value = 1`, ``, 'P6 条件变化(切范围)后工单页码回第 1 页', ) mutate( 'M31-paging-does-not-commit-section', 'stores/eventStore.js', ` sections.value = { ...sections.value, workOrderList: { ...result, loaded: true } }`, ``, 'P2 翻到第 2 页:请求带 page=2、分区换成 6–10 号工单', ) mutate( 'M32-stale-response-committed', 'stores/eventStore.js', ` // 迟到响应丢弃:期间又翻过页/切过条件,这一页不落分区。 if (mine !== workOrderToken) return sections.value.workOrderList`, ``, 'P7 快速连点只认最后一页(迟到响应丢弃,独立令牌)', ) // ---- 时间窗(2026-10-22 维护者反馈「列表没按时间筛」)---- mutate( 'M33-request-drops-time-window', 'stores/eventStore.js', ` fetchWorkOrderList( { page: workOrderPage.value, pageSize: WORK_ORDER_LIST_PAGE_SIZE, startTime: workOrderWindow.startTime, endTime: workOrderWindow.endTime }, requestOptions, ),`, ` fetchWorkOrderList( { page: workOrderPage.value, pageSize: WORK_ORDER_LIST_PAGE_SIZE }, requestOptions, ),`, 'P9 首屏工单列表请求带当前窗口(7 天的 startTime/endTime 都非空)', ) mutate( 'M34-paging-recomputes-window', 'stores/eventStore.js', ` const window = workOrderWindow || activeConditions()`, ` const window = activeConditions()`, 'P10 翻页沿用同一时间窗(第 2 页的 startTime/endTime 与首屏完全一致)', ) mutate( 'M35-range-change-keeps-old-window', 'stores/eventStore.js', ` workOrderWindow = { startTime: active.startTime, endTime: active.endTime }`, ``, 'P11 切到 30 天后请求换成 30 天窗口(且回第 1 页)', ) console.log('') console.log(`T65B 变异测试:${proved}/${injected} 处变异被对应断言抓住。`) rmSync(reportDir, { recursive: true, force: true }) if (proved < injected) process.exit(1)