verify-v14.mjs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-v14.mjs —— v14 验收(只量任务书「一」的四条;「二」是查证,见 docs/v14-report.md)。
  4. *
  5. * node scripts/verify-v14.mjs --tag before 只打印、不判分
  6. * node scripts/verify-v14.mjs 判分:任一项不达标 exit 1
  7. * --json out.json
  8. *
  9. * 量什么(/pool 每张 kf-opportunity-card):
  10. * - 含「未填写」字样的元素数(跨 shadow,取文本节点所在元素)
  11. * - 卡高(host 高)、行内极差
  12. * - 卡内最大连续空白:叶子文字矩形按 top 排,相邻两块竖直间隙的最大值(同 v13 算法)
  13. * - 有描述的卡 .one 文本仍在
  14. */
  15. import { writeFileSync } from 'node:fs';
  16. import { join, dirname } from 'node:path';
  17. import { fileURLToPath } from 'node:url';
  18. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  19. const argv = process.argv.slice(2);
  20. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  21. const TAG = arg('--tag', 'after');
  22. const JUDGE = TAG !== 'before';
  23. const JSON_OUT = arg('--json', '');
  24. if (!process.env.CHROME_PATH) {
  25. const pinned = '/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell';
  26. const { existsSync } = await import('node:fs');
  27. if (existsSync(pinned)) process.env.CHROME_PATH = pinned;
  28. }
  29. const { startServer, launch, DEEP } = await import('./v11b-lib.mjs');
  30. const srv = await startServer(Number(process.env.VERIFY_PORT || 4195));
  31. const browser = await launch();
  32. const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
  33. const page = await ctx.newPage();
  34. const errors = [];
  35. page.on('pageerror', (e) => errors.push(e.message));
  36. await page.goto(new URL('/pool', srv.base).href, { waitUntil: 'networkidle' });
  37. await page.waitForFunction(() => {
  38. const a = document.querySelector('kf-app');
  39. const v = a?.shadowRoot?.querySelector('view-pool');
  40. return v && v.shadowRoot && v.shadowRoot.children.length > 0;
  41. }, null, { timeout: 15000 });
  42. await page.waitForTimeout(400);
  43. const r = await page.evaluate(`(() => { ${DEEP}
  44. const root = appR().querySelector('view-pool').shadowRoot;
  45. const cards = [...root.querySelectorAll('kf-opportunity-card')].filter((c) => c.getBoundingClientRect().height > 0);
  46. const allTextEls = (node, acc = []) => {
  47. for (const el of node.querySelectorAll ? node.querySelectorAll('*') : []) {
  48. if ([...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.includes('未填写'))) acc.push(el);
  49. if (el.shadowRoot) allTextEls(el.shadowRoot, acc);
  50. }
  51. return acc;
  52. };
  53. const info = cards.map((card) => {
  54. const sr = card.shadowRoot;
  55. const face = sr.querySelector('kf-glass-card')?.shadowRoot?.querySelector('.face') || sr.querySelector('.face');
  56. const c = face || sr.querySelector('.wrap');
  57. const one = sr.querySelector('.one');
  58. const textAll = [...c.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()));
  59. const inner = textAll.filter((e) => !textAll.some((o) => o !== e && e.contains(o)));
  60. const boxes = inner.map((e) => { const rr = e.getBoundingClientRect(); return { top: Math.round(rr.top), bottom: Math.round(rr.bottom) }; })
  61. .filter((b) => b.bottom > b.top).sort((a, b) => a.top - b.top);
  62. let maxGap = 0, gapAt = '', lo = null, hi = null;
  63. for (const b of boxes) {
  64. if (hi == null) { lo = b.top; hi = b.bottom; continue; }
  65. if (b.bottom <= lo) { const g = lo - b.bottom; if (g > maxGap) { maxGap = g; gapAt = b.bottom + '→' + lo; } }
  66. lo = Math.min(lo, b.top); hi = Math.max(hi, b.bottom);
  67. }
  68. return { title: (sr.querySelector('h3')?.textContent || '').trim().slice(0, 12),
  69. h: Math.round(card.getBoundingClientRect().height), top: Math.round(card.getBoundingClientRect().top),
  70. hasOne: !!one, oneTxt: one ? one.textContent.trim().slice(0, 20) : null, maxGap, gapAt };
  71. });
  72. const placeholderEls = allTextEls(root).length;
  73. return { n: cards.length, cards: info, placeholderEls };
  74. })()`);
  75. const hs = r.cards.map((c) => c.h);
  76. const rowSpread = new Map();
  77. for (const c of r.cards) { const k = c.top; rowSpread.set(k, [...(rowSpread.get(k) || []), c.h]); }
  78. let maxRowDiff = 0;
  79. for (const v of rowSpread.values()) maxRowDiff = Math.max(maxRowDiff, Math.max(...v) - Math.min(...v));
  80. const withOne = r.cards.filter((c) => c.hasOne), withoutOne = r.cards.filter((c) => !c.hasOne);
  81. const maxGap = Math.max(0, ...r.cards.map((c) => c.maxGap));
  82. console.log(`· /pool 卡片数 ${r.n};卡高 [${hs.join(',')}];行内极差 ${maxRowDiff}px;全部极差 ${Math.max(...hs) - Math.min(...hs)}px`);
  83. console.log(`· 含「未填写」元素数 ${r.placeholderEls}`);
  84. console.log(`· 有描述 ${withOne.length} 张(.one 文本:${withOne.map((c) => c.oneTxt).join(' | ') || '无'});无描述 ${withoutOne.length} 张 ${withoutOne.map((c) => c.title).join(',')}`);
  85. console.log(`· 卡内最大连续空白:${r.cards.map((c) => c.title + '=' + c.maxGap + (c.gapAt ? '@' + c.gapAt : '')).join(', ')}`);
  86. if (errors.length) console.log(`· 页面错误 ${errors.length}:${errors.slice(0, 3).join(' / ')}`);
  87. const ok = r.placeholderEls === 0 && maxRowDiff <= 2 && maxGap <= 24 && withOne.length + withoutOne.length === r.n && errors.length === 0;
  88. const out = { tag: TAG, at: new Date().toISOString(), ...r, maxRowDiff, maxGap, ok };
  89. if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(out, null, 2));
  90. console.log(JUDGE ? (ok ? '判分:4/4 达标' : `判分:不达标(placeholder=${r.placeholderEls} rowDiff=${maxRowDiff} maxGap=${maxGap})`) : '基线(不判分)');
  91. await browser.close();
  92. srv.kill();
  93. process.exit(JUDGE && !ok ? 1 : 0);