| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- /**
- * 票 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)
|