t43-stacked-gap-and-pie-interactions.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. /**
  2. * 票 43 离线闸门:工单堆叠图语义间隔 + 饼图/环图交互。
  3. *
  4. * 两组断言(都不启浏览器、不联网):
  5. * S. 堆叠条连续:段间边界由 domain 的**接力取整**(`startPct`/`widthPct`)给出——
  6. * 每段终点=下一段起点,末段终点恒 100;并**实证旧缺陷**:工单 mock 向量
  7. * [0,1,2,1,1,3,1,0] 在旧的 `left`/`width` 各自独立取整下,「待验收→已结案」
  8. * 边界确实露 1 个百分点的底色缝(诊断复现,防止把视觉现象当设计)。
  9. * 口径不变:`total`/`start`/`ratio` 原样保留(票 43 非目标:不改统计字段)。
  10. * P. 饼图/环图交互:扇区键盘可聚焦(tabindex + 每片可访问名称),focus/hover
  11. * 走同一个共享详情浮层与同一档放大反馈;原生 `<title>` 移除(详情入口只留一个,
  12. * 票 37 给趋势点定的成例);外层 svg 由 role="img" 改 role="group"(img 会把
  13. * 子树对辅助技术整棵藏掉);环形图图例悬停与饼图图例同款(此前漏接浮层)。
  14. *
  15. * 守卫有牙(变异测试,跑法见各 MUTATION 注释):
  16. * M1 把 domain 改回各自独立取整 → S1/S2 转红;
  17. * M2 删掉扇区 tabindex/focus 接线 → P1 转红;
  18. * M3 恢复扇区 `<title>` → P2 转红。
  19. *
  20. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t43-stacked-gap-and-pie-interactions.mjs
  21. */
  22. import assert from 'node:assert/strict'
  23. import { readFileSync } from 'node:fs'
  24. // Vue 在 node ESM 下可直接 import(票 37/43 轮次均验证过);P9c 运行时身份探针要用真 Vue,
  25. // 不能用自造 proxy 模拟——模拟会把"代理语义变了"这种回归漏掉。
  26. import { reactive, ref, shallowRef, toRaw } from 'vue'
  27. import { stackedBlock, donutBlock } from '../../../src/modules/cockpit/domain/panelBlocks.js'
  28. import { planStatusPie } from '../../../src/modules/cockpit/domain/statusPie.js'
  29. const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
  30. let passed = 0
  31. let failed = 0
  32. function check(name, condition, message) {
  33. if (condition) {
  34. passed += 1
  35. console.log(`PASS ${name}`)
  36. } else {
  37. failed += 1
  38. console.error(`FAIL ${name}: ${message}`)
  39. }
  40. }
  41. // ================================================================ S. 堆叠条连续(票 43 范围 1/2)
  42. const panelVue = read('components/PanelBlocks.vue')
  43. {
  44. // 工单 mock 报警族向量(contractMocks 的 alarmWorkOrderStates,按 0–7 状态名喂进 domain)。
  45. const names = ['待派单', '待接单', '已接单', '处理中', '待验收', '已结案', '延期中', '已取消']
  46. const counts = [0, 1, 2, 1, 1, 3, 1, 0]
  47. const block = stackedBlock(
  48. '报警 · 工单状态分布',
  49. names.map((label, i) => ({ key: String(i), label, count: counts[i] })),
  50. )
  51. // S1 连续由构造保证:每段终点=下一段起点;首段起点 0;末段终点 100(total>0 时)。
  52. const segs = block.rows.filter((row) => row.count > 0)
  53. check(
  54. 'S1-relay-continuity',
  55. block.rows[0].startPct === 0 &&
  56. block.rows.every((row, i) => i === 0 || row.startPct === block.rows[i - 1].startPct + block.rows[i - 1].widthPct) &&
  57. block.rows[block.rows.length - 1].startPct + block.rows[block.rows.length - 1].widthPct === 100,
  58. '条段必须首尾接力(无缝、末段收在 100)',
  59. )
  60. // S2 实证旧缺陷(诊断的复现):旧公式 left=round(start*100)、width=round(ratio*100)
  61. // 在本向量下「待验收→已结案」边界露缝 1 个百分点;新接力公式同边界不露缝。
  62. // 这条断言钉住的是**根因判断**:间隔=独立舍入的绘制缺陷,不是语义边界设计。
  63. const oldAt = (i) => {
  64. const accBefore = counts.slice(0, i).reduce((s, c) => s + c, 0)
  65. const start = accBefore / 9
  66. const width = counts[i] / 9
  67. return { left: Math.round(start * 100), end: Math.round(start * 100) + Math.round(width * 100) }
  68. }
  69. const oldGap = oldAt(5).left - oldAt(4).end // 已结案段的旧 left − 待验收段的旧 end
  70. const newGap = block.rows[5].startPct - (block.rows[4].startPct + block.rows[4].widthPct)
  71. check('S2-old-defect-reproduced-and-fixed', oldGap === 1 && newGap === 0, `旧公式实测缝=${oldGap}(应为 1,复现维护者所见);新公式缝=${newGap}(应为 0)`)
  72. // S3 宽度总和恰为 100;0 计数行不占条宽但保留行(图例含义可查的既有立场)。
  73. check(
  74. 'S3-widths-sum-and-zero-rows',
  75. segs.length === 6 &&
  76. block.rows.reduce((s, row) => s + row.widthPct, 0) === 100 &&
  77. block.rows[0].widthPct === 0 &&
  78. block.rows[7].widthPct === 0,
  79. '宽度合计 100;0 计数行保留(widthPct=0)不占条宽',
  80. )
  81. // S4 统计口径不变:total/start/ratio 原样(票 43 非目标——不改统计字段与状态口径)。
  82. const accCheck = []
  83. let acc = 0
  84. for (const c of counts) {
  85. accCheck.push(acc / 9)
  86. acc += c
  87. }
  88. check(
  89. 'S4-ratio-caliber-unchanged',
  90. block.total === 9 &&
  91. block.rows.every((row, i) => row.start === accCheck[i] && Math.abs(row.ratio - counts[i] / 9) < 1e-12),
  92. 'start/ratio 仍是未取整的精确占比(只改绘制边界,不改口径)',
  93. )
  94. // S5 渲染层接缝(静态守卫):堆叠段的样式消费 startPct/widthPct,不再独立取整。
  95. // 只查堆叠段那一段样式——`Math.round(row.ratio * 100)` 在条形图填充宽度上是合法既有用法。
  96. const stackedStyle = panelVue.match(/class="panel-stacked__seg"[\s\S]{0,400}/)
  97. const segBlock = stackedStyle ? stackedStyle[0] : ''
  98. check(
  99. 'S5-renderer-uses-relay',
  100. /left: `\$\{row\.startPct\}%`/.test(segBlock) &&
  101. /width: `\$\{row\.widthPct\}%`/.test(segBlock) &&
  102. !/Math\.round\(row\.(start|ratio) \* 100\)/.test(segBlock),
  103. '堆叠段样式必须消费接力值;该段内不得再有独立取整表达式',
  104. )
  105. // MUTATION M1:把 domain 的 endPct 改回 Math.round(ratio*100) 独立取整 → S1/S2/S3 红。
  106. }
  107. // ================================================================ P. 饼图/环图交互(票 43 范围 3/4;重开轮修订)
  108. //
  109. // 维护者复验反馈(2026-10-15 下午):① 扇区本身悬停不触发(只有图例触发)——根因=可见
  110. // 环带太窄,指针事件只有 painted 的窄环收得到;② 图例标注与数值隔整行宽,无关联感。
  111. // 修复=透明整楔命中区(hitPath,内半径 0)+ 扇区⇄图例双向联动高亮 + 数值紧跟标签。
  112. // 本组守卫因此从「扇区上有 tabindex」改为「命中楔上有 tabindex,且可见扇区由联动类放大」。
  113. {
  114. const panelVue = read('components/PanelBlocks.vue')
  115. // CSS 守卫必须**先剥注释**:把期望值写进注释会骗过 `[^}]*` 式匹配(票 36 的截断
  116. // 哨兵教训——守卫查的是声明,不是散文)。剥完再测,注释里出现正确值不算数。
  117. const css = panelVue.replace(/\/\*[\s\S]*?\*\//g, '')
  118. // P0 domain 几何:planStatusPie 与 donutBlock 的每个扇区带 `hitPath`(到圆心的整楔)。
  119. const piePlan = planStatusPie({
  120. distribution: { available: true, counts: { ALARM: 1, OFFLINE: 6 } },
  121. rows: [
  122. { key: 'ALARM', label: '报警', tone: 'lv1', shape: 'dot' },
  123. { key: 'OFFLINE', label: '离线', tone: 'point-offline', shape: 'dot' },
  124. ],
  125. })
  126. check(
  127. 'P0a-pie-hitpath-wedge-to-center',
  128. piePlan.slices.length === 2 &&
  129. piePlan.slices.every((s) => typeof s.hitPath === 'string' && s.hitPath.includes('50.000 50.000') && s.hitPath !== s.path),
  130. '饼图扇区必须带 hitPath=到圆心 (50,50) 的整楔(与可见环带不同路径)',
  131. )
  132. const donutHit = donutBlock('行业分类', [
  133. { code: 'GAS', label: '燃气', count: 2 },
  134. { code: 'WATER_SUPPLY', label: '供水', count: 1 },
  135. ])
  136. check(
  137. 'P0b-donut-hitpath-wedge-to-center',
  138. donutHit.slices.every((s) => typeof s.hitPath === 'string' && s.hitPath.includes('50.000 50.000') && s.hitPath !== s.path),
  139. '环形图扇区必须带 hitPath(同一办法,两处不分家)',
  140. )
  141. // P1 命中楔承载交互:tabindex + 可访问名称(类别:数量)+ mouseenter/focus 双入口 + 联动写入。
  142. for (const hit of ['panel-pie__hit', 'panel-donut__hit']) {
  143. const m = panelVue.match(new RegExp(`<path(?:(?!</path>)[\\s\\S])*?class="${hit}"[\\s\\S]*?</path>`))
  144. const seg = m ? m[0] : ''
  145. check(
  146. `P1-${hit}-carries-interaction`,
  147. /tabindex="0"/.test(seg) &&
  148. /role="img"/.test(seg) &&
  149. /:aria-label="`\$\{slice\.label\}:\$\{slice\.count\}`"/.test(seg) &&
  150. /@mouseenter="showChartTip\(\$event, slice\.label, slice\.count,[^"]*\); setChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg) &&
  151. /@focus="showChartTip\(\$event, slice\.label, slice\.count,[^"]*\); setChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg) &&
  152. /@blur="hideChartTip\(\); clearChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg),
  153. `${hit} 必须 tabindex=0 + aria-label(类别:数量)+ hover/focus 同接浮层与联动`,
  154. )
  155. }
  156. // P1b 可见扇区不再自己承载交互(aria-hidden、无 tabindex),由 `.is-hovered` 联动放大。
  157. for (const sliceCls of ['panel-pie__slice', 'panel-donut__slice']) {
  158. const m = panelVue.match(new RegExp(`<path(?:(?!</path>)[\\s\\S])*?class="${sliceCls}"[\\s\\S]*?</path>`))
  159. const seg = m ? m[0] : ''
  160. check(
  161. `P1b-${sliceCls}-passive-linked`,
  162. !/tabindex/.test(seg) &&
  163. /aria-hidden="true"/.test(seg) &&
  164. /'is-hovered': isChartHovered\(chartScope\(index, block\), slice\.key\)/.test(seg),
  165. `${sliceCls} 可见扇区必须被动(aria-hidden、无 tabindex),由联动类 is-hovered 放大`,
  166. )
  167. }
  168. // P2 详情入口只留一个:扇区不带原生 <title>(浮层是唯一详情面)。
  169. check('P2-no-native-title-on-slices', !/<title>\{\{ slice\.label \}\}/.test(panelVue), '饼/环扇区不得再有原生 <title>')
  170. // P3 外层 svg role="group"(img 会藏子树,名称与焦点不可达)。
  171. check(
  172. 'P3-svg-role-group',
  173. /class="panel-pie__chart"[^>]*role="group"/.test(panelVue) &&
  174. /class="panel-donut__chart"[^>]*role="group"/.test(panelVue) &&
  175. !/class="panel-(pie|donut)__chart"[^>]*role="img"/.test(panelVue),
  176. '饼/环外层 svg 必须 role="group"(保留 aria-label 作组名)',
  177. )
  178. // P4 联动放大 CSS:`.is-hovered` 驱动(不再是扇区 :hover——事件在命中楔上),
  179. // 焦点环画在命中楔(focus-visible 描边),两侧同档。
  180. // 票 43(第三轮):放大档统一加到 scale(1.1) + brightness(1.35) + 白描边——
  181. // 维护者反馈「没有放大」;1.05/1.06 在 64–72px 图上只有 ~1.5–2px 位移,不够明显。
  182. check(
  183. 'P4-hovered-css-and-focus-ring',
  184. /\.panel-pie__slice\.is-hovered\s*\{[\s\S]{0,200}?transform: scale\(1\.1\)/.test(css) &&
  185. /\.panel-donut__slice\.is-hovered\s*\{[\s\S]{0,200}?transform: scale\(1\.1\)/.test(css) &&
  186. /\.panel-pie__slice\.is-hovered\s*\{[\s\S]{0,200}?filter: brightness\(1\.35\)/.test(css) &&
  187. /\.panel-donut__slice\.is-hovered\s*\{[\s\S]{0,200}?filter: brightness\(1\.35\)/.test(css) &&
  188. !/\.panel-(pie|donut)__slice:hover/.test(css) &&
  189. /\.panel-pie__hit:focus-visible\s*\{[\s\S]{0,120}?stroke:/.test(css) &&
  190. /\.panel-donut__hit:focus-visible\s*\{[\s\S]{0,120}?stroke:/.test(css),
  191. '放大必须由 .is-hovered 驱动(1.1 档 + 提亮)、扇区 :hover 必须清零、命中楔必须有焦点描边',
  192. )
  193. // P5 图例行 ⇄ 扇区双向:图例悬停写同一个联动状态(两侧同时亮),浮层同入口。
  194. check(
  195. 'P5-legend-two-way',
  196. /@mouseenter="showChartTip\(\$event, row\.label, row\.count, barColor\(row\.tone\)\); setChartHover\(chartScope\(index, block\), row\.key\)"/.test(panelVue) &&
  197. /@mouseenter="showChartTip\(\$event, row\.label, row\.count, pieToneColor\(row\.tone, row\.key\)\); setChartHover\(chartScope\(index, block\), row\.key\)"/.test(panelVue) &&
  198. /'is-hovered': isChartHovered\(chartScope\(index, block\), row\.key\)/.test(panelVue) &&
  199. /\.panel-(donut|pie)__row\.is-hovered/.test(panelVue),
  200. '饼/环图例悬停必须写联动状态且行有 is-hovered 样式(扇区⇄图例两侧同时亮)',
  201. )
  202. // P6 标注与数值成对(维护者:离得太远没有关联感):
  203. // 饼图行不得 space-between、环形计数不得 margin-left:auto、标签不得生长
  204. // (`flex: 1 …` 会把数值顶回行尾——第一轮修复就栽在这里,守卫必须钉住 0 生长)。
  205. check(
  206. 'P6-label-value-pairing',
  207. !/\.panel-pie__row\s*\{[^}]*justify-content: space-between/.test(css) &&
  208. !/\.panel-donut__count\s*\{[^}]*margin-left: auto/.test(css) &&
  209. /\.panel-donut__label\s*\{[^}]*flex: 0 1 auto/.test(css),
  210. '图例数值必须紧跟标签(去 space-between / margin-left:auto / 标签生长)',
  211. )
  212. // P7 中心读数让位命中区(否则悬停环图中心永远不触发扇区)。
  213. check(
  214. 'P7-donut-total-pointer-none',
  215. /\.panel-donut__total\s*\{[^}]*pointer-events: none/.test(css),
  216. '.panel-donut__total 必须 pointer-events:none(命中楔覆盖圆心)',
  217. )
  218. // MUTATION M6:`.panel-donut__label` 改回 `flex: 1 1 auto`(生长顶回数值)→ P6 红
  219. // (本轮变异实测:不剥注释时该变异曾假绿——注释里写了期望值,守卫信了散文)。
  220. // P8(第四轮根因,静态):联动键必须是**跨重算稳定的标量 scope**,不能是对象身份。
  221. // 外壳时钟每秒重渲染 + `:view="panelViewOf(id)"` 是方法调用 → 每帧全新 block 对象,
  222. // `owner === block` 在下一帧静默失效 → hover 未移出、高亮自己掉(浮层存标量所以还在)。
  223. // 守卫钉住:① chartScope() 函数体取的是 view.name + index + kind + label 这类稳定标量;
  224. // ② 状态存的是 scope 字符串;③ 全文件不再出现对象身份比较(owner: toRaw(block))。
  225. // 注:函数体含模板字符串的 `}`,不能用 `[^}]*` 匹配——先按行提取函数体再断言。
  226. const scopeFn = panelVue.match(/function chartScope\(index, block\)\s*\{[\s\S]*?\n\}/)
  227. const scopeBody = scopeFn ? scopeFn[0] : ''
  228. check(
  229. 'P8-hover-key-is-stable-scalar-scope',
  230. /props\.view\.name/.test(scopeBody) &&
  231. /\$\{index\}/.test(scopeBody) &&
  232. /block\.kind/.test(scopeBody) &&
  233. /chartHover\.value = \{ scope, key \}/.test(panelVue) &&
  234. /current\.scope === scope && current\.key === key/.test(panelVue) &&
  235. !/owner: toRaw\(block\)/.test(panelVue) &&
  236. !/current\.owner ===/.test(panelVue),
  237. '联动键必须是 chartScope(稳定标量),不得再用 block 对象身份(重渲染会漂移)',
  238. )
  239. // P8b(第四轮根因,运行时):直接复现"对象身份键 vs 标量 scope 键"在重算下的行为——
  240. // 模拟 panelViewOf 每帧返回新对象:用对象身份当键,新对象查不回(false);
  241. // 用 chartScope 那类标量拼键,新对象仍查得回(true)。这条钉住**机制本身**:
  242. // 谁把键改回对象身份,这里先红。
  243. {
  244. const mkBlock = () => ({ kind: 'pie', label: '点位状态分布' }) // 每次新对象,字段相同
  245. const b1 = mkBlock()
  246. const b2 = mkBlock() // 模拟下一帧重渲染:同内容、不同引用
  247. const identityKey = { owner: b1 }
  248. const scopeOf = (index, block) => `viewName|${index}|${block.kind}|${block.label || ''}`
  249. const scopeKey = { scope: scopeOf(0, b1) }
  250. check(
  251. 'P8b-scope-key-survives-recompute-identity-does-not',
  252. identityKey.owner !== b2 && scopeKey.scope === scopeOf(0, b2),
  253. '对象身份键在重算后查不回(!==),标量 scope 键查得回(===)——这就是"高亮自己消失"的机制',
  254. )
  255. }
  256. // P9(第三轮根因,静态):联动状态用 shallowRef(避免深层代理;第四轮后虽以标量键为主,
  257. // 但 shallowRef 仍是"整体替换即触发渲染"的正确容器)。
  258. check(
  259. 'P9-hover-state-shallow-ref',
  260. /const chartHover = shallowRef\(null\)/.test(panelVue) &&
  261. /import \{[^}]*\bshallowRef\b[^}]*\} from 'vue'/.test(panelVue),
  262. 'chartHover 必须 shallowRef(整体替换即触发渲染,不做深层包装)',
  263. )
  264. // P9c(第三轮根因,运行时):深层 ref 会把存入的对象换成 reactive 代理——
  265. // 这条探针钉住 Vue 的代理语义本身(第四轮改成标量键后,代理与否都不再影响比较,
  266. // 但保留此探针记录机制演进,且 shallowRef 仍是正确容器)。
  267. {
  268. const raw = { kind: 'pie' }
  269. const host = reactive({ blocks: [raw] })
  270. const deep = ref(null)
  271. deep.value = { owner: host.blocks[0] }
  272. const shallow = shallowRef(null)
  273. shallow.value = { owner: toRaw(host.blocks[0]) }
  274. check(
  275. 'P9c-vue-identity-semantics',
  276. deep.value.owner !== raw && shallow.value.owner === raw,
  277. 'Vue 语义前提:深层 ref 代理破坏身份(!== 原始),shallowRef+toRaw 保持身份(=== 原始)',
  278. )
  279. }
  280. // MUTATION M2:删命中楔 tabindex → P1 红;M3:恢复 <title> → P2 红;
  281. // M4:图例不写 setChartHover → P5 红;M5:恢复 margin-left:auto → P6 红;
  282. // M7:shallowRef 改回 ref → P9 红;
  283. // M8(第四轮根因):把 chartScope 换回对象身份键(setChartHover(block,...) 且 owner===block)→ P8 红。
  284. }
  285. // ================================================================ 域层:环形图既有形状不回归
  286. {
  287. // 票 37 交付的 donutBlock 形状(slices/rows/total/action)不因本票变化。
  288. const d = donutBlock('行业分类', [
  289. { code: 'GAS', label: '燃气', count: 2 },
  290. { code: 'WATER_SUPPLY', label: '供水', count: 1 },
  291. { code: 'X', label: '零类', count: 0 },
  292. ])
  293. check(
  294. 'D1-donut-shape-unchanged',
  295. d.total === 3 && d.slices.length === 2 && d.rows.length === 3 && d.action === '',
  296. 'donutBlock 输出形状保持(0 计数行保留图例、不产扇区)',
  297. )
  298. const d2 = donutBlock('行业分类', [{ code: 'GAS', label: '燃气', count: 1 }], { action: 'industry' })
  299. check('D2-donut-action-kept', d2.action === 'industry' && d2.rows[0].actionKey === 'GAS', '可点接缝(panel-action)保持')
  300. }
  301. console.log('')
  302. console.log(`T43 stacked gap & pie interactions: ${failed ? `${failed} FAILED` : `${passed} passed`}`)
  303. if (failed) process.exit(1)