/** * 票 60 闸门变异测试:证明 t60 的每条关键断言**抓得住它要防的缺陷**(t59/t46 同一纪律)。 * * 做法:对源文件做**单点**变异注入 → 跑 t60 → 断言指定断言转红 → 按字节还原并校验一致。 * * 十条变异对应本票的真实风险: * M1 卡片下标退回页内 0 起 → R8 红(走查/探针按第 N 台取数会串位) * M2 关联清单退回全量直渲 → R2 红(本票交付本身被吃掉) * M3 未命中子清单退回全量展开 → T2 红(维护者裁定 1 被吃掉) * M4 差额读数改读当前窗口 → T5 红(票 14「另有 N 台」不再可追溯) * M5 标题总数改读窗口条数 → K2 红(票 42 口径:总数只认当前快照) * M6 计数行开关默认改 false → S1 红(批次一既有消费方静默丢计数行) * M7 组件里重新长翻页算术 → S6 红(条文 11 单源失守) * M8 翻页出口多开一个事件名 → S5 红(两套翻页通道) * M9 换点位不再复位页码 → Z4 红(条文 5/6) * M10 复位触发器退回数组 getter → Z3 红(票 43 教训:同值也触发=假复位) * * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t60-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('t60-point-detail-lists.mjs', import.meta.url)) const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}` /** * 跑闸门并取回输出。刻意用**文件重定向**而不是管道捕获(t46/t59 实测: * Windows 沙箱下捕获子进程 stdio 的命名管道是文档化边界(EPERM),管道版拿回空输出 * → 变异全部"未转红"的假信号)。 */ const reportDir = mkdtempSync(join(tmpdir(), 't60-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 } } function failingNames(output) { return [...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') // 变异源文本按 CRLF 归一后再匹配(票 37 实测踩过:行尾不一致时 replace 静默不命中)。 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 && /does not provide an export|SyntaxError|Cannot find (module|export)/.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}: 还原失败`) } } const CARD = 'components/PointDetailCard.vue' const PAGER = 'components/PagerNav.vue' // ---- 窗口与断言面(本票交付本身) ---- mutate( 'M1-card-index-back-to-page-local', CARD, ':data-device-card="pagedCards.from - 1 + index"', ':data-device-card="index"', 'R8', ) mutate( 'M2-layer-list-reverts-to-full-render', CARD, 'v-for="row in pagedLayerRows.rows"', 'v-for="row in hitDeviceRows"', 'R2', ) mutate( 'M3-unmatched-list-reverts-to-full-expand', CARD, 'v-for="row in pagedUnmatchedRows.rows"', 'v-for="row in unmatchedDeviceRows"', 'T2', ) mutate( 'M4-more-count-reads-current-window', CARD, ':data-device-more-count="unmatchedDeviceRows.length"', ':data-device-more-count="pagedUnmatchedRows.rows.length"', 'T5', ) mutate( 'M5-layer-total-reads-window-size', CARD, 'data-device-layer-total>{{ deviceLayerTotal }}', 'data-device-layer-total>{{ pagedLayerRows.total }}', 'K2', ) // ---- 共享层的默认形态(批次一既有消费方不得被本票静默改掉) ---- mutate( 'M6-show-count-default-false', PAGER, 'showCount: { type: Boolean, default: true }', 'showCount: { type: Boolean, default: false }', 'S1', ) mutate( 'M7-second-arithmetic-in-component', CARD, ' return pagedRows(rows, pages.value[key])', ' const p = Number(pages.value[key]) || 1; const pc = Math.max(1, Math.ceil(rows.length / 10)); const c = Math.min(p, pc); return { rows: rows.slice((c - 1) * 10, c * 10), page: c, pageCount: pc, total: rows.length, from: rows.length ? (c - 1) * 10 + 1 : 0 }', 'S6', ) mutate( 'M8-second-page-outlet', CARD, "@page=\"(target) => gotoPage('cards', target)\"", "@turnPage=\"(target) => gotoPage('cards', target)\"", 'S5', ) // ---- 复位规则(条文 5/6 + 票 43 的稳定标量键教训) ---- mutate( 'M9-point-change-keeps-page', CARD, ' pages.value = { cards: 1, layer: 1, unmatched: 1 }', ' pages.value = pages.value', 'Z5', ) mutate( 'M10-reset-trigger-back-to-array-getter', CARD, ' () => `${selection.value.pointCode}|${selection.value.deviceCode}`,', ' () => [selection.value.pointCode, selection.value.deviceCode],', 'Z3', ) console.log('') console.log(`T60 变异测试:${proved}/${injected} 处变异被对应断言抓住。`) rmSync(reportDir, { recursive: true, force: true }) if (proved < injected) process.exit(1)