/** * 票 59 闸门变异测试:证明 t59 的每条关键断言**抓得住它要防的缺陷**(与 t46-mutations 同一纪律)。 * * 做法:对源文件做**单点**变异注入 → 跑 t59 → 断言指定断言转红 → 按字节还原并校验一致。 * 变异只在本脚本运行期间存活几秒,跑完即还原。 * * 十三条变异对应票 59 的真实风险: * M1 窗口全列阈值被改成 5 → W2 必须红(7 页就收缩=条文 2 的「≤7 全列」失守) * M2 右侧省略号不推了 → W3 必须红(收缩窗口缺省略号=「恒含省略号」失守) * M3 跳页夹回放 NaN 过 → J3 必须红(页码状态被 NaN 污染,控件画成「第 NaN 页」) * M4 跳页只夹下不夹上 → J2 必须红(超界页跳进空页) * M5 一页也画控件 → S1 必须红(条文 4「一页不画」失守) * M6 翻页出口改名出第二个事件 → S2 必须红(「共用同一翻页出口」失守) * M7 到边按钮不再禁用 → S3 必须红(票 46 既有语义回退) * M8 当前页 aria-current 丢了 → S4 必须红(条文 9 失守) * M9 跳页绕过 clampPage → S6 必须红(夹回判据名存实亡) * M10 兼容再导出被改名 → t59 具名 import 加载失败=崩溃式抓住(条文 11 的单源锚) * M11 组件里重新长出翻页算术 → C4 必须红(条文 11「禁止组件重写算术」) * M12 工单回退成全量直渲 → C3 必须红(本票「工单状态新接入分页」交付被吃掉) * M13 换分类不再回第 1 页 → C5 必须红(条文 6 复位规则失守) * * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t59-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('t59-shared-pager.mjs', import.meta.url)) const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}` /** * 跑闸门并取回输出。 * 刻意用**文件重定向**而不是管道捕获(t46 实测:Windows 沙箱下捕获子进程 stdio 的命名管道 * 是文档化的边界(EPERM),管道版拿回空输出 → 变异全部"未转红"的假信号)。 */ const reportDir = mkdtempSync(join(tmpdir(), 't59-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)) { // 崩溃也算抓住(导出改名后闸门具名 import 加载失败),单列不与"断言转红"混淆。 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}: 还原失败`) } } // ---- pagination.js:窗口与夹回(纯函数闸门) ---- mutate( 'M1-window-full-list-threshold-shrunk', 'domain/pagination.js', ' if (total <= 7) {', ' if (total <= 5) {', 'W2-seven-pages-still-full', ) mutate( 'M2-shrink-drops-right-ellipsis', 'domain/pagination.js', ' if (windowEnd < total - 1) items.push(PAGE_ELLIPSIS)', ' if (false) items.push(PAGE_ELLIPSIS)', 'W3-eight-pages-shrinks', ) mutate( 'M3-clampPage-lets-NaN-through', 'domain/pagination.js', ' if (!Number.isFinite(num)) return 1', ' if (!Number.isFinite(num)) return num', 'J3-non-numeric-clamps-to-one', ) mutate( 'M4-clampPage-loses-upper-bound', 'domain/pagination.js', ' return Math.min(Math.max(1, Math.trunc(num)), limit)', ' return Math.max(1, Math.trunc(num))', 'J2-clamp-to-range', ) // ---- PagerNav.vue:共享控件的行为契约 ---- mutate( 'M5-single-page-still-renders-pager', 'components/PagerNav.vue', 'const visible = computed(() => props.pageCount > 1)', 'const visible = computed(() => props.pageCount >= 1)', 'S1-one-page-renders-nothing', ) mutate( 'M6-second-page-outlet', 'components/PagerNav.vue', " emit('page', target)", " emit('turn', target)", 'S2-single-page-outlet', ) mutate( 'M7-edge-disable-gone', 'components/PagerNav.vue', ':disabled="page <= 1" data-page-prev', ':disabled="false" data-page-prev', 'S3-edge-disabled-with-placeholder', ) mutate( 'M8-aria-current-dropped', 'components/PagerNav.vue', `:aria-current="item === page ? 'page' : undefined"`, ':aria-current="undefined"', 'S4-aria-group-current', ) mutate( 'M9-jump-skips-clamp', 'components/PagerNav.vue', ' const target = clampPage(jumpValue.value, props.pageCount)', ' const target = Number(jumpValue.value)', 'S6-jump-goes-through-clamp-and-same-outlet', ) // ---- 兼容再导出(条文 11 的单源锚):改名后闸门加载失败=崩溃式抓住 ---- mutate( 'M10-compat-export-renamed', 'domain/equipmentDetailView.js', "export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows } from './pagination.js'", "export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows as pagedRowsV2 } from './pagination.js'", 'P2-compat-export-is-same-function', ) // ---- 消费方:设备详情三清单 ---- mutate( 'M11-second-arithmetic-in-component', 'components/EquipmentDetailSections.vue', ' return pagedRows(visibleRows(panel), pages.value[panel.name] || 1)', ' const list = visibleRows(panel); const p = pages.value[panel.name] || 1; return { rows: list.slice((p - 1) * 10, p * 10), page: p, pageCount: Math.ceil(list.length / 10), total: list.length }', 'C4-no-local-pagination-arithmetic', ) mutate( 'M12-orders-revert-to-full-render', 'components/EquipmentDetailSections.vue', 'v-for="(order, index) in pagedOf(panel).rows"', 'v-for="(order, index) in visibleRows(panel)"', 'C3-orders-paginated', ) mutate( 'M13-category-keeps-page', 'components/EquipmentDetailSections.vue', ' pages.value = { ...pages.value, [panelName]: 1 }', ' pages.value = pages.value', 'C5-reset-rules-intact', ) console.log('') console.log(`T59 变异测试:${proved}/${injected} 处变异被对应断言抓住。`) rmSync(reportDir, { recursive: true, force: true }) if (proved < injected) process.exit(1)