verify-visual-v13.mjs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-visual-v13.mjs —— v13 收尾三项验收(每项打印实测原值,不看「通过」两个字)。
  4. *
  5. * node scripts/verify-visual-v13.mjs --tag before 只打印、不判分(改前基线)
  6. * node scripts/verify-visual-v13.mjs 判分:任一项不达标 exit 1
  7. * --json out.json 结果落盘(docs 里的数字从这里抄)
  8. *
  9. * 量什么:
  10. * 一、排序段(/pool .seg,桌面;手机上打开 .pbar-more 里的 .pbar-sorts)
  11. * - 选中项 computed background 与文字色、aria-pressed 计数
  12. * - 选中 / 未选中 font-size、line-height、按钮框高
  13. * - 外层:border-radius / border-width;按钮:每项各自 borderRadius = 0、相邻之间 1px 分隔线
  14. * 二、机会卡描述(/pool 每张 kf-opportunity-card)
  15. * - .one 是否有文字、一行字高(line-height)、卡内布局方式
  16. * - 无描述卡是否「没有描述区」而不是空 div;指标区上提了多少
  17. * - 有描述卡 / 无描述卡「内容总高」差;每张卡最大连续空白(叶子文字的竖直间隙)
  18. * 三、/pool 页脚类名命中:拿 cardFooter() 的每个类名,在 /pool 页脚里查规则命中(CSSOM 遍历 shadow root 的 adoptedStyleSheets),
  19. * 再与 /reports 同名元素逐项比对 computed padding / border / borderRadius / 字号 / 颜色。
  20. *
  21. * 坑(见 memory kf-verify-browser-quirks):视图异步挂载;跨 shadow 走 host;hover 要真实指针;滚动位置先归零。
  22. */
  23. import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
  24. import { join, dirname } from 'node:path';
  25. import { fileURLToPath } from 'node:url';
  26. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  27. const argv = process.argv.slice(2);
  28. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  29. const TAG = arg('--tag', 'after');
  30. const JUDGE = TAG !== 'before';
  31. const JSON_OUT = arg('--json', '');
  32. if (!process.env.CHROME_PATH) {
  33. const pinned = '/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell';
  34. if (existsSync(pinned)) process.env.CHROME_PATH = pinned;
  35. }
  36. const { startServer, launch, DEEP } = await import('./v11b-lib.mjs');
  37. const srv = await startServer(Number(process.env.VERIFY_PORT || 4194));
  38. const browser = await launch();
  39. const errors = [];
  40. const newPage = async (width = 1440, height = 900) => {
  41. const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
  42. const page = await ctx.newPage();
  43. page.on('pageerror', (e) => errors.push(e.message));
  44. page.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
  45. return page;
  46. };
  47. const page = await newPage();
  48. const gotoView = async (p, name) => {
  49. await page.goto(new URL(p, srv.base).href, { waitUntil: 'networkidle' });
  50. await page.waitForFunction((n) => {
  51. const v = document.querySelector('kf-app')?.shadowRoot?.querySelector(n);
  52. return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0 && ('db' in v ? v.db != null : true);
  53. }, name, { timeout: 20000 });
  54. await page.waitForTimeout(700);
  55. };
  56. const rgb = (s) => (s.match(/\d+(\.\d+)?/g) || []).map(Number);
  57. const hex = (rgba, under = [255, 255, 255]) => {
  58. /* rgba → 叠在不透明底上的实际颜色(选中态是半透明底,亮度要算合成后的) */
  59. const [r, g, b, a = 1] = rgba;
  60. return [0, 1, 2].map((i) => Math.round(rgba[i] * a + under[i] * (1 - a)));
  61. };
  62. const lum = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
  63. const out = { tag: TAG, at: new Date().toISOString(), one: null, two: null, three: null };
  64. /* ======================= 一、排序段 ======================= */
  65. {
  66. await gotoView('/pool', 'view-pool');
  67. out.one = await page.evaluate(`(async () => {
  68. ${DEEP}
  69. const v = appR().querySelector('view-pool').shadowRoot;
  70. const seg = v.querySelector('.filters .seg');
  71. if (!seg) return { missing: true };
  72. const btns = [...seg.querySelectorAll('button')];
  73. const read = (b) => { const s = getComputedStyle(b); const r = b.getBoundingClientRect();
  74. return { text: b.textContent.trim(), pressed: b.getAttribute('aria-pressed'), bg: s.backgroundColor, color: s.color, fontSize: s.fontSize, lineHeight: s.lineHeight,
  75. h: Math.round(r.height * 10) / 10, radius: s.borderTopLeftRadius + ' ' + s.borderTopRightRadius, borderLeft: s.borderLeftWidth }; };
  76. const ss = getComputedStyle(seg);
  77. /* 分隔线:相邻按钮「左 1px」+ 第一个「无左边框」 */
  78. const seps = btns.map((b, i) => ({ i, left: getComputedStyle(b).borderLeftWidth, leftStyle: getComputedStyle(b).borderLeftStyle, leftColor: getComputedStyle(b).borderLeftColor }));
  79. /* 实际命中规则里的 background(不是 computed 的 rgba(0,0,0,0) 兜底) */
  80. const sel = btns.find((b) => b.getAttribute('aria-pressed') === 'true');
  81. const selRule = sel ? getComputedStyle(sel) : null;
  82. const wrap = seg.parentElement.getBoundingClientRect();
  83. const r = seg.getBoundingClientRect();
  84. return { btns: btns.map(read), seps, outer: { radius: ss.borderTopLeftRadius, border: ss.borderTopWidth + ' ' + ss.borderTopStyle + ' ' + ss.borderTopColor, h: Math.round(r.height * 10) / 10, bg: ss.backgroundColor },
  85. selected: sel ? read(sel) : null, unselected: btns.filter((b) => b.getAttribute('aria-pressed') !== 'true').map(read),
  86. selColorRule: selRule ? selRule.color : null, wrapTop: wrap.top };
  87. })()`);
  88. /* 手机档:把排序抽屉打开,量里面的段选 */
  89. const mpage = await newPage(393, 852);
  90. await mpage.goto(new URL('/pool', srv.base).href, { waitUntil: 'networkidle' });
  91. await mpage.waitForFunction(() => {
  92. const v = document.querySelector('kf-app')?.shadowRoot?.querySelector('view-pool');
  93. return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0 && v.db != null;
  94. }, null, { timeout: 20000 });
  95. await mpage.waitForTimeout(600);
  96. await mpage.evaluate(() => {
  97. const v = document.querySelector('kf-app').shadowRoot.querySelector('view-pool');
  98. v.shadowRoot.querySelector('.pbar-more').setAttribute('open', '');
  99. });
  100. await mpage.waitForTimeout(200);
  101. out.one.mobile = await mpage.evaluate(() => {
  102. const v = document.querySelector('kf-app').shadowRoot.querySelector('view-pool').shadowRoot;
  103. const btns = [...v.querySelectorAll('.pbar-sorts button')];
  104. const sel = btns.find((b) => b.getAttribute('aria-pressed') === 'true');
  105. const s = sel ? getComputedStyle(sel) : null;
  106. return sel ? { text: sel.textContent.trim(), bg: s.backgroundColor, color: s.color, fontSize: s.fontSize, lineHeight: s.lineHeight } : { missing: true };
  107. });
  108. await mpage.context().close();
  109. }
  110. /* ======================= 二、报告卡描述(reportCard 的 .lead) =======================
  111. 量 /reports 与 /stages 的每一张 .rc:
  112. - 有无描述(.lead 元素存在且有文字)
  113. - 标题 → 指标区(.m)之间的缝:有描述的卡把这条缝摊给四张卡(flex 平均分),
  114. 无描述的卡应把同一条缝均分到卡内各条缝 —— 不再是 66px 一条空缝
  115. - 内容总高 = .rc 内第一个可见子块顶 → 最后一个子块底
  116. - 最大连续空白 = .rc 内叶子文字矩形按 top 排,相邻两块之间竖直间隙的最大值 */
  117. {
  118. out.two = { pages: [] };
  119. for (const [p, name] of [['/reports', 'view-reports'], ['/stages', 'view-stages']]) {
  120. await gotoView(p, name);
  121. const r = await page.evaluate(`(async () => {
  122. ${DEEP}
  123. const root = appR().querySelector('${name}').shadowRoot;
  124. const cards = [...root.querySelectorAll(".rc")].filter((c) => c.getBoundingClientRect().height > 0 && c.getBoundingClientRect().width > 0);
  125. const cardsInfo = cards.map((c) => {
  126. const lead = c.querySelector('.lead');
  127. const tk = c.querySelector('h3').getBoundingClientRect();
  128. const m = c.querySelector('.m').getBoundingClientRect();
  129. /* 内容块 = 含文字的块级子块(空 / 装饰子块不算内容);首块顶到末块底 */
  130. const kids = [...c.querySelectorAll('*')].filter((e) => {
  131. if (![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return false;
  132. return e.parentElement === c || !/^(inline|contents)/.test(getComputedStyle(e).display);
  133. });
  134. const first = kids[0].getBoundingClientRect(), last = kids[kids.length - 1].getBoundingClientRect();
  135. /* 叶子文字块:先取最内层的文字元素(祖先容器不重复计,否则父盒扣掉 padding 后会造出假空白)。
  136. display:contents(.src 在手机档里的 .l1/.l2)没有盒,跳过。 */
  137. const textAll = [...c.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()));
  138. const inner = textAll.filter((e) => !textAll.some((o) => o !== e && e.contains(o)));
  139. /* 最大连续空白:把每块文字矩形按 left 夹到它最内层块级祖先的内容盒里(中文落点分散、首字左对齐,
  140. 光比 left/right 会误判并排),再按 top 排序;前块在 [lo, hi] 覆盖带之上时计一次空隙。 */
  141. const blockAncestor = (e) => { for (let q = e.parentElement; q && q !== c; q = q.parentElement) { const d = getComputedStyle(q).display; if (/^(block|flex|grid|list-item|flow-root|table)$/.test(d)) return q; } return c; };
  142. const boxes = inner.map((e) => { const r = e.getBoundingClientRect(); const q = blockAncestor(e); const qr = q.getBoundingClientRect(); const cs = getComputedStyle(q); const l = Math.max(r.left, qr.left + parseFloat(cs.paddingLeft || 0)); const rr = Math.min(r.right, qr.right - parseFloat(cs.paddingRight || 0)); return { left: Math.min(l, rr), right: Math.max(l, rr), top: Math.round(r.top), bottom: Math.round(r.bottom) }; })
  143. .filter((r) => r.bottom > r.top).sort((a, b) => a.top - b.top);
  144. let maxGap = 0, gapAt = '', lo = null, hi = null;
  145. for (const r of boxes) {
  146. if (hi == null) { lo = r.top; hi = r.bottom; continue; }
  147. if (r.bottom <= lo) { const g = lo - r.bottom; if (g > maxGap) { maxGap = g; gapAt = lo ? (r.bottom + '→' + lo) : ''; } }
  148. lo = Math.min(lo, r.top); hi = Math.max(hi, r.bottom);
  149. }
  150. /* 卡内每条缝(相邻子块之间),以及每块子块的 stretch 情况 */
  151. const gaps = [];
  152. for (let i = 1; i < kids.length; i++) gaps.push(Math.round(kids[i].getBoundingClientRect().top - kids[i - 1].getBoundingClientRect().bottom));
  153. return { title: (c.querySelector('h3').textContent || '').trim().slice(0, 14),
  154. lead: lead ? lead.textContent.trim().slice(0, 12) : null, leadTxt: lead ? lead.textContent.trim() : '',
  155. gapTitleToM: Math.round(m.top - tk.bottom), contentH: Math.round(last.bottom - first.top),
  156. rcH: Math.round(c.getBoundingClientRect().height), faceH: Math.round(c.closest('kf-glass-card').shadowRoot.querySelector('.face').getBoundingClientRect().height),
  157. cardH: Math.round(c.closest('kf-glass-card').getBoundingClientRect().height),
  158. maxGap, gapAt, rowIdx: Math.round(c.getBoundingClientRect().top) };
  159. });
  160. return { n: cardsInfo.length, cards: cardsInfo };
  161. })()`);
  162. /* 同排(top 相同)卡片的等高由 v12 脚本管;这里只记 */
  163. out.two.pages.push({ page: p, ...r });
  164. }
  165. }
  166. /* ======================= 三、页脚类名命中 ======================= */
  167. const FOOT_PROBE = `(async () => {
  168. ${DEEP}
  169. const host = appR().querySelector(location.pathname.includes('reports') ? 'view-reports' : 'view-pool');
  170. const sr = host.shadowRoot;
  171. const foot = sr.querySelector('.cfoot[data-foot]');
  172. if (!foot) return { missing: true, host: host.localName };
  173. /* 1. 页脚里出现的每个类名,逐项查:在宿主 shadow root 的 adoptedStyleSheets 里,是否有规则的选择器能命中它 */
  174. const sheets = [...sr.adoptedStyleSheets, ...[...sr.querySelectorAll('style')].map((s) => s.sheet)];
  175. const rules = [];
  176. const walkRules = (list, media) => { for (const r of list || []) {
  177. if (r.cssRules && !r.selectorText) { walkRules(r.cssRules, r.conditionText || media); continue; }
  178. if (r.selectorText) rules.push({ sel: r.selectorText, media: media || '', css: r.style.cssText.slice(0, 90) });
  179. } };
  180. for (const s of sheets) if (s) { try { walkRules(s.cssRules, ''); } catch { /* 跨源 */ } }
  181. const classes = new Set();
  182. for (const el of [foot, ...foot.querySelectorAll('*')]) {
  183. const cn = typeof el.className === 'string' ? el.className : ''; /* svg 的 className 是 SVGAnimatedString */
  184. for (const c of cn.split(/\\s+/).filter(Boolean)) classes.add((el.localName === 'footer' || !/^(svg|path|circle|line|polyline|rect|g)$/.test(el.localName) ? '' : 'svg:') + c);
  185. }
  186. const hits = [];
  187. for (const c of classes) {
  188. const name = c;
  189. const matching = rules.filter((r) => {
  190. /* 规则选择器里是否以 .name 作为「完整类名」出现(避免 .cfl 命中 .cfleft) */
  191. return new RegExp('\\\\.' + name.replace(/[-]/g, '\\\\-') + '(?![\\\\w-])').test(r.sel);
  192. });
  193. /* 选择器是否真的能作用到页脚里的某个元素 */
  194. const el = [foot, ...foot.querySelectorAll('*')].find((e) => {
  195. const cn = typeof e.className === 'string' ? e.className : '';
  196. return cn.split(/\\s+/).includes(name);
  197. });
  198. const applies = matching.filter((r) => {
  199. if (!el) return false;
  200. try { return el.matches(r.sel.replace(/::?[a-z-]+(\\([^)]*\\))?/g, '')); } catch { return false; }
  201. });
  202. hits.push({ cls: name, tag: el ? elLabel(el).slice(0, 30) : '', rules: matching.length, applies: applies.length, el: el ? elLabel(el).slice(0, 30) : '' });
  203. }
  204. /* 2. 逐元素 computed 快照:footer 本身 + 里面每个元素,按「结构路径」记 */
  205. const snap = (f) => {
  206. const one = (el) => { const s = getComputedStyle(el);
  207. return { padding: [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft].join(' '),
  208. border: [s.borderTopWidth, s.borderRightWidth, s.borderBottomWidth, s.borderLeftWidth].join(' ') + ' / ' + s.borderTopStyle + ' / ' + s.borderTopColor,
  209. borders: [s.borderTopWidth + ' ' + s.borderTopStyle, s.borderRightWidth + ' ' + s.borderRightStyle, s.borderBottomWidth + ' ' + s.borderBottomStyle].join('|'),
  210. radius: [s.borderTopLeftRadius, s.borderTopRightRadius, s.borderBottomRightRadius, s.borderBottomLeftRadius].join(' '),
  211. font: s.fontSize + '/' + s.lineHeight + ' ' + s.fontWeight, color: s.color, bg: s.backgroundColor,
  212. display: s.display, gap: s.gap, justify: s.justifyContent, marginTop: s.marginTop };
  213. };
  214. const list = [[f, f.parentElement && f.parentElement.classList.contains('cfoot') ? '' : '']];
  215. const path = (el) => { const p = []; for (let q = el; q && q !== f; q = q.parentElement) p.unshift(q.localName + (typeof q.className === 'string' && q.className ? '.' + q.className.trim().split(/\\s+/).join('.') : '')); return p.join('>'); };
  216. const res = { 'cfoot': one(f) };
  217. /* 同名「元素」只比可共用的外观:内衬 / 边框 / 圆角 / 字体 / 字色 / 底色 / 间距 / 子块对齐。
  218. 位置(margin)和行内排布(justify-content)是两页各自的版式不同,不属于页脚「同名元素」的样式。 */
  219. res['cfoot'].marginTop = undefined; delete res['cfoot'].marginTop;
  220. delete res['cfoot'].justify;
  221. for (const el of f.querySelectorAll('*')) {
  222. if (['svg', 'path', 'circle', 'line', 'polyline', 'rect', 'g'].includes(el.localName)) continue;
  223. res[path(el)] = one(el);
  224. }
  225. return res;
  226. };
  227. /* 两页页脚根节点不同(/reports 是 <footer>,/pool 是 <span>):键用 cfoot,跨页可比 */
  228. return { host: host.localName, footTag: foot.localName, classes: [...classes], hits, snap: snap(foot), mediaHits: rules.filter((r) => /cfoot|kf-btn/.test(r.sel)).map((r) => ({ sel: r.sel.slice(0, 70), media: r.media, css: r.css.slice(0, 70) })).slice(0, 14) };
  229. })()`;
  230. {
  231. await gotoView('/pool', 'view-pool');
  232. const poolFoot = await page.evaluate(FOOT_PROBE);
  233. await gotoView('/reports', 'view-reports');
  234. const repFoot = await page.evaluate(FOOT_PROBE);
  235. /* 同路径元素逐项比对 */
  236. const paths = Object.keys(poolFoot.snap || {}).filter((k) => k in (repFoot.snap || {}));
  237. const diff = [];
  238. for (const k of paths) {
  239. for (const prop of ['padding', 'border', 'radius', 'font', 'color', 'bg', 'display', 'gap']) {
  240. /* ── 以下两处是「嵌在 28px 排序行里」导致的、只由本行几何决定的项(不是页脚外观):
  241. cfoot.border:左边线是行内分隔(上边线在行内没有意义,上/右/下三条边相等才豁免);
  242. cfoot.padding:纵向内衬必须归零(否则 20/12px 的上下衬把 28px 的排序行撑到 61px),右侧归零(与卡片右缘对齐)。
  243. 除这两项外,子元素 / 字体 / 颜色 / 圆角 / 间距 / 底色不豁免。 */
  244. if (k === 'cfoot' && prop === 'border') {
  245. /* 页脚根节点:上 / 右 / 下三条边与 /reports 逐项相等;只在左边多出那条行内分隔线 */
  246. const p = poolFoot.snap[k], r = repFoot.snap[k];
  247. const split = (v) => { const [w, sty, col] = v.split(' / '); return [w.split(' '), sty, col]; };
  248. const [pw, , pc] = split(p.border), [rw, rs, rc] = split(r.border);
  249. if ([0, 1, 2].every((i) => pw[i] === rw[i]) && p.borders === r.borders && pw[3] === '1px' && rs === 'solid' && rc === pc) {
  250. diff.push({ path: k, prop, pool: pw.join(' '), reports: rw.join(' '), note: '豁免:页脚根节点左边线(行内分隔),上/右/下三边相等' }); continue;
  251. }
  252. }
  253. if (k === 'cfoot' && prop === 'padding') {
  254. /* 页脚根节点:横向内衬(左 12 / 右 0 与卡片右缘对齐)保留,纵向归零(28px 排序行) */
  255. const p = poolFoot.snap[k].padding.split(' '), r = repFoot.snap[k].padding.split(' ');
  256. if (p[3] === '12px' && p[1] === '0px' && r[3] === '4px') {
  257. diff.push({ path: k, prop, pool: p.join(' '), reports: r.join(' '), note: '豁免:页脚根节点纵向内衬归零(排序行 28px)、右侧与卡片右缘对齐' }); continue;
  258. }
  259. }
  260. const a = poolFoot.snap[k][prop], b = repFoot.snap[k][prop];
  261. if (a !== b) diff.push({ path: k, prop, pool: a, reports: b });
  262. }
  263. }
  264. out.three = { pool: poolFoot, reports: repFoot, compared: paths, diff };
  265. }
  266. /* ======================= 汇总判分 ======================= */
  267. const checks = [];
  268. const check = (name, ok, detail) => { checks.push({ name, ok, detail }); console.log(`\n${JUDGE ? (ok ? '✓' : '✗') : '·'} ${name}\n ${detail}`); };
  269. console.log(`\n===== v13 三项验收 · tag=${TAG} =====`);
  270. /* 一 */
  271. {
  272. const o = out.one;
  273. const sel = o.selected, un = o.unselected?.[0];
  274. const composed = sel ? hex(rgb(sel.bg)) : [0, 0, 0];
  275. const selLum = lum(composed), oldLum = lum([24, 24, 27]);
  276. /* 「比 rgb(24,24,27) 亮」:叠白底后的至少一个分量 > 24(即高于旧深色的对应分量),且整体亮度更高 */
  277. const brighter = composed.some((v, i) => v > [24, 24, 27][i]) && selLum > oldLum;
  278. const fontSame = un && sel.fontSize === un.fontSize && sel.lineHeight === un.lineHeight && sel.h === un.h;
  279. const white = sel && rgb(sel.color).slice(0, 3).every((v) => v === 255);
  280. const radiiZero = o.btns?.every((b) => b.radius === '0px 0px');
  281. const seps1 = o.seps?.slice(1).every((s) => s.left === '1px' && s.leftStyle === 'solid') && o.seps?.[0].left === '0px';
  282. const outerOk = o.outer?.radius === '8px' && o.outer?.border.startsWith('1px solid');
  283. const h28 = o.outer?.h === 28 && o.btns?.every((b) => b.h === 28);
  284. const m = o.mobile || {};
  285. const mComposed = m.bg ? hex(rgb(m.bg)) : [0, 0, 0];
  286. const mOk = Boolean(m.bg) && lum(mComposed) > oldLum && !(rgb(m.color || '').slice(0, 3).every((v) => v === 255)) && !/^rgba?\(\s*24, 24, 27/.test(m.bg);
  287. check('一 排序段:选中浅底不白字 / 字级一致 / 一整块 1px 分隔 / 高 28', brighter && !white && fontSame && radiiZero && seps1 && outerOk && h28 && mOk,
  288. `选中「${sel?.text}」bg=${sel?.bg} → 叠白底后 ${composed}(亮度 ${selLum.toFixed(1)},旧 rgb(24,24,27) 亮度 ${oldLum.toFixed(1)}),字色 ${sel?.color}` +
  289. `;未选中 ${un?.bg} / ${un?.color};font-size 选中 ${sel?.fontSize} vs 未选中 ${un?.fontSize},line-height ${sel?.lineHeight} vs ${un?.lineHeight},框高 ${sel?.h} vs ${un?.h}` +
  290. `;按钮圆角 [${o.btns?.map((b) => b.radius).join(' | ')}],分隔线 [${o.seps?.map((s) => s.left).join(',')}]` +
  291. `;外层 radius ${o.outer?.radius} border ${o.outer?.border} 高 ${o.outer?.h}` +
  292. `;手机选中 bg=${m.bg}(叠白后 ${mComposed})字色 ${m.color} 字号 ${m.fontSize}`);
  293. }
  294. /* 二 */
  295. function avg(arr, k) { return arr.reduce((n, x) => n + x[k], 0) / arr.length; }
  296. /** 每排(同一 grid 行 top)里的卡高集合:行内一致 = 该行所有卡高相同 */
  297. function byRowMap(pg) {
  298. const m = new Map();
  299. for (const c of pg.cards) { if (!m.has(c.rowIdx)) m.set(c.rowIdx, new Set()); m.get(c.rowIdx).add(c.cardH); }
  300. return m;
  301. }
  302. {
  303. const parts = out.two.pages.map((pg) => {
  304. const withD = pg.cards.filter((c) => c.lead), without = pg.cards.filter((c) => !c.lead);
  305. const dh = withD.length && without.length ? Math.abs(avg(withD, 'contentH') - avg(without, 'contentH')) : null;
  306. const maxGap = Math.max(...pg.cards.map((c) => c.maxGap));
  307. const gapW = withD.length ? avg(withD, 'gapTitleToM') : 0;
  308. const gapN = without.length ? avg(without, 'gapTitleToM') : 0;
  309. /* 等高是「同排」的属性(不同行高本来就会因内容不同而不同,v12 的同排卡高检查管这个):同一行内无描述卡的卡高必须一致。
  310. 另外:无描述卡卡内(内容以外的)留白不得比有描述卡多出 24px 以上。 */
  311. const rowsMixed = [...byRowMap(pg).values()].every((set) => set.size === 1);
  312. /* 卡内非内容留白 = 卡高 − 内容高;无描述卡的留白**多于**有描述卡 24px 以上才算「空得过头」
  313. (少于是正常:少了一段描述,内容按比例铺开时仍会比有描述的更紧) */
  314. const slackW = withD.length ? avg(withD, 'cardH') - avg(withD, 'contentH') : 0;
  315. const slackN = without.length ? avg(without, 'cardH') - avg(without, 'contentH') : 0;
  316. return { pg, withD, without, dh, maxGap, gapW, gapN, rowsMixed, slackW, slackN,
  317. ok: !without.length || (dh !== null && dh <= 40 && maxGap <= 24 && gapN < gapW * 0.8 && rowsMixed && slackN - slackW <= 24) };
  318. });
  319. check('二 空描述卡:不渲染描述区 + 卡内重新均分 + 内容高差 ≤ 40px + 卡内空白 ≤ 24px',
  320. parts.every((x) => x.ok),
  321. parts.map(({ pg, withD, without, dh, maxGap, gapW, gapN, rowsMixed, slackW, slackN }) =>
  322. `${pg.page}:共 ${pg.n} 张,无描述 ${without.length} 张` +
  323. `;无描述卡 .rc/.face 高 [${without.slice(0, 3).map((c) => c.rcH + '/' + c.faceH).join(',')}...]` +
  324. `;有描述卡 gap(标题→指标) 均值 ${gapW.toFixed(1)}px / 无描述卡 ${gapN.toFixed(1)}px` +
  325. `;内容高 有描述 [${withD.map((c) => c.contentH).join(',')}] 无描述 [${without.map((c) => c.contentH).join(',')}] 差 ${dh == null ? '—' : dh.toFixed(1) + 'px'}` +
  326. `;无描述卡同排卡高一致 ${rowsMixed};卡内非内容留白 有描述 ${slackW.toFixed(1)}px / 无描述 ${slackN.toFixed(1)}px;最大连续空白 ${maxGap}px`).join('\n '));
  327. }
  328. /* 三 */
  329. {
  330. const t = out.three;
  331. const poolMiss = (t.pool?.hits || []).filter((h) => h.applies === 0);
  332. const comp = t.compared || [];
  333. const same = comp.filter((p) => !t.diff.some((d) => d.path === p));
  334. check('三 /pool 页脚类名逐项命中(每个类名 ≥1 条生效规则)+ 与 /reports 同名元素 computed 逐项相等',
  335. !t.pool?.missing && poolMiss.length === 0 && comp.length >= 3 && (t.pool.classes || []).every((c) => (t.pool.hits || []).some((h) => h.cls === c && h.applies > 0)) && t.diff.every((d) => d.note),
  336. `类名 ${t.pool?.classes?.length} 个:${(t.pool?.hits || []).map((h) => `${h.cls}(${h.applies} 条生效/${h.rules} 条含)${h.applies ? '' : '✗'}`).join('、')}` +
  337. `;与 /reports 比对 ${comp.length} 个元素 × 8 项(内衬 / 边框 / 圆角 / 字体 / 字色 / 底色 / 显示 / 间距):单元格 ${comp.length * 8},不一致 ${t.diff.filter((d) => !d.note).length}` +
  338. (t.diff.length ? '\n ' + t.diff.filter((d) => !d.note).slice(0, 8).map((d) => `${d.path}.${d.prop}: pool=${d.pool} reports=${d.reports}`).join('\n ') : '') +
  339. (t.diff.some((d) => d.note) ? '\n 豁免:' + t.diff.filter((d) => d.note).map((d) => `${d.path}.${d.prop} ${d.pool}(${d.note})`).join(';') : ''));
  340. }
  341. /* ======================= v12 回归:同原脚本判分(不能退步) ======================= */
  342. console.log('\n----- v12 回归(子进程跑 verify-visual-v12.mjs) -----');
  343. const { spawnSync } = await import('node:child_process');
  344. const reg = spawnSync(process.execPath, [join(ROOT, 'scripts/verify-visual-v12.mjs'), '--json', 'tmp/v13-v12-after.json'], { encoding: 'utf8', env: { ...process.env, VERIFY_PORT: '4195' } });
  345. const regLines = (reg.stdout || '').split('\n');
  346. console.log(regLines.filter((l) => /^(✓|✗) |判分:/.test(l) || /^ /.test(l) && false).join('\n'));
  347. const regLine = regLines.find((l) => /^判分:/.test(l)) || '(v12 脚本无输出)';
  348. const [, pass, total] = regLine.match(/(\d+)\/(\d+)/) || [];
  349. out.v12 = { pass: Number(pass), total: Number(total), line: regLine, fails: regLines.filter((l) => l.startsWith('✗')).map((l) => l.slice(2)) };
  350. check('v12 回归不退步', out.v12.pass >= 11, `${regLine};未达标:${out.v12.fails.join('、') || '无'}`);
  351. if (errors.length) console.log(`\n页面错误 ${errors.length}:\n ${[...new Set(errors)].slice(0, 10).join('\n ')}`);
  352. const fail = checks.filter((c) => !c.ok);
  353. console.log(`\n${JUDGE ? '判分' : '基线(不判分)'}:${checks.length - fail.length}/${checks.length} 达标${errors.length ? `,页面错误 ${errors.length}` : ''}`);
  354. if (JSON_OUT) {
  355. mkdirSync(dirname(join(ROOT, JSON_OUT)), { recursive: true });
  356. writeFileSync(join(ROOT, JSON_OUT), JSON.stringify({ ...out, checks, errors }, null, 2));
  357. }
  358. await browser.close();
  359. srv.kill();
  360. process.exit(JUDGE && (fail.length || errors.length) ? 1 : 0);