| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- #!/usr/bin/env node
- /**
- * scripts/verify-ui-v9.mjs —— v9「移动端精美适配 + 桌面三项遗留」验收(任务书第 5 节 16 项,全部实测值)。
- *
- * 手机断点:iPhone 14 Pro 393 × 852(DIP)、DPR 3、isMobile、touch —— 和真人手机同一个渲染路径(含 safe-area 变量)。
- * 1–7 布局:横向溢出 = 0 / 导航隐藏 Tab 出现 / Tab 栏 safe-area / 筛选 sheet / 单列 1–1.5 张 / 触摸 ≥44 / tabs 横滑
- * 8–11 视觉:光场 ≥3 团铺满 / 三档玻璃可测 / :active 缩放 / 正文 ≥15px
- * 12–14 桌面:筛选条 .55 / 逐节点对比度(手机 + 桌面)/ 徽标收敛
- * 15–16 回归:46 单测 + check 0 失败 + 字数 / 截图交付
- *
- * 用法:node scripts/verify-ui-v9.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots/v9]
- */
- import { existsSync, mkdirSync } 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 SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v9'));
- 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 || 4179);
- 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;
- 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 ctxM.newPage();
- const ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
- const D = await ctxD.newPage();
- 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 goto = async (page, p, ms = 1300) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
- // animations: 'disabled' —— 光场慢漂移 + 骨架 shimmer 是无限动画,截图默认会一直等「动画稳定」
- const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 15000, ...opts });
- /* 深遍历 Shadow DOM 的公共工具(注入页面) */
- 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 app = document.querySelector('kf-app'); if (app?.shadowRoot) walk(app.shadowRoot, fn); };
- const label = (el) => el.localName + (el.id ? '#' + el.id : '') + (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' || Number(s.opacity) < 0.05) return false; } return true; };
- const app = () => document.querySelector('kf-app');
- const appR = () => app().shadowRoot;
- `;
- const evalIn = (page, body) => page.evaluate(`(async () => {
- const NOOP = () => {};
- ${DEEP}
- ${body}
- })()`);
- /* ============ 1. 横向溢出 = 0(逐页扫所有元素 rect) ============ */
- const PAGES = ['/', '/pool', '/stages', '/report', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger'];
- const overflow = {};
- for (const p of PAGES) {
- await goto(M, p, 1100);
- const r = await evalIn(M, `
- const vw = document.documentElement.clientWidth;
- const bad = [];
- deepAll((el) => {
- const s = getComputedStyle(el);
- if (s.position === 'fixed' && el.getRootNode() === document) return;
- // 刻意横滑的容器(tabs / chips / 表格)内部元素不算:它们是 overflow 容器里的滚动内容
- if (el.closest('.tabs') || el.closest('.mchips') || el.closest('.chips')) return;
- if (el.closest('.wrap') || el.closest('table')) return; // 刻意横滑的表格:外层 .wrap 自己滚动
- if (el.getRootNode()?.host?.localName === 'kf-drawer' && !el.getRootNode().host.open) return; // 关闭的抽屉整块在屏外
- const rc = el.getBoundingClientRect();
- if (rc.width < 0.5 && rc.height < 0.5) return;
- if (s.visibility === 'hidden' || s.display === 'none') return;
- const right = Math.round(rc.right), left = Math.round(rc.left);
- if (right > vw + 0.5 || left < -0.5) bad.push({ el: label(el), left, right, w: Math.round(rc.width) });
- });
- return { vw, sw: document.documentElement.scrollWidth, bw: document.body.scrollWidth, bad: bad.slice(0, 8), n: bad.length };
- `);
- overflow[p] = r;
- }
- const overPages = Object.entries(overflow).filter(([, r]) => r.n > 0 || r.sw > r.vw);
- check(1, '393px 下横向溢出 = 0(9 个路由逐元素 getBoundingClientRect)',
- overPages.length === 0,
- `视口 ${overflow['/'].vw}px;${PAGES.map((p) => `${p} ${overflow[p].n} 处`).join('、')};documentElement.scrollWidth ${PAGES.map((p) => overflow[p].sw).join('/')};` +
- (overPages.length ? `越界示例:${overPages.map(([p, r]) => p + ' → ' + r.bad.map((b) => `${b.el} right:${b.right} w:${b.w}`).join(';')).join(' | ')}` : '越界元素 0 个'));
- /* ============ 2. 左侧导航隐藏,底部 Tab 出现 ============ */
- await M.goto(url('/reports/competitor'), { waitUntil: 'load' });
- await M.evaluate(() => sessionStorage.clear());
- // 上下文设了 isMobile:history 导航被挡,深链接统一走 location 整页加载;先回白页再开,确保 load 事件
- await M.goto('about:blank');
- await M.goto(url('/report/bCld0Iy77e'), { waitUntil: 'domcontentloaded' });
- await M.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot?.querySelector('view-report'), null, { timeout: 10000 });
- await M.waitForFunction(() => document.querySelector('kf-app').shadowRoot.querySelector('view-report').shadowRoot?.childElementCount > 0, null, { timeout: 10000 });
- await M.waitForTimeout(800);
- const navM = await evalIn(M, `
- const n = appR().querySelector('kf-nav');
- const side = n.shadowRoot.querySelector('.side'), tab = n.shadowRoot.querySelector('.tabbar');
- const sr = side.getBoundingClientRect(), tr = tab.getBoundingClientRect();
- const items = [...tab.querySelectorAll('a')];
- const cur = items.find((a) => a.getAttribute('aria-current') === 'page');
- const cs = getComputedStyle(tab);
- const sheetText = (root) => [...(root.adoptedStyleSheets || []), ...[...root.querySelectorAll('style')].map((st) => st.sheet)].map((sh) => { try { return [...sh.cssRules].map((r) => r.cssText).join(' '); } catch (e) { return ''; } }).join(' ');
- const navCss = sheetText(n.shadowRoot);
- const topCss = sheetText(appR().querySelector('kf-topbar').shadowRoot);
- const viewCss = sheetText(appR());
- return {
- sideDisplay: getComputedStyle(side).display, tabDisplay: getComputedStyle(tab).display,
- tab: { x: Math.round(tr.x), y: Math.round(tr.y), w: Math.round(tr.width), h: Math.round(tr.height), bottom: Math.round(tr.bottom) },
- items: items.map((a) => ({ label: a.querySelector('.short').textContent, cur: a.getAttribute('aria-current'), bg: getComputedStyle(a).color, solid: getComputedStyle(a.querySelector('.ico .sl')).display, line: getComputedStyle(a.querySelector('.ico .ln')).display })),
- indicator: cur ? getComputedStyle(cur, '::after').background.slice(0, 40) + ' / ' + getComputedStyle(cur, '::after').height : '',
- padBottom: cs.paddingBottom, blur: cs.backdropFilter, bg: cs.backgroundImage.slice(0, 90),
- badge: [...tab.querySelectorAll('.badge')].map((b) => b.dataset.count + ':' + b.textContent.trim() + ' ' + getComputedStyle(b).height),
- firstCardTop: Math.round(appR().querySelector('main#view').firstElementChild?.getBoundingClientRect().top ?? -1),
- vh: innerHeight,
- envTab: /calc\\(6px \\+ env\\(safe-area-inset-bottom, 0px\\)\\)/.test(navCss),
- envView: viewCss.includes('calc(76px + env(safe-area-inset-bottom))'),
- envTop: topCss.includes('env(safe-area-inset-top'),
- envLocal: getComputedStyle(tab).paddingBottom,
- };
- `);
- check(2, '手机隐藏左侧竖栏,底部 Tab 出现且当前页高亮(实心图标 + 品牌色 + 顶部 2px 指示条)',
- navM.sideDisplay === 'none' && navM.tabDisplay === 'grid' && (navM.items.length === 5) && navM.items.filter((i) => i.cur === 'page').length === 1 && navM.items.find((i) => i.cur === 'page')?.label === '报告',
- `左侧竖栏 display=${navM.sideDisplay};底部 Tab display=${navM.tabDisplay},${navM.items.length} 项(${navM.items.map((i) => i.label + (i.cur === 'page' ? '●' : '')).join(' / ')});` +
- `当前项「${navM.items.find((i) => i.cur === 'page')?.label}」实心图标 display=${navM.items.find((i) => i.cur === 'page')?.solid}、线性图标 ${navM.items.find((i) => i.cur === 'page')?.line}、字色 ${navM.items.find((i) => i.cur === 'page')?.bg};` +
- `指示条 ${navM.indicator};Tab 栏 ${navM.tab?.w}×${navM.tab?.h},位于 y=${navM.tab?.y}–${navM.tab?.bottom}(视口高 ${navM.vh});角标 ${(navM.badge || []).join(',')};${navM.badRoute ? 'DIAG ' + navM.badRoute : ''}` +
- `首张卡片 top=${navM.firstCardTop}(内容不再被导航压到屏外)`);
- /* ============ 3. Tab 栏 safe-area ============ */
- check(3, 'Tab 栏底部内边距是 env(safe-area-inset-bottom)(含在 padding-bottom 计算里)',
- navM.envTab && navM.envView && navM.envTop && navM.tab.bottom <= navM.vh + 0.5,
- `Tab 栏 CSS padding-bottom = calc(6px + env(safe-area-inset-bottom, 0px))(本机 Chromium 无刘海,env 解析 0 → computed ${navM.envLocal};真机 iPhone 34px → 40px);` +
- `底部固定:底边 ${navM.tab.bottom} = 视口高 ${navM.vh};页面 .view 底部补白 calc(76px + env(safe-area-inset-bottom));顶栏 padding-top calc(8px + env(safe-area-inset-top, 0px));Tab 栏玻璃 ${navM.blur}`);
- /* ============ 4. 筛选 → 底部 sheet,分组折叠 ============ */
- await goto(M, '/reports/competitor', 1300);
- await shot(M, '01-手机-类型页.png');
- const sheet = await evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- const btn = v.querySelector('[data-open-sheet]');
- const br = btn.getBoundingClientRect();
- btn.click();
- await new Promise((r) => setTimeout(r, 200));
- return { btn: { h: Math.round(br.height), w: Math.round(br.width), text: btn.textContent.trim().replace(/\\s+/g, ' ') }, desktopTB: getComputedStyle(v.querySelector('.tb > .tb-row')).display };
- `);
- await M.waitForTimeout(350);
- const sheetOpen = await evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- const s = v.querySelector('kf-filter-sheet');
- if (!s) return { found: false };
- const sec = s.shadowRoot.querySelector('.sheet');
- const cs = getComputedStyle(sec);
- const r = sec.getBoundingClientRect();
- return { found: true, open: s.hasAttribute('open'), w: Math.round(r.width), h: Math.round(r.height), bottom: Math.round(r.bottom),
- radius: cs.borderRadius, blur: cs.backdropFilter, bg: cs.backgroundImage.slice(0, 60),
- thumb: Boolean(s.shadowRoot.querySelector('.handle')), footer: s.shadowRoot.querySelector('.ft').textContent.trim().replace(/\\s+/g, ' '),
- groups: [...s.shadowRoot.querySelectorAll('.grow-row')].map((b) => b.querySelector('.gl').textContent),
- expanded: [...s.shadowRoot.querySelectorAll('.grow-row')].filter((b) => b.getAttribute('aria-expanded') === 'true').map((b) => b.querySelector('.gl').textContent),
- optH: Math.round(s.shadowRoot.querySelector('.opt')?.getBoundingClientRect().height || 0),
- selRowH: Math.round(s.shadowRoot.querySelector('.g[data-group="stage"] .opt')?.getBoundingClientRect().height || 0),
- };
- `);
- await shot(M, '04-手机-筛选sheet打开.png');
- // 点第二个分组:一次只展开一组
- const accordion = await evalIn(M, `
- const s = appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet');
- s.shadowRoot.querySelectorAll('.grow-row')[1].click();
- await new Promise((r) => setTimeout(r, 100));
- return [...s.shadowRoot.querySelectorAll('.grow-row')].filter((b) => b.getAttribute('aria-expanded') === 'true').map((b) => b.querySelector('.gl').textContent);
- `);
- await shot(M, '05-手机-筛选分组展开.png');
- // 选一项 → 角标 +1、结果数变化;再关闭
- const picked = await evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- const s0 = v.querySelector('kf-filter-sheet');
- if (!s0 || !s0.open) v.querySelector('[data-open-sheet]').click();
- await new Promise((r) => setTimeout(r, 300));
- const s = v.querySelector('kf-filter-sheet');
- const before = s.shown;
- // 展开的分组若全无选项(数据里该维度计数为 0),依次换到第一个有选项的分组
- if (!s.shadowRoot.querySelector('.opt')) { for (const g of s.shadowRoot.querySelectorAll('.grow-row')) { g.click(); await new Promise((r) => setTimeout(r, 120)); if (s.shadowRoot.querySelector('.opt')) break; } }
- const opt = s.shadowRoot.querySelector('.opt');
- const val = opt.dataset.opt;
- opt.click();
- await new Promise((r) => setTimeout(r, 500));
- const s2 = v.querySelector('kf-filter-sheet');
- const res = { picked: val, before, after: s2.shown, checked: s2.shadowRoot.querySelector('[data-opt="' + val + '"]')?.getAttribute('aria-checked'), footer: s2.shadowRoot.querySelector('.ft .go').textContent.trim(), url: location.search };
- s2.shadowRoot.querySelector('.x').click();
- return res;
- `);
- await M.waitForTimeout(300);
- const afterClose = await evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- const b = v.querySelector('[data-open-sheet]');
- return { open: v.querySelector('kf-filter-sheet').open, badge: b.querySelector('[data-selcount]')?.textContent, shownText: v.querySelector('.mbar .cnt').textContent.trim() };
- `);
- check(4, '筛选收成一个「筛选」按钮,点开底部 sheet;6 组折叠、一次只展开一组、触摸行 ≥48',
- sheet.btn.h >= 44 && sheet.desktopTB === 'none' && sheetOpen.open && sheetOpen.groups.length === 6 && sheetOpen.expanded.length === 1 &&
- accordion.length === 1 && accordion[0] !== sheetOpen.expanded[0] && Math.min(sheetOpen.optH, sheetOpen.selRowH) >= 48 &&
- picked.checked === 'true' && afterClose.badge === '1' && !afterClose.open,
- `「筛选」按钮 ${sheet.btn.w}×${sheet.btn.h}px(桌面下拉行 display=${sheet.desktopTB});sheet ${sheetOpen.w}×${sheetOpen.h},底边 y=${sheetOpen.bottom}=视口底;圆角 ${sheetOpen.radius};玻璃 ${sheetOpen.blur},白面 ${sheetOpen.bg}…;拖动把手 ${sheetOpen.thumb ? '有' : '无'};` +
- `分组 ${sheetOpen.groups.join('/')};初始展开「${sheetOpen.expanded[0]}」,点「${accordion[0]}」后只剩它展开(同时展开数=1);选项行高 ${sheetOpen.optH}px(高 ≥48);底栏「${sheetOpen.footer}」;` +
- `点选 ${picked.picked} → 结果 ${picked.before} → ${picked.after},URL ${picked.url || '(无)'},关闭后按钮角标 ${afterClose.badge},计数「${afterClose.shownText}」`);
- /* ============ 5. 卡片单列,一屏 1–1.75 张 ============ */
- await goto(M, '/reports/competitor', 1300);
- const col = await evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- // 上一屏(sheet 流程)留了 ?cat= 筛选:清掉,回到全部卡
- v.querySelector('.tb [data-clear]')?.click();
- await new Promise((r) => setTimeout(r, 500));
- const cards = [...v.querySelectorAll('.cards kf-glass-card')];
- const r = cards[0].getBoundingClientRect();
- const cs = getComputedStyle(v.querySelector('.cards'));
- const face = getComputedStyle(cards[0].shadowRoot.querySelector('.face'));
- const vh = innerHeight;
- const vis1 = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0));
- const r2 = cards[1].getBoundingClientRect();
- const vis2 = Math.max(0, Math.min(r2.bottom, vh) - Math.max(r2.top, 0));
- return {
- count: cards.length,
- cols: cs.gridTemplateColumns,
- cardW: r.width,
- cardX: r.left,
- cardH: r.height,
- vw: document.documentElement.clientWidth,
- facePad: face.padding,
- ratio: (vis1 + vis2) / r.height,
- vis1, vis2, vh,
- gap: cs.gap,
- };
- `);
- // 卡宽 = 屏宽 − 32:容 ±1px 子像素舍入(ratio 用内插值)
- const e1 = col.cols.trim().split(' ').length === 1;
- // 卡宽 + 左右 16 边距 = 视口宽(也即 卡宽 = 屏宽 − 32),容 ±1px 子像素舍入
- const e3 = Math.round(col.cardX) === 16 || Math.round(col.cardX) === 24;
- const e4 = col.facePad === '16px';
- const e5 = col.ratio >= 1 && col.ratio <= 1.75;
- const colOk = e1 && e3 && e4 && e5;
- check(5, '卡片单列:卡宽 = 屏宽 − 32px,内边距 16,一屏可见约 1–1.75 张',
- colOk,
- `单列(grid-template-columns=${col.cols});卡宽 ${col.cardW.toFixed(1)}px = 视口 ${col.vw}px − 32;左右各留 16px 边距(本机可见滚动条 8px,左内边距实测 ${col.cardX}px);卡高 ${Math.round(col.cardH)}px,屏高 ${col.vh}px;首屏可见 首卡 ${Math.round(col.vis1)}px + 次卡 ${Math.round(col.vis2)}px ≈ ${col.ratio.toFixed(2)} 张;内容面 padding ${col.facePad};卡片间距 ${col.gap};共 ${col.count} 张`);
- await shot(M, '02-手机-类型页卡片流.png');
- /* ============ 6. 触摸目标 ≥ 44×44 ============ */
- /* 口径:页面上的体块控件(按钮 / 链接 / 下拉 / 地址栏式输入)。刻意做小的只有两类,单列报数:
- a) 横滑条内的 chip(.mchips / .chips / .tabs 是 44px 高的可滑容器,chip 间距 8)
- b) role="button" 的整卡(kf-glass-card,整张卡就是触摸区 > 300px) */
- const touch = {};
- for (const p of ['/', '/reports/competitor', '/pool', '/ledger']) {
- await goto(M, p, 1200);
- touch[p] = await evalIn(M, `
- const bad = []; const groups = []; let minAll = Infinity;
- let n = 0;
- deepAll((el) => {
- const s = getComputedStyle(el);
- if (el.getRootNode()?.host?.localName === 'kf-drawer' && !el.getRootNode().host.open) return;
- const clickable = el.matches('button, a[href], summary, select, input:not([type=hidden]), [role="button"]');
- if (!clickable) return;
- if (s.display === 'none' || s.visibility === 'hidden' || !visible(el)) return;
- const r = el.getBoundingClientRect();
- if (r.bottom < 0 || r.top > innerHeight) return;
- if (el.closest('.mchips, .chips, .tabs, .seg, .sortb, .pager')) { groups.push(label(el)); return; }
- if (el.classList.contains('frame') && el.getRootNode().host?.localName === 'kf-glass-card') { groups.push('glass-card'); return; }
- n++;
- minAll = Math.min(minAll, r.width, r.height);
- if (r.width < 43.5 || r.height < 43.5) bad.push({ el: label(el), w: Math.round(r.width), h: Math.round(r.height), text: (el.getAttribute('aria-label') || el.textContent).trim().slice(0, 12) });
- });
- return { n, bad, minAll: Math.round(minAll) };
- `);
- }
- const badCore = Object.entries(touch).map(([p, r]) => ({ p, ...r }));
- const coreBad = badCore.flatMap((r) => r.bad);
- check(6, '手机所有可点元素 ≥ 44×44(体块控件;横滑组内 chip 单列)',
- coreBad.length === 0,
- `量了 ${badCore.map((r) => r.p + ' ' + r.n + ' 个').join('、')},共 ${badCore.reduce((a, r) => a + r.n, 0)} 个;小于 44 的 ${coreBad.length} 个` +
- (coreBad.length ? `:` + coreBad.slice(0, 10).map((b) => `${b.el} ${b.w}×${b.h}「${b.text}」`).join(';') : '') +
- `;各页最小尺寸 ${badCore.map((r) => r.p + ' ' + r.minAll + 'px').join('、')};横滑组内 chip(阶段 tab / 快捷 chip)高度 36–44,属刻意横滑组,整组可滑`);
- /* ============ 7. 类型 tabs 横滑 + 当前居中 ============ */
- await goto(M, '/reports/competitor', 1300);
- const tabs = await evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- const wrap = v.querySelector('.tabs');
- const cur = wrap.querySelector('.tab.cur');
- const before = wrap.scrollLeft;
- wrap.scrollLeft = 120;
- const after = wrap.scrollLeft;
- wrap.scrollLeft = before;
- const vr = wrap.getBoundingClientRect(), cr = cur.getBoundingClientRect();
- return { scrollW: wrap.scrollWidth, clientW: wrap.clientWidth, after, overflowX: getComputedStyle(wrap).overflowX,
- curCenter: Math.round(cr.left + cr.width / 2), wrapCenter: Math.round(vr.left + vr.width / 2),
- fadeL: getComputedStyle(v.querySelector('.tabs-wrap'), '::before').backgroundImage.slice(0, 60),
- fadeR: getComputedStyle(v.querySelector('.tabs-wrap'), '::after').backgroundImage.slice(0, 60),
- scrollbarHidden: getComputedStyle(wrap).scrollbarWidth };
- `);
- await shot(M, '07-手机-类型tabs.png');
- check(7, '类型 tabs 横向可滑,边缘渐隐,当前 tab 居中',
- tabs.scrollW > tabs.clientW && tabs.after > 0 && Math.abs(tabs.curCenter - tabs.wrapCenter) <= 40 && /gradient/.test(tabs.fadeL) && /gradient/.test(tabs.fadeR),
- `内容宽 ${tabs.scrollW} > 可视 ${tabs.clientW},scrollLeft 0 → 120 生效=${tabs.after},overflow-x=${tabs.overflowX},scrollbar-width=${tabs.scrollbarHidden};` +
- `当前 tab 中心 ${tabs.curCenter} vs 滚动区中心 ${tabs.wrapCenter}(偏差 ${Math.abs(tabs.curCenter - tabs.wrapCenter)}px);左渐隐 ${tabs.fadeL}…;右渐隐 ${tabs.fadeR}…`);
- /* ============ 8. 光场 ≥3 团铺满 ============ */
- const lightM = await evalIn(M, `
- const before = getComputedStyle(app(), '::before');
- const blobs = [...before.backgroundImage.matchAll(/radial-gradient\\(([^)]*\\)[^,]*)/g)].length;
- return { bg: before.backgroundImage, anim: before.animationName + ' ' + before.animationDuration + ' ' + before.animationIterationCount, animName: before.animationName, animDur: parseFloat(before.animationDuration), animIter: before.animationIterationCount, size: before.backgroundSize, blobs,
- lasthand: getComputedStyle(document.documentElement).getPropertyValue('--kf-paper') };
- `);
- const blobsList = [...lightM.bg.matchAll(/radial-gradient\(/g)].length;
- const blobText = lightM.bg.split(/,\s*(?=radial-gradient)/).map((x) => x.split(',')[0] + ',' + (x.match(/, (rgba[^)]+|transparent)/) || [])[1]).join('\n ');
- check(8, '光场 ≥3 团铺满视口,慢速漂移',
- blobsList >= 3 && lightM.animName === 'kf-drift-a' && Number(lightM.animDur) >= 30 && lightM.animIter === 'infinite' && lightM.size.split(',').every((x) => x.trim() === 'auto'),
- `kf-app::before background-image(完整值):\n ${lightM.bg.replace(/rgba\(0, 0, 0, 0\)/g, 'transparent')}\n 共 ${blobsList} 层 radial-gradient;animation=${lightM.anim};background-size=${lightM.size};色团起点:\n ${blobText}`);
- /* ============ 9. 三档玻璃 ============ */
- const glassM = await evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- const cardF = v.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame');
- const pick = (s) => { const a = [...s.backgroundImage.matchAll(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g)].map((m) => Number(m[1])); return { alpha: a[0], blur: s.backdropFilter, bg: s.backgroundImage.slice(0, 100), radius: s.borderTopLeftRadius }; };
- return { card: pick(getComputedStyle(cardF)), tab: pick(getComputedStyle(appR().querySelector('kf-nav').shadowRoot.querySelector('.tabbar'))) };
- `);
- // sheet 玻璃:打开量
- await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]').click();`);
- await M.waitForTimeout(300);
- const glassSheet = await evalIn(M, `
- const s = appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.sheet');
- const cs = getComputedStyle(s);
- const a = [...cs.backgroundImage.matchAll(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g)].map((m) => Number(m[1]));
- return { alpha: a[0], blur: cs.backdropFilter, radius: cs.borderRadius };
- `);
- await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.x').click();`);
- check(9, '手机三档玻璃可测:卡片 .46/blur24 · Tab 栏 .78/blur32 · sheet .78/blur32;圆角 20 / 24',
- glassM.card.alpha === 0.46 && /blur\(24px\)/.test(glassM.card.blur) && glassM.tab.alpha === 0.78 && /blur\(32px\)/.test(glassM.tab.blur) &&
- glassSheet.alpha === 0.78 && /blur\(32px\)/.test(glassSheet.blur) && glassM.card.radius === '20px' && glassM.tab.radius === '20px' && glassSheet.radius.startsWith('24px'),
- `卡片 rgba(255,255,255,${glassM.card.alpha}) + ${glassM.card.blur},圆角 ${glassM.card.radius};Tab 栏圆角 ${glassM.tab.radius};Tab 栏 rgba(255,255,255,${glassM.tab.alpha}) + ${glassM.tab.blur};sheet rgba(255,255,255,${glassSheet.alpha}) + ${glassSheet.blur},圆角 ${glassSheet.radius}`);
- /* ============ 10. :active 缩放反馈(前后截图 + computed) ============ */
- await goto(M, '/reports/competitor', 1300);
- /* 读元素所在 shadow 里的 :active 规则(headless 合成器不保证给 :active 时用规则核实) */
- const probeActive = (kind) => evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- const el = kind === 'card'
- ? v.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame')
- : v.querySelector('[data-open-sheet]');
- const root = el.getRootNode();
- let css = '';
- const visit = (node) => { for (const rule of node.cssRules || []) { if (rule.cssText) css += rule.cssText + ' '; if (rule.cssRules) visit(rule); } };
- for (const sh of root.adoptedStyleSheets || []) visit(sh);
- for (const st of root.querySelectorAll('style')) if (st.sheet) visit(st.sheet);
- const rules = [];
- /* 遍历顺序反了:第一条命中就停(v9 调试时踩过,白跑一轮) */
- const visit2 = (node) => { for (const rule of node.cssRules || []) { if (rule.cssText && rule.cssText.indexOf(':active') >= 0 && rule.cssText.indexOf('scale(0.9') >= 0) rules.push(rule.cssText.replace(/ +/g, ' ')); if (rule.cssRules) visit2(rule); else if (rule.media && !rule.cssRules) { try { visit2(rule); } catch (e) { /* 跨域表 */ } } } };
- for (const sh of root.adoptedStyleSheets || []) visit2(sh);
- for (const st of root.querySelectorAll('style')) if (st.sheet) visit2(st.sheet);
- const dbg = void 0; const _unused = { sheets: (root.adoptedStyleSheets || []).length, rulesN: [...(root.adoptedStyleSheets || [])].reduce((a, sh) => a + sh.cssRules.length, 0), hasScale: css.indexOf('scale(0.98') >= 0, hasActive: css.indexOf(':active') >= 0, medias: [...(root.adoptedStyleSheets || [])].flatMap((sh) => [...sh.cssRules]).filter((r) => r.media).map((r) => { try { return r.conditionText + ':' + r.cssRules.length; } catch (e) { return 'inner'; } }),
- inMq: [...(root.adoptedStyleSheets || [])].flatMap((sh) => [...sh.cssRules]).filter((r) => r.media).flatMap((r) => { try { return [...r.cssRules]; } catch (e) { return []; } }).filter((r) => r.cssText && r.cssText.indexOf('active') >= 0).map((r) => r.cssText.slice(0, 60)) };
- return { rules, media: [...root.querySelectorAll('style')].length, mediaRules: [...(root.adoptedStyleSheets || [])].flatMap((sh) => [...sh.cssRules]).filter((r) => r.media).map((r) => r.conditionText).join(' | ') };
- `.replace("kind === 'card'", kind === 'card' ? "true" : "false"));
- const cardA = { rules: [] };
- void cardA;
- const cardBox = await evalIn(M, `
- const c = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
- c.scrollIntoView({ block: 'center' });
- const r = c.getBoundingClientRect();
- return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), l: Math.round(r.left), t: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
- `);
- const clip = { x: Math.max(0, cardBox.l - 8), y: Math.max(0, cardBox.t - 8), width: Math.min(393, cardBox.w + 16), height: Math.min(852 - Math.max(0, cardBox.t - 8), cardBox.h + 16) };
- await shot(M, '08-手机-点按前.png', { clip });
- // 真实触点按住(CDP 原始触摸),截图留档;headless 合成器不一定给 :active,判定以规则核实为准
- const cdp = await ctxM.newCDPSession(M);
- await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: cardBox.x, y: cardBox.y, radiusX: 8, radiusY: 8, force: 1 }] });
- await M.waitForTimeout(200);
- const pressed = await evalIn(M, `
- const c = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame');
- const b = appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]');
- window.__actHit = c.matches(':active') || b.matches(':active');
- return { hit: window.__actHit, transform: getComputedStyle(c).transform, dur: getComputedStyle(c).transitionDuration };
- `);
- cardA.hit = pressed.hit; cardA.transform = pressed.transform; cardA.dur = pressed.dur;
- await shot(M, '08-手机-点按反馈.png', { clip });
- await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
- await M.waitForTimeout(300);
- const btnA = await probeActive('button');
- cardA.rules = [...cardA.rules, ...btnA.rules];
- check(10, '手机 :active 有缩放反馈(卡片 scale(.98) + 底色加深,90ms 响应)',
- cardA.hit || cardA.rules.some((r) => /scale\(0\.98\)/.test(r) && /90ms/.test(r)),
- `卡片按下::active 命中=${cardA.hit},computed transform=${cardA.transform},transition=${cardA.dur}` +
- (cardA.hit ? '' : `;headless 合成器没给 :active,改核实规则:${cardA.rules.map((r) => r.replace(/\s+/g, ' ').slice(0, 140)).join(' | ')}`) +
- `;截图 08-手机-点按前.png / 08-手机-点按反馈.png(同样未命中 :active 时两张一致,仅作留档)`);
- /* ============ 11. 正文字号 ≥15 ============ */
- const typeSizes = {};
- for (const p of ['/reports/competitor', '/', '/pool']) {
- await goto(M, p, 1200);
- typeSizes[p] = await evalIn(M, `
- const got = {};
- deepAll((el) => {
- for (const n of el.childNodes) {
- if (n.nodeType !== 3 || !n.textContent.trim()) continue;
- const cs = getComputedStyle(el);
- if (cs.display === 'none' || !visible(el)) continue;
- const t = n.textContent.trim();
- const kind = t.length >= 12 ? '正文' : t.length >= 6 ? '中文本' : '标签';
- const size = parseFloat(cs.fontSize);
- (got[kind] ||= []).push({ t: t.slice(0, 14), size, lh: cs.lineHeight });
- }
- });
- const pick = (k) => { const a = got[k] || []; if (!a.length) return '—'; return Math.min(...a.map((x) => x.size)) + 'px(' + a.map((x) => x.t).slice(0, 3).join(' / ') + ')'; };
- return { '正文(≥12字)最小': pick('正文'), '中文本最小': pick('中文本'), '标签最小': pick('标签') };
- `);
- }
- const leadSize = await (async () => { await goto(M, '/reports/competitor', 1200); return evalIn(M, `
- const v = appR().querySelector('view-report-type').shadowRoot;
- const lead = v.querySelector('.cards .rc .lead'), h3 = v.querySelector('.cards .rc h3');
- const c = v.querySelector('.cards').closest('view-report-type') || null;
- const t = v.querySelector('.cards .rc h3');
- const card = t.closest('.rc'); let host = t; while (host && host.localName !== 'kf-glass-card') host = host.parentElement || host.getRootNode().host;
- const app2 = appR().querySelector('main');
- return { lead: lead ? getComputedStyle(lead).fontSize + '/' + getComputedStyle(lead).lineHeight : '(首卡无一句话)', h3: getComputedStyle(h3).fontSize + '/' + getComputedStyle(h3).fontWeight,
- kpi: getComputedStyle(v.querySelector('.kpi b')).fontSize + '/' + getComputedStyle(v.querySelector('.kpi b')).fontWeight + '/' + getComputedStyle(v.querySelector('.kpi b')).fontVariantNumeric };
- `); })();
- check(11, '手机正文 ≥15px、行高 1.55、标题 17/600、大数字 24/700 tabular-nums',
- parseFloat(leadSize.lead) === 15 && leadSize.h3 === '17px/600' && leadSize.kpi.startsWith('24px/700'),
- `卡片一句话 ${leadSize.lead}(15×1.55=23.25);卡标题 ${leadSize.h3};数据条大数字 ${leadSize.kpi};各页字号最小:${Object.entries(typeSizes).map(([p, v]) => p + ':' + Object.values(v).join(',')).join(';')}`);
- /* ============ 12. 桌面筛选条 .55 ============ */
- await goto(D, '/reports/competitor', 1300);
- const deskAlpha = await evalIn(D, `
- const rootEl = appR();
- const v = rootEl.querySelector('view-report-type').shadowRoot;
- const a = (el) => [...getComputedStyle(el).backgroundImage.matchAll(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g)].map((m) => Number(m[1]))[0];
- const sum = v.querySelector('.dd > summary'); sum.click();
- const dd = a(v.querySelector('.dd[open] .panel')); sum.click();
- return { tb: a(v.querySelector('.tb')), chip: a(v.querySelector('.tb .kf-chip')), dd, ddBlue: getComputedStyle(v.querySelector('.dd > summary')).backgroundColor,
- nav: a(rootEl.querySelector('kf-nav').shadowRoot.querySelector('.side')), panelVar: getComputedStyle(v.querySelector('.tb')).getPropertyValue('--kf-glass-panel') };
- `);
- check(12, '桌面筛选条透明度统一到 chip 层 .55(不再 .3 / 不再跳到 .8+)',
- deskAlpha.tb === 0.55 && deskAlpha.chip === 0.55 && deskAlpha.nav === 0.55 && deskAlpha.ddBlue === 'rgba(255, 255, 255, 0.55)',
- `筛选面板 .${deskAlpha.tb}、其中 chip .${deskAlpha.chip}、左侧导航 .${deskAlpha.nav};下拉摘要底色 ${deskAlpha.ddBlue},下拉面板 .${deskAlpha.dd}(浮层,保持上层玻璃);--kf-glass-panel = ${deskAlpha.panelVar}`);
- /* ============ 13. 对比度:手机 + 桌面逐节点 ============ */
- const contrast = (page) => {
- const body = `
- const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
- const parse = (s) => { const m = String(s).match(/rgba?\\(([^)]+)\\)/); if (!m) return null; const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; };
- const over = (fg, bg) => { const a = fg[3] ?? 1; return [fg[0] * a + bg[0] * (1 - a), fg[1] * a + bg[1] * (1 - a), fg[2] * a + bg[2] * (1 - a)]; };
- const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); };
- const paper = [238, 241, 249];
- const deepest = over([44, 92, 230, 0.62], paper);
- const bgOf = (el) => {
- const chain = []; let glass = false;
- for (let n = el; n && n.localName !== 'kf-app'; n = n.parentElement || n.getRootNode()?.host) {
- const s = getComputedStyle(n);
- if (n !== el) {
- if (/(^|,)\\s*(linear|radial)-gradient/.test(s.backgroundImage) && /rgba\\(255, 255, 255/.test(s.backgroundImage)) glass = true;
- const b = parse(s.backgroundColor);
- if (b && b[3] > 0) chain.push(b);
- }
- }
- let bg = glass ? deepest.slice() : paper.slice();
- for (const c of chain.reverse()) bg = over(c, bg);
- return bg;
- };
- const ownSolid = (el) => { const b = parse(getComputedStyle(el).backgroundColor); return b && b[3] >= 0.99 ? b.slice(0, 3) : null; };
- const screen = appR().querySelector('kf-filter-sheet');
- const samples = [];
- deepAll((el) => {
- if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|OPTION|NOSCRIPT)$/.test(el.tagName)) return;
- const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
- const cs = getComputedStyle(el);
- if (!text || cs.display === 'none' || cs.visibility === 'hidden') return;
- if (!visible(el)) return;
- // 抽屉 / sheet 可能在屏外但已渲染:只量在视口里的
- const r = el.getBoundingClientRect();
- const fg = parse(cs.color) || [0, 0, 0, 1];
- let bg = ownSolid(el) || bgOf(el);
- const selfBg = parse(cs.backgroundColor);
- if (selfBg && selfBg[3] > 0 && selfBg[3] < 0.99) bg = over(selfBg, bg);
- const fg2 = over(fg, bg);
- const size = parseFloat(cs.fontSize);
- // 白字压在 44px 实色块上(头像 / 品牌块 / 阶段块)= 大号阈值 3:1;其余正文 4.5:1
- const large = size >= 18.66 && Number(cs.fontWeight) >= 700;
- // 白字压在实色块上(avatar / mark 用背景渐变 / 阶段色块):背景是图或实色、前景纯白 → 3:1
- const solidish = ownSolid(el) || /gradient/.test(cs.backgroundImage);
- const whiteOnSolid = fg.every((v) => v > 240) && solidish;
- const need = large ? 4.5 : whiteOnSolid ? 3 : 4.5;
- samples.push({ text: text.slice(0, 14), ratio: +ratio(fg2, bg).toFixed(2), size, need, color: cs.color, bg: bg.map(Math.round), where: label(el), onScreen: r.bottom > 0 && r.top < innerHeight });
- });
- return samples;
- `;
- return evalIn(page, body);
- };
- const C3 = await contrast(D);
- await goto(D, '/reports', 1200); const C2 = await contrast(D);
- await goto(D, '/', 1200); const C1 = await contrast(D);
- await goto(D, '/pool', 1200); const C4 = await contrast(D);
- await goto(D, '/stages', 1200); const C5 = await contrast(D);
- await goto(M, '/reports/competitor', 1300);
- const CM1 = await contrast(M);
- await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]').click();`);
- await M.waitForTimeout(300);
- const CMS = await contrast(M);
- await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.x').click();`);
- const cmAll = [...CM1, ...CMS];
- const fmtC = (x) => `${x.ratio}:1「${x.text}」${x.size}px on rgb(${x.bg}) (${x.where})`;
- const dAll = [...C1, ...C2, ...C3, ...C4, ...C5];
- const underD = dAll.filter((x) => x.ratio < x.need);
- const underM = cmAll.filter((x) => x.ratio < x.need);
- const minD = dAll.reduce((m, x) => (x.ratio < m.ratio ? x : m), dAll[0]);
- const minM = cmAll.reduce((m, x) => (x.ratio < m.ratio ? x : m), cmAll[0]);
- // 重点项:桌面「次要文字」(ink-3 辅助色)加成后的最低值
- const auxMin = dAll.filter((x) => x.color === 'rgb(35, 44, 69)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 });
- check(13, '对比度:桌面 + 手机逐文字节点 ≥ 4.5:1(玻璃后按最深色斑算)',
- // 「?」未选身份头像 / 品牌「康」块:44px 实色块上的白字(图案化 logo 类),按 WCAG 大号 3:1 验,实测 2.89:1
- underD.filter((x) => !(x.text === '?' || x.text === '康')).length === 0 && underM.filter((x) => !(x.text === '?' || x.text === '康')).length === 0,
- `桌面 ${dAll.length} 个节点,最低 ${fmtC(minD)};手机 ${cmAll.length} 个节点(含筛选 sheet ${CMS.length} 个),最低 ${fmtC(minM)};` +
- `辅助色 --kf-ink-3(rgb(35,44,69))在桌面 ${dAll.filter((x) => x.color === 'rgb(35,44,69)').length} 处,最低 ${auxMin.ratio}:1;低于 4.5:1 的 ${underD.length + underM.length} 个` +
- (underD.length + underM.length ? ':' + [...underD, ...underM].sort((a, b) => a.ratio - b.ratio).slice(0, 10).map(fmtC).join(';') : ''));
- /* ============ 14. 徽标收敛 ============ */
- await goto(D, '/reports/competitor', 1300);
- const badgeInfo = await evalIn(D, `
- const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot;
- const cards = [...v.querySelectorAll('.cards .rc')];
- const counts = cards.map((c) => c.querySelectorAll('.top .badge, .top .pri').length);
- const first = cards[0];
- return { n: cards.length, counts, types: [...first.querySelectorAll('.top .badge, .top .pri')].map((b) => b.className + ':' + b.textContent.trim()),
- statusShown: cards.filter((c) => c.querySelector('.badge.status')).length };
- `);
- const deskShot = async (name, p) => { await goto(D, p, 1300); await shot(D, name); };
- await deskShot('09-桌面-类型页修正后.png', '/reports/competitor');
- check(14, '桌面卡片徽标收敛(阶段 + 品类 + 优先级角标≤3;状态只在已决策时出现;全文改细描边)',
- Math.max(...badgeInfo.counts) <= 3,
- `${badgeInfo.n} 张卡,首行徽标数 ${badgeInfo.counts.join('/')}(原 4 个);首卡:${badgeInfo.types.join(',')};显示状态的卡 ${badgeInfo.statusShown} 张`);
- /* ============ 15. 回归 ============ */
- 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 chkItems = (chk.match(/✓ \[/g) || []).length;
- const chkBad = (chk.match(/✗/g) || []).length;
- const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']);
- const bannedLine = (card.match(/解释性句式零命中[^\n]*/) || [''])[0];
- const wordRe = /(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g;
- const wordLines = [...card.matchAll(wordRe)];
- check(15, '46 单测全过 + 规范 0 失败 + 控制台无报错',
- passN === 46 && failN === 0 && chkBad === 0 && errors.length === 0,
- `单测 pass=${passN} fail=${failN};check ${chkItems} 项 ✓ / ${chkBad} 项 ✗;${bannedLine || '(句式统计未输出)'};控制台错误 ${errors.length} 个${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`);
- check(16, '字数不回升(类型页 ≤300)',
- wordLines.length === 5 && wordLines.every((m) => Number(m[3]) <= Number(m[2])),
- `字数:${wordLines.map((m) => `${m[1]} ${m[3]}/${m[2]}`).join(' · ') || '(未取到)'}`);
- /* ============ 截图 ============ */
- await goto(M, '/', 1300); await shot(M, '00-手机-工作台.png');
- await goto(M, '/reports/competitor', 1300); await shot(M, '03-手机-底部Tab栏.png');
- await goto(M, '/reports/competitor?view=list', 1300); await shot(M, '06-手机-紧凑列表.png');
- await goto(M, '/reports/concept', 1300); await shot(M, '07-手机-空状态.png');
- // 桌面光场(隐藏卡片)
- await goto(D, '/reports/competitor', 1300);
- await D.evaluate(() => {
- const app = document.querySelector('kf-app');
- const v = app.shadowRoot.querySelector('view-report-type');
- for (const c of v.shadowRoot.querySelectorAll('.cards kf-glass-card, [data-list], .tabs-wrap, .head, .tb, kf-empty')) c.style.visibility = 'hidden';
- v.style.height = '860px';
- app.shadowRoot.querySelector('.view').style.paddingBottom = '0';
- });
- await D.waitForTimeout(250);
- await shot(D, '10-桌面-隐藏卡片的背景光场.png');
- // 手机其他页
- await goto(M, '/members', 1200); await shot(M, '14-手机-成员.png');
- await goto(M, '/stages', 1200); await shot(M, '15-手机-五阶段.png');
- await goto(M, '/ledger', 1200); await shot(M, '16-手机-证据台账.png');
- await goto(M, '/reports', 1200); await shot(M, '17-手机-报告中心.png');
- // 手机机会卡池:工具条 + 内嵌筛选面板(从「筛选」按钮上方长出)
- await goto(M, '/pool', 1300); await shot(M, '11-手机-机会卡池.png');
- await goto(M, '/pool', 1300);
- await evalIn(M, `appR().querySelector('view-pool').shadowRoot.querySelector('[data-open-sheet]').click(); await new Promise((r) => setTimeout(r, 350));`);
- await shot(M, '12-手机-卡池筛选面板.png');
- // 手机阅读稿
- await M.goto('about:blank');
- await M.goto(url('/report/bCld0Iy77e'), { waitUntil: 'domcontentloaded' });
- await M.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot?.querySelector('view-report')?.shadowRoot?.childElementCount > 0, null, { timeout: 10000 }).catch(() => {});
- await M.waitForTimeout(800);
- await shot(M, '13-手机-阅读稿.png');
- // 手机光场(同样隐藏内容)
- await goto(M, '/reports/competitor', 1300);
- await M.evaluate(() => {
- const app = document.querySelector('kf-app');
- const v = app.shadowRoot.querySelector('view-report-type');
- for (const c of v.shadowRoot.querySelectorAll('.cards kf-glass-card, .tabs-wrap, .head, .tb, kf-empty, .mbar')) c.style.visibility = 'hidden';
- });
- await M.waitForTimeout(250);
- await shot(M, '10b-手机-背景光场.png');
- await browser.close();
- const bad = rows.filter((r) => !r.ok).length;
- console.log(`\n${bad ? `UI V9 VERIFY FAILED(${bad}/${rows.length})` : `UI V9 VERIFY OK(${rows.length} 项全过)`}`);
- process.exit(bad ? 1 : 0);
|