| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- /**
- * 票 64 闸门变异测试:证明 t64 的每条关键断言**抓得住它要防的缺陷**(t46/t59 同一纪律)。
- *
- * 做法:对源文件做**单点**变异注入(M16 是唯一例外:两处必须成对才等价该缺陷,见其说明)
- * → 跑 t64 → 断言指定断言转红 → 按字节还原并校验一致。
- * 十七处变异对应票 64 的真实风险:
- * M1 渲染层不分声明一律切窗口 → N8 必须红(排除项被「顺手接入」,规范边界失守)
- * M2 块标题退回「指标占位」 → D1 必须红(维护者点名的块名纠正被吃掉)
- * M3 domain 撤下分页声明 → D3 必须红(全量直渲复活=一屏几千张卡)
- * M4 行键退回 行业|指标码 → D7 必须红(真实 699 条同码整块碰撞,keyed v-for 无身份)
- * M5 pageScope 不吃片区 → D11 必须红(换片区不复位=停在第 20 页看新范围)
- * M6 domain 撤下来源说明 → D8 必须红(票 64 第 4 条「如实说明口径」丢失)
- * M7 来源说明不进折叠表 → S5 必须红(说明在界面上隐形=票 56 记过的同类缺陷)
- * M8 总数由当前页反推 → S2 必须红(共 699 变共 10,条文 7 失守)
- * M9 组件重写翻页算术 → N4 必须红(条文 11 的单源失守)
- * M10 一页也画控件 → S7 必须红(条文 4 失守)
- * M11 页码键每次读都漂移 → R2 必须红(写进去的页码读不回来=控件永远显示第 1 页)
- * M12 pagedRows 丢掉越界夹回 → R5 必须红(跳 999 画空表冒充「没有数据」)
- * M13 窗口起点差一格 → V12 必须红(页码显示对、画的是上一批行;只有逐页对内容抓得住)
- * M14 清槽变成全清 → P4 必须红(换一次筛选把别的块页码也抹了,牵连复位)
- * M15 复位 watch 被撤 → V4 必须红(条文 5/6 的复位半边失守)
- * M16 条件编进键且无清槽 watch → V11c 必须红(本票第一版设计:切回旧行业停在第 3 页)
- * M17 读写键不一致 → V1 必须红(点了页码没反应)
- *
- * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t64-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('t64-operation-metrics-pagination.mjs', import.meta.url))
- const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
- /**
- * 跑闸门并取回输出。刻意用**文件重定向**而不是管道捕获(t46/t59 实测:
- * Windows 沙箱下捕获子进程 stdio 的命名管道是文档化边界(EPERM),
- * 管道版拿回空输出 → 变异全部「未转红」的假信号)。
- */
- const reportDir = mkdtempSync(join(tmpdir(), 't64-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')
- runAndJudge(label, original, path, expectRed)
- }
- /**
- * 多点变异(一次改同一文件的多处)——用于**必须成对才成立**的缺陷。
- * 典型:把筛选条件编进页码键**并且**没有清槽 watch,才等于本票第一版的设计;
- * 只编键不清槽是等价变异(行为与正确版一致),抓住它没有意义,见 M16 的说明。
- */
- function mutateMany(label, file, edits, expectRed) {
- const path = srcPath(file)
- const original = readFileSync(path, 'utf8')
- let mutated = original
- for (const [from, to] of edits) {
- const candidates = [from, from.replace(/\n/g, '\r\n')]
- const needle = candidates.find((candidate) => mutated.includes(candidate))
- if (!needle) {
- console.error(`FAIL ${label}: 变异源未命中(第 ${edits.findIndex((e) => e[0] === from) + 1} 处,需人工更新脚本)`)
- injected += 1
- return
- }
- mutated = mutated.replace(needle, to)
- }
- injected += 1
- writeFileSync(path, mutated, 'utf8')
- runAndJudge(label, original, path, expectRed)
- }
- function runAndJudge(label, original, path, expectRed) {
- 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}: 还原失败`)
- }
- }
- // ---- 渲染层:声明驱动 / 单源 / 反推 ----
- mutate(
- 'M1-windowing-ignores-declaration',
- 'components/PanelBlocks.vue',
- ' if (block.paginate !== true) return { rows, page: 1, pageCount: 1, total: rows.length }',
- ' if (false) return { rows, page: 1, pageCount: 1, total: rows.length }',
- 'N8 未声明分页的 12 行块全量画出、零控件(排除项由声明保护,不由运气)',
- )
- mutate(
- 'M9-second-arithmetic-in-render',
- 'components/PanelBlocks.vue',
- ' return pagedRows(rows, pageOf(block))',
- ' const p = pageOf(block); const pc = Math.ceil(rows.length / 10) || 1; return { rows: rows.slice((p - 1) * 10, p * 10), page: p, pageCount: pc, total: rows.length }',
- 'N4 渲染层不自写翻页算术(无 rows.slice/Math.ceil/自画 pager)',
- )
- mutate(
- 'M8-total-reverse-derived',
- 'components/PanelBlocks.vue',
- ':total="windowOf(block).total"',
- ':total="windowOf(block).rows.length"',
- 'S2 计数行「第 1/70 页 · 共 699 条」',
- )
- mutate(
- 'M7-source-note-not-disclosed',
- 'components/PanelBlocks.vue',
- " { key: 'source', field: 'sourceNote', hook: 'data-panel-source-note' },\n",
- '',
- 'S5 来源说明在 DOM 里(折叠不丢文本)',
- )
- mutate(
- 'M10-pager-renders-for-single-page',
- 'components/PagerNav.vue',
- 'const visible = computed(() => props.pageCount > 1)',
- 'const visible = computed(() => props.pageCount >= 1)',
- 'S7 不足一页不画控件(条文 4)',
- )
- // ---- 领域层:标题 / 声明 / 行键 / 作用域 / 来源说明 ----
- mutate(
- 'M2-title-reverts-to-placeholder',
- 'domain/infrastructurePanels.js',
- " '运行指标',",
- " '指标占位',",
- 'D1 块标题改为如实说法(「指标占位」已不在视图里)',
- )
- mutate(
- 'M3-pagination-declaration-dropped',
- 'domain/infrastructurePanels.js',
- ' { paginate: true, pageScope: operationPageScope(active, extra) },',
- ' { paginate: false, pageScope: operationPageScope(active, extra) },',
- 'D3 领域层声明本块分页',
- )
- mutate(
- 'M4-row-key-collides-on-real-payload',
- 'domain/infrastructurePanels.js',
- " key: `${active.code || ''}|${metric.code}|${index}`,",
- " key: `${active.code || ''}|${metric.code}`,",
- 'D7 每行 key 唯一(真实 699 条同码,旧键整块碰撞)',
- )
- mutate(
- 'M5-pageScope-ignores-district',
- 'domain/infrastructurePanels.js',
- " const district = extra && typeof extra.scope === 'string' ? extra.scope : ''",
- " const district = ''",
- 'D11 换片区 pageScope 跟着换',
- )
- mutate(
- 'M6-source-note-emptied',
- 'domain/infrastructurePanels.js',
- " sourceNote: '运行指标由后端按设施实体逐条生成(水厂/泵站/管网/排水设施四类适配器),一条指标对应一个设施;当前行业取后端回显,不跨行业合并',",
- " sourceNote: '',",
- 'D8 来源与口径说明到位(设施逐条生成、不跨行业合并、不说成监测点)',
- )
- // ---- 单源:寻址稳定性 / 越界夹回 ----
- // ---- 单源:寻址稳定性 / 复位点名 / 越界夹回 ----
- mutate(
- 'M11-page-slot-key-drifts-per-read',
- 'domain/pagination.js',
- ' return `list@${owner}`',
- ' return `list@${owner}@${Math.random()}`',
- 'R2 写后读回同值且不改入参',
- )
- // M14 清槽变成「全清」:一次换筛选把别的面板/别的块的页码也一并抹掉。
- // 条文 5 只说「条件变化的那块回第 1 页」,牵连复位会让用户在无关块上被莫名弹回。
- mutate(
- 'M14-reset-clears-every-block',
- 'domain/pagination.js',
- ' if (!keys.has(key)) next[key] = value',
- ' if (false) next[key] = value',
- 'P4 清槽只清点名块(其它块页码不受牵连)',
- )
- // M15 复位半边被撤(条件变化不再清槽)→ 停在第 3 页的人切了行业仍看第 3 页。
- mutate(
- 'M15-reset-watch-gone',
- 'components/PanelBlocks.vue',
- ' if (changed.length) listPages.value = resetListPages(listPages.value, changed)',
- ' if (false) listPages.value = resetListPages(listPages.value, changed)',
- 'V4 换片区筛选页码复位第 1 页',
- )
- mutate(
- 'M12-pagedRows-loses-clamp',
- 'domain/pagination.js',
- ' const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)',
- ' const current = Math.max(1, Math.trunc(Number(page) || 1))',
- 'R5 越界由 pagedRows 夹回最后一页(写入端不另做夹取)',
- )
- // M13 窗口起点差一格(分页最典型也最难看的错:页码显示对、画的是上一批行)。
- // 只有 V11 那种**逐页对内容**的判据抓得住;数卡片条数(S1/V10)会照样绿。
- mutate(
- 'M13-window-start-off-by-one',
- 'domain/pagination.js',
- ' const start = (current - 1) * size',
- ' const start = Math.max(0, current - 2) * size',
- 'V12 全 70 页逐页与 domain 切片完全一致(窗口算术与内容不错位)',
- )
- // M16 本票**第一版设计**的忠实复现(成对变异,两处一起改才等价):
- // ① 把筛选条件编进页码归属键;② 同时没有清槽 watch(第一版就是「复位由构造成立」,
- // 不写 watch)。切走再切回时读到**旧槽位**,停在第 3 页的人切回燃气仍看第 3 页——
- // 违反条文 6。只有 V11c 判得出:V4/V5(第一次切走)与 V3(刷新保持)照样绿。
- // 注:只改 ①(保留 watch)是**等价变异**——changed 过滤恰好会把那个旧槽位点名清掉,
- // 行为与正确版一致,实测假绿;所以本条按第一版的成对形态注入。
- mutateMany(
- 'M16-condition-baked-into-page-key-no-watch',
- 'components/PanelBlocks.vue',
- [
- [
- ' return `${props.view.name}|${block.label}`',
- " return `${props.view.name}|${block.label}|${block.pageScope || ''}`",
- ],
- [
- ' if (changed.length) listPages.value = resetListPages(listPages.value, changed)',
- ' if (false) listPages.value = resetListPages(listPages.value, changed)',
- ],
- ],
- 'V11c 切回燃气仍回第 1 页(旧设计的缺陷:会停在第 3 页)',
- )
- // M17 读写键不一致(写用块键、读用「块键+条件」):翻页写进去的页码读不回来。
- // 条文 11 要求寻址只有一份;这类缺陷表现为「点了页码没反应」,
- // 必须被 V1(点页码→画第 11–20 行)抓住。
- mutate(
- 'M17-read-write-key-mismatch',
- 'components/PanelBlocks.vue',
- ' return readListPage(listPages.value, blockKey(block))',
- ' return readListPage(listPages.value, `${blockKey(block)}|${block.pageScope || \'\'}`)',
- 'V1 点页码 2 → 画的是第 11–20 行(逐条对 key/标题/读数)',
- )
- console.log('')
- console.log(`T64 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
- rmSync(reportDir, { recursive: true, force: true })
- if (proved < injected) process.exit(1)
|