/**
* 票 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 恢复扇区 `` → 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(`)[\\s\\S])*?class="${hit}"[\\s\\S]*?`))
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(`)[\\s\\S])*?class="${sliceCls}"[\\s\\S]*?`))
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 详情入口只留一个:扇区不带原生 (浮层是唯一详情面)。
check('P2-no-native-title-on-slices', !/\{\{ slice\.label \}\}/.test(panelVue), '饼/环扇区不得再有原生 ')
// 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:恢复 → 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)