| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139 |
- #!/usr/bin/env node
- /**
- * scripts/verify-viz.mjs —— v11b 三、P1:四类微图表(sparkline / 进度环·进度条 / 对比条 / 热力小格)实测。
- *
- * 每类都要在**真实页面位置**出现(不是造组件放着),并且:
- * - 图不替代数字:每个图表元素旁边(父级两三层内)有可见数字文本;
- * - 图表本身 aria-hidden(读屏只读数字);
- * - 不引第三方图表库(静态 grep <canvas> / chart.js / echarts / d3);
- * - 无渐变填充(图表元素的 background-image 里没有 gradient)。
- * 每页打印:四类图表各几个、在哪个视图、尺寸;最后截图到 docs/screenshots/v11b/viz-*.png。
- *
- * 用法:node scripts/verify-viz.mjs [--url …] [--shots docs/screenshots/v11b]
- */
- import { mkdirSync } from 'node:fs';
- import { join } from 'node:path';
- import { startServer, launch, evalIn, openView, table, ROOT, DEEP } from './v11b-lib.mjs';
- const argv = process.argv.slice(2);
- const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
- const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11b'));
- mkdirSync(SHOTS, { recursive: true });
- const srv = arg('--url', '') ? null : await startServer(4198);
- const base = arg('--url', '') || srv.base;
- const browser = await launch();
- const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
- const cdp = await page.context().newCDPSession(page);
- await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
- await page.emulateMedia({ reducedMotion: 'reduce' });
- const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/voc', '/members', '/ledger'];
- /* 页面里执行的判定(字符串,塞进 page.evaluate):点名的四个自定义元素;
- 报告卡里的迷你环是 report-card 自己画的 <svg class="ring">,单独算一类「环」 */
- const KIND = {
- spark: "el.localName === 'kf-spark'",
- ring: "el.localName === 'kf-ring' || (el.localName === 'svg' && el.classList.contains('ring'))",
- bars: "el.localName === 'kf-bars'",
- heat: "el.localName === 'kf-heat'",
- };
- const KIND_CN = { spark: 'sparkline', ring: '进度环', bars: '对比条', heat: '热力小格' };
- const found = Object.fromEntries(Object.keys(KIND).map((k) => [k, []]));
- const px = (n) => `${Math.round(n * 10) / 10}px`;
- for (const r of ROUTES) {
- await openView(page, base, r, { me: '赵磊', wait: 700 });
- const got = await page.evaluate(`(async () => { ${DEEP}\n` + `
- const out = [];
- deepAll((el) => {
- const KINDS = ${JSON.stringify(KIND)};
- for (const [k, src] of Object.entries(KINDS)) {
- if (!new Function('el', 'return (' + src + ')')(el) || !visible(el)) continue;
- const r = el.getBoundingClientRect();
- if (r.width < 6 || r.height < 4) continue;
- /* 父级是同一类图表时只记最外层,避免重复计数 */
- const p = el.parentElement; if (p && (p.localName === el.localName) && /^(kf-|.*heat$|.*ring$|.*bars$|.*spark$)/.test(p.localName)) continue;
- /* 读屏:图表 aria-hidden;数字在父级三层内(含 shadow 宿主) */
- /* 宿主自己不挂 aria-hidden 时,看它内部真正的图(shadow root 里的第一个元素)是否 aria-hidden */
- const inner = el.shadowRoot?.firstElementChild;
- const selfHidden = el.getAttribute('aria-hidden') === 'true' || !!el.querySelector('[aria-hidden="true"]') || inner?.getAttribute('aria-hidden') === 'true';
- let host = el, num = '';
- for (let i = 0; i < 4 && host; i++) {
- const t = [...host.childNodes].filter((n) => n.nodeType === 3).map((n) => n.data).join('') + (host === el ? '' : host.textContent);
- const m = t.match(/\\d+(?:\\.\\d+)?/);
- if (m) { num = m[0]; break; }
- host = host.parentElement || host.parentNode?.host || host.getRootNode()?.host || null;
- }
- const cs = getComputedStyle(el.localName === 'svg' ? el : el);
- const bg = cs.backgroundImage || '';
- out.push({ kind: k, label: el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : ''),
- w: r.width, h: r.height, hidden: selfHidden, num, grad: /gradient/.test(bg) });
- }
- });
- return out;
- })()`);
- for (const x of got) found[x.kind].push({ ...x, route: r });
- await page.screenshot({ path: join(SHOTS, `viz-${r === '/' ? 'workbench' : r.slice(1).replace(/\//g, '-')}.png`), fullPage: true, animations: 'disabled' });
- }
- console.log('## 每类图表的出现位置(路由 / 元素 / 尺寸 / 旁边数字 / aria-hidden / 渐变)\n');
- const rows = [];
- for (const k of Object.keys(found)) {
- for (const x of found[k]) rows.push({
- type: KIND_CN[k], route: x.route, el: x.label.slice(0, 22),
- size: `${px(x.w)}×${px(x.h)}`, num: x.num || '—', aria: x.hidden ? '✓' : '✗', grad: x.grad ? '有' : '无',
- });
- }
- table(rows, [['类型', 'type'], ['路由', 'route'], ['元素', 'el'], ['尺寸', 'size'], ['旁数字', 'num'], ['aria-hidden', 'aria'], ['渐变', 'grad']]);
- const counts = Object.fromEntries(Object.keys(found).map((k) => [k, found[k].length]));
- console.log(`\n各类型计数:${Object.entries(counts).map(([k, n]) => `${KIND_CN[k]} ${n}`).join(' · ')}`);
- const fails = [];
- for (const [k, n] of Object.entries(counts)) if (n < 1) fails.push(`${KIND_CN[k]} 在 7 个路由里一处都没有`);
- const noNum = rows.filter((x) => x.num === '—');
- if (noNum.length) fails.push(`${noNum.length} 个图表旁边没找到数字(图不能替代数字)`);
- const notHidden = rows.filter((x) => x.aria !== '✓');
- if (notHidden.length) fails.push(`${notHidden.length} 个图表没有 aria-hidden`);
- const grad = rows.filter((x) => x.grad === '有');
- if (grad.length) fails.push(`${grad.length} 个图表用了渐变填充`);
- /* 规格抽查:环直径 40–48 / 描边 3;条高 4 / 圆角 2;热力格 12–14 / 圆角 2;spark 宽 64–80 高 20(或小号 56×16) */
- const perPage = [];
- for (const r of ['/members', '/reports/voc', '/ledger', '/']) {
- await openView(page, base, r, { me: '赵磊', wait: 600 });
- const sp = await evalIn(page, `
- const out = { ring: [], bars: [], heat: [], spark: [], steps: 0 };
- deepAll((el) => {
- if (!visible(el)) return;
- 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 : '?']); }
- 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 : '?']); }
- 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]); }
- if (el.localName === 'kf-spark') { const b = el.getBoundingClientRect(); out.spark.push([Math.round(b.width), Math.round(b.height)]); }
- });
- return out;
- `);
- perPage.push([r, sp]);
- }
- console.log('\n## 规格抽查(按页)');
- for (const [r, sp] of perPage) {
- const u = (arr) => [...new Set(arr)].join(' / ') || '—';
- 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]}`))}`);
- }
- const all = perPage.flatMap(([, sp]) => ({ ring: sp.ring, bars: sp.bars, heat: sp.heat, spark: sp.spark }));
- const bad = [];
- if (!all.some((x) => x.ring.length)) bad.push('没有量到进度环');
- 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`); }
- for (const [h, r1] of all.flatMap((x) => x.bars)) { if (h !== 4) bad.push(`对比条高 ${h} ≠ 4px`); if (r1 !== '2px') bad.push(`对比条圆角 ${r1} ≠ 2px`); }
- 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`); }
- 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)`); }
- if (bad.length) fails.push(`规格不符:${[...new Set(bad)].join(';')}`);
- await browser.close();
- srv?.kill();
- if (fails.length) {
- console.log('\n失败项:');
- for (const f of fails) console.log(` ✗ ${f}`);
- process.exit(1);
- }
- console.log('\n全部达标(exit 0)');
|