verify-card.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-card.mjs —— 本轮(v7「卡片 / 视觉优先」)验收的实测脚本,只量不猜。
  4. *
  5. * 量五类东西:
  6. * 1. 字数:页面模板里的中文字符(去注释),对照任务书上限
  7. * 2. 句式:被点名删除的解释性文案是否零命中(去注释后 grep)
  8. * 3. 玻璃:卡片 computed style(background-image / background-color / backdrop-filter / 内衬),
  9. * 背景光场是否真的画出来(关掉卡片前后、关掉光场前后的像素差)
  10. * 4. 对比度:按 WCAG 公式,逐个文字节点量「前景 vs 实际合成底色」的比值,取最小值
  11. * 5. 截图 + 交互(维度叠加 / chips / 排序 / 视图 / 分页 / 刷新保留)实测
  12. *
  13. * 用法:node scripts/verify-card.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots/v7]
  14. */
  15. import { readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
  16. import { join, dirname } from 'node:path';
  17. import { fileURLToPath } from 'node:url';
  18. import { spawn } from 'node:child_process';
  19. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  20. const argv = process.argv.slice(2);
  21. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  22. const STATIC_ONLY = argv.includes('--static-only');
  23. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v7'));
  24. mkdirSync(SHOTS, { recursive: true });
  25. let fails = 0;
  26. const line = (ok, name, detail = '') => {
  27. console.log(`${ok ? '✓' : '✗'} ${name}${detail ? `\n ${detail}` : ''}`);
  28. if (!ok) fails++;
  29. };
  30. /* ---------------- 1. 字数 + 2. 句式(静态) ---------------- */
  31. const LIMITS = [
  32. ['src/pages/view-report-type.js', '类型页', 300],
  33. ['src/pages/view-workbench.js', '工作台', 350],
  34. ['src/pages/view-stages.js', '阶段页', 300],
  35. ['src/pages/view-report.js', '报告详情', 250],
  36. ['src/pages/view-reports.js', '报告中心', 200],
  37. ];
  38. const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
  39. console.log('—— 字数(去注释后的中文字符)——');
  40. for (const [f, name, cap] of LIMITS) {
  41. const n = (strip(readFileSync(join(ROOT, f), 'utf8')).match(/[一-鿿]/g) || []).length;
  42. line(n <= cap, `${name} ≤ ${cap}`, `实测 ${n} 个中文字符(${f})`);
  43. }
  44. const BANNED = ['这页在回答', '换一个阶段', '实心竖线', '淡色底线', '按对位对象横向排开', '近 7 天新增', '人均', '只有登记信息', '手头事实', '读图:', '暂无数据', '这阶段在回答', '这章在回答', '本类型全部条目'];
  45. const walk = (dir, out = []) => {
  46. if (!existsSync(dir)) return out;
  47. for (const e of readdirSync(dir, { withFileTypes: true })) {
  48. const p = join(dir, e.name);
  49. if (e.isDirectory()) walk(p, out);
  50. else if (p.endsWith('.js')) out.push(p);
  51. }
  52. return out;
  53. };
  54. const uiFiles = ['src/pages', 'src/components', 'src/layout'].flatMap((d) => walk(join(ROOT, d)));
  55. const hits = [];
  56. for (const f of uiFiles) {
  57. const body = strip(readFileSync(f, 'utf8'));
  58. for (const b of BANNED) if (body.includes(b)) hits.push(`${f.replace(ROOT + '/', '')}: 「${b}」`);
  59. }
  60. line(hits.length === 0, `解释性句式零命中(${BANNED.length} 条句式 × ${uiFiles.length} 个界面文件)`, hits.join(';') || `全部 0 命中`);
  61. /* ---------------- 浏览器 ---------------- */
  62. async function loadPlaywright() {
  63. for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) {
  64. if (existsSync(c)) return (await import(join(c, 'index.js'))).default ?? (await import(`${c}/index.js`));
  65. }
  66. return import('playwright');
  67. }
  68. let server = null;
  69. let base = arg('--url', '');
  70. if (!base) {
  71. const { writeConfig } = await import('./config.mjs');
  72. const { prefix } = writeConfig({ env: 'local' });
  73. const port = Number(process.env.VERIFY_PORT || 4176);
  74. server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
  75. await new Promise((r) => setTimeout(r, 700));
  76. base = `http://127.0.0.1:${port}${prefix}`;
  77. }
  78. process.on('exit', () => server?.kill());
  79. const pw = await loadPlaywright();
  80. 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'] });
  81. const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
  82. const page = await ctx.newPage();
  83. const url = (p) => new URL(p, base).href;
  84. const go = async (p, ms = 1200) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
  85. const shot = (name, opts = {}) => page.screenshot({ path: join(SHOTS, name), ...opts });
  86. /* 读页面里某处像素的平均 RGB(把整页截图交给页面自己的 canvas 解码) */
  87. async function avgRGB(region) {
  88. const b64 = (await page.screenshot({ clip: region })).toString('base64');
  89. return page.evaluate(async (b64) => {
  90. const img = new Image();
  91. img.src = 'data:image/png;base64,' + b64;
  92. await img.decode();
  93. const c = document.createElement('canvas');
  94. c.width = img.width; c.height = img.height;
  95. const g = c.getContext('2d');
  96. g.drawImage(img, 0, 0);
  97. const d = g.getImageData(0, 0, c.width, c.height).data;
  98. let r = 0, gg = 0, b = 0, n = 0;
  99. for (let i = 0; i < d.length; i += 4) { r += d[i]; gg += d[i + 1]; b += d[i + 2]; n++; }
  100. return [Math.round(r / n), Math.round(gg / n), Math.round(b / n)];
  101. }, b64);
  102. }
  103. if (STATIC_ONLY) {
  104. console.log(`\n${fails === 0 ? 'ALL OK' : `FAILED ${fails}`} · 静态项(--static-only)`);
  105. process.exit(fails ? 1 : 0);
  106. }
  107. /* ---------------- 3. 玻璃 + 光场 ---------------- */
  108. await go('/reports/competitor', 1400);
  109. const glass = await page.evaluate(() => {
  110. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  111. const host = v.shadowRoot.querySelector('.cards kf-glass-card');
  112. const frame = host.shadowRoot.querySelector('.frame');
  113. const face = host.shadowRoot.querySelector('.face');
  114. const cs = getComputedStyle(frame);
  115. const app = document.querySelector('kf-app');
  116. const before = getComputedStyle(app, '::before');
  117. return {
  118. frameBgImage: cs.backgroundImage,
  119. frameBgColor: cs.backgroundColor,
  120. backdrop: cs.backdropFilter || cs.webkitBackdropFilter,
  121. borderTop: `${cs.borderTopWidth} ${cs.borderTopStyle} ${cs.borderTopColor}`,
  122. borderEdge: (cs.backgroundImage.match(/linear-gradient\(135deg[^)]*\)[^,]*\)?/g) || [''])[0],
  123. shadow: cs.boxShadow,
  124. faceBg: getComputedStyle(face).backgroundColor,
  125. sceneBefore: { z: before.zIndex, filter: before.filter, bg: before.backgroundImage.slice(0, 160), content: before.content },
  126. ambientInDoc: Boolean(document.querySelector('.kf-ambient')),
  127. ambientInApp: Boolean(app.shadowRoot.querySelector('.kf-ambient')),
  128. };
  129. });
  130. const whiteAlpha = (glass.frameBgImage.match(/rgba\(255, 255, 255, ([\d.]+)\)/g) || []).map((s) => Number(s.match(/([\d.]+)\)$/)[1]));
  131. line(/rgba\(255, 255, 255, 0\.5\d/.test(glass.frameBgImage) && glass.frameBgColor === 'rgba(0, 0, 0, 0)',
  132. '16a 卡片白面实测半透明(computed background)',
  133. `background-image 里的白层 = ${JSON.stringify(whiteAlpha)};background-color = ${glass.frameBgColor}`);
  134. line(/blur\(24px\) saturate\(1\.?8\)/.test(glass.backdrop), '16b backdrop-filter 真的生效', `computed backdrop-filter = ${glass.backdrop}`);
  135. line(/linear-gradient\(135deg/.test(glass.borderEdge), '11 发丝边是 135° 渐变(不是 1px 灰线)', `border = ${glass.borderTop};边层 = ${glass.borderEdge.slice(0, 110)}`);
  136. line(/inset/.test(glass.shadow) && glass.shadow.includes('0px 1px 0px'), '内高光在', `box-shadow = ${glass.shadow.slice(0, 120)}`);
  137. line(glass.sceneBefore.content === '""' && glass.sceneBefore.z === '0' && /blur\(\d+px\)/.test(glass.sceneBefore.filter),
  138. '15a 背景光场是应用根上的正层级伪元素(DOM 里能查到)', `kf-app::before → z-index=${glass.sceneBefore.z},filter=${glass.sceneBefore.filter},content=${glass.sceneBefore.content}`);
  139. line(!glass.ambientInApp, '15b 旧负层级光场节点已移除(根因:负 z-index 层在长页面里整组不绘制)', `kf-app shadow 内 .kf-ambient 存在?${glass.ambientInApp};document 内?${glass.ambientInDoc}`);
  140. /* 光场可见性:同一区域「光场开 / 关」两帧的像素差;「卡片开 / 关」两帧的像素差 */
  141. const region = await page.evaluate(() => {
  142. const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type');
  143. const host = v.shadowRoot.querySelector('.cards kf-glass-card');
  144. host.scrollIntoView({ block: 'center' });
  145. const r = host.getBoundingClientRect();
  146. return { x: Math.round(r.x + 40), y: Math.round(r.y + 40), width: Math.round(r.width - 60), height: 50 };
  147. });
  148. const withScene = await avgRGB(region);
  149. await page.screenshot({ path: join(SHOTS, '15-bg-sheen-on.png') });
  150. const bgRegion = { x: 280, y: 820, width: 760, height: 120 };
  151. await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = 'hidden'; });
  152. await page.waitForTimeout(150);
  153. const bgOn = await avgRGB(bgRegion);
  154. 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); });
  155. await page.waitForTimeout(150);
  156. const noScene = await avgRGB(region);
  157. const bgOff = await avgRGB(bgRegion);
  158. await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = ''; });
  159. await page.screenshot({ path: join(SHOTS, '15-bg-sheen-off.png') });
  160. await page.evaluate(() => document.getElementById('kf-scene-off').remove());
  161. const diff = Math.max(...bgOn.map((v, i) => Math.abs(v - bgOff[i])));
  162. const cardDiff = Math.max(...withScene.map((v, i) => Math.abs(v - noScene[i])));
  163. line(cardDiff >= 10, '15c 光场可见(同位置开 / 关光场的像素差)', `卡片区域(玻璃背后)开 / 关光场平均色差 = ${cardDiff};空白背景区 = ${diff};开 = rgb(${bgOn}),关 = rgb(${bgOff})`);
  164. /* 卡片关掉后,背景色斑还在(截一张无卡片的背景) */
  165. await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = 'hidden'; });
  166. await page.waitForTimeout(150);
  167. await shot('15-bg-cards-hidden.png');
  168. await page.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type'); v.shadowRoot.querySelector('.cards').style.visibility = ''; });
  169. await page.screenshot({ path: join(SHOTS, '01-type-competitor-cards.png') });
  170. /* ---------------- 4. 对比度(WCAG,逐个文字节点) ---------------- */
  171. await page.waitForTimeout(600); // 等卡片组件把插槽内容分配完(.rc 在 light DOM)
  172. const contrast = await page.evaluate(async () => {
  173. const lum = ([r, g, b]) => {
  174. const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
  175. return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
  176. };
  177. const parse = (s) => {
  178. const m = String(s).match(/rgba?\(([^)]+)\)/);
  179. if (!m) return [0, 0, 0, 1];
  180. const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number);
  181. return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1];
  182. };
  183. // 从文字节点往上,把每一层半透明背景按 alpha 叠起来 → 实际看到的底色
  184. const bgUnder = (el) => {
  185. const layers = [];
  186. for (let n = el; n && n !== document.documentElement; n = n.parentElement || (n.getRootNode().host ?? null)) {
  187. const cs = getComputedStyle(n);
  188. const c = parse(cs.backgroundColor);
  189. layers.push([c, cs.backgroundImage]);
  190. if (c[3] === 1) break;
  191. }
  192. let base = [230, 234, 246];
  193. const bodyBg = parse(getComputedStyle(document.body).backgroundColor);
  194. if (bodyBg[3] > 0) base = bodyBg.slice(0, 3);
  195. for (const [c] of layers.reverse()) {
  196. const a = c[3];
  197. base = [c[0] * a + base[0] * (1 - a), c[1] * a + base[1] * (1 - a), c[2] * a + base[2] * (1 - a)];
  198. }
  199. return base;
  200. };
  201. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  202. const out = [];
  203. for (const host of [...v.shadowRoot.querySelectorAll('.cards kf-glass-card')].filter((h) => h.querySelector('.rc'))) {
  204. const card = host.querySelector('.rc'); // 卡片内容在宿主元素的 light DOM 里���插槽分配)
  205. if (!card) continue;
  206. for (const el of card.querySelectorAll('h3, .lead, .k, .v, .v.dim, .src, .src .who, .src .it, .src .tm, .badge, .fresh, .seg + *')) {
  207. const t = [...el.childNodes].filter((n) => n.nodeType === 3 || (n.nodeType === 1 && n.textContent.trim() && !n.matches('svg'))).map((n) => n.textContent).join('').trim();
  208. if (!t) continue;
  209. const cs = getComputedStyle(el);
  210. const fg = parse(cs.color);
  211. const bg = bgUnder(el);
  212. // 前景也按自身 alpha 叠到底色上(文字没有透明度时 fg[3]=1,不变)
  213. const a = fg[3];
  214. const comp = [fg[0] * a + bg[0] * (1 - a), fg[1] * a + bg[1] * (1 - a), fg[2] * a + bg[2] * (1 - a)];
  215. const L1 = lum(comp), L2 = lum(bg);
  216. const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
  217. break; // 每张卡只量一处标题 + 后面统一量
  218. }
  219. // 量这张卡里每个有文字的节点,取最小的
  220. for (const el of card.querySelectorAll('h3, .lead, .k, .v, .src, .src .who, .src .it, .src .tm, .badge, .fresh')) {
  221. const t = el.textContent.trim();
  222. if (!t) continue;
  223. const cs = getComputedStyle(el);
  224. const fg = parse(cs.color);
  225. const bg = bgUnder(el);
  226. const L1 = lum(fg), L2 = lum(bg);
  227. const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
  228. 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) });
  229. }
  230. }
  231. // 徽标是白字压色块:单独算(前景白 vs 徽标自己的背景)
  232. const badges = [];
  233. for (const b of v.shadowRoot.querySelectorAll('.cards .badge')) {
  234. const cs = getComputedStyle(b);
  235. const fg = parse(cs.color); const bg = parse(cs.backgroundColor);
  236. const L1 = lum(fg), L2 = lum(bg.slice(0, 3));
  237. 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 });
  238. }
  239. return { nodes: out, badges };
  240. }).catch((e) => ({ error: String(e) }));
  241. if (contrast.error || !contrast.nodes?.length) line(false, '17 对比度测量失败', contrast.error || '没有量到文字节点');
  242. else {
  243. const small = contrast.nodes.filter((n) => n.ratio < 4.5);
  244. const big = contrast.nodes.filter((n) => n.size >= 18.66 && n.ratio < 3);
  245. const min = contrast.nodes.reduce((m, n) => (n.ratio < m.ratio ? n : m), contrast.nodes[0]);
  246. const minBadge = contrast.badges.reduce((m, n) => (n.ratio < m.ratio ? n : m), contrast.badges[0]);
  247. line(small.length === 0 && big.length === 0, '17 文字对比度 ≥ 4.5:1(正文)/ 3:1(大字号)',
  248. `${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} 个`);
  249. }
  250. /* ---------------- 8. 缩略图在(缩略图画在卡片内部,需要翻到带 report 的卡) ---------------- */
  251. await go('/reports/competitor?full=1', 1200);
  252. await page.waitForTimeout(600); // 等卡片组件把插槽内容分配完(.rc 在 light DOM)
  253. const thumbInfo = await page.evaluate(() => {
  254. const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type');
  255. const t = v.shadowRoot.querySelector('.cards .thumb');
  256. return t ? { w: Math.round(t.getBoundingClientRect().width), bars: t.querySelectorAll('i').length, dots: t.querySelectorAll('.dots b').length } : null;
  257. });
  258. line(Boolean(thumbInfo), '8 有报告结构的卡显示缩略图(章节条 + 结论点)', thumbInfo ? `${thumbInfo.w}px 宽,${thumbInfo.bars} 根章节条,${thumbInfo.dots} 个结论点` : 'competitor?full=1 下没有带 report 的卡');
  259. await page.evaluate(() => {
  260. const card = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards .thumb')?.closest('kf-glass-card');
  261. card?.scrollIntoView({ block: 'center' });
  262. });
  263. await page.waitForTimeout(200);
  264. await page.screenshot({ path: join(SHOTS, '08-card-with-thumbnail.png') });
  265. /* ---------------- 6. 来源四要素 ---------------- */
  266. await go('/reports/competitor', 1200);
  267. const src = await page.evaluate(() => {
  268. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  269. const row = v.shadowRoot.querySelector('.cards .src');
  270. return { text: row.textContent.replace(/\s+/g, ' ').trim(), icons: row.querySelectorAll('svg').length, inBottom: Math.round(row.getBoundingClientRect().bottom - row.closest('.rc').getBoundingClientRect().bottom) };
  271. });
  272. line(src.icons >= 3 && src.text.includes('周涛'), '6 卡片底部来源四要素(图标打头)', `读出:「${src.text}」;图标 ${src.icons} 个;距卡片底 ${src.inBottom}px`);
  273. /* ---------------- 9–14 交互 ---------------- */
  274. const V = () => page.evaluate(() => {
  275. const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type') || document.querySelector('kf-app').shadowRoot.querySelector('view-reports');
  276. const root = v.shadowRoot.querySelector('view-report-type') || v;
  277. const s = root.shadowRoot;
  278. 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()) };
  279. });
  280. const click = (sel) => page.evaluate((sel) => {
  281. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
  282. const root = v.querySelector('view-reports')?.shadowRoot.querySelector('view-report-type') || v.querySelector('view-report-type');
  283. const el = root.shadowRoot.querySelector(sel);
  284. if (!el) throw new Error('missing ' + sel);
  285. el.click();
  286. }, sel).then(() => page.waitForTimeout(300));
  287. await go('/reports/competitor', 1200);
  288. 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})`));
  289. line(dims.length >= 6, '9a 分类维度数', dims.join(' '));
  290. const counts0 = await V();
  291. await click('details[data-dim="stage"] summary'); await page.waitForTimeout(80); await click('details[data-dim="stage"] [data-opt="stage:S3"]');
  292. const c1 = await V();
  293. await click('details[data-dim="ev"] summary'); await page.waitForTimeout(80); await click('details[data-dim="ev"] [data-opt="ev:中"]');
  294. const c2 = await V();
  295. await click('details[data-dim="cat"] summary'); await page.waitForTimeout(80); await click('details[data-dim="cat"] [data-opt="cat:拌面"]');
  296. const c3 = await V();
  297. await click('details[data-dim="who"] summary'); await page.waitForTimeout(80); await click('details[data-dim="who"] [data-opt="who:陈静"]');
  298. const c4 = await V();
  299. await click('details[data-dim="m"] summary'); await page.waitForTimeout(80); await click('details[data-dim="m"] [data-opt="m:skill"]');
  300. const c5 = await V();
  301. let c6 = c5;
  302. if (await page.evaluate(() => document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('[data-opt="pri:A"]'))) {
  303. await click('details[data-dim="pri"] summary'); await page.waitForTimeout(80); await click('details[data-dim="pri"] [data-opt="pri:A"]'); c6 = await V();
  304. }
  305. await click('[data-clear]');
  306. for (const [dim, v] of [['stage', 'S3'], ['ev', '中'], ['cat', '拌面'], ['who', '陈静'], ['m', 'skill']]) {
  307. await click(`details[data-dim="${dim}"] summary`); await page.waitForTimeout(80);
  308. await click(`details[data-dim="${dim}"] [data-opt="${dim}:${v}"]`);
  309. }
  310. await click('details[data-dim="pri"] summary'); await page.waitForTimeout(200);
  311. await page.screenshot({ path: join(SHOTS, '09-six-dimensions.png') });
  312. await page.mouse.click(5, 5);
  313. line([c1, c2, c3, c4, c5, c6].every((x) => x.n < counts0.n), '9b 六个维度逐个叠加,每叠加一层数量都变',
  314. `全部 ${counts0.count} → S3 ${c1.count} → +证据中 ${c2.count} → +拌面 ${c3.count} → +陈静 ${c4.count} → +Skill ${c5.count} → +A级 ${c6.count};URL = ${c6.url}`);
  315. await click('[data-clear]');
  316. const chips = await page.evaluate(() => [...document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelectorAll('[data-chip]')].map((c) => c.dataset.chip));
  317. const chipResults = [];
  318. for (const k of ['hasEv', 'mine', 'full', 'recent']) { await click(`[data-chip="${k}"]`); chipResults.push([k, await V()]); await click(`[data-chip="${k}"]`); }
  319. await click('[data-chip="full"]');
  320. await page.screenshot({ path: join(SHOTS, '10-chips-active.png') });
  321. await click('[data-clear]');
  322. line(chips.length >= 4, '10 快捷 chips + 清除按钮', `${chips.join(' / ')} + [data-clear];逐个点击后计数:${chipResults.map(([k, x]) => `${k} ${x.count}`).join(',')}(mine 未选身份时不生效)`);
  323. await click('[data-sort="score"]'); const s1 = await V();
  324. await click('[data-sort="new"]'); const s2 = await V();
  325. await click('[data-sort="ev"]'); const s3 = await V();
  326. line(new Set([s1.order.join('|'), s2.order.join('|'), s3.order.join('|')]).size === 3, '11 三种排序,顺序真的不同',
  327. `评分:${s1.order.join(' / ')}|最新:${s2.order.join(' / ')}|证据:${s3.order.join(' / ')}`);
  328. await click('[data-view="list"]');
  329. 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) }; });
  330. await page.screenshot({ path: join(SHOTS, '12-compact-list.png') });
  331. await click('[data-view="cards"]');
  332. line(Boolean(list.rows), '12 卡片流 / 紧凑列表可切换', `列表模式一行高 ${list.h}px,一屏可放 ~${list.h ? Math.floor(820 / list.h) : 0} 条;卡片模式一屏 3 张(大卡)`);
  333. await page.screenshot({ path: join(SHOTS, '12-card-flow.png') });
  334. await click('[data-page="2"]');
  335. 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 }; });
  336. await page.screenshot({ path: join(SHOTS, '13-pagination.png') });
  337. line(Boolean(pg.pager), '13 分页(数量可见,页码可跳)', `第 2 页后:页码 ${pg.pages} 个,「${pg.pager}」`);
  338. await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(1000);
  339. const after = await V();
  340. line(after.url === '?page=2' || after.url.includes('page=2'), '14 刷新后筛选 / 页码还在(URL 记住)', `刷新后 URL = ${after.url || '(空)'}`);
  341. /* 空态 + 悬浮 + 其它页截图 */
  342. await go('/reports/competitor?stage=S3&who=不存在的人', 900);
  343. await shot('18-empty-filter.png');
  344. await go('/reports/concept', 900);
  345. await shot('18-empty-type.png');
  346. await go('/reports/competitor', 1200);
  347. await page.hover('kf-app >> view-report-type >> kf-glass-card');
  348. await page.waitForTimeout(300);
  349. await shot('19-card-hover.png');
  350. 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; });
  351. line(hoverT !== 'none', '卡片悬停上浮 + 高光', `hover 时 transform = ${hoverT}`);
  352. await go('/reports', 3200);
  353. await shot('04-reports-center.png');
  354. await go('/reports/voc', 1200);
  355. await shot('02-type-voc.png');
  356. await go('/stages', 1200);
  357. await shot('06-stages.png');
  358. await go('/', 1500);
  359. await shot('05-workbench.png');
  360. await go('/reports', 3200);
  361. await click('[data-view="list"]').catch(() => {});
  362. await shot('12b-reports-center-list.png');
  363. await browser.close();
  364. console.log(`\n${fails === 0 ? 'ALL OK' : `FAILED ${fails}`} · 截图 → ${SHOTS}`);
  365. process.exit(fails ? 1 : 0);