verify-viz.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-viz.mjs —— v11b 三、P1:四类微图表(sparkline / 进度环·进度条 / 对比条 / 热力小格)实测。
  4. *
  5. * 每类都要在**真实页面位置**出现(不是造组件放着),并且:
  6. * - 图不替代数字:每个图表元素旁边(父级两三层内)有可见数字文本;
  7. * - 图表本身 aria-hidden(读屏只读数字);
  8. * - 不引第三方图表库(静态 grep <canvas> / chart.js / echarts / d3);
  9. * - 无渐变填充(图表元素的 background-image 里没有 gradient)。
  10. * 每页打印:四类图表各几个、在哪个视图、尺寸;最后截图到 docs/screenshots/v11b/viz-*.png。
  11. *
  12. * 用法:node scripts/verify-viz.mjs [--url …] [--shots docs/screenshots/v11b]
  13. */
  14. import { mkdirSync } from 'node:fs';
  15. import { join } from 'node:path';
  16. import { startServer, launch, evalIn, openView, table, ROOT, DEEP } from './v11b-lib.mjs';
  17. const argv = process.argv.slice(2);
  18. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  19. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11b'));
  20. mkdirSync(SHOTS, { recursive: true });
  21. const srv = arg('--url', '') ? null : await startServer(4198);
  22. const base = arg('--url', '') || srv.base;
  23. const browser = await launch();
  24. const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
  25. const cdp = await page.context().newCDPSession(page);
  26. await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
  27. await page.emulateMedia({ reducedMotion: 'reduce' });
  28. const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/voc', '/members', '/ledger'];
  29. /* 页面里执行的判定(字符串,塞进 page.evaluate):点名的四个自定义元素;
  30. 报告卡里的迷你环是 report-card 自己画的 <svg class="ring">,单独算一类「环」 */
  31. const KIND = {
  32. spark: "el.localName === 'kf-spark'",
  33. ring: "el.localName === 'kf-ring' || (el.localName === 'svg' && el.classList.contains('ring'))",
  34. bars: "el.localName === 'kf-bars'",
  35. heat: "el.localName === 'kf-heat'",
  36. };
  37. const KIND_CN = { spark: 'sparkline', ring: '进度环', bars: '对比条', heat: '热力小格' };
  38. const found = Object.fromEntries(Object.keys(KIND).map((k) => [k, []]));
  39. const px = (n) => `${Math.round(n * 10) / 10}px`;
  40. for (const r of ROUTES) {
  41. await openView(page, base, r, { me: '赵磊', wait: 700 });
  42. const got = await page.evaluate(`(async () => { ${DEEP}\n` + `
  43. const out = [];
  44. deepAll((el) => {
  45. const KINDS = ${JSON.stringify(KIND)};
  46. for (const [k, src] of Object.entries(KINDS)) {
  47. if (!new Function('el', 'return (' + src + ')')(el) || !visible(el)) continue;
  48. const r = el.getBoundingClientRect();
  49. if (r.width < 6 || r.height < 4) continue;
  50. /* 父级是同一类图表时只记最外层,避免重复计数 */
  51. const p = el.parentElement; if (p && (p.localName === el.localName) && /^(kf-|.*heat$|.*ring$|.*bars$|.*spark$)/.test(p.localName)) continue;
  52. /* 读屏:图表 aria-hidden;数字在父级三层内(含 shadow 宿主) */
  53. /* 宿主自己不挂 aria-hidden 时,看它内部真正的图(shadow root 里的第一个元素)是否 aria-hidden */
  54. const inner = el.shadowRoot?.firstElementChild;
  55. const selfHidden = el.getAttribute('aria-hidden') === 'true' || !!el.querySelector('[aria-hidden="true"]') || inner?.getAttribute('aria-hidden') === 'true';
  56. let host = el, num = '';
  57. for (let i = 0; i < 4 && host; i++) {
  58. const t = [...host.childNodes].filter((n) => n.nodeType === 3).map((n) => n.data).join('') + (host === el ? '' : host.textContent);
  59. const m = t.match(/\\d+(?:\\.\\d+)?/);
  60. if (m) { num = m[0]; break; }
  61. host = host.parentElement || host.parentNode?.host || host.getRootNode()?.host || null;
  62. }
  63. const cs = getComputedStyle(el.localName === 'svg' ? el : el);
  64. const bg = cs.backgroundImage || '';
  65. out.push({ kind: k, label: el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : ''),
  66. w: r.width, h: r.height, hidden: selfHidden, num, grad: /gradient/.test(bg) });
  67. }
  68. });
  69. return out;
  70. })()`);
  71. for (const x of got) found[x.kind].push({ ...x, route: r });
  72. await page.screenshot({ path: join(SHOTS, `viz-${r === '/' ? 'workbench' : r.slice(1).replace(/\//g, '-')}.png`), fullPage: true, animations: 'disabled' });
  73. }
  74. console.log('## 每类图表的出现位置(路由 / 元素 / 尺寸 / 旁边数字 / aria-hidden / 渐变)\n');
  75. const rows = [];
  76. for (const k of Object.keys(found)) {
  77. for (const x of found[k]) rows.push({
  78. type: KIND_CN[k], route: x.route, el: x.label.slice(0, 22),
  79. size: `${px(x.w)}×${px(x.h)}`, num: x.num || '—', aria: x.hidden ? '✓' : '✗', grad: x.grad ? '有' : '无',
  80. });
  81. }
  82. table(rows, [['类型', 'type'], ['路由', 'route'], ['元素', 'el'], ['尺寸', 'size'], ['旁数字', 'num'], ['aria-hidden', 'aria'], ['渐变', 'grad']]);
  83. const counts = Object.fromEntries(Object.keys(found).map((k) => [k, found[k].length]));
  84. console.log(`\n各类型计数:${Object.entries(counts).map(([k, n]) => `${KIND_CN[k]} ${n}`).join(' · ')}`);
  85. const fails = [];
  86. for (const [k, n] of Object.entries(counts)) if (n < 1) fails.push(`${KIND_CN[k]} 在 7 个路由里一处都没有`);
  87. const noNum = rows.filter((x) => x.num === '—');
  88. if (noNum.length) fails.push(`${noNum.length} 个图表旁边没找到数字(图不能替代数字)`);
  89. const notHidden = rows.filter((x) => x.aria !== '✓');
  90. if (notHidden.length) fails.push(`${notHidden.length} 个图表没有 aria-hidden`);
  91. const grad = rows.filter((x) => x.grad === '有');
  92. if (grad.length) fails.push(`${grad.length} 个图表用了渐变填充`);
  93. /* 规格抽查:环直径 40–48 / 描边 3;条高 4 / 圆角 2;热力格 12–14 / 圆角 2;spark 宽 64–80 高 20(或小号 56×16) */
  94. const perPage = [];
  95. for (const r of ['/members', '/reports/voc', '/ledger', '/']) {
  96. await openView(page, base, r, { me: '赵磊', wait: 600 });
  97. const sp = await evalIn(page, `
  98. const out = { ring: [], bars: [], heat: [], spark: [], steps: 0 };
  99. deepAll((el) => {
  100. if (!visible(el)) return;
  101. if (el.localName === 'kf-ring' && el.getBoundingClientRect().width) { const c = el.shadowRoot?.querySelector('circle.fill'); out.ring.push([el.getBoundingClientRect().width, c ? getComputedStyle(c).strokeWidth : '?']); }
  102. if (el.localName === 'kf-bars') { const t = el.shadowRoot.querySelector('.t'); const f = el.shadowRoot.querySelector('.f'); out.bars.push([t.getBoundingClientRect().height, getComputedStyle(t).borderRadius, f ? getComputedStyle(f).borderRadius : '?']); }
  103. if (el.localName === 'kf-heat') { const i = el.shadowRoot.querySelector('.heat i[data-step]'); if (i) out.heat.push([i.getBoundingClientRect().width, getComputedStyle(i).borderRadius]); }
  104. if (el.localName === 'kf-spark') { const b = el.getBoundingClientRect(); out.spark.push([Math.round(b.width), Math.round(b.height)]); }
  105. });
  106. return out;
  107. `);
  108. perPage.push([r, sp]);
  109. }
  110. console.log('\n## 规格抽查(按页)');
  111. for (const [r, sp] of perPage) {
  112. const u = (arr) => [...new Set(arr)].join(' / ') || '—';
  113. console.log(` ${r.padEnd(14)} 环 ${u(sp.ring.map((x) => `${x[0]}px/描边${x[1]}`))} | 对比条 ${u(sp.bars.map((x) => `高${x[0]}px/圆角${x[1]}`))} | 热力格 ${u(sp.heat.map((x) => `${x[0]}px/圆角${x[1]}`))} | spark ${u(sp.spark.map((x) => `${x[0]}×${x[1]}`))}`);
  114. }
  115. const all = perPage.flatMap(([, sp]) => ({ ring: sp.ring, bars: sp.bars, heat: sp.heat, spark: sp.spark }));
  116. const bad = [];
  117. if (!all.some((x) => x.ring.length)) bad.push('没有量到进度环');
  118. for (const [d, s] of all.flatMap((x) => x.ring)) { if (!(d >= 40 && d <= 48)) bad.push(`环直径 ${d}px 不在 40–48`); if (parseFloat(s) !== 3) bad.push(`环描边 ${s} ≠ 3px`); }
  119. for (const [h, r1] of all.flatMap((x) => x.bars)) { if (h !== 4) bad.push(`对比条高 ${h} ≠ 4px`); if (r1 !== '2px') bad.push(`对比条圆角 ${r1} ≠ 2px`); }
  120. for (const [w, r1] of all.flatMap((x) => x.heat)) { if (!(w >= 12 && w <= 14)) bad.push(`热力格 ${w}px 不在 12–14`); if (r1 !== '2px') bad.push(`热力格圆角 ${r1} ≠ 2px`); }
  121. for (const [w, h] of all.flatMap((x) => x.spark)) { if (!((w >= 64 && w <= 80 && h === 20) || (w === 56 && h === 16))) bad.push(`spark ${w}×${h} 不在规格(64–80×20 / 56×16)`); }
  122. if (bad.length) fails.push(`规格不符:${[...new Set(bad)].join(';')}`);
  123. await browser.close();
  124. srv?.kill();
  125. if (fails.length) {
  126. console.log('\n失败项:');
  127. for (const f of fails) console.log(` ✗ ${f}`);
  128. process.exit(1);
  129. }
  130. console.log('\n全部达标(exit 0)');