| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- /**
- * 票 46 闸门变异测试:证明 t46 的每条关键断言**抓得住它要防的缺陷**。
- *
- * 做法(与 t44-mutations 同一纪律):对源文件做**单点**变异注入 → 跑 t46 → 断言指定断言转红
- * → 按字节还原并校验一致。变异只在本脚本运行期间存活几秒,跑完即还原。
- *
- * 四条变异对应票 46 的四个真实风险:
- * M1 filterByCategory 认不出的键回**全量**("筛了个寂寞"的假绿)→ A4c 必须红
- * M2 统计整段缺失时画**一排 0**(虚假零值,票 16 明令禁止)→ D1 必须红
- * M3 展开详情的必选项没值就**丢掉**(数据时间/状态/来源/关联对象消失)→ C1 必须红
- * M4 认不出的等级**并进 level:1**(把未知猜成真实等级)→ B2 必须红
- *
- * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t46-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('t46-detail-cards-history.mjs', import.meta.url))
- const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
- /**
- * 跑闸门并取回输出。
- * 刻意用**文件重定向**而不是管道捕获:Windows 沙箱下捕获子进程 stdio 的命名管道
- * 是文档化的边界(EPERM),管道版实测拿回空输出 → 变异全部"未转红"的假信号。
- */
- const reportDir = mkdtempSync(join(tmpdir(), 't46-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)) {
- // **崩溃也算抓住**,而且是最硬的一种:变异改掉了 domain 的真实导出,
- // 闸门自己的具名 import 就加载失败(一条 PASS 都印不出来)。
- // 单列成 CAUGHT-BY-CRASH,不与"某条断言转红"混为一谈,也不误记成假绿。
- 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-unknown-key-returns-all',
- 'domain/equipmentDetailView.js',
- ' if (!key) return list\n return list.filter((row) => row.categoryKey === key)',
- ' if (!key) return list\n const hits = list.filter((row) => row.categoryKey === key)\n return hits.length ? hits : list',
- 'A4c-unknown-key-is-empty',
- )
- mutate(
- 'M2-missing-statistics-draws-zeros',
- 'domain/detailFlow.js',
- ' if (!statistics || !Array.isArray(statistics.missing) || statistics.missing.length > 0) return null',
- ' if (!statistics || !Array.isArray(statistics.missing)) return null',
- 'D1-missing-statistics-hides-kpi',
- )
- mutate(
- 'M3-mandatory-detail-rows-dropped',
- 'domain/equipmentDetailView.js',
- ' if (!keepEmpty && text === DETAIL_PLACEHOLDER) return null',
- ' if (text === DETAIL_PLACEHOLDER) return null',
- 'C1-mandatory-detail-rows',
- )
- mutate(
- 'M4-unknown-level-guessed-as-level1',
- 'domain/equipmentDetailView.js',
- " categoryKey: levelNumber === null ? 'level:unknown' : `level:${levelNumber}`,",
- ' categoryKey: `level:${levelNumber === null ? 1 : levelNumber}`,',
- 'B2-unknown-level-not-guessed',
- )
- // ---- 返工(2026-10-15 维护者第 1/2 条)新增的三条变异 ----
- mutate(
- 'M5-pager-does-not-clamp-out-of-range',
- 'domain/equipmentDetailView.js',
- ' const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)',
- ' const current = Math.max(1, Math.trunc(Number(page) || 1))',
- 'G3-out-of-range-clamped',
- )
- mutate(
- 'M6-image-tab-dropped-from-archive',
- 'domain/equipmentDetailView.js',
- " { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },",
- " { name: 'archive', label: '设备档案', sections: ['archive'] },",
- 'F1-tab-plan-shape',
- )
- mutate(
- 'M7-icon-registry-accepts-inherited-keys',
- 'domain/pointStatus.js',
- ' return Object.prototype.hasOwnProperty.call(LEGEND_ICON_BY_KEY, key) ? LEGEND_ICON_BY_KEY[key] : \'\'',
- ' return LEGEND_ICON_BY_KEY[key] || \'\'',
- 'H5-blank-key-no-icon',
- )
- // E11 的牙:把 domain 导出改名(组件 import 的名字就落空)→ 必须被"按真实导出判"抓住。
- // 这条改的是 t46 **自己也 import** 的名字,所以闸门直接加载失败(崩溃式抓住,最硬的一种)。
- mutate(
- 'M8-domain-export-renamed',
- 'domain/equipmentDetailView.js',
- 'export function pagedRows(rows, page, pageSize = HISTORY_PAGE_SIZE) {',
- 'export function pagedRowsRenamed(rows, page, pageSize = HISTORY_PAGE_SIZE) {',
- 'E11-imported-names-are-real-exports',
- )
- // M8b 才是 E11 判据本身的牙:改一个**只有组件 import、t46 自己不 import** 的名字
- // (`archiveRows`)—— 闸门仍能加载,此时必须由 E11 那条"按真实导出判"转红。
- // 只有崩溃没有断言红,说明判据没被单独验证过(本仓"守卫比它要守的东西宽"的同一类坑)。
- mutate(
- 'M8b-component-only-export-renamed',
- 'domain/equipmentDetailView.js',
- 'export function archiveRows(data) {',
- 'export function archiveRowsRenamed(data) {',
- 'E11-imported-names-are-real-exports',
- )
- // 标签页牙:页签顺序被打乱(默认页不再第一)→ F1 必须红。
- mutate(
- 'M9-tabs-order-shuffled',
- 'domain/equipmentDetailView.js',
- " { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },\n { name: 'metrics', label: '最新指标', sections: ['metrics'] },",
- " { name: 'metrics', label: '最新指标', sections: ['metrics'] },\n { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },",
- 'F1-tab-plan-shape',
- )
- // 图例对齐牙:详情状态视图丢掉图标 → H2 必须红。
- mutate(
- 'M10-status-view-drops-icon',
- 'domain/detailFlow.js',
- ' label: labelOverride || visual.label,\n icon: legendIconFor(visual.key),\n }\n}\n\n/**\n * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。',
- ' label: labelOverride || visual.label,\n }\n}\n\n/**\n * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。',
- 'H2-point-status-matches-top-legend',
- )
- console.log('')
- console.log(`T46 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
- rmSync(reportDir, { recursive: true, force: true })
- if (proved < injected) process.exit(1)
|