/** * 票 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 * 走同一个共享详情浮层与同一档放大反馈;原生 `` 移除(详情入口只留一个, * 票 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' // Vue 在 node ESM 下可直接 import(票 37/43 轮次均验证过);P9c 运行时身份探针要用真 Vue, // 不能用自造 proxy 模拟——模拟会把"代理语义变了"这种回归漏掉。 import { reactive, ref, shallowRef, toRaw } from 'vue' import { stackedBlock, donutBlock } from '../../../src/modules/cockpit/domain/panelBlocks.js' import { planStatusPie } from '../../../src/modules/cockpit/domain/statusPie.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;重开轮修订) // // 维护者复验反馈(2026-10-15 下午):① 扇区本身悬停不触发(只有图例触发)——根因=可见 // 环带太窄,指针事件只有 painted 的窄环收得到;② 图例标注与数值隔整行宽,无关联感。 // 修复=透明整楔命中区(hitPath,内半径 0)+ 扇区⇄图例双向联动高亮 + 数值紧跟标签。 // 本组守卫因此从「扇区上有 tabindex」改为「命中楔上有 tabindex,且可见扇区由联动类放大」。 { const panelVue = read('components/PanelBlocks.vue') // CSS 守卫必须**先剥注释**:把期望值写进注释会骗过 `[^}]*` 式匹配(票 36 的截断 // 哨兵教训——守卫查的是声明,不是散文)。剥完再测,注释里出现正确值不算数。 const css = panelVue.replace(/\/\*[\s\S]*?\*\//g, '') // P0 domain 几何:planStatusPie 与 donutBlock 的每个扇区带 `hitPath`(到圆心的整楔)。 const piePlan = planStatusPie({ distribution: { available: true, counts: { ALARM: 1, OFFLINE: 6 } }, rows: [ { key: 'ALARM', label: '报警', tone: 'lv1', shape: 'dot' }, { key: 'OFFLINE', label: '离线', tone: 'point-offline', shape: 'dot' }, ], }) check( 'P0a-pie-hitpath-wedge-to-center', piePlan.slices.length === 2 && piePlan.slices.every((s) => typeof s.hitPath === 'string' && s.hitPath.includes('50.000 50.000') && s.hitPath !== s.path), '饼图扇区必须带 hitPath=到圆心 (50,50) 的整楔(与可见环带不同路径)', ) const donutHit = donutBlock('行业分类', [ { code: 'GAS', label: '燃气', count: 2 }, { code: 'WATER_SUPPLY', label: '供水', count: 1 }, ]) check( 'P0b-donut-hitpath-wedge-to-center', donutHit.slices.every((s) => typeof s.hitPath === 'string' && s.hitPath.includes('50.000 50.000') && s.hitPath !== s.path), '环形图扇区必须带 hitPath(同一办法,两处不分家)', ) // P1 命中楔承载交互:tabindex + 可访问名称(类别:数量)+ mouseenter/focus 双入口 + 联动写入。 for (const hit of ['panel-pie__hit', 'panel-donut__hit']) { const m = panelVue.match(new RegExp(`<path(?:(?!</path>)[\\s\\S])*?class="${hit}"[\\s\\S]*?</path>`)) const seg = m ? m[0] : '' check( `P1-${hit}-carries-interaction`, /tabindex="0"/.test(seg) && /role="img"/.test(seg) && /:aria-label="`\$\{slice\.label\}:\$\{slice\.count\}`"/.test(seg) && /@mouseenter="showChartTip\(\$event, slice\.label, slice\.count,[^"]*\); setChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg) && /@focus="showChartTip\(\$event, slice\.label, slice\.count,[^"]*\); setChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg) && /@blur="hideChartTip\(\); clearChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg), `${hit} 必须 tabindex=0 + aria-label(类别:数量)+ hover/focus 同接浮层与联动`, ) } // P1b 可见扇区不再自己承载交互(aria-hidden、无 tabindex),由 `.is-hovered` 联动放大。 for (const sliceCls of ['panel-pie__slice', 'panel-donut__slice']) { const m = panelVue.match(new RegExp(`<path(?:(?!</path>)[\\s\\S])*?class="${sliceCls}"[\\s\\S]*?</path>`)) const seg = m ? m[0] : '' check( `P1b-${sliceCls}-passive-linked`, !/tabindex/.test(seg) && /aria-hidden="true"/.test(seg) && /'is-hovered': isChartHovered\(chartScope\(index, block\), slice\.key\)/.test(seg), `${sliceCls} 可见扇区必须被动(aria-hidden、无 tabindex),由联动类 is-hovered 放大`, ) } // P2 详情入口只留一个:扇区不带原生 <title>(浮层是唯一详情面)。 check('P2-no-native-title-on-slices', !/<title>\{\{ slice\.label \}\}/.test(panelVue), '饼/环扇区不得再有原生 <title>') // P3 外层 svg role="group"(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 联动放大 CSS:`.is-hovered` 驱动(不再是扇区 :hover——事件在命中楔上), // 焦点环画在命中楔(focus-visible 描边),两侧同档。 // 票 43(第三轮):放大档统一加到 scale(1.1) + brightness(1.35) + 白描边—— // 维护者反馈「没有放大」;1.05/1.06 在 64–72px 图上只有 ~1.5–2px 位移,不够明显。 check( 'P4-hovered-css-and-focus-ring', /\.panel-pie__slice\.is-hovered\s*\{[\s\S]{0,200}?transform: scale\(1\.1\)/.test(css) && /\.panel-donut__slice\.is-hovered\s*\{[\s\S]{0,200}?transform: scale\(1\.1\)/.test(css) && /\.panel-pie__slice\.is-hovered\s*\{[\s\S]{0,200}?filter: brightness\(1\.35\)/.test(css) && /\.panel-donut__slice\.is-hovered\s*\{[\s\S]{0,200}?filter: brightness\(1\.35\)/.test(css) && !/\.panel-(pie|donut)__slice:hover/.test(css) && /\.panel-pie__hit:focus-visible\s*\{[\s\S]{0,120}?stroke:/.test(css) && /\.panel-donut__hit:focus-visible\s*\{[\s\S]{0,120}?stroke:/.test(css), '放大必须由 .is-hovered 驱动(1.1 档 + 提亮)、扇区 :hover 必须清零、命中楔必须有焦点描边', ) // P5 图例行 ⇄ 扇区双向:图例悬停写同一个联动状态(两侧同时亮),浮层同入口。 check( 'P5-legend-two-way', /@mouseenter="showChartTip\(\$event, row\.label, row\.count, barColor\(row\.tone\)\); setChartHover\(chartScope\(index, block\), row\.key\)"/.test(panelVue) && /@mouseenter="showChartTip\(\$event, row\.label, row\.count, pieToneColor\(row\.tone, row\.key\)\); setChartHover\(chartScope\(index, block\), row\.key\)"/.test(panelVue) && /'is-hovered': isChartHovered\(chartScope\(index, block\), row\.key\)/.test(panelVue) && /\.panel-(donut|pie)__row\.is-hovered/.test(panelVue), '饼/环图例悬停必须写联动状态且行有 is-hovered 样式(扇区⇄图例两侧同时亮)', ) // P6 标注与数值成对(维护者:离得太远没有关联感): // 饼图行不得 space-between、环形计数不得 margin-left:auto、标签不得生长 // (`flex: 1 …` 会把数值顶回行尾——第一轮修复就栽在这里,守卫必须钉住 0 生长)。 check( 'P6-label-value-pairing', !/\.panel-pie__row\s*\{[^}]*justify-content: space-between/.test(css) && !/\.panel-donut__count\s*\{[^}]*margin-left: auto/.test(css) && /\.panel-donut__label\s*\{[^}]*flex: 0 1 auto/.test(css), '图例数值必须紧跟标签(去 space-between / margin-left:auto / 标签生长)', ) // P7 中心读数让位命中区(否则悬停环图中心永远不触发扇区)。 check( 'P7-donut-total-pointer-none', /\.panel-donut__total\s*\{[^}]*pointer-events: none/.test(css), '.panel-donut__total 必须 pointer-events:none(命中楔覆盖圆心)', ) // MUTATION M6:`.panel-donut__label` 改回 `flex: 1 1 auto`(生长顶回数值)→ P6 红 // (本轮变异实测:不剥注释时该变异曾假绿——注释里写了期望值,守卫信了散文)。 // P8(第四轮根因,静态):联动键必须是**跨重算稳定的标量 scope**,不能是对象身份。 // 外壳时钟每秒重渲染 + `:view="panelViewOf(id)"` 是方法调用 → 每帧全新 block 对象, // `owner === block` 在下一帧静默失效 → hover 未移出、高亮自己掉(浮层存标量所以还在)。 // 守卫钉住:① chartScope() 函数体取的是 view.name + index + kind + label 这类稳定标量; // ② 状态存的是 scope 字符串;③ 全文件不再出现对象身份比较(owner: toRaw(block))。 // 注:函数体含模板字符串的 `}`,不能用 `[^}]*` 匹配——先按行提取函数体再断言。 const scopeFn = panelVue.match(/function chartScope\(index, block\)\s*\{[\s\S]*?\n\}/) const scopeBody = scopeFn ? scopeFn[0] : '' check( 'P8-hover-key-is-stable-scalar-scope', /props\.view\.name/.test(scopeBody) && /\$\{index\}/.test(scopeBody) && /block\.kind/.test(scopeBody) && /chartHover\.value = \{ scope, key \}/.test(panelVue) && /current\.scope === scope && current\.key === key/.test(panelVue) && !/owner: toRaw\(block\)/.test(panelVue) && !/current\.owner ===/.test(panelVue), '联动键必须是 chartScope(稳定标量),不得再用 block 对象身份(重渲染会漂移)', ) // P8b(第四轮根因,运行时):直接复现"对象身份键 vs 标量 scope 键"在重算下的行为—— // 模拟 panelViewOf 每帧返回新对象:用对象身份当键,新对象查不回(false); // 用 chartScope 那类标量拼键,新对象仍查得回(true)。这条钉住**机制本身**: // 谁把键改回对象身份,这里先红。 { const mkBlock = () => ({ kind: 'pie', label: '点位状态分布' }) // 每次新对象,字段相同 const b1 = mkBlock() const b2 = mkBlock() // 模拟下一帧重渲染:同内容、不同引用 const identityKey = { owner: b1 } const scopeOf = (index, block) => `viewName|${index}|${block.kind}|${block.label || ''}` const scopeKey = { scope: scopeOf(0, b1) } check( 'P8b-scope-key-survives-recompute-identity-does-not', identityKey.owner !== b2 && scopeKey.scope === scopeOf(0, b2), '对象身份键在重算后查不回(!==),标量 scope 键查得回(===)——这就是"高亮自己消失"的机制', ) } // P9(第三轮根因,静态):联动状态用 shallowRef(避免深层代理;第四轮后虽以标量键为主, // 但 shallowRef 仍是"整体替换即触发渲染"的正确容器)。 check( 'P9-hover-state-shallow-ref', /const chartHover = shallowRef\(null\)/.test(panelVue) && /import \{[^}]*\bshallowRef\b[^}]*\} from 'vue'/.test(panelVue), 'chartHover 必须 shallowRef(整体替换即触发渲染,不做深层包装)', ) // P9c(第三轮根因,运行时):深层 ref 会把存入的对象换成 reactive 代理—— // 这条探针钉住 Vue 的代理语义本身(第四轮改成标量键后,代理与否都不再影响比较, // 但保留此探针记录机制演进,且 shallowRef 仍是正确容器)。 { const raw = { kind: 'pie' } const host = reactive({ blocks: [raw] }) const deep = ref(null) deep.value = { owner: host.blocks[0] } const shallow = shallowRef(null) shallow.value = { owner: toRaw(host.blocks[0]) } check( 'P9c-vue-identity-semantics', deep.value.owner !== raw && shallow.value.owner === raw, 'Vue 语义前提:深层 ref 代理破坏身份(!== 原始),shallowRef+toRaw 保持身份(=== 原始)', ) } // MUTATION M2:删命中楔 tabindex → P1 红;M3:恢复 <title> → P2 红; // M4:图例不写 setChartHover → P5 红;M5:恢复 margin-left:auto → P6 红; // M7:shallowRef 改回 ref → P9 红; // M8(第四轮根因):把 chartScope 换回对象身份键(setChartHover(block,...) 且 owner===block)→ P8 红。 } // ================================================================ 域层:环形图既有形状不回归 { // 票 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)