/** * 票 65 闸门变异测试:证明 t65 的每条关键断言**抓得住它要防的缺陷**(t46/t59/t64 同一纪律)。 * * 做法:对源文件做**单点**变异注入 → 跑 t65 → 断言指定断言转红 → 按字节还原并校验一致。 * 每处变异对应票 65 里的一个真实风险: * M1 预警族仍画工单口径 KPI → A3/A4/A7c 必须红(本票要防的**本体缺陷**) * M2 预警族仍画工单状态分布块 → A4 必须红(KPI 改了但图表没改 = 半分流) * M3 预警族用 `handlingCounts` 冒充预警读数 → A2/A3 必须红(把工单派生数画成预警数) * M4 等级分布改「全部铺 0」 → A5 必须红(真实计数被抹成 0) * M5 发生趋势不按日合并 → A6 必须红(169 个小时桶直接铺折线) * M6 报警族被一并改成预警口径 → A7 必须红(分流做成「两族都一样」) * M7 标题不吃切换词表 → B1/B2/D4 必须红(标题不随模式换) * M8 口径说明两族共用一张 → B3 必须红(预警族的缺口说明被报警口径顶掉) * M9 分流只看切换参数不看回显 → C1 必须红(回显优先级失守) * M10 分流只看回显不看切换参数 → C2 必须红(mock/旧载荷不分流) * M11 面板组件不传 extra.mode → E1/D1 必须红(接线断链:预警模式仍画工单口径) * M12 外壳标题回到静态 labelForPanel → E2/D4 必须红(外框标题不换) * M13 外壳另写一份标题字面量 → E3 必须红(第二张词表长出) * M14 mock 把预警族工单字段藏掉 → F2 必须红(契约缺口被 mock 掩盖) * M15 缺失读数补 0 → G1/G2 必须红(票 22/42 纪律失守) * * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t65-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('t65-warning-efficiency-family-split.mjs', import.meta.url)) const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}` /** * 跑闸门并取回输出。刻意用**文件重定向**而不是管道捕获(t46/t59/t64 实测: * Windows 沙箱下捕获子进程 stdio 的命名管道是文档化边界(EPERM), * 管道版拿回空输出 → 变异全部「未转红」的假信号)。 */ const reportDir = mkdtempSync(join(tmpdir(), 't65-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) } 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}: 还原失败`) } } // ---- 本体:预警族必须不画工单口径(两面:KPI 与图表)---- mutate( 'M1-warning-family-still-draws-work-order-kpis', 'domain/warningResponsePanels.js', ` if (family === EVENT_FAMILY.WARNING) {`, ` if (family === EVENT_FAMILY.WARNING && false) {`, 'A4 预警模式不画工单口径(处置率/工单状态分布/结案时长)', ) // M1b 只改 KPI 不改图表 = 半分流(KPI 对了、堆叠条还在画工单状态)。 // 与 M1 分开注入:M1 会把整支预警分支跳过,看不出「只改一半也算失守」。 mutate( 'M1b-warning-family-hides-work-order-kpis-but-keeps-stacked', 'domain/warningResponsePanels.js', ` warningEfficiencyKpis(current), barsBlock('预警等级分布', current.levelDistribution || []),`, ` alarmEfficiencyKpis(current), barsBlock('预警等级分布', current.levelDistribution || []),`, 'A4 预警模式不画工单口径(处置率/工单状态分布/结案时长)', ) mutate( 'M2-warning-family-keeps-work-order-stacked-block', 'domain/warningResponsePanels.js', ` barsBlock('预警等级分布', current.levelDistribution || []), trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }),`, ` barsBlock('预警等级分布', current.levelDistribution || []), stackedBlock('工单状态分布', current.workOrderStates || []), trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }),`, 'A4 预警模式不画工单口径(处置率/工单状态分布/结案时长)', ) // M3 票面点名的「假读数」:预警族的关联计数改用**工单派生**字段(handled/cancelled), // 这正是票面 Acceptance 第 2 条禁止的「用一个不存在的对象统计预警」 // (实测预警族 handled=cancelled=0,而真实 missing=全部 —— 一画就把 5 条预警说成 0 条未关联)。 mutate( 'M3-work-order-derived-counts-painted-as-warning', 'domain/warningResponsePanels.js', ` linkedWorkOrderCount: numberish(summary.linkedWorkOrderCount === undefined ? reader.raw.linkedWorkOrderCount : summary.linkedWorkOrderCount), missingWorkOrderCount: numberish(summary.missingWorkOrderCount === undefined ? reader.raw.missingWorkOrderCount : summary.missingWorkOrderCount),`, ` linkedWorkOrderCount: numberish(summary.handledCount), missingWorkOrderCount: numberish(summary.cancelledCount),`, 'A2 预警族工单关联实况是真实计数(预警不挂工单:0 关联 / 全部未关联)', ) // M4 等级分布退化成「四级全铺 0」:看上去像合规的空面板,实际把真实计数抹掉。 mutate( 'M4-level-distribution-zeroed', 'domain/warningResponsePanels.js', ` count: events.filter((item) => Number(item && item.level) === level).length,`, ` count: 0,`, 'A5 预警等级分布逐级等于真实事件行统计', ) // M5 发生趋势不按日合并:169 个小时桶直接铺到折线上(票 61 的历史趋势合并口径被绕过)。 mutate( 'M5-event-trend-not-merged-by-day', 'domain/warningResponsePanels.js', ` const trend = historyTrendRows(current.eventTrend || [])`, ` const trend = trendRows(current.eventTrend || [])`, 'A6 预警发生趋势按日合并(小时桶→天,不重采样、不补点)', ) // M6 分流做过头:报警族也被改成预警口径(「两族一样」= 另一种没分流)。 mutate( 'M6-alarm-family-also-drawn-as-warning', 'domain/warningResponsePanels.js', ` return sectionView('responseEfficiency', section, [ alarmEfficiencyKpis(current),`, ` return sectionView('responseEfficiency', section, [ ...(family === EVENT_FAMILY.ALARM ? warningEfficiencyKpis(current).items.map((item) => item) : alarmEfficiencyKpis(current).items).length ? [] : [],`, 'A7 报警族保留工单口径(处置率/响应与关闭时长/工单状态分布)', ) // ---- 词表与口径说明 ---- mutate( 'M7-title-ignores-switch-word-table', 'domain/warningResponsePanels.js', ' return `${eventListSwitchView(mode).label}效率`', " return '预警效率'", 'B1 面板标题复用事件族单一词表(预警/报警 + 「效率」)', ) mutate( 'M8-gap-note-shared-across-families', 'domain/warningResponsePanels.js', ` note: WARNING_EFFICIENCY_GAP,`, ` note: ALARM_EFFICIENCY_BASIS,`, 'B3 口径说明随族换且各自如实', ) // ---- 分流依据:回显与切换参数的优先级 ---- mutate( 'M9-split-ignores-backend-echo', 'domain/warningResponsePanels.js', ` const family = current.family || (extra && extra.mode) || EVENT_FAMILY.ALARM`, ` const family = (extra && extra.mode) || current.family || EVENT_FAMILY.ALARM`, 'C1 data.family(后端回显)优先于 extra.mode(切换参数)', ) mutate( 'M10-split-ignores-switch-param', 'domain/warningResponsePanels.js', ` const family = current.family || (extra && extra.mode) || EVENT_FAMILY.ALARM`, ` const family = current.family || EVENT_FAMILY.ALARM`, 'C2 回显缺失时按切换参数分流(extra.mode=WARNING → 预警口径)', ) // ---- 接线断链 ---- mutate( 'M11-pane-does-not-pass-mode', 'components/WarningResponsePane.vue', // 票 65 追加需求后第四参扩成多行对象,变异点改为「整段 extra 不传」—— // 这与原来的语义完全一致(接线断链),只是不再依赖单行字面量。 ` return panelView(props.panelId, current.data, current, { mode: eventStore.mode, workOrderList: eventStore.sections.workOrderList, })`, ` return panelView(props.panelId, current.data, current)`, 'E1 面板组件把当前切换族交给 domain(否则预警接线永远落回报警口径)', ) mutate( 'M12-shell-title-back-to-static-label', 'CockpitShell.vue', ` if (panelId === 'responseEfficiency') return responseEfficiencyTitle(eventStore.mode)`, ` if (panelId === 'responseEfficiency') return labelForPanel(panelId)`, 'E2 外壳面板标题走 domain 单源(responseEfficiencyTitle),不另起一张词表', ) mutate( 'M13-shell-grows-second-title-literal', 'CockpitShell.vue', `function panelTitle(panelId) {`, `function responseEfficiencyTitle2(mode) { return mode === 'ALARM' ? '报警效率' : '预警效率' }\nfunction panelTitle(panelId) {`, 'E3 预警/报警效率标题字面量只存在于 domain(外壳零第二份)', ) // ---- mock 同构:不许把契约缺口藏起来 ---- mutate( 'M14-mock-hides-work-order-fields-on-warning', 'mocks/contractMocks.js', ` handlingEfficiency: { value: blank ? 0 : numerator / denominator, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' },`, ` handlingEfficiency: source.mode === 'WARNING' ? null : { value: blank ? 0 : numerator / denominator, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' },`, 'F2 mock 两族仍保留工单口径字段(契约现状,不伪装成已分流)', ) // ---- 缺失即占位(票 22/42 纪律)---- mutate( 'M15-missing-counts-painted-as-zero', 'domain/warningResponsePanels.js', ` { key: 'warningTotal', label: '预警条数', value: valueText(current.totalEvents), unit: '条' },`, ` { key: 'warningTotal', label: '预警条数', value: current.totalEvents === null || current.totalEvents === undefined ? '0' : valueText(current.totalEvents), unit: '条' },`, 'G1 预警族读数缺失画占位符(不把「没有」画成 0 条)', ) // ---- 报警族「当前工单列表」(2026-10-21 追加需求 1)---- mutate( 'M16-alarm-list-block-missing', 'domain/warningResponsePanels.js', ` workOrderListBlock(extra),`, ``, 'H1 报警模式出现「当前工单列表」块,且排在统计图之后(先结论后明细)', ) mutate( 'M17-card-rows-lose-click-contract', 'domain/warningResponsePanels.js', ` code: row.code,`, ` code: '',`, 'H2 卡片行带可点契约与状态词表(code 触发渲染层 open-card;tone 来自既有色键)', ) mutate( 'M18-pagination-from-page-length', 'domain/warningResponsePanels.js', ` serverPage: paging ? { page: paging.page, pageCount: paging.totalPages, total: paging.total } : undefined,`, ` serverPage: paging ? { page: paging.page, pageCount: 1, total: orders.length } : undefined,`, 'H3 分页读后端回显(共 7 张 / 2 页),不由本页 2 条反推', ) mutate( 'M19-failed-list-drawn-as-empty', 'domain/warningResponsePanels.js', ` if (section.state !== 'success' && section.state !== 'stale' && orders.length === 0) {`, ` if (false) {`, 'H6 取数失败说「未取到」,与「确实没有」分开(不把缺数据画成 0 张)', ) mutate( 'M20-warning-family-also-gets-work-order-list', 'domain/warningResponsePanels.js', ` barsBlock('预警等级分布', current.levelDistribution || []),`, ` barsBlock('预警等级分布', current.levelDistribution || []), workOrderListBlock(extra),`, 'H8 预警模式不出现工单列表(预警不挂工单,与「不画工单口径」同一条纪律)', ) mutate( 'M21-state-tone-not-partitioned', 'domain/warningResponsePanels.js', ` if (state.code === 0 || state.code === 1) return 'lv1'`, ` if (state.code === 0 || state.code === 1) return 'neutral'`, 'H9b 色调随状态语义分档(待办≠在办≠已结案≠已取消)', ) // ---- 预警口径说明文案(2026-10-21 追加需求 2)---- mutate( 'M22-gap-note-back-to-endpoint-wording', 'domain/warningResponsePanels.js', ` '预警不挂工单,所以这里不画工单口径的处置率、工单状态分布与结案时长;' +`, ` '本端点对预警族仍只返回工单口径字段,预警不挂工单,所以这里不画处置率;' +`, 'I1 预警口径说明不含接口词汇(「本端点」「该端点」「/events/」等不上大屏)', ) mutate( 'M23-gap-note-drops-readings-first', 'domain/warningResponsePanels.js', ` '下方的条数、等级分布与发生趋势都是本窗口内预警自身的真实读数。' +`, ``, 'I2 前两句先说清「这些数是什么、能不能信」(先讲读数再讲缺口)', ) // ---- 全量工单端点契约(2026-10-21 追加需求 1 的数据面)---- mutate( 'M24-list-pagination-dropped', 'services/detailApi.js', ` pagination: paging`, ` pagination: paging ? { page: 1, pageSize: 5, total: 1, totalPages: 1 } : null`, 'J2 分页四件套逐项读后端回显(page/pageSize/total/totalPages,不由行数反推)', ) mutate( 'M25-list-association-flag-lost', 'services/detailApi.js', ` orders: Array.isArray(payload && payload.workOrders) ? payload.workOrders.map(toWorkOrder) : [],`, ` orders: Array.isArray(payload && payload.workOrders) ? payload.workOrders.map((row) => ({ ...toWorkOrder(row), associatedWithCurrentEvent: null })) : [],`, 'J3 工单的关联标记如实透传(列表里能分辨「关联了报警」与「纯设备工单」)', ) mutate( 'M26-mock-list-always-returns-everything', 'mocks/contractMocks.js', ` workOrders: all.slice((safePage - 1) * pageSize, safePage * pageSize),`, ` workOrders: all,`, 'J5 mock 全量工单与真实同形(有 pagination;换 pageSize 后翻页真的切窗口,不是永远回全量)', ) console.log('') console.log(`T65 变异测试:${proved}/${injected} 处变异被对应断言抓住。`) rmSync(reportDir, { recursive: true, force: true }) if (proved < injected) process.exit(1)