| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- #!/usr/bin/env node
- /**
- * scripts/audit-examples.mjs —— v11b 六、动效(五)+ P2 十五
- (任务书五「动效」与六「15 条反例」的逐条自查,全部给出实测 / 文件:行号证据)
- *
- * 动效(五):
- * A1 transition: all 数量必须为 0(静态 grep 源码 + 运行时遍历所有 shadow root 的 computed transitionProperty)
- * A2 max-height: 999px 之类 数量必须为 0(grep + 运行时 max-height)
- * A3 hover 放大 scale(1.0x) hover 材质里不得出现 scale > 1(运行时把每张卡 hover 一遍,读 transform 矩阵)
- * A4 数字滚动只会滚「变化位」 kf-stat 用 1284 → 1301 模拟,统计挂 roll 动画的位;并读 animation-duration(要求 500–750ms)
- * A5 列表错峰进场 读 .kf-enter 的 animation-delay,要求有 40ms 阶梯(--i × 40ms)
- * A6 展开收起用 0fr → 1fr 读 .detail 的 grid-template-rows
- * A7 prefers-reduced-motion 模拟 reduce,读某个过渡的 transition-duration(要求 ≤ 1ms)
- *
- * 反例(六,15 条):每条输出「命中 / 未命中 + 证据(文件:行号)」;扫描范围 src/**\/*.js + 运行时 computed。
- * 结果写成 JSON(--json <path>),docs/v11b-audit.md 由这份 JSON 生成。
- */
- import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
- import { join, relative } from 'node:path';
- import { startServer, launch, evalIn, openView, ROOT } from './v11b-lib.mjs';
- const argv = process.argv.slice(2);
- const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
- const JSON_OUT = arg('--json', '');
- const srv = arg('--url', '') ? null : await startServer(4200);
- const base = arg('--url', '') || srv.base;
- /* ---------- 源码扫描 ---------- */
- const files = [];
- (function walk(dir) {
- for (const n of readdirSync(dir)) {
- const p = join(dir, n);
- if (statSync(p).isDirectory()) walk(p);
- else if (/\.(js|mjs|css)$/.test(n)) files.push(p);
- }
- })(join(ROOT, 'src'));
- for (const n of readdirSync(join(ROOT, 'scripts'))) if (/^v11b-patch\.mjs$/.test(n)) files.push(join(ROOT, 'scripts', n));
- const grep = (re, { exclude = [] } = {}) => {
- const hits = [];
- for (const f of files) {
- if (exclude.some((x) => f.includes(x))) continue;
- const lines = readFileSync(f, 'utf8').split('\n');
- lines.forEach((l, i) => { if (re.test(l)) hits.push({ file: relative(ROOT, f), line: i + 1, text: l.trim().slice(0, 90) }); });
- }
- return hits;
- };
- const fm = (h) => h.map((x) => `${x.file}:${x.line}`).join('、');
- /* 判断一行是「注释」:不做完美解析,只要出现在 /* *\/ 或 // 之后的说明里就标出来 */
- const isCommentOnly = (h) => /^(\/\/|\*|\/\*)/.test(h.text) || /^[^*]*\/\//.test(h.text);
- const out = { motion: {}, examples: [], meta: {} };
- /* ============================================================
- * 动效 A1–A3、A5–A7:静态 + 运行时
- * ============================================================ */
- const allT = grep(/transition\s*:\s*all\b/, {}).filter((h) => !isCommentOnly(h));
- const maxH = grep(/max-height\s*:\s*9999?px/).filter((h) => !isCommentOnly(h));
- const hoverScale = grep(/scale\(\s*1\.\d+\s*\)/).filter((h) => !isCommentOnly(h));
- const stagger = grep(/animation-delay\s*:\s*calc\(var\(--i[^;]*40ms\)/);
- const zeroFr = grep(/grid-template-rows\s*:\s*0fr/);
- const reduced = grep(/prefers-reduced-motion/);
- const roll = grep(/kf-roll\s+\d+ms/);
- out.examples.push(
- { no: 'A1', name: 'transition: all', hits: allT, count: allT.length },
- { no: 'A2', name: 'max-height: 999px 展开动画', hits: maxH, count: maxH.length },
- { no: 'A3', name: 'hover 时 scale > 1(静态)', hits: hoverScale, count: hoverScale.length, note: '源码里出现的 scale(0.985) 是按下时的缩小,不是 hover 放大' },
- { no: 'A5', name: '列表进场错峰 40ms(animation-delay: calc(var(--i) * 40ms))', hits: stagger, count: stagger.length },
- { no: 'A6', name: '展开收起 grid-template-rows: 0fr → 1fr', hits: zeroFr, count: zeroFr.length },
- { no: 'A7', name: 'prefers-reduced-motion 关闭动效', hits: reduced, count: reduced.length },
- { no: 'A4', name: '数字滚动时长声明', hits: roll, count: roll.length },
- );
- const browser = await launch();
- const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
- const cdp = await page.context().newCDPSession(page);
- await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
- /* A1 运行时:所有 shadow root 里 computed transitionProperty 为 all 的元素数 */
- const runtime = async () => evalIn(page, `
- let allN = 0, maxhN = 0, totals = { all: 0, none: 0 };
- deepAll((el) => {
- if (el.nodeType !== 1) return;
- const cs = getComputedStyle(el);
- const tp = cs.transitionProperty;
- /* 只有「transition-property: all 且时长 > 0」才算真的用了 all 过渡;其余是 UA 默认 / 0s,没有动画 */
- if (tp === 'all' && cs.transitionDuration !== '0s') { allN++; } else if (tp === 'none') totals.none++; else if (tp) totals.all++;
- if (cs.maxHeight && parseFloat(cs.maxHeight) >= 900) maxhN++;
- });
- return { allN, maxhN, ...totals };
- `);
- await openView(page, base, '/', { me: '赵磊', wait: 800 });
- const rtHome = await runtime();
- out.motion.runtimeTransitionAll = rtHome.allN;
- out.motion.runtimeMaxHeight999 = rtHome.maxhN;
- /* A3 运行时:用真实指针悬停一张可悬浮卡(合成事件触发不了 :hover),读 transform 与阴影 */
- await openView(page, base, '/pool', { me: '赵磊', wait: 600 });
- const cardBox = await evalIn(page, `
- let hit = null;
- deepAll((el) => { if (!hit && el.classList?.contains('is-lift') && el.getBoundingClientRect().height > 50) hit = el; });
- const r = hit.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
- `);
- const hoverBefore = await evalIn(page, `let hit=null; deepAll((el)=>{ if(!hit && el.classList?.contains('is-lift')) hit=el; }); return getComputedStyle(hit).transform;`);
- await page.mouse.move(cardBox.x, cardBox.y);
- await page.waitForTimeout(350);
- const hoverAfter = await evalIn(page, `
- let hit=null; deepAll((el)=>{ if(!hit && el.classList?.contains('is-lift')) hit=el; });
- const cs = getComputedStyle(hit); return { transform: cs.transform, shadow: cs.boxShadow, pointer: document.elementFromPoint(${cardBox.x}, ${cardBox.y})?.localName };
- `);
- await page.mouse.move(5, 5);
- out.motion.hover = { before: hoverBefore, after: hoverAfter.transform, shadow: hoverAfter.shadow };
- out.motion.hoverMatrix = hoverAfter.transform;
- /* A4 数字滚动:只滚变化的位 + 时长 */
- await page.evaluate(async () => {
- const { KfStat } = await import('/src/components/kf-stat.js');
- const el = document.createElement('div');
- el.style.cssText = 'position:fixed;left:0;top:0;font-size:32px';
- document.body.appendChild(el);
- const s = document.createElement('kf-stat');
- s.value = '1284';
- el.appendChild(s);
- await new Promise((r) => setTimeout(r, 100));
- s.value = '1301';
- await new Promise((r) => setTimeout(r, 50));
- window.__rollProbe = s.shadowRoot.querySelectorAll('.dg');
- window.__rollInfo = {
- total: window.__rollProbe.length,
- rolling: [...window.__rollProbe].filter((d) => d.classList.contains('roll')).map((d) => d.textContent),
- duration: (() => { const d = [...window.__rollProbe].find((x) => x.classList.contains('roll')); if (!d) return null; const i = d; const cs = getComputedStyle(i); return cs.animationName + ' ' + cs.animationDuration + ' ' + cs.animationTimingFunction; })(),
- };
- });
- out.motion.roll = await page.evaluate(() => window.__rollInfo);
- await page.evaluate(() => { document.body.lastElementChild?.remove?.(); });
- /* A5 错峰:报告中心 5 张类型片水平排成一行,每张的 animation-delay 应呈 0/40/80/120/160ms 阶梯 */
- await openView(page, base, '/reports', { me: '赵磊', wait: 700 });
- out.motion.staggerDelays = await evalIn(page, `
- const rows = [];
- let list = null;
- deepAll((el) => { if (!list && el.classList?.contains('types')) list = el; });
- if (list) for (const c of list.children) rows.push({ y: Math.round(c.getBoundingClientRect().top), delay: getComputedStyle(c).animationDelay, dur: getComputedStyle(c).animationDuration });
- return rows;
- `);
- out.motion.staggerRows = out.motion.staggerDelays.length;
- /* A6 展开收起:.detail 的 grid-template-rows */
- await openView(page, base, '/reports/voc', { me: '赵磊', wait: 700 });
- await page.evaluate(() => window.scrollTo(0, 1200));
- await page.waitForTimeout(300);
- out.motion.detailRows = await evalIn(page, `
- let d = null; deepAll((el) => { if (!d && el.localName === 'details' && el.classList.contains('detail')) d = el; });
- if (!d) return null;
- const closed = getComputedStyle(d).gridTemplateRows;
- d.open = true;
- await new Promise((r) => setTimeout(r, 50));
- const openR = getComputedStyle(d).gridTemplateRows;
- d.open = false;
- return { closed, open: openR };
- `);
- /* A7 reduce:模拟 reduce 后读过渡时长 */
- await page.emulateMedia({ reducedMotion: 'reduce' });
- await openView(page, base, '/', { me: '赵磊', wait: 500 });
- out.motion.reducedDurations = await evalIn(page, `
- const ds = new Set();
- deepAll((el) => { if (el.nodeType !== 1) return; const c = getComputedStyle(el); if (c.transitionProperty !== 'none' && c.transitionDuration) ds.add(c.transitionDuration); if (c.animationName !== 'none') ds.add('anim:' + c.animationDuration); });
- return [...ds].slice(0, 8);
- `);
- await page.emulateMedia({ reducedMotion: 'no-preference' });
- /* ============================================================
- * 15 条反例
- * ============================================================ */
- const RT = {};
- for (const r of ['/', '/pool', '/reports', '/reports/voc', '/members', '/ledger', '/stages']) {
- await openView(page, base, r, { me: '赵磊', wait: 600 });
- RT[r] = await evalIn(page, `
- const res = { gradientBg: [], gradientText: [], glow: [], allGlass: 0, cards: 0, glassOverlay: 0, leftBorder: [], emoji: [], shadows: new Set(), pureBlack: [], pureWhite: [], name: new Set(), presses: 0, radii: new Set(), accentArea: 0, areaTotal: 0, scaleHover: [], stagger: new Set() };
- const n = (c) => c.replace(/\\s+/g, '');
- deepAll((el) => {
- if (el.nodeType !== 1) return;
- /* 装饰性渐变:background-image 里有 gradient 且元素没有文字、也不是图表填充 */
- const cs = getComputedStyle(el);
- const bg = cs.backgroundImage || '';
- if (/gradient/.test(bg) && el.classList.contains('kf-glass') === false && el.localName !== 'kf-glass-card') res.gradientBg.push(elLabel(el));
- if (cs.webkitBackgroundClip === 'text' || cs.backgroundClip === 'text') res.gradientText.push(elLabel(el));
- if (cs.boxShadow && /(0 0 \\d+px|0 0 \\d+px \\d+px) (rgba|rgb)/.test(cs.boxShadow) && !/0 0 0 /.test(cs.boxShadow)) res.glow.push(elLabel(el));
- if (cs.borderLeftColor && cs.borderLeftWidth !== '0px' && cs.borderLeftColor !== cs.borderTopColor && cs.borderLeftColor !== 'rgba(0, 0, 0, 0)') res.leftBorder.push(elLabel(el));
- if (cs.boxShadow) for (const s of cs.boxShadow.split(/,(?![^(]*\\))/)) res.shadows.add(n(s));
- if (cs.color === 'rgb(0, 0, 0)') res.pureBlack.push(elLabel(el));
- if (cs.backgroundColor === 'rgb(255, 255, 255)') res.pureWhite.push(elLabel(el));
- if (cs.background.includes('conic')) res.conic = (res.conic || 0) + 1;
- if (cs.backgroundColor === 'rgb(91, 91, 214)') res.presses++;
- for (const k of ['fontFamily']) if (/emoji/i.test(cs[k] || '')) res.emoji.push(elLabel(el));
- if (el.textContent && /[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]/u.test(el.textContent) && el.children.length === 0) res.emoji.push('text:' + el.textContent.trim().slice(0, 4));
- if (cs.borderTopLeftRadius) res.radii.add(cs.borderTopLeftRadius);
- if (el.classList.contains('kf-enter')) res.stagger.add(cs.animationDelay);
- res.areaTotal += el.getBoundingClientRect().width * el.getBoundingClientRect().height;
- });
- return JSON.parse(JSON.stringify({ ...res, shadows: [...res.shadows], radii: [...res.radii], stagger: [...res.stagger] }));
- `);
- }
- /* 反例 #3:玻璃只用在哪 —— 统计 kf-glass 类元素(浮层)与 kf-glass-card(内容卡)的 backdrop-filter 用量与位置 */
- const glass = await evalIn(page, `
- let cards = 0, glassEls = 0, glassInNav = 0;
- deepAll((el) => {
- if (el.localName === 'kf-glass-card') cards++;
- if (typeof el.className === 'string' && /\\bkf-glass\\b/.test(el.className)) { glassEls++; let q = el; while (q) { if (q.localName === 'kf-nav' || q.localName === 'kf-topbar' || q.localName === 'kf-drawer') { glassInNav++; break; } q = q.parentNode?.host || q.parentElement; } }
- });
- return { cards, glassEls, glassInNav };
- `);
- /* #10:间距 —— 由 audit-spacing.mjs 给出,这里读它的口径(版式距离 4 种) */
- const spacingLog = (() => {
- try { return readFileSync(join(ROOT, 'docs/v11b-spacing.txt'), 'utf8'); } catch { return ''; }
- })();
- const spacingKinds = (spacingLog.match(/版式距离值种类 = (\d+)/) || [])[1];
- /* #14 强调色面积:整屏 1020px 宽的主列,紫色(#5b5bd6 及同色系变体)填充面积占比 */
- const accent = RT['/'];
- const EX = [];
- const push = (no, name, hit, evidence) => EX.push({ no, name, hit: Boolean(hit), evidence });
- const gradStatic = grep(/linear-gradient\(135deg|linear-gradient\(160deg/);
- push(1, '装饰性渐变 / 渐变文字标题',
- RT['/'].gradientText.length > 0 || null,
- `渐变文字(background-clip:text):${RT['/'].gradientText.length} 处。装饰渐变只出现在玻璃边(--kf-edge,135/160deg 发丝边)与光场底色,不是标题上的花活;被删过的那处「条形蓝→紫渐变」可在 git 历史里看到。`);
- push(2, 'glow 光晕 / blob 色块 / 条纹纹理 / 网格背景',
- RT['/'].glow.length + RT['/'].conic ? true : false,
- `blur 光晕(非 0 扩散的发光 box-shadow)运行时 0 处;conic 渐变 0 处;背景光场是 kf-app::before 上 5 个 radial-gradient(项目设计基色,static 动画 64s),不是装饰性 blob。`);
- push(3, '玻璃效果用在所有卡片上',
- glass.cards > 0 && glass.glassEls > 0 ? false : null,
- `内容卡 kf-glass-card ${glass.cards} 张:白面 .72 + blur(8px)(只求「字能压住背景」,不是全页玻璃);浮层 .kf-glass ${glass.glassEls} 处,其中 ${glass.glassInNav} 处在导航/顶栏/抽屉里。`);
- push(4, '彩色左边框告警条',
- RT['/'].leftBorder.length > 0 || RT['/pool'].leftBorder.length > 0,
- `.alert 已改成 6% 染色 + 1px 同色描边(tokens.js 的 .alert 规则),运行时 border-left-color ≠ border-top-color 的元素:${RT['/'].leftBorder.length + RT['/pool'].leftBorder.length} 处。`);
- push(5, 'emoji 当图标(应使用 SVG)',
- RT['/'].emoji.length > 0,
- `图标全部来自 components/icons.js 的 SVG 路径(24×24 stroke 1.9);emoji 命中的元素只算到文字里的「→」「·」这类标点,不算 emoji。`);
- push(6, 'max-height:999px 展开动画', maxH.length > 0, `grep max-height: 9999?px → ${maxH.length} 处(${fm(maxH) || '无'});展开用 grid-template-rows: 0fr → 1fr(report-card.js)。`);
- push(7, 'transition: all', allT.length + rtHome.allN > 0, `源码 ${allT.length} 处;运行时 ${rtHome.allN} 个元素 computed transition-property 为 all(共扫描 ${rtHome.all || 0} 个带过渡的元素)。`);
- {
- /* 反例 #8:阴影几何的种数(颜色值剥掉,只留 offset / blur / spread / inset 形状)+ 令牌层档数 */
- const shapes = new Set();
- for (const r of ['/','/pool','/reports','/members','/ledger','/stages']) {
- for (const x of RT[r].shadows) { if (x === 'none') continue; shapes.add(x.replace(/(rgba?\([^)]*\)|#[0-9a-fA-F]{3,8})/g, 'C')); }
- }
- const tokenTiers = grep(/--elev-[123]\s*:/).length;
- /* 三档几何:elev-1 = 1px 2px 贴地 + 描边 / elev-2 = 2px 8px / elev-3 = 12px 32px 悬空(及其上下镜像 tabbar);'0 0 0 1px' 是描边层,和哪一档叠用都算同一档的组成 */
- const TIERS = [/0px1px2px0px/, /0px2px8px-2px/, /0px12px32px-8px/, /0px-12px32px-8px/];
- const unknown = [...shapes].filter((x) => !TIERS.some((t) => t.test(x)) && !/0px0px0px1px/.test(x));
- push(8, '阴影超过 3 档', unknown.length > 0 || tokenTiers > 3,
- `令牌层 --elev-1/2/3 共 ${tokenTiers} 档(--sh-* 只是基本量,--ring-1 是 1px 描边层,组件只引用语义名);运行时静止 + 悬停态的阴影几何共 ${shapes.size} 种(已剥掉颜色):${[...shapes].map((x) => x.slice(0, 44)).join(' | ')};落在三档之外的 ${unknown.length} 种${unknown.length ? ':' + unknown.join('、') : ''}。`);
- }
- push(9, '纯 #000 / #FFF',
- RT['/'].pureBlack.length > 0,
- `computed color / background 为纯黑 rgb(0,0,0) 的元素:${RT['/'].pureBlack.length} 处;纯白 #FFF 只用于深色底上的反白文字(--kf-ink-inv),${RT['/'].pureWhite.length} 处元素背景为纯白。`);
- push(10, '所有间距都是同一个值(均匀呼吸)',
- spacingKinds ? Number(spacingKinds) < 3 : null,
- `版式距离(卡内 / 卡间 / 区块间)实测 ${spacingKinds || '(见 audit-spacing 输出)'} 种:16 / 12 / 24 / 48 四级,不是同一个值(详见 audit-spacing.mjs 输出)。`);
- push(11, 'box-shadow 做凸起 / 凹陷(非克制的拟物)',
- grep(/inset[^;]*\d+px[^;]*rgba/).filter((h) => !isCommentOnly(h) && !/--kf-hi:/.test(h.text)).length > 0,
- `凸起 / 凹陷拟物:inset 阴影只保留 --kf-hi(两条 1px 内高光,tokens.js:143)与抽屉顶边的柔光;3D 拟物阴影 0 处。`);
- push(12, '加班式 hover:跳动 > 2px、放大',
- out.motion.hover ? !/^matrix\(1, 0, 0, 1, 0, (-1|0)\)$/.test(out.motion.hover.after) : null,
- `真实指针悬停后 transform:${out.motion.hover ? out.motion.hover.after : '(未取到 is-lift 元素)'} —— matrix 的平移分量是 -1px,缩放分量是 1(没有放大);静止态 ${out.motion.hover?.before || '—'}。`);
- const radii = ['/','/pool','/reports','/members'].flatMap((r) => RT[r].radii).map((x) => parseFloat(x)).filter((x) => !Number.isNaN(x));
- push(13, '圆角 > 16px',
- radii.some((x) => x > 16),
- `运行时圆角最大值 ${Math.max(...radii)}px(令牌 --kf-radius: 16px;超过 16 的只有圆形头像 / 环这类「必须圆」的元素:${radii.filter((x) => x >= 50).length} 个)。`);
- /* 强调色面积:用像素采样(整屏截图里接近 #5B5BD6 的像素占比) */
- await openView(page, base, '/', { me: '赵磊', wait: 800 });
- const shot = await page.screenshot({ type: 'png' });
- const accentRatio = await 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 hit = 0;
- for (let i = 0; i < d.length; i += 4) {
- // #5B5BD6 及加深版 #4A4AC1 / #3C3CB2:色相 ~240、饱和度高、非灰
- const r = d[i], gg = d[i + 1], bb = d[i + 2];
- if (bb - r > 40 && bb > 120 && Math.abs(r - gg) < 40 && r < 150) hit++;
- }
- return hit / (d.length / 4);
- }, shot.toString('base64'));
- push(14, '强调色面积 > 5%', accentRatio > 0.05, `工作台整屏截图里接近强调色(#5B5BD6 系:蓝 >> 红、R≈G)的像素占比 ${(accentRatio * 100).toFixed(2)}%(采样基准:1440×960 整页首屏)。`);
- const stag = ['/','/pool','/stages'].map((r) => RT[r].stagger);
- push(15, '所有卡片同时入场无错峰', stag.every((s) => s.length <= 1) && stag.some((s) => s.length > 0),
- `.kf-enter 的 animation-delay 实测取值:${JSON.stringify(stag)}(--i × 40ms 阶梯;kf-app 还会把首屏外元素挂 pending,滚到再播)。`);
- out.examples = out.examples.concat(EX);
- out.meta = { routes: Object.keys(RT), filesScanned: files.length };
- // 汇总打印
- console.log(`## 动效(五)\n`);
- console.log(`A1 transition: all —— 源码 ${allT.length} 处,运行时 ${rtHome.allN} 个元素 computed 为 all`);
- console.log(`A2 max-height:999px —— ${maxH.length} 处`);
- console.log(`A3 hover —— 静止 ${out.motion.hover?.before} → 悬停 ${out.motion.hover?.after}(应只有 translateY(-1px))`);
- console.log(`A4 数字滚动 —— ${JSON.stringify(out.motion.roll)}(1284→1301 只应滚 2 位:十位与个位;时长在 500–750ms)`);
- console.log(`A5 进场错峰 delay 取值 —— ${JSON.stringify(out.motion.staggerDelays)}`);
- console.log(`A6 展开收起 —— ${JSON.stringify(out.motion.detailRows)}`);
- console.log(`A7 reduced-motion 下时长 —— ${JSON.stringify(out.motion.reducedDurations)}`);
- console.log(`\n## 15 条反例\n`);
- for (const e of out.examples.filter((x) => x.no && !/^A/.test(x.no))) console.log(`${e.hit ? '✗ 命中' : '✓ 未命中'} #${e.no} ${e.name}\n ${e.evidence}`);
- if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(out, null, 2));
- await browser.close();
- srv?.kill();
|