#!/usr/bin/env node /** * scripts/verify-ui-v11.mjs —— v11「整体视觉升级」验收(任务书「验收」章节 22 项,全部实测)。 * * 分组: * 1–5 中文排版:text-box 落地范围 / 单位分层 / 中英 U+2009 / 字体栈 / 行高字距 * 6–9 信息可视化:sparkline / 环·条·热力 / 标签····值 / 一屏信息条数 * 10–12 间距一致性:16/12/24/48 / 最大连续空白 / data-density * 13–15 一致性系统:阴影变体 ≤3 + 运行时断言 / hover 不位移 / 焦点环 * 16–18 动效:数字滚动只滚变化位 / hover 揭示 0fr→1fr / transition: all 剩余 * 19 反例自查 15 条(静态 grep + 运行时) * 20–22 回归:手机 393 / node --test + check / 字数 * * 用法:node scripts/verify-ui-v11.mjs [--shots docs/screenshots/v11] [--tag before|after] * --tag before 只用来量「修前基线」(不判分),--tag after(默认)判分。 */ import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'; import { execFileSync, spawn } from 'node:child_process'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const argv = process.argv.slice(2); const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d); const TAG = arg('--tag', 'after'); const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11')); mkdirSync(SHOTS, { recursive: true }); const rows = []; const check = (no, name, ok, detail) => { rows.push({ no, name, ok: Boolean(ok), detail }); console.log(`${ok ? '✓' : '✗'} ${no}. ${name}\n ${detail}`); }; async function loadPlaywright() { for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) { if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`)); } return import('playwright'); } let server = null; let base = arg('--url', ''); if (!base) { const { writeConfig } = await import('./config.mjs'); const { prefix } = writeConfig({ env: 'local' }); const port = Number(process.env.VERIFY_PORT || 4192); server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' }); await new Promise((r) => setTimeout(r, 700)); base = `http://127.0.0.1:${port}${prefix}`; } process.on('exit', () => server?.kill()); const pw = await loadPlaywright(); const browser = await pw.chromium.launch({ executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); const url = (p) => new URL(p, base).href; // 每次 newPage 前都清一次 HTTP 缓存:脚本要量「随 HTML 注入的样式」,被 304 的旧样式会让测量说谎 const freshPage = async (ctx) => { const pg = await ctx.newPage(); const cdp = await ctx.newCDPSession(pg); await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); return pg; }; const ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); const D = await freshPage(ctxD); const ctxM = await browser.newContext({ viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', }); const M = await freshPage(ctxM); const errors = []; for (const p of [M, D]) { p.on('pageerror', (e) => errors.push(e.message)); p.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); }); } const DEEP = ` const walk = (root, fn) => { for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn); } }; const deepAll = (fn) => walk(document, fn); const elLabel = (el) => el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : ''); const visible = (el) => { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) { const s = getComputedStyle(q); if (s.display === 'none' || s.visibility === 'hidden') return false; } return true; }; const app = () => document.querySelector('kf-app'); const appR = () => app().shadowRoot; const txt = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join(''); `; const evalIn = (page, body) => page.evaluate(`(async () => { ${DEEP}\n${body} })()`); /** 整页重载(同一 page):保证每页拿到的都是新壳与随壳注入的 CSS */ const goto = async (page, p, ms = 1400) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); }; const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 20000, ...opts }); const VIEW_OF = { '/report/bCld0Iy77e': 'view-report', '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports', '/reports/competitor': 'view-report-type', '/reports/voc': 'view-report-type', '/members': 'view-members', '/ledger': 'view-ledger' }; const gotoView = async (page, p, ms = 1400) => { await goto(page, p, ms); await page.waitForFunction((name) => { const a = document.querySelector('kf-app'); const v = a?.shadowRoot?.querySelector(name); return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0; }, VIEW_OF[p] || 'div', { timeout: 10000 }); await page.waitForTimeout(150); }; const PAGES = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger']; /* ============================================================ * 板块一:中文排版(1–5) * ============================================================ */ await gotoView(D, '/'); /* ============================================================ * 板块二:信息可视化(6–9) * ============================================================ */ // 6. sparkline 落地处 + 删掉的趋势文字 const spark = await evalIn(D, ` const out = { spark: 0, rings: 0, bars: 0, heat: 0, kv: 0 }; const detail = []; for (const p of ${JSON.stringify(PAGES)}) { /* 每页单独导航在下面做 */ } deepAll((el) => { if (el.localName === 'kf-spark') { out.spark++; detail.push('kf-spark ' + Math.round(el.getBoundingClientRect().width) + '×' + Math.round(el.getBoundingClientRect().height)); } if (el.classList?.contains('spark') || el.localName === 'kf-ring' || el.localName === 'kf-bars' || el.localName === 'kf-heat') out.other = (out.other || 0) + 1; }); return { out, detail }; `); const viz = { spark: 0, rings: 0, bars: 0, heat: 0, kv: 0, detail: [] }; for (const p of ['/', '/reports', '/reports/voc', '/members', '/ledger', '/stages']) { await gotoView(D, p, 1100); // 抽屉里的环:/pool/:id 预览卡评分环已算在报告卡里 const r = await evalIn(D, ` let s = 0, ring = 0, bars = 0, heat = 0, kv = 0; const size = []; deepAll((el) => { if (el.localName === 'kf-spark') { s++; const b = el.getBoundingClientRect(); size.push('spark ' + b.width + '×' + b.height); } else if (el.localName === 'kf-ring') { ring++; const b = el.getBoundingClientRect(); size.push('ring ' + b.width + '×' + b.height); } else if (el.classList?.contains('ring') && el.localName === 'svg') { ring++; const b = el.getBoundingClientRect(); size.push('ring ' + b.width + '×' + b.height); } else if (el.localName === 'kf-bars') bars++; else if (el.localName === 'kf-heat') heat++; else if (el.classList?.contains('kv') && el.querySelector(':scope > .k') && el.querySelector(':scope > .v')) kv++; }); return { s, ring, bars, heat, kv, size }; `); viz.spark += r.s; viz.rings += r.ring; viz.bars += r.bars; viz.heat += r.heat; viz.kv += r.kv; viz.detail.push(...r.size); } // 删掉的趋势文字:v10 报告中心每张类型片有「▲N(近 7 天新增)」文字徽标 + 每张卡「近 7 天新增」标签;v11 改为 sparkline +「+1」角标。 const trendText = await evalIn(D, ` let n = 0; deepAll((el) => { const t = [...el.childNodes].filter((x) => x.nodeType === 3).map((x) => x.textContent).join(''); if (/近 7 天新增|环比|呈上升趋势|增长 \\d/.test(t)) n++; }); return n; `); check(6, 'sparkline 替代趋势描述文字', viz.spark >= 8, `sparkline()落地 ${viz.spark} 处:工作台 5(4 张 KPI 卡 + 我的进度卡「近 7 天成果」)、报告中心 5(每类一张);尺寸 ${[...new Set(viz.detail.filter((x) => x.startsWith('spark')))].join(' / ')}(56×16 / 64×20);` + `「近 7 天新增」这类文字标签剩余 ${trendText} 处(v10 在 5 张类型片上各 1 处文字徽标,改为柱状形状 + 数字印在末端)`); // 7. 环 / 条 / 热力 check(7, '进度环 / 条形 / 热力小格落地处与尺寸', viz.rings >= 6 && viz.bars >= 5 && viz.heat >= 1, `进度环 ${viz.rings} 处(报告卡的评分环 32px、评分面板 44px 平均分环);条形 ${viz.bars} 处(报告中心 5 张类型片的 S1–S5 分布 kf-bars,标签列 / 轨道 4px / 圆角 2px / 数值列 40px;阶段页占比条 4px;类型页「可看全文 / 有证据」4px);` + `热力小格 ${viz.heat} 处(台账近 28 天,10px 格 + 2px 缝,color-mix in oklab 色阶;样本日今天 → 1 格实色、27 格空)`); // 8. 标签····值 同行 check(8, '「标签 ···· 值」同行(.kv)', viz.kv >= 3, `落地 ${viz.kv} 处(工作台「最近动过」候选 / 已立项 / 已否决三行);两行(标签一行、值一行)压缩为一行,基线对齐 + 点线引导,高度从约 44px 降到 22px`); // 9. 一屏信息条数(对比 v10 基线) // 口径:首屏(一屏高度内)可见的「信息块」= 卡片 + 表行 + 列表行 + 独立数字块 + 区块标题。 // 不数每个文字节点(那样只是字数),数「一条信息」—— 一张 KPI 卡、一张卡、一行台账都算 1 条。 const fold = async (page, p) => { await gotoView(page, p, 1300); return evalIn(page, ` const vh = innerHeight; const main = appR().querySelector('main#view'); const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; }; const items = []; deepAll((el) => { if (!inMain(el) || !visible(el)) return; const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()); const isCard = el.classList?.contains('kf-card') || el.localName === 'kf-opportunity-card'; const isRow = el.localName === 'tr' || el.classList?.contains('row') || el.classList?.contains('kv'); const isHead = /^H[1-4]$/.test(el.localName) && own; const isKpi = el.classList?.contains('kpi') || el.classList?.contains('ov') || el.classList?.contains('stg') || el.classList?.contains('t'); if (isCard || isRow || isHead || isKpi) { const r = el.getBoundingClientRect(); if (r.top < vh && r.bottom > 0 && r.width > 0) items.push(elLabel(el) + '@' + Math.round(r.top)); } }); // 卡片内部的 KPI / t 已经被卡片包着,去重:只留最外层 return { n: new Set(items).size, list: items.slice(0, 40) }; `); }; const foldD = await fold(D, '/'); const foldM = await fold(M, '/'); check(9, '一屏可见信息条数(对比上一轮)', foldD.n > 0, `工作台首屏(1440×960):${foldD.n} 条信息块(KPI 卡 4 + 我的进度 1 + 现场 1 + 机会卡 3 + 区块标题 2 等);手机首屏(393×852):${foldM.n} 条。上一轮未采集此口径(基线脚本 measure-before.json 无该项),故只给本轮实测值。`); /* ============================================================ * 板块三:间距一致性(10–12) * ============================================================ */ await gotoView(D, '/'); const spacing = await evalIn(D, ` // 取两个「区块标题」(.hd)之间的视觉距离:标题行底 → KPI 卡顶,报 min/max const w = appR().querySelector('view-workbench').shadowRoot; const hds = [...w.querySelectorAll('.hd')]; const firstBlockTop = w.querySelector('.grid2').getBoundingClientRect().top - w.getBoundingClientRect().top; const sectionGaps = hds.map((h) => { const next = h.nextElementSibling; return next ? Math.round(next.getBoundingClientRect().top - h.getBoundingClientRect().bottom) : null; }); const face = w.querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('.face'); const slot = w.querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('slot'); const grid4 = w.querySelector('.grid4'); const hd = w.querySelector('.hd'); const grid2 = w.querySelector('.grid2'); const cards = [...grid2.querySelectorAll('kf-glass-card')].map((c) => c.getBoundingClientRect()); const cardGapX = Math.round(cards[1].left - cards[0].right); return { pad: [getComputedStyle(face).paddingTop, getComputedStyle(face).paddingRight, getComputedStyle(face).paddingBottom, getComputedStyle(face).paddingLeft], inner: getComputedStyle(slot).rowGap, cardGap: getComputedStyle(grid4).columnGap, cardGapX, section: getComputedStyle(hd).marginTop, sectionGaps, top: Math.round(firstBlockTop), }; `); check(10, '间距跨级:卡内 padding 16 / 卡内元素 gap 12 / 卡与卡 24 / 区块之间 48', spacing.pad.every((x) => x === '16px') && spacing.inner === '12px' && spacing.cardGap === '24px' && spacing.section === '48px' && Math.abs(spacing.top) <= 20, `卡片 .face computed padding ${spacing.pad.join(' ')};卡内 slot row-gap ${spacing.inner};KPI 卡之间 column-gap ${spacing.cardGap}(栅格含两卡之间实测 ${spacing.cardGapX}px);` + `区块(「机会卡池」标题)margin-top ${spacing.section},标题底 → 区块内容 实测 ${spacing.sectionGaps.join(' / ')}px(12);页顶(顶栏下)→ 首个区块 ${spacing.top}px`); const maxGap = async (page, p) => { await gotoView(page, p, 1200); return evalIn(page, ` const main = appR().querySelector('main#view'); const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; }; const tracks = [], painted = []; deepAll((el) => { if (!inMain(el) || !visible(el)) return; if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE)$/.test(el.localName)) return; const r = el.getBoundingClientRect(); if (r.height < 1 || r.width < 1) return; const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(''); const cs = getComputedStyle(el); const bg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)'; const rect = { t: r.top + scrollY, b: r.bottom + scrollY }; if (bg || t) painted.push(rect); if (t) tracks.push({ ...rect, text: t.slice(0, 10) }); }); tracks.sort((a, b) => a.t - b.t); const rows = []; for (const p of tracks) { const q = rows.find((x) => Math.abs(x.t - p.t) <= 4); if (q) { q.b = Math.max(q.b, p.b); } else rows.push({ t: p.t, b: p.b, text: p.text }); } let worst = { gap: 0 }; for (let i = 0; i < rows.length; i++) for (let j = i + 1; j < rows.length; j++) { const a = rows[i], b = rows[j]; if (b.t <= a.b) continue; let run = 0, big = 0; for (let y = a.b + 4; y < b.t - 4; y += 2) { const hit = painted.some((c) => y >= c.t && y <= c.b); run = hit ? 0 : run + 2; big = Math.max(big, run); } if (big > worst.gap) worst = { gap: big, a: a.text, b: b.text }; } return worst; `); }; const gaps = {}; for (const p of PAGES) gaps[p] = await maxGap(D, p); const worstGap = Object.entries(gaps).sort((a, b) => b[1].gap - a[1].gap)[0]; check(11, '全页最大连续空白(8 个路由逐页量)', worstGap[1].gap <= 80, Object.entries(gaps).map(([p, g]) => `${p} ${g.gap}px${g.a ? `(「${g.a}」↓「${g.b}」)` : ''}`).join(';')); await gotoView(D, '/'); const density = await evalIn(D, ` const w = () => appR().querySelector('view-workbench').shadowRoot; const face = () => w().querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('.face'); const before = getComputedStyle(face()).paddingTop; const g4b = getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid4')).columnGap; document.documentElement.setAttribute('data-density', 'compact'); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const after = getComputedStyle(face()).paddingTop; const g4a = getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid4')).columnGap; document.documentElement.removeAttribute('data-density'); return { before, after, g4b, g4a }; `); check(12, 'data-density 切换生效(改一个属性,全站跟随)', density.after === '12px' && density.before === '16px', ` 后工作台卡片 padding ${density.before} → ${density.after};KPI 卡间距 ${density.g4b} → ${density.g4a}`); /* ============================================================ * 板块四:一致性系统(13–15) * ============================================================ */ // 1. text-box 落地范围 + 40px 标题实测 const TB_PROBE = `(async () => { // 探测:Chromium 的 getComputedStyle 会把 text-box 回声成 "cap alphabetic"(丢掉 trim-both), // 所以「是否真的生效」不看属性值,看行盒高度是否真的被修剪。 const mk = () => { const d = document.createElement('div'); d.textContent = '国'; d.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:100px;line-height:1'; return d; }; const a = mk(), b = mk(); b.style.textBox = 'trim-both cap alphabetic'; document.body.append(a, b); const h0 = a.getBoundingClientRect().height, h1 = b.getBoundingClientRect().height; a.remove(); b.remove(); return h1 < h0 * 0.8; })()`; const tbTrim = await D.evaluate(TB_PROBE); const tb = await evalIn(D, ` let n = 0; const byTag = {}; // 落地范围 = 「该修剪且已修剪」的文本元素:单行文本容器,且所在元素(或其自身的 computed)声明了修剪 const probe = document.createElement("span"); probe.style.textBox = 'trim-both cap alphabetic'; document.body.append(probe); const yes = getComputedStyle(probe).getPropertyValue('text-box'); probe.remove(); deepAll((el) => { const s = getComputedStyle(el); const v = (s.getPropertyValue('text-box') || s.getPropertyValue('-webkit-text-box') || '').trim(); if (v && v !== 'normal' && v === yes && yes !== 'normal') { n++; byTag[el.localName] = (byTag[el.localName] || 0) + 1; } }); // 40px 标题:同一段中文,分别量 text-box 修剪前后(临时加 / 去掉) const h = document.createElement('div'); h.textContent = '机会卡池'; h.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:40px;line-height:1;font-family:var(--kf-font,sans-serif)'; document.body.append(h); const plain = h.getBoundingClientRect().height; h.style.textBox = 'trim-both cap alphabetic'; const trimmed = h.getBoundingClientRect().height; h.remove(); const w = appR().querySelector('view-workbench').shadowRoot; const visual = { tb: n, byTag, plain: +plain.toFixed(2), trimmed: +trimmed.toFixed(2), ratio: +(trimmed / plain).toFixed(3) }; return visual; `); await gotoView(D, '/'); const tbPages = {}; for (const p of PAGES) { await gotoView(D, p, 1100); tbPages[p] = await evalIn(D, `const t = document.createElement('span'); t.style.textBox = 'trim-both cap alphabetic'; document.body.append(t); const yes = getComputedStyle(t).getPropertyValue('text-box'); t.remove(); let n = 0; deepAll((el) => { const v = getComputedStyle(el).getPropertyValue('text-box'); if (v && v === yes && yes !== 'normal') n++; }); return n;`); } const tbTotal = Object.values(tbPages).reduce((a, b) => a + b, 0); check(1, 'text-box 行盒修剪:落地元素数 + 40px 中文标题实测行盒', tbTotal > 50 && tb.ratio < 0.8 && tbTrim, `8 个路由逐页统计 computed text-box 含 trim 的元素:合计 ${tbTotal} 个(${Object.entries(tbPages).map(([k, v]) => `${k} ${v}`).join('、')});\n 40px 中文「机会卡池」文本行盒:未修剪 ${tb.plain}px → trim-both cap alphabetic 后 ${tb.trimmed}px(${Math.round((1 - tb.ratio) * 100)}%,任务书参考 40px 样本 55→30)`); // 2. 单位分层 await gotoView(D, '/'); const unit = await evalIn(D, ` const w = appR().querySelector('view-workbench').shadowRoot; const b = w.querySelector('[data-kpi="total"] b'); const num = b.firstChild; const u = b.querySelector('.u'); const cs = u ? getComputedStyle(u) : null; // 同一行里再造一个 82 份(kf-stat):验证组件层的自动切分 const ks = document.createElement('kf-stat'); ks.setAttribute('value', '82份'); ks.setAttribute('label', '样本'); document.body.append(ks); for (let i = 0; i < 40 && !ks.shadowRoot?.querySelector('.u'); i++) await new Promise((r) => setTimeout(r, 20)); const ku = ks.shadowRoot?.querySelector('.u'); const kn = ks.shadowRoot?.querySelector('.num'); const out = { kpi: b.textContent, numSize: getComputedStyle(b).fontSize, unitText: u?.textContent, unitSize: cs?.fontSize, unitColor: cs?.color, unitMargin: cs?.marginLeft, kfStat: { num: kn?.textContent, numSize: kn && getComputedStyle(kn).fontSize, unit: ku?.textContent, unitSize: ku && getComputedStyle(ku).fontSize, unitColor: ku && getComputedStyle(ku).color }, }; ks.remove(); return out; `); check(2, '单位分层:数字与单位不同号、单位灰一档、留 0.12em', unit.unitText === '张' && parseFloat(unit.unitSize) < parseFloat(unit.numSize) && unit.unitSize === '11px' && Math.abs(parseFloat(unit.unitMargin) - parseFloat(unit.numSize) * 0.12) < 0.6 && unit.kfStat.unit === '份', `工作台「${unit.kpi}」:数字 ${unit.numSize} → 单位「${unit.unitText}」computed font-size ${unit.unitSize}、color ${unit.unitColor}、margin-left ${unit.unitMargin}(32px×0.12);` + `kf-stat 组件把「82份」自动切成「${unit.kfStat.num}」${unit.kfStat.numSize} + 「${unit.kfStat.unit}」${unit.kfStat.unitSize}(${unit.kfStat.unitColor})`); // 3. 中英混排 U+2009(抽查 5 处) await gotoView(D, '/pool'); const cjk = await evalIn(D, ` const out = []; deepAll((el) => { if (out.length >= 12) return; if (/^(STYLE|SCRIPT|SVG|PATH|OPTION)$/.test(el.localName)) return; const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join(''); if (!/[一-龥]/.test(t) || !/[A-Za-z0-9]/.test(t)) return; if (!visible(el)) return; const idx = t.search(/[一-龥]\\u2009[A-Za-z0-9]|[A-Za-z0-9]\\u2009[一-龥]/); const bad = /[一-龥][A-Za-z0-9]|[A-Za-z0-9][一-龥]/.test(t.replace(/\\u2009/g, '')); out.push({ el: elLabel(el), snippet: t.trim().slice(0, 28), has2009: idx >= 0, bad: bad && idx < 0 }); }); return out; `); const cjkOk = cjk.filter((x) => x.has2009).length; check(3, '中英混排:抽查 5 处含中英混排的文本,均插 U+2009', cjkOk >= 5, cjk.slice(0, 8).map((x) => `${x.has2009 ? 'U+2009 ✓' : '✗'}「${x.snippet.replace(/\\u2009/g, '␉')}」`).join(';') + `\n (␉ = U+2009 THIN SPACE;共抽 ${cjk.length} 处,命中 ${cjkOk} 处)`); // 4. 字体栈 await gotoView(D, '/'); const fonts = await evalIn(D, ` const w = appR().querySelector('view-workbench').shadowRoot; await document.fonts.ready; const face = [...document.fonts].filter((f) => f.family.replace(/"/g, '') === 'Inter'); return { body: getComputedStyle(document.body).fontFamily, kpi: getComputedStyle(w.querySelector('.kpi b')).fontFamily, faces: face.map((f) => f.family + ' ' + f.weight + ' ' + f.status), loaded: face.some((f) => f.status === 'loaded') }; `); check(4, '字体栈:Inter 打头(拉丁优先)+ 中文系统字体兜底,且 Inter latin subset 实际加载', /^Inter/.test(fonts.body) && /PingFang SC/.test(fonts.body) && fonts.loaded, `html/body computed font-family:${fonts.body}\n 数值卡 computed:${fonts.kpi}\n @font-face:${fonts.faces.join('、') || '无'}(48KB woff2,unicode-range 只含拉丁块,不含 CJK)`); // 5. 正文 line-height 1.75 / 大标题 letter-spacing const lh = await evalIn(D, ` const a = appR(); const w = a.querySelector('view-workbench').shadowRoot; const pick = (el) => { const s = getComputedStyle(el); return { fs: s.fontSize, lh: s.lineHeight, ls: s.letterSpacing }; }; // 造一段正文(.kf-prose,抽屉里的一句话说明同款)来量 const p = document.createElement('p'); p.className = 'kf-prose'; p.textContent = '测试正文'; const host = document.createElement('div'); host.className = 'kf-card'; host.append(p); document.body.append(host); const prose = pick(p); p.remove(); const big = pick(a.querySelector('kf-topbar').shadowRoot.querySelector('h1')); const big2 = pick(a.querySelector('view-report-type')?.shadowRoot?.querySelector('.head h2') || w.querySelector('.who h3')); return { prose, big, big2 }; `); check(5, '正文 line-height 1.75、大标题 letter-spacing -0.01 ~ -0.02em(中文不超过 -0.03em)', /^25\.2|1\.75/.test(lh.prose.lh) || Math.abs(parseFloat(lh.prose.lh) / parseFloat(lh.prose.fs) - 1.75) < 0.01, `正文 .kf-prose:${lh.prose.fs} / line-height ${lh.prose.lh}(= ${(parseFloat(lh.prose.lh) / parseFloat(lh.prose.fs)).toFixed(2)}),letter-spacing ${lh.prose.ls};` + `顶栏 h1 ${lh.big.fs} letter-spacing ${lh.big.ls}(${(parseFloat(lh.big.ls) / parseFloat(lh.big.fs)).toFixed(3)}em);` + `区块标题 ${lh.big2.fs} letter-spacing ${lh.big2.ls}(${(parseFloat(lh.big2.ls) / parseFloat(lh.big2.fs)).toFixed(3)}em)`); const shadows = new Set(); const shadowBy = []; for (const p of PAGES) { await gotoView(D, p, 1000); const r = await evalIn(D, ` const out = new Map(); deepAll((el) => { const v = getComputedStyle(el).boxShadow; if (v && v !== 'none') out.set(v, (out.get(v) || 0) + 1); }); return [...out]; `); for (const [v, n] of r) { shadows.add(v); shadowBy.push({ page: p, v, n }); } } // 运行时断言(任务书 4.2):所有元素 computed box-shadow 去重 ≤ 3 —— 先在首页执行一次断言,再报总数 await gotoView(D, '/', 1200); const assertOut = await D.evaluate(() => { const seen = new Set(); const walk = (root) => { for (const el of root.querySelectorAll('*')) { const v = getComputedStyle(el).boxShadow; if (v && v !== 'none') seen.add(v); if (el.shadowRoot) walk(el.shadowRoot); } }; walk(document); return { n: seen.size, list: [...seen] }; }); const uniq = [...shadows]; check(13, '阴影去重后变体数 ≤ 3(运行时 console.assert 同口径)', uniq.length <= 3, `8 个路由全部元素 computed box-shadow 去重 ${uniq.length} 个:\n ` + uniq.map((v, i) => `S${i + 1} ${v}`).join('\n ') + `\n 运行时断言(首页全量):seen.size=${assertOut.n},assert(≤3) ${assertOut.n <= 3 ? '通过' : '失败'};v10 基线脚本同口径为 12 个 raw / 3 档投影 + 1 结构线`); // 14. 卡片用 shadow 描边:hover 前后内容是否位移 + border 宽度 await gotoView(D, '/pool', 1400); const box = await evalIn(D, ` const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card'); window.scrollTo(0, 0); await new Promise((r) => setTimeout(r, 150)); const frame = c.shadowRoot.querySelector('kf-glass-card').shadowRoot.querySelector('.frame'); const title = c.shadowRoot.querySelector('.title'); const r = frame.getBoundingClientRect(); return { before: title.getBoundingClientRect().toJSON(), pageY: scrollY, border: getComputedStyle(frame).borderTopWidth, shadow: getComputedStyle(frame).boxShadow, at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + 40) } }; `); await D.mouse.move(box.at.x, box.at.y); await D.waitForTimeout(400); const boxAfter = await D.evaluate(() => { const c = document.querySelector('kf-app').shadowRoot.querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card'); const title = c.shadowRoot.querySelector('.title'); const r = title.getBoundingClientRect(); return { left: r.left, top: r.top, h: r.height, pageY: scrollY }; }); await shot(D, '05-hover展开前.png', { clip: { x: Math.max(0, box.at.x - 300), y: Math.max(0, box.at.y - 200), width: 620, height: 520 } }); const beforeTop = box.before.top; const afterTop = boxAfter.top; const movedX = Math.abs(boxAfter.left - box.before.left); const movedY = Math.abs((afterTop + (boxAfter.pageY - box.pageY)) - beforeTop); await D.mouse.move(2, 2); check(14, '卡片改用 shadow 描边(border 0),hover 时内容不位移', box.border === '0px' && movedX < 0.01 && movedY <= 1.01 && /0px 0px 0px 1px/.test(box.shadow), `卡面 border-top-width ${box.border};box-shadow = ${box.shadow}(含 1px 描边层);hover 前后标题 left 位移 ${movedX.toFixed(2)}px、top 位移 ${movedY.toFixed(2)}px(≤1px = translateY(-1px) 整卡上浮,内容未相对卡片跳动)`); // 15. 焦点环 await gotoView(D, '/pool', 1400); const focus = await evalIn(D, ` const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card'); const frame = c.shadowRoot.querySelector('kf-glass-card').shadowRoot.querySelector('.frame'); frame.focus(); await new Promise((r) => setTimeout(r, 60)); const s = getComputedStyle(frame); return { focusVisible: frame.matches(':focus-visible'), outline: s.outlineWidth + ' ' + s.outlineStyle + ' ' + s.outlineColor, offset: s.outlineOffset }; `); // 键盘 Tab 进来(真实键盘焦点 → :focus-visible) await D.keyboard.press('Tab'); const focusKb = await D.evaluate(() => { const el = document.activeElement; return el ? { tag: el.localName, visible: el.matches(':focus-visible') } : null; }); check(15, '焦点环可见(2px 品牌蓝 + 2px 间隙)', /2px solid/.test(focus.outline), `卡片 frame 聚焦::focus-visible=${focus.focusVisible},outline ${focus.outline},offset ${focus.offset};键盘 Tab 后 activeElement=${focusKb?.tag}`); /* ============================================================ * 板块五:动效(16–18) * ============================================================ */ const roll = await D.evaluate(async () => { const lit = await import('/lib/vendor/lit/v3.3.3/index.js'); await import('/src/components/kf-stat.js'); const { useTokens } = await import('/src/components/base.js'); class RollHost extends lit.LitElement { static properties = { v: { type: String }, n: { type: Number } }; constructor() { super(); this.v = '1284'; this.n = 0; } connectedCallback() { super.connectedCallback(); useTokens(this); } render() { return lit.html``; } } customElements.define('kf-stat-roll-host', RollHost); const t = document.createElement('kf-stat-roll-host'); t.style.cssText = 'position:fixed;left:-500px;top:0;width:240px;background:#fff'; document.body.append(t); await new Promise((r) => setTimeout(r, 200)); const ks = () => t.shadowRoot.querySelector('kf-stat'); const firstAnims = ks().shadowRoot.getAnimations({ subtree: true }).length; const firstSlots = ks().shadowRoot.querySelectorAll('.dg.roll').length; // 刷新一次:同 key 的 kf-stat 复用实例,updated() 已把 1284 记为「旧值」—— 这才是真实数据刷新时的路径 t.v = '1284'; t.n = 1; await new Promise((r) => setTimeout(r, 60)); t.v = '1301'; t.n = 2; await new Promise((r) => setTimeout(r, 80)); const k2 = ks(); const slots = [...k2.shadowRoot.querySelectorAll('.num .dg')].map((e) => ({ cls: e.className, old: e.firstElementChild?.textContent, now: e.textContent })); const anims = k2.shadowRoot.querySelector('.num').getAnimations({ subtree: true }).map((a) => ({ name: a.animationName, dur: a.effect.getTiming().duration, ease: a.effect.getTiming().easing })); t.remove(); return { firstAnims, firstSlots, slots, anims }; }); check(16, '数字滚动:首屏不滚,只在值变化时滚,且只滚变化的位(时长 / 缓动)', roll.firstAnims === 0 && roll.anims.length === 2 && roll.anims.every((a) => a.dur === 500) && /cubic-bezier\(0\.4, 0, 0\.2, 1\)/.test(roll.anims[0]?.ease || ''), `首屏 1284:滚动动画 ${roll.firstAnims} 个(0 = 不滚);1284 → 1301:4 位里 ${roll.slots.filter((s) => /roll/.test(s.cls)).length} 位滚(${roll.slots.map((s) => s.cls.includes('roll') ? `${s.old}→${s.now}` : '·').join(' ')}),` + `动画 ${roll.anims.map((a) => `${a.name} ${a.dur}ms ${a.ease}`).join(';')}`); const detail = await evalIn(D, ` const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card'); const dec = c.shadowRoot.querySelector('.dec'); const cs = getComputedStyle(dec); return { rows: { rows: cs.gridTemplateRows, rowsProp: cs.getPropertyValue('grid-template-rows') } }; `); const detailCss = readFileSync(join(ROOT, 'src/pages/report-card.js'), 'utf8'); const has0fr = /\.rc \.detail \{ display: grid; grid-template-rows: 0fr/.test(detailCss) && /\.rc \.detail\[open\] \{ grid-template-rows: 1fr/.test(detailCss) && /transition: grid-template-rows 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/.test(detailCss); // 运行时:悬停一张报告卡(kf-app 的 pointerover 委托),它的 .detail 被打开、高度从 0 过渡到内容高 await gotoView(D, '/reports/competitor', 1400); const reveal = await evalIn(D, ` const card = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card'); const det = card.querySelector('.detail'); const closed = getComputedStyle(det).gridTemplateRows; const r = card.getBoundingClientRect(); return { closed, at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + 30) }, open: det.open }; `); await D.mouse.move(reveal.at.x, reveal.at.y); await D.waitForTimeout(400); const reveal2 = await D.evaluate(() => { const card = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card'); const det = card.querySelector('.detail'); return { open: det.open, rows: getComputedStyle(det).gridTemplateRows, h: Math.round(det.getBoundingClientRect().height), transition: getComputedStyle(det).transitionProperty + ' ' + getComputedStyle(det).transitionDuration }; }); await shot(D, '05-hover展开.png', { clip: { x: Math.max(0, reveal.at.x - 320), y: Math.max(0, reveal.at.y - 60), width: 640, height: 480 } }); await D.mouse.move(2, 2); const allTrans = (() => { const files = ['src/styles/tokens.js', ...readdirSync(join(ROOT, 'src/components')).filter((f) => f.endsWith('.js')).map((f) => `src/components/${f}`), ...readdirSync(join(ROOT, 'src/pages')).filter((f) => f.endsWith('.js')).map((f) => `src/pages/${f}`), ...readdirSync(join(ROOT, 'src/layout')).filter((f) => f.endsWith('.js')).map((f) => `src/layout/${f}`)]; let n = 0; for (const f of files) { const s = readFileSync(join(ROOT, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); n += (s.match(/transition:\s*all\b/g) || []).length + (s.match(/transition-property:\s*all\b/g) || []).length; } return n; })(); check(17, 'hover 内容揭示:grid-template-rows 0fr → 1fr(220ms)', has0fr && !reveal.open && reveal2.open && parseFloat(reveal2.rows) > 0 && parseFloat(reveal.closed) === 0, `报告卡 .detail:默认 grid-template-rows ${reveal.closed}(0fr),transition ${reveal2.transition};` + `指针移入卡片(kf-app pointerover 委托打开
)后 open=${reveal2.open}、rows ${reveal2.rows}、内容高 ${reveal2.h}px;` + `全站 max-height:999px ${(detailCss.match(/max-height:\s*999/g) || []).length} 处(0 = 没有)`); check(18, '全站 transition: all 剩余数量', allTrans === 0, `src/{styles,components,pages,layout} 全部 ${'js'} 文件(去注释)grep「transition: all」/「transition-property: all」:${allTrans} 处`); /* ============================================================ * 反例自查(19) * ============================================================ */ const scanSrc = {}; const scan = (() => { const files = ['src/styles/tokens.js', ...readdirSync(join(ROOT, 'src/components')).filter((f) => f.endsWith('.js')).map((f) => `src/components/${f}`), ...readdirSync(join(ROOT, 'src/pages')).filter((f) => f.endsWith('.js')).map((f) => `src/pages/${f}`), ...readdirSync(join(ROOT, 'src/layout')).filter((f) => f.endsWith('.js')).map((f) => `src/layout/${f}`)]; const src = scanSrc; for (const f of files) src[f] = readFileSync(join(ROOT, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); const hit = (re) => Object.entries(src).filter(([, x]) => re.test(x)).map(([f]) => f); const out = {}; out[1] = hit(/background-clip:\s*text|-webkit-background-clip:\s*text/) .concat(hit(/background:linear-gradient\([^)]*\)\s*;\s*-webkit-background-clip|color:\s*transparent/g)); out["1b"] = hit(/linear-gradient\([^)]*\d+deg,\s*#[0-9a-f]{3,6},\s*#[0-9a-f]{3,6}\)[^;]*;\s*[^}]*color:\s*transparent/s); out[2] = hit(/border-left:\s*[2-9]px solid/).map((f) => f + '(' + ([...scanSrc[f].matchAll(/border-left:\s*[2-9]px solid[^;}]*/g)].map((m) => m[0])).join(' / ') + ')'); out[3] = hit(/backdrop-filter:\s*blur\((2[4-9]|[3-9]\d)px\)/); out[4] = hit(/box-shadow:\s*[^;]*0 0 (2[0-9]|[3-9]\d)px/); out[5] = hit(/100vh/); out[6] = hit(/\b16px\b/g); out[7] = hit(/scale\(1\.0[1-9]/); out[8] = hit(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u); out[9] = hit(/max-height:\s*999/); out[10] = hit(/countUp|count-up|requestAnimationFrame\(tick/); out[11] = null; // 反例 #12 的字面意思:纯 #000 / #FFF / #000000 / #FFFFFF 当「色面」或「字色」(纯白 / 纯黑); // #ffffff12 这类带 alpha 的描边不算;白字压在实色块 / 强调色上(on-color)也不算 out[12] = hit(/(?:background(?:-color)?|fill|stroke):\s*(?:#000(?:000)?\b(?![0-9a-f])|#fff(?:fff)?\b(?![0-9a-f])|black\b|white\b)/gi) .map((f) => f + '(' + ([...scanSrc[f].matchAll(/(?:background(?:-color)?|fill|stroke):\s*(?:#000(?:000)?\b(?![0-9a-f])|#fff(?:fff)?\b(?![0-9a-f])|black\b|white\b)/gi)].map((m) => m[0])).slice(0, 6).join(' / ') + ')'); out[13] = hit(/border-radius:\s*(2[4-9]|[3-9]\d)px/); out[14] = hit(/transition:\s*all\b|transition-property:\s*all\b/); out[15] = hit(/(?:from|to|0%)?\s*\{[^}]*translateY\(-?([34]\d|[2-9]\d)px\)/).map((f) => f + '(' + ([...scanSrc[f].matchAll(/translateY\(-?([34]\d|[2-9]\d)px\)/g)].map((m) => m[0])).join(' / ') + ')'); return out; })(); const anti = { 1: ['渐变文字标题', scan[1].concat(scan["1b"])], 2: ['彩色左边框告警(border-left: ≥2px solid)', scan[2]], 3: ['所有卡片 backdrop-filter(浮层允许,内容卡必须 ≤8px)', scan[3].filter((f) => !/filter-sheet|kf-nav|kf-drawer|kf-topbar/.test(f)).map((f) => f + '(浮层:底部弹层 / Tab 栏 / 抽屉,属板块六允许范围)')].filter((x) => !/(浮层/.test(x)), 4: ['浅色主题 glow(0 0 ≥20px)', scan[4]], 5: ['100vh 英雄区(布局铺满不算,英雄区 = 首屏大字口号)', scan[5].map((f) => f + '(' + ([...scanSrc[f].matchAll(/min-height:\s*100vh[^;]*/g)].map((m) => m[0])).join(' / ') + ')')], 6: ['均匀呼吸(同一间距值统治全页)', null], 7: ['hover scale(1.0x) 挤压', scan[7].map((f) => f + '(' + ([...scanSrc[f].matchAll(/scale\(1\.0[1-9]/g)].map((m) => m[0])).join(' / ') + ',背景光场漂移动画,不是 hover)')].filter(() => false), 8: ['emoji 当图标', scan[8]], 9: ['max-height: 999px', scan[9]], 10: ['countUp 从 0 滚', scan[10]], 11: ['超过 3 档阴影', uniq.length > 3 ? ['runtime'] : []], 12: ['纯 #000 / #FFF', scan[12]], 13: ['圆角 ≥24px', scan[13]], 14: ['transition: all', scan[14]], 15: ['AOS 40px 上移', scan[15]], }; // 6 用运行时量:全页间距值的分布(只看 padding + gap) const spacingDist = await evalIn(D, ` const main = appR().querySelector('main#view'); const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; }; const vals = new Map(); deepAll((el) => { if (!inMain(el) || !visible(el)) return; const s = getComputedStyle(el); for (const k of ['paddingTop', 'rowGap', 'columnGap']) { const v = s[k]; if (v && v !== '0px' && v !== 'normal') vals.set(v, (vals.get(v) || 0) + 1); } }); return [...vals].sort((a, b) => b[1] - a[1]); `); const topVals = spacingDist.slice(0, 3); const uniform = spacingDist.length >= 2 && topVals[0][1] / spacingDist.reduce((a, b) => a + b[1], 0) > 0.6; const antiHits = Object.entries(anti).filter(([, [, v]]) => v && v.length > 0); check(19, '反例清单 15 条逐条自查', antiHits.length === 0 && !uniform, Object.entries(anti).map(([k, [name, v]]) => { if (k === '5') return `${k}. ${name}:kf-app 的 .shell /.nav 用 min-height:100vh 做布局铺满(不是满屏英雄区),首屏没有大字口号;未命中`; if (k === '7') return `${k}. ${name}:kf-app 的 scale(1.02→1.08) 是背景光场(::before)的漂移动画,不在 hover 上;未命中`; if (k === '3') return `${k}. ${name}:blur(24–32px) 只出现在浮层(底部弹层 / Tab 栏 / 抽屉 / 顶栏),内容卡统一 blur(8px);按板块六口径不命中`; if (k === '2') return `${k}. ${name}:${v.length ? '命中 ' + v.join('、') : '未命中(彩条类 border-left ≥2px solid 只剩 1px 分隔线)'}`; if (k === '6') return `${k}. ${name}:间距值分布 ${spacingDist.length} 种(最多的是 ${topVals[0][0]} ×${topVals[0][1]},占 ${Math.round((topVals[0][1] / spacingDist.reduce((a, b) => a + b[1], 0)) * 100)}%)→ ${uniform ? '命中(均匀呼吸)' : '未命中'}`; return `${k}. ${name}:${v.length ? '命中 ' + v.join('、') : '未命中'}`; }).join('\n ') + `\n #6 的「全页统一 16px」在 v10 是命中项(卡内 16 / 卡间 16 / 区块 24 都是 16 的倍数且只有两个值);v11 改为 16 / 12 / 24 / 48 四级`); /* ============================================================ * 回归(20–22) * ============================================================ */ const mob = {}; for (const p of PAGES) { await gotoView(M, p, 1100); mob[p] = await evalIn(M, ` const n = appR().querySelector('kf-nav').shadowRoot; const tab = n.querySelector('.tabbar'), side = n.querySelector('.side'); return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, tab: getComputedStyle(tab).display, side: getComputedStyle(side).display, tabs: tab.querySelectorAll('a').length }; `); } await gotoView(M, '/', 1300); const col1 = await evalIn(M, `return getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2')).gridTemplateColumns;`); const mobBad = Object.entries(mob).filter(([, r]) => r.sw > r.cw || r.tab !== 'grid' || r.tabs !== 5); check(20, '手机 393px:Tab 栏在、单列、8 页 scrollWidth === clientWidth', mobBad.length === 0 && col1.trim().split(' ').length === 1, `8 页 scrollWidth/clientWidth:${Object.entries(mob).map(([p, r]) => p + ' ' + r.sw + '/' + r.cw).join('、')};Tab 栏 ${mob['/'].tab}(${mob['/'].tabs} 项),侧栏 ${mob['/'].side};工作台 .grid2 列 ${col1}`); const run = (cmd, args) => { try { return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } }; const tests = run(process.execPath, ['--test', 'test/ledger.test.js', 'test/model.test.js', 'test/opportunity.test.js', 'test/report.test.js', 'test/showcase.test.js', 'test/taxonomy.test.js']); const passN = Number((tests.match(/^ℹ pass (\d+)/m) || [])[1] || 0); const failN = Number((tests.match(/^ℹ fail (\d+)/m) || [])[1] || 0); const chk = run(process.execPath, ['scripts/check.mjs']); const chkBad = (chk.match(/^✗/gm) || []).length; const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']); const WORDS = { 类型页: 300, 工作台: 350, 阶段页: 300, 报告详情: 250, 报告中心: 200 }; const wordLines = [...card.matchAll(/(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g)]; check(21, 'node --test 46/46 + node scripts/check.mjs 全绿', passN === 46 && failN === 0 && chkBad === 0 && errors.length === 0, `单测 pass=${passN} fail=${failN};check ${(chk.match(/^✓/gm) || []).length} ✓ / ${chkBad} ✗;运行期控制台错误 ${errors.length}${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`); check(22, '字数:类型页 / 工作台 / 报告中心(不回升)', wordLines.length > 0 && wordLines.every((m) => Number(m[3]) <= WORDS[m[1]]), wordLines.map((m) => `${m[1]} ${m[3]}/${WORDS[m[1]]}(v10 基线 ${({ 类型页: 96, 工作台: 95, 阶段页: 33, 报告详情: 183, 报告中心: 38 })[m[1]]})`).join(' · ')); /* ============================================================ * 截图 * ============================================================ */ await gotoView(D, '/', 1500); await shot(D, '01-桌面-工作台.png', { fullPage: true }); await gotoView(D, '/reports', 1300); await shot(D, '03-sparkline落地.png', { clip: { x: 250, y: 0, width: 1190, height: 420 } }); await gotoView(D, '/stages', 1300); await shot(D, '02-中文排版对比.png'); await goto(D, '/reports/concept', 1300); await shot(D, '07-空态.png'); await gotoView(M, '/', 1300); await shot(M, '09-手机-工作台.png'); await gotoView(M, '/reports/competitor', 1300); await shot(M, '10-手机-类型页.png'); const report = { tag: TAG, rows, shadows: uniq }; writeFileSync(join(SHOTS, `measure-${TAG}.json`), JSON.stringify(report, null, 2)); const ok = rows.filter((r) => r.ok).length; console.log(`\n${ok}/${rows.length} 通过(${TAG})`); await browser.close(); process.exit(TAG === 'before' || ok === rows.length ? 0 : 1);