| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- #!/usr/bin/env node
- /**
- * scripts/verify-card.mjs —— 本轮(v7「卡片 / 视觉优先」)验收的实测脚本,只量不猜。
- *
- * 量五类东西:
- * 1. 字数:页面模板里的中文字符(去注释),对照任务书上限
- * 2. 句式:被点名删除的解释性文案是否零命中(去注释后 grep)
- * 3. 玻璃:卡片 computed style(background-image / background-color / backdrop-filter / 内衬),
- * 背景光场是否真的画出来(关掉卡片前后、关掉光场前后的像素差)
- * 4. 对比度:按 WCAG 公式,逐个文字节点量「前景 vs 实际合成底色」的比值,取最小值
- * 5. 截图 + 交互(维度叠加 / chips / 排序 / 视图 / 分页 / 刷新保留)实测
- *
- * 用法:node scripts/verify-card.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots/v7]
- */
- import { readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
- import { join, dirname } from 'node:path';
- import { fileURLToPath } from 'node:url';
- import { spawn } from 'node:child_process';
- 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 STATIC_ONLY = argv.includes('--static-only');
- const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v7'));
- mkdirSync(SHOTS, { recursive: true });
- let fails = 0;
- const line = (ok, name, detail = '') => {
- console.log(`${ok ? '✓' : '✗'} ${name}${detail ? `\n ${detail}` : ''}`);
- if (!ok) fails++;
- };
- /* ---------------- 1. 字数 + 2. 句式(静态) ---------------- */
- const LIMITS = [
- ['src/pages/view-report-type.js', '类型页', 300],
- ['src/pages/view-workbench.js', '工作台', 350],
- ['src/pages/view-stages.js', '阶段页', 300],
- ['src/pages/view-report.js', '报告详情', 250],
- ['src/pages/view-reports.js', '报告中心', 200],
- ];
- const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
- console.log('—— 字数(去注释后的中文字符)——');
- for (const [f, name, cap] of LIMITS) {
- const n = (strip(readFileSync(join(ROOT, f), 'utf8')).match(/[一-鿿]/g) || []).length;
- line(n <= cap, `${name} ≤ ${cap}`, `实测 ${n} 个中文字符(${f})`);
- }
- const BANNED = ['这页在回答', '换一个阶段', '实心竖线', '淡色底线', '按对位对象横向排开', '近 7 天新增', '人均', '只有登记信息', '手头事实', '读图:', '暂无数据', '这阶段在回答', '这章在回答', '本类型全部条目'];
- const walk = (dir, out = []) => {
- if (!existsSync(dir)) return out;
- for (const e of readdirSync(dir, { withFileTypes: true })) {
- const p = join(dir, e.name);
- if (e.isDirectory()) walk(p, out);
- else if (p.endsWith('.js')) out.push(p);
- }
- return out;
- };
- const uiFiles = ['src/pages', 'src/components', 'src/layout'].flatMap((d) => walk(join(ROOT, d)));
- const hits = [];
- for (const f of uiFiles) {
- const body = strip(readFileSync(f, 'utf8'));
- for (const b of BANNED) if (body.includes(b)) hits.push(`${f.replace(ROOT + '/', '')}: 「${b}」`);
- }
- line(hits.length === 0, `解释性句式零命中(${BANNED.length} 条句式 × ${uiFiles.length} 个界面文件)`, hits.join(';') || `全部 0 命中`);
- /* ---------------- 浏览器 ---------------- */
- 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(join(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 || 4176);
- 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 ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
- const page = await ctx.newPage();
- const url = (p) => new URL(p, base).href;
- const go = async (p, ms = 1200) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
- const shot = (name, opts = {}) => page.screenshot({ path: join(SHOTS, name), ...opts });
- /* 读页面里某处像素的平均 RGB(把整页截图交给页面自己的 canvas 解码) */
- async function avgRGB(region) {
- const b64 = (await page.screenshot({ clip: region })).toString('base64');
- return page.evaluate(async (b64) => {
- const img = new Image();
- img.src = 'data:image/png;base64,' + b64;
- await img.decode();
- const c = document.createElement('canvas');
- c.width = img.width; c.height = img.height;
- const g = c.getContext('2d');
- g.drawImage(img, 0, 0);
- const d = g.getImageData(0, 0, c.width, c.height).data;
- let r = 0, gg = 0, b = 0, n = 0;
- for (let i = 0; i < d.length; i += 4) { r += d[i]; gg += d[i + 1]; b += d[i + 2]; n++; }
- return [Math.round(r / n), Math.round(gg / n), Math.round(b / n)];
- }, b64);
- }
- if (STATIC_ONLY) {
- console.log(`\n${fails === 0 ? 'ALL OK' : `FAILED ${fails}`} · 静态项(--static-only)`);
- process.exit(fails ? 1 : 0);
- }
- /* ---------------- 3. 玻璃 + 光场 ---------------- */
- await go('/reports/competitor', 1400);
- const glass = await page.evaluate(() => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
- const host = v.shadowRoot.querySelector('.cards kf-glass-card');
- const frame = host.shadowRoot.querySelector('.frame');
- const face = host.shadowRoot.querySelector('.face');
- const cs = getComputedStyle(frame);
- const app = document.querySelector('kf-app');
- const before = getComputedStyle(app, '::before');
- return {
- frameBgImage: cs.backgroundImage,
- frameBgColor: cs.backgroundColor,
- backdrop: cs.backdropFilter || cs.webkitBackdropFilter,
- borderTop: `${cs.borderTopWidth} ${cs.borderTopStyle} ${cs.borderTopColor}`,
- borderEdge: (cs.backgroundImage.match(/linear-gradient\(135deg[^)]*\)[^,]*\)?/g) || [''])[0],
- shadow: cs.boxShadow,
- faceBg: getComputedStyle(face).backgroundColor,
- sceneBefore: { z: before.zIndex, filter: before.filter, bg: before.backgroundImage.slice(0, 160), content: before.content },
- ambientInDoc: Boolean(document.querySelector('.kf-ambient')),
- ambientInApp: Boolean(app.shadowRoot.querySelector('.kf-ambient')),
- };
- });
- const whiteAlpha = (glass.frameBgImage.match(/rgba\(255, 255, 255, ([\d.]+)\)/g) || []).map((s) => Number(s.match(/([\d.]+)\)$/)[1]));
- line(/rgba\(255, 255, 255, 0\.5\d/.test(glass.frameBgImage) && glass.frameBgColor === 'rgba(0, 0, 0, 0)',
- '16a 卡片白面实测半透明(computed background)',
- `background-image 里的白层 = ${JSON.stringify(whiteAlpha)};background-color = ${glass.frameBgColor}`);
- line(/blur\(24px\) saturate\(1\.?8\)/.test(glass.backdrop), '16b backdrop-filter 真的生效', `computed backdrop-filter = ${glass.backdrop}`);
- line(/linear-gradient\(135deg/.test(glass.borderEdge), '11 发丝边是 135° 渐变(不是 1px 灰线)', `border = ${glass.borderTop};边层 = ${glass.borderEdge.slice(0, 110)}`);
- line(/inset/.test(glass.shadow) && glass.shadow.includes('0px 1px 0px'), '内高光在', `box-shadow = ${glass.shadow.slice(0, 120)}`);
- line(glass.sceneBefore.content === '""' && glass.sceneBefore.z === '0' && /blur\(\d+px\)/.test(glass.sceneBefore.filter),
- '15a 背景光场是应用根上的正层级伪元素(DOM 里能查到)', `kf-app::before → z-index=${glass.sceneBefore.z},filter=${glass.sceneBefore.filter},content=${glass.sceneBefore.content}`);
- line(!glass.ambientInApp, '15b 旧负层级光场节点已移除(根因:负 z-index 层在长页面里整组不绘制)', `kf-app shadow 内 .kf-ambient 存在?${glass.ambientInApp};document 内?${glass.ambientInDoc}`);
- /* 光场可见性:同一区域「光场开 / 关」两帧的像素差;「卡片开 / 关」两帧的像素差 */
- const region = await page.evaluate(() => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type');
- const host = v.shadowRoot.querySelector('.cards kf-glass-card');
- host.scrollIntoView({ block: 'center' });
- const r = host.getBoundingClientRect();
- return { x: Math.round(r.x + 40), y: Math.round(r.y + 40), width: Math.round(r.width - 60), height: 50 };
- });
- const withScene = await avgRGB(region);
- await page.screenshot({ path: join(SHOTS, '15-bg-sheen-on.png') });
- const bgRegion = { x: 280, y: 820, width: 760, height: 120 };
- await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = 'hidden'; });
- await page.waitForTimeout(150);
- const bgOn = await avgRGB(bgRegion);
- await page.evaluate(() => { const s = document.createElement('style'); s.textContent = 'kf-app{--x:0} kf-app::before,kf-app::after{display:none!important}'; s.id = 'kf-scene-off'; document.head.appendChild(s); });
- await page.waitForTimeout(150);
- const noScene = await avgRGB(region);
- const bgOff = await avgRGB(bgRegion);
- await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = ''; });
- await page.screenshot({ path: join(SHOTS, '15-bg-sheen-off.png') });
- await page.evaluate(() => document.getElementById('kf-scene-off').remove());
- const diff = Math.max(...bgOn.map((v, i) => Math.abs(v - bgOff[i])));
- const cardDiff = Math.max(...withScene.map((v, i) => Math.abs(v - noScene[i])));
- line(cardDiff >= 10, '15c 光场可见(同位置开 / 关光场的像素差)', `卡片区域(玻璃背后)开 / 关光场平均色差 = ${cardDiff};空白背景区 = ${diff};开 = rgb(${bgOn}),关 = rgb(${bgOff})`);
- /* 卡片关掉后,背景色斑还在(截一张无卡片的背景) */
- await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = 'hidden'; });
- await page.waitForTimeout(150);
- await shot('15-bg-cards-hidden.png');
- await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = ''; });
- await page.screenshot({ path: join(SHOTS, '01-type-competitor-cards.png') });
- /* ---------------- 4. 对比度(WCAG,逐个文字节点) ---------------- */
- await page.waitForTimeout(600); // 等卡片组件把插槽内容分配完(.rc 在 light DOM)
- const contrast = await page.evaluate(async () => {
- const lum = ([r, g, b]) => {
- const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 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 [0, 0, 0, 1];
- const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number);
- return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1];
- };
- // 从文字节点往上,把每一层半透明背景按 alpha 叠起来 → 实际看到的底色
- const bgUnder = (el) => {
- const layers = [];
- for (let n = el; n && n !== document.documentElement; n = n.parentElement || (n.getRootNode().host ?? null)) {
- const cs = getComputedStyle(n);
- const c = parse(cs.backgroundColor);
- layers.push([c, cs.backgroundImage]);
- if (c[3] === 1) break;
- }
- let base = [230, 234, 246];
- const bodyBg = parse(getComputedStyle(document.body).backgroundColor);
- if (bodyBg[3] > 0) base = bodyBg.slice(0, 3);
- for (const [c] of layers.reverse()) {
- const a = c[3];
- base = [c[0] * a + base[0] * (1 - a), c[1] * a + base[1] * (1 - a), c[2] * a + base[2] * (1 - a)];
- }
- return base;
- };
- const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
- const out = [];
- for (const host of [...v.shadowRoot.querySelectorAll('.cards kf-glass-card')].filter((h) => h.querySelector('.rc'))) {
- const card = host.querySelector('.rc'); // 卡片内容在宿主元素的 light DOM 里���插槽分配)
- if (!card) continue;
- for (const el of card.querySelectorAll('h3, .lead, .k, .v, .v.dim, .src, .src .who, .src .it, .src .tm, .badge, .fresh, .seg + *')) {
- const t = [...el.childNodes].filter((n) => n.nodeType === 3 || (n.nodeType === 1 && n.textContent.trim() && !n.matches('svg'))).map((n) => n.textContent).join('').trim();
- if (!t) continue;
- const cs = getComputedStyle(el);
- const fg = parse(cs.color);
- const bg = bgUnder(el);
- // 前景也按自身 alpha 叠到底色上(文字没有透明度时 fg[3]=1,不变)
- const a = fg[3];
- const comp = [fg[0] * a + bg[0] * (1 - a), fg[1] * a + bg[1] * (1 - a), fg[2] * a + bg[2] * (1 - a)];
- const L1 = lum(comp), L2 = lum(bg);
- const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
- break; // 每张卡只量一处标题 + 后面统一量
- }
- // 量这张卡里每个有文字的节点,取最小的
- for (const el of card.querySelectorAll('h3, .lead, .k, .v, .src, .src .who, .src .it, .src .tm, .badge, .fresh')) {
- const t = el.textContent.trim();
- if (!t) continue;
- const cs = getComputedStyle(el);
- const fg = parse(cs.color);
- const bg = bgUnder(el);
- const L1 = lum(fg), L2 = lum(bg);
- const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
- out.push({ text: t.slice(0, 14), cls: el.className || el.localName, fg: cs.color, bg: bg.map((x) => Math.round(x)), ratio: Math.round(ratio * 100) / 100, size: parseFloat(cs.fontSize) });
- }
- }
- // 徽标是白字压色块:单独算(前景白 vs 徽标自己的背景)
- const badges = [];
- for (const b of v.shadowRoot.querySelectorAll('.cards .badge')) {
- const cs = getComputedStyle(b);
- const fg = parse(cs.color); const bg = parse(cs.backgroundColor);
- const L1 = lum(fg), L2 = lum(bg.slice(0, 3));
- badges.push({ text: b.textContent.trim(), fg: cs.color, bg: cs.backgroundColor, ratio: Math.round(((Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05)) * 100) / 100 });
- }
- return { nodes: out, badges };
- }).catch((e) => ({ error: String(e) }));
- if (contrast.error || !contrast.nodes?.length) line(false, '17 对比度测量失败', contrast.error || '没有量到文字节点');
- else {
- const small = contrast.nodes.filter((n) => n.ratio < 4.5);
- const big = contrast.nodes.filter((n) => n.size >= 18.66 && n.ratio < 3);
- const min = contrast.nodes.reduce((m, n) => (n.ratio < m.ratio ? n : m), contrast.nodes[0]);
- const minBadge = contrast.badges.reduce((m, n) => (n.ratio < m.ratio ? n : m), contrast.badges[0]);
- line(small.length === 0 && big.length === 0, '17 文字对比度 ≥ 4.5:1(正文)/ 3:1(大字号)',
- `${contrast.nodes.length} 个文字节点 + ${contrast.badges.length} 个徽标;最低 = ${min.ratio}:1(「${min.text}」 ${min.fg} on rgb(${min.bg}));徽标最低 = ${minBadge.ratio}:1(「${minBadge.text}」);低于 4.5 的 ${small.length} 个`);
- }
- /* ---------------- 8. 缩略图在(缩略图画在卡片内部,需要翻到带 report 的卡) ---------------- */
- await go('/reports/competitor?full=1', 1200);
- await page.waitForTimeout(600); // 等卡片组件把插槽内容分配完(.rc 在 light DOM)
- const thumbInfo = await page.evaluate(() => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type');
- const t = v.shadowRoot.querySelector('.cards .thumb');
- return t ? { w: Math.round(t.getBoundingClientRect().width), bars: t.querySelectorAll('i').length, dots: t.querySelectorAll('.dots b').length } : null;
- });
- line(Boolean(thumbInfo), '8 有报告结构的卡显示缩略图(章节条 + 结论点)', thumbInfo ? `${thumbInfo.w}px 宽,${thumbInfo.bars} 根章节条,${thumbInfo.dots} 个结论点` : 'competitor?full=1 下没有带 report 的卡');
- await page.evaluate(() => {
- const card = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards .thumb')?.closest('kf-glass-card');
- card?.scrollIntoView({ block: 'center' });
- });
- await page.waitForTimeout(200);
- await page.screenshot({ path: join(SHOTS, '08-card-with-thumbnail.png') });
- /* ---------------- 6. 来源四要素 ---------------- */
- await go('/reports/competitor', 1200);
- const src = await page.evaluate(() => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
- const row = v.shadowRoot.querySelector('.cards .src');
- return { text: row.textContent.replace(/\s+/g, ' ').trim(), icons: row.querySelectorAll('svg').length, inBottom: Math.round(row.getBoundingClientRect().bottom - row.closest('.rc').getBoundingClientRect().bottom) };
- });
- line(src.icons >= 3 && src.text.includes('周涛'), '6 卡片底部来源四要素(图标打头)', `读出:「${src.text}」;图标 ${src.icons} 个;距卡片底 ${src.inBottom}px`);
- /* ---------------- 9–14 交互 ---------------- */
- const V = () => page.evaluate(() => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type') || document.querySelector('kf-app').shadowRoot.querySelector('view-reports');
- const root = v.shadowRoot.querySelector('view-report-type') || v;
- const s = root.shadowRoot;
- return { n: s.querySelectorAll('.cards kf-glass-card, .row').length, count: s.querySelector('[data-count]')?.textContent.replace(/\s+/g, ' ').trim(), url: decodeURIComponent(location.search), order: [...s.querySelectorAll('.cards .rc h3, .row .t')].slice(0, 3).map((x) => x.textContent.trim()) };
- });
- const click = (sel) => page.evaluate((sel) => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
- const root = v.querySelector('view-reports')?.shadowRoot.querySelector('view-report-type') || v.querySelector('view-report-type');
- const el = root.shadowRoot.querySelector(sel);
- if (!el) throw new Error('missing ' + sel);
- el.click();
- }, sel).then(() => page.waitForTimeout(300));
- await go('/reports/competitor', 1200);
- const dims = await page.evaluate(() => [...document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelectorAll('.dd')].map((d) => `${d.dataset.dim}(${d.querySelectorAll('.opt').length})`));
- line(dims.length >= 6, '9a 分类维度数', dims.join(' '));
- const counts0 = await V();
- await click('details[data-dim="stage"] summary'); await page.waitForTimeout(80); await click('details[data-dim="stage"] [data-opt="stage:S3"]');
- const c1 = await V();
- await click('details[data-dim="ev"] summary'); await page.waitForTimeout(80); await click('details[data-dim="ev"] [data-opt="ev:中"]');
- const c2 = await V();
- await click('details[data-dim="cat"] summary'); await page.waitForTimeout(80); await click('details[data-dim="cat"] [data-opt="cat:拌面"]');
- const c3 = await V();
- await click('details[data-dim="who"] summary'); await page.waitForTimeout(80); await click('details[data-dim="who"] [data-opt="who:陈静"]');
- const c4 = await V();
- await click('details[data-dim="m"] summary'); await page.waitForTimeout(80); await click('details[data-dim="m"] [data-opt="m:skill"]');
- const c5 = await V();
- let c6 = c5;
- if (await page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('[data-opt="pri:A"]'))) {
- await click('details[data-dim="pri"] summary'); await page.waitForTimeout(80); await click('details[data-dim="pri"] [data-opt="pri:A"]'); c6 = await V();
- }
- await click('[data-clear]');
- for (const [dim, v] of [['stage', 'S3'], ['ev', '中'], ['cat', '拌面'], ['who', '陈静'], ['m', 'skill']]) {
- await click(`details[data-dim="${dim}"] summary`); await page.waitForTimeout(80);
- await click(`details[data-dim="${dim}"] [data-opt="${dim}:${v}"]`);
- }
- await click('details[data-dim="pri"] summary'); await page.waitForTimeout(200);
- await page.screenshot({ path: join(SHOTS, '09-six-dimensions.png') });
- await page.mouse.click(5, 5);
- line([c1, c2, c3, c4, c5, c6].every((x) => x.n < counts0.n), '9b 六个维度逐个叠加,每叠加一层数量都变',
- `全部 ${counts0.count} → S3 ${c1.count} → +证据中 ${c2.count} → +拌面 ${c3.count} → +陈静 ${c4.count} → +Skill ${c5.count} → +A级 ${c6.count};URL = ${c6.url}`);
- await click('[data-clear]');
- const chips = await page.evaluate(() => [...document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelectorAll('[data-chip]')].map((c) => c.dataset.chip));
- const chipResults = [];
- for (const k of ['hasEv', 'mine', 'full', 'recent']) { await click(`[data-chip="${k}"]`); chipResults.push([k, await V()]); await click(`[data-chip="${k}"]`); }
- await click('[data-chip="full"]');
- await page.screenshot({ path: join(SHOTS, '10-chips-active.png') });
- await click('[data-clear]');
- line(chips.length >= 4, '10 快捷 chips + 清除按钮', `${chips.join(' / ')} + [data-clear];逐个点击后计数:${chipResults.map(([k, x]) => `${k} ${x.count}`).join(',')}(mine 未选身份时不生效)`);
- await click('[data-sort="score"]'); const s1 = await V();
- await click('[data-sort="new"]'); const s2 = await V();
- await click('[data-sort="ev"]'); const s3 = await V();
- line(new Set([s1.order.join('|'), s2.order.join('|'), s3.order.join('|')]).size === 3, '11 三种排序,顺序真的不同',
- `评分:${s1.order.join(' / ')}|最新:${s2.order.join(' / ')}|证据:${s3.order.join(' / ')}`);
- await click('[data-view="list"]');
- const list = await page.evaluate(() => { const s = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot; const r = s.querySelector('.row'); return { rows: s.querySelectorAll('.row').length, h: r && Math.round(r.getBoundingClientRect().height) }; });
- await page.screenshot({ path: join(SHOTS, '12-compact-list.png') });
- await click('[data-view="cards"]');
- line(Boolean(list.rows), '12 卡片流 / 紧凑列表可切换', `列表模式一行高 ${list.h}px,一屏可放 ~${list.h ? Math.floor(820 / list.h) : 0} 条;卡片模式一屏 3 张(大卡)`);
- await page.screenshot({ path: join(SHOTS, '12-card-flow.png') });
- await click('[data-page="2"]');
- const pg = await page.evaluate(() => { const s = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot; return { pager: s.querySelector('[data-pager] .sz')?.textContent.trim(), pages: s.querySelectorAll('[data-pager] button[data-page]').length - 2 }; });
- await page.screenshot({ path: join(SHOTS, '13-pagination.png') });
- line(Boolean(pg.pager), '13 分页(数量可见,页码可跳)', `第 2 页后:页码 ${pg.pages} 个,「${pg.pager}」`);
- await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(1000);
- const after = await V();
- line(after.url === '?page=2' || after.url.includes('page=2'), '14 刷新后筛选 / 页码还在(URL 记住)', `刷新后 URL = ${after.url || '(空)'}`);
- /* 空态 + 悬浮 + 其它页截图 */
- await go('/reports/competitor?stage=S3&who=不存在的人', 900);
- await shot('18-empty-filter.png');
- await go('/reports/concept', 900);
- await shot('18-empty-type.png');
- await go('/reports/competitor', 1200);
- await page.hover('kf-app >> view-report-type >> kf-glass-card');
- await page.waitForTimeout(300);
- await shot('19-card-hover.png');
- const hoverT = await page.evaluate(() => { const h = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card'); return getComputedStyle(h.shadowRoot.querySelector('.frame')).transform; });
- line(hoverT !== 'none', '卡片悬停上浮 + 高光', `hover 时 transform = ${hoverT}`);
- await go('/reports', 3200);
- await shot('04-reports-center.png');
- await go('/reports/voc', 1200);
- await shot('02-type-voc.png');
- await go('/stages', 1200);
- await shot('06-stages.png');
- await go('/', 1500);
- await shot('05-workbench.png');
- await go('/reports', 3200);
- await click('[data-view="list"]').catch(() => {});
- await shot('12b-reports-center-list.png');
- await browser.close();
- console.log(`\n${fails === 0 ? 'ALL OK' : `FAILED ${fails}`} · 截图 → ${SHOTS}`);
- process.exit(fails ? 1 : 0);
|