| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- /**
- * 票 43 离线闸门:工单堆叠图语义间隔 + 饼图/环图交互。
- *
- * 两组断言(都不启浏览器、不联网):
- * S. 堆叠条连续:段间边界由 domain 的**接力取整**(`startPct`/`widthPct`)给出——
- * 每段终点=下一段起点,末段终点恒 100;并**实证旧缺陷**:工单 mock 向量
- * [0,1,2,1,1,3,1,0] 在旧的 `left`/`width` 各自独立取整下,「待验收→已结案」
- * 边界确实露 1 个百分点的底色缝(诊断复现,防止把视觉现象当设计)。
- * 口径不变:`total`/`start`/`ratio` 原样保留(票 43 非目标:不改统计字段)。
- * P. 饼图/环图交互:扇区键盘可聚焦(tabindex + 每片可访问名称),focus/hover
- * 走同一个共享详情浮层与同一档放大反馈;原生 `<title>` 移除(详情入口只留一个,
- * 票 37 给趋势点定的成例);外层 svg 由 role="img" 改 role="group"(img 会把
- * 子树对辅助技术整棵藏掉);环形图图例悬停与饼图图例同款(此前漏接浮层)。
- *
- * 守卫有牙(变异测试,跑法见各 MUTATION 注释):
- * M1 把 domain 改回各自独立取整 → S1/S2 转红;
- * M2 删掉扇区 tabindex/focus 接线 → P1 转红;
- * M3 恢复扇区 `<title>` → P2 转红。
- *
- * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t43-stacked-gap-and-pie-interactions.mjs
- */
- import assert from 'node:assert/strict'
- import { readFileSync } from 'node:fs'
- import { stackedBlock, donutBlock } from '../../../src/modules/cockpit/domain/panelBlocks.js'
- const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
- let passed = 0
- let failed = 0
- function check(name, condition, message) {
- if (condition) {
- passed += 1
- console.log(`PASS ${name}`)
- } else {
- failed += 1
- console.error(`FAIL ${name}: ${message}`)
- }
- }
- // ================================================================ S. 堆叠条连续(票 43 范围 1/2)
- const panelVue = read('components/PanelBlocks.vue')
- {
- // 工单 mock 报警族向量(contractMocks 的 alarmWorkOrderStates,按 0–7 状态名喂进 domain)。
- const names = ['待派单', '待接单', '已接单', '处理中', '待验收', '已结案', '延期中', '已取消']
- const counts = [0, 1, 2, 1, 1, 3, 1, 0]
- const block = stackedBlock(
- '报警 · 工单状态分布',
- names.map((label, i) => ({ key: String(i), label, count: counts[i] })),
- )
- // S1 连续由构造保证:每段终点=下一段起点;首段起点 0;末段终点 100(total>0 时)。
- const segs = block.rows.filter((row) => row.count > 0)
- check(
- 'S1-relay-continuity',
- block.rows[0].startPct === 0 &&
- block.rows.every((row, i) => i === 0 || row.startPct === block.rows[i - 1].startPct + block.rows[i - 1].widthPct) &&
- block.rows[block.rows.length - 1].startPct + block.rows[block.rows.length - 1].widthPct === 100,
- '条段必须首尾接力(无缝、末段收在 100)',
- )
- // S2 实证旧缺陷(诊断的复现):旧公式 left=round(start*100)、width=round(ratio*100)
- // 在本向量下「待验收→已结案」边界露缝 1 个百分点;新接力公式同边界不露缝。
- // 这条断言钉住的是**根因判断**:间隔=独立舍入的绘制缺陷,不是语义边界设计。
- const oldAt = (i) => {
- const accBefore = counts.slice(0, i).reduce((s, c) => s + c, 0)
- const start = accBefore / 9
- const width = counts[i] / 9
- return { left: Math.round(start * 100), end: Math.round(start * 100) + Math.round(width * 100) }
- }
- const oldGap = oldAt(5).left - oldAt(4).end // 已结案段的旧 left − 待验收段的旧 end
- const newGap = block.rows[5].startPct - (block.rows[4].startPct + block.rows[4].widthPct)
- check('S2-old-defect-reproduced-and-fixed', oldGap === 1 && newGap === 0, `旧公式实测缝=${oldGap}(应为 1,复现维护者所见);新公式缝=${newGap}(应为 0)`)
- // S3 宽度总和恰为 100;0 计数行不占条宽但保留行(图例含义可查的既有立场)。
- check(
- 'S3-widths-sum-and-zero-rows',
- segs.length === 6 &&
- block.rows.reduce((s, row) => s + row.widthPct, 0) === 100 &&
- block.rows[0].widthPct === 0 &&
- block.rows[7].widthPct === 0,
- '宽度合计 100;0 计数行保留(widthPct=0)不占条宽',
- )
- // S4 统计口径不变:total/start/ratio 原样(票 43 非目标——不改统计字段与状态口径)。
- const accCheck = []
- let acc = 0
- for (const c of counts) {
- accCheck.push(acc / 9)
- acc += c
- }
- check(
- 'S4-ratio-caliber-unchanged',
- block.total === 9 &&
- block.rows.every((row, i) => row.start === accCheck[i] && Math.abs(row.ratio - counts[i] / 9) < 1e-12),
- 'start/ratio 仍是未取整的精确占比(只改绘制边界,不改口径)',
- )
- // S5 渲染层接缝(静态守卫):堆叠段的样式消费 startPct/widthPct,不再独立取整。
- // 只查堆叠段那一段样式——`Math.round(row.ratio * 100)` 在条形图填充宽度上是合法既有用法。
- const stackedStyle = panelVue.match(/class="panel-stacked__seg"[\s\S]{0,400}/)
- const segBlock = stackedStyle ? stackedStyle[0] : ''
- check(
- 'S5-renderer-uses-relay',
- /left: `\$\{row\.startPct\}%`/.test(segBlock) &&
- /width: `\$\{row\.widthPct\}%`/.test(segBlock) &&
- !/Math\.round\(row\.(start|ratio) \* 100\)/.test(segBlock),
- '堆叠段样式必须消费接力值;该段内不得再有独立取整表达式',
- )
- // MUTATION M1:把 domain 的 endPct 改回 Math.round(ratio*100) 独立取整 → S1/S2/S3 红。
- }
- // ================================================================ P. 饼图/环图交互(票 43 范围 3/4)
- {
- const panelVue = read('components/PanelBlocks.vue')
- // P1 扇区键盘可聚焦 + 可访问名称 + focus/blur 接共享浮层(饼图与环形图两片都要)。
- // 「一个元素内」匹配:`(?:(?!</path>)[\s\S])*?` 保证不从上一片 `<path>` 借断言,
- // 否则删掉饼图的 tabindex 也照样绿(守卫必须有牙)。
- for (const cls of ['panel-pie__slice', 'panel-donut__slice']) {
- const m = panelVue.match(new RegExp(`<path(?:(?!</path>)[\\s\\S])*?class="${cls}"[\\s\\S]*?</path>`))
- const seg = m ? m[0] : ''
- check(
- `P1-${cls}-focusable`,
- /tabindex="0"/.test(seg) &&
- /role="img"/.test(seg) &&
- /:aria-label="`\$\{slice\.label\}:\$\{slice\.count\}`"/.test(seg) &&
- /@focus="showChartTip\(\$event, slice\.label, slice\.count,/.test(seg) &&
- /@blur="hideChartTip"/.test(seg),
- `${cls} 扇区必须 tabindex=0 + 每片 aria-label(类别:数量)+ focus/blur 接浮层`,
- )
- }
- // P2 详情入口只留一个:扇区不再带原生 <title>(延迟提示与自绘浮层双重叠加,票 37 趋势点成例)。
- check(
- 'P2-no-native-title-on-slices',
- !/<title>\{\{ slice\.label \}\}/.test(panelVue),
- '饼/环扇区不得再有原生 <title>(aria-label 是唯一入口)',
- )
- // P3 外层 svg 改 role="group":role="img" 会把子树对辅助技术整棵藏掉,扇区名称读不到。
- check(
- 'P3-svg-role-group',
- /class="panel-pie__chart"[^>]*role="group"/.test(panelVue) &&
- /class="panel-donut__chart"[^>]*role="group"/.test(panelVue) &&
- !/class="panel-(pie|donut)__chart"[^>]*role="img"/.test(panelVue),
- '饼/环外层 svg 必须 role="group"(保留 aria-label 作组名)',
- )
- // P4 focus 与 hover 同档反馈(CSS 静态守卫):放大 + 增亮 + 焦点描边,两图同款。
- check(
- 'P4-focus-visible-mirrors-hover',
- /\.panel-pie__slice:focus-visible\s*\{[\s\S]{0,220}?transform: scale\(1\.05\)/.test(panelVue) &&
- /\.panel-donut__slice:focus-visible\s*\{[\s\S]{0,220}?transform: scale\(1\.06\)/.test(panelVue) &&
- /\.panel-pie__slice:focus-visible\s*\{[\s\S]{0,260}?stroke:/.test(panelVue),
- ':focus-visible 必须与 :hover 同一档放大,并有可见的焦点描边',
- )
- // P5 环形图图例悬停接共享浮层(此前漏接——饼图图例有、环形图没有,同款交互不该分家)。
- check(
- 'P5-donut-legend-hover-parity',
- /@mouseenter="showChartTip\(\$event, row\.label, row\.count, barColor\(row\.tone\)\)"/.test(panelVue) &&
- /class="panel-donut__row"[\s\S]{0,900}@mouseenter/.test(panelVue),
- '环形图图例行必须与饼图图例同款悬停浮层',
- )
- // MUTATION M2:删掉饼图扇区 tabindex/@focus → P1 红;M3:恢复 <title> → P2 红。
- }
- // ================================================================ 域层:环形图既有形状不回归
- {
- // 票 37 交付的 donutBlock 形状(slices/rows/total/action)不因本票变化。
- const d = donutBlock('行业分类', [
- { code: 'GAS', label: '燃气', count: 2 },
- { code: 'WATER_SUPPLY', label: '供水', count: 1 },
- { code: 'X', label: '零类', count: 0 },
- ])
- check(
- 'D1-donut-shape-unchanged',
- d.total === 3 && d.slices.length === 2 && d.rows.length === 3 && d.action === '',
- 'donutBlock 输出形状保持(0 计数行保留图例、不产扇区)',
- )
- const d2 = donutBlock('行业分类', [{ code: 'GAS', label: '燃气', count: 1 }], { action: 'industry' })
- check('D2-donut-action-kept', d2.action === 'industry' && d2.rows[0].actionKey === 'GAS', '可点接缝(panel-action)保持')
- }
- console.log('')
- console.log(`T43 stacked gap & pie interactions: ${failed ? `${failed} FAILED` : `${passed} passed`}`)
- if (failed) process.exit(1)
|