/** * `scripts/utils/check-motion-constraints.ts` 的回归测试。 * * 这个门禁的存在理由(N-29 + N-22/N-24/A-6「永不可失败的门禁」),决定了测试的形状: * 每条规则都必须**两头都被证明** —— * ① 合规夹具必须判 0(否则门禁是「常红」,会把人训练成忽略它); * ② 违规夹具必须判非 0,且**是它对应的那条规则**判红(否则门禁是「常绿」)。 * 再加一条元测试:断言四条规则的并集 == 声明的规则全集,任何一条规则变得不可触发即红。 * * 跑法(jest 的 roots 是 src,故本目录需显式指定):npm run check:motion:test */ import fs from 'fs'; import os from 'os'; import path from 'path'; import { spawnSync } from 'child_process'; import { MOTION_RULES, EXIT_CLEAN, EXIT_VIOLATION, EXIT_UNMEASURABLE, markTransitionLines, parseDurationMs, parseTailwindDuration, runScan, type RuleId, } from '../check-motion-constraints'; const SCRIPT_PATH = path.resolve(__dirname, '..', 'check-motion-constraints.ts'); /** 合规令牌层:曲线允许集合与 --transition-* 档位都齐活(脚本从这里读允许集合,不硬编码) */ const CLEAN_CSS = ` :root { --transition-instant: 100ms; --transition-fast: 180ms; --transition-normal: 280ms; --transition-slow: 450ms; --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --ease-ink: cubic-bezier(0.22, 1, 0.36, 1); --card-transition: transform var(--transition-normal) var(--ease-ink); } .card { transition: color var(--transition-fast) var(--ease-ink); } .glow { animation: pulse-soft 2s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } `; const CLEAN_TSX = ` 'use client'; import { motion } from 'framer-motion'; const EASE_OUT = [0.22, 1, 0.36, 1] as const; export function Card() { return ( ); } `; const CONFIG_JS = ` module.exports = { theme: { extend: { animation: { 'fade-in-up': 'fadeInUp 0.7s var(--ease-ink) forwards', 'pulse-soft': 'pulse-soft 2s ease-in-out infinite', }, }, }, }; `; let workspace: string; beforeAll(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-gate-')); }); afterAll(() => { fs.rmSync(workspace, { recursive: true, force: true }); }); /** * 写一棵最小夹具树并按门禁口径跑一遍。 * 每次调用用独立子目录,避免用例间互相污染。 */ function scanFixture(fixture: { css?: string; source?: string; config?: string; omitSrc?: boolean; omitCss?: boolean; omitConfig?: boolean; }) { const root = fs.mkdtempSync(path.join(workspace, 'case-')); fs.mkdirSync(path.join(root, 'src', 'app'), { recursive: true }); fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true }); if (!fixture.omitCss) { fs.writeFileSync(path.join(root, 'src', 'app', 'globals.css'), fixture.css ?? CLEAN_CSS, 'utf8'); } if (!fixture.omitSrc) { fs.writeFileSync( path.join(root, 'src', 'components', 'card.tsx'), fixture.source ?? CLEAN_TSX, 'utf8', ); } if (!fixture.omitConfig) { fs.writeFileSync(path.join(root, 'tailwind.config.js'), fixture.config ?? CONFIG_JS, 'utf8'); } const result = runScan({ root, srcDir: 'src', cssFiles: ['src/app/globals.css'], configFiles: ['tailwind.config.js'], }); return { root, ...result, rules: new Set(result.violations.map((violation) => violation.rule)), countOf: (rule: RuleId) => result.violations.filter((violation) => violation.rule === rule).length, byRule: (rule: RuleId) => result.violations.filter((violation) => violation.rule === rule), }; } describe('parseDurationMs —— 时长单位口径', () => { it('ms 与 s 都能解析,非时长形态返回 null', () => { expect(parseDurationMs('180ms')).toBe(180); expect(parseDurationMs(' 0.6s')).toBe(600); expect(parseDurationMs('.28s')).toBeNull(); // 前导点写法不被承认,故不会静默算成 0 expect(parseDurationMs('24px')).toBeNull(); expect(parseDurationMs('var(--transition-fast)')).toBeNull(); }); }); describe('parseTailwindDuration —— Tailwind 时长类的单位口径', () => { it('裸数字按 ms 解释,带单位则换算(duration-900 必须是 900ms,不能被静默忽略)', () => { expect(parseTailwindDuration('150')).toBe(150); expect(parseTailwindDuration('900')).toBe(900); expect(parseTailwindDuration('0.6s')).toBe(600); expect(parseTailwindDuration('1200ms')).toBe(1200); }); }); describe('markTransitionLines —— 区分 framer transition 对象与其他 duration', () => { it('只在 transition 对象内部为 true', () => { const lines = [ 'const variants = {', ' visible: {', ' transition: {', ' duration: 0.28,', ' },', ' },', '};', "toast('已保存', { duration: 3000 });", ]; // 只有 transition 对象自身(声明行 + 其内部行)为 true;外层对象与末尾的 sonner toast 都不算 expect(markTransitionLines(lines)).toEqual([false, false, true, true, false, false, false, false]); }); }); describe('合规夹具:门禁必须判 0(防「常红」)', () => { it('干净令牌层 + 干净源码 → EXIT_CLEAN,且确实看到了文件', () => { const result = scanFixture({}); expect(result.violations).toEqual([]); expect(result.exitCode).toBe(EXIT_CLEAN); expect(result.counts.sourceFiles).toBe(1); expect(result.counts.durationLiterals).toBeGreaterThan(0); expect(result.allowedCurves.length).toBeGreaterThanOrEqual(2); }); it('infinite 循环动画与 !important reduced-motion 硬开关按契约豁免', () => { const result = scanFixture({}); expect(result.countOf('entrance-cap')).toBe(0); expect(result.countOf('css-transition-token')).toBe(0); }); it('*-delay 是延迟/错落而非时长:800ms 的 animation-delay 不判 entrance-cap', () => { // 回归:首轮实现把 .animation-delay-800 误判成超上限(stagger 属 CONTEXT.md:78 的另一条口径) const result = scanFixture({ css: `${CLEAN_CSS}\n.animation-delay-800 { animation-delay: 800ms; }\n.slow-in { transition-delay: 900ms; }\n`, }); expect(result.countOf('entrance-cap')).toBe(0); expect(result.countOf('css-transition-token')).toBe(0); }); it('允许集合来自 CSS 令牌层而非硬编码:自定义曲线声明后被使用即合规', () => { const result = scanFixture({ css: ` :root { --transition-fast: 200ms; --transition-normal: 280ms; --ease-ink: cubic-bezier(0.22, 1, 0.36, 1); --ease-bespoke: cubic-bezier(0.1, 0.2, 0.3, 0.4); } `, source: `const EASE_CUSTOM = [0.1, 0.2, 0.3, 0.4] as const; export const C = () => ; `, }); expect(result.violations).toEqual([]); }); }); describe('违规夹具:每条规则都必须能判红(防「常绿」)', () => { it('R1 token-band —— fast 超入场档 / instant 非反馈档', () => { const result = scanFixture({ css: ` :root { --transition-instant: 250ms; --transition-fast: 400ms; --transition-normal: 280ms; --ease-ink: cubic-bezier(0.22, 1, 0.36, 1); } `, }); expect(result.exitCode).toBe(EXIT_VIOLATION); expect(result.countOf('token-band')).toBe(2); expect(result.byRule('token-band').map((v) => v.token)).toEqual(['250ms', '400ms']); }); it('R2 entrance-cap —— 令牌 1200ms / framer 1.4s / duration-900 类 / 配置串 1.1s', () => { const result = scanFixture({ css: ` :root { --transition-instant: 100ms; --transition-fast: 180ms; --transition-normal: 280ms; --transition-gentle: 1200ms; --ease-ink: cubic-bezier(0.22, 1, 0.36, 1); } .banner { transition: opacity var(--transition-gentle) var(--ease-ink); } `, source: `export const Bad = () => ( ); `, config: `module.exports = { theme: { extend: { animation: { 'slow-in': 'fadeIn 1.1s var(--ease-ink) forwards' } } } };`, }); expect(result.exitCode).toBe(EXIT_VIOLATION); // 令牌自身 + 经 var() 解析到该令牌的声明 + framer 1.4s + duration-900 类 + 内联 0.9s + 配置串 1.1s expect(result.countOf('entrance-cap')).toBe(6); expect(result.byRule('entrance-cap').map((violation) => violation.file)).toEqual([ 'src/app/globals.css', 'src/app/globals.css', 'src/components/card.tsx', 'src/components/card.tsx', 'src/components/card.tsx', 'tailwind.config.js', ]); }); it('R3 css-transition-token —— 裸 ms/s 字面量与悬空 var() 引用', () => { const result = scanFixture({ css: ` :root { --transition-instant: 100ms; --transition-fast: 180ms; --transition-normal: 280ms; --ease-ink: cubic-bezier(0.22, 1, 0.36, 1); } .glow-border::before { transition: opacity 0.6s var(--ease-ink); } .ghost { transition: transform var(--transition-ghost) var(--ease-ink); } `, }); expect(result.exitCode).toBe(EXIT_VIOLATION); expect(result.countOf('css-transition-token')).toBe(2); expect(result.byRule('css-transition-token').map((v) => v.token)).toEqual(['0.6s', 'var(--transition-ghost)']); }); it('R4 easing-palette —— 非令牌曲线(framer 数组 / 具名常量 / CSS / Tailwind 任意值)与 --ease-ink 基准漂移', () => { const result = scanFixture({ css: ` :root { --transition-instant: 100ms; --transition-fast: 180ms; --transition-normal: 280ms; --ease-ink: cubic-bezier(0.4, 0, 0.2, 1); } .bad { transition: opacity var(--transition-fast) cubic-bezier(0.9, 0, 0.1, 0.9); } `, source: `const EASE = [0.16, 1, 0.3, 1] as const; export const Bad = () => ( ); `, }); expect(result.exitCode).toBe(EXIT_VIOLATION); // --ease-ink 基准 + CSS 写死曲线 + const EASE + ease: [] + Tailwind 任意值 expect(result.countOf('easing-palette')).toBe(5); const files = result.byRule('easing-palette').map((violation) => violation.file); expect(new Set(files)).toEqual(new Set(['src/app/globals.css', 'src/components/card.tsx'])); }); it('测试文件与被排除目录不参与判定(e2e 断言里出现违规是合规的)', () => { const root = fs.mkdtempSync(path.join(workspace, 'excluded-')); fs.mkdirSync(path.join(root, 'src', 'app'), { recursive: true }); fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true }); fs.writeFileSync(path.join(root, 'src', 'app', 'globals.css'), CLEAN_CSS, 'utf8'); fs.writeFileSync(path.join(root, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8'); fs.writeFileSync( path.join(root, 'src', 'components', 'card.test.tsx'), 'const EASE = [0.99, 0.98, 0.97, 0.96] as const;\n', 'utf8', ); fs.writeFileSync(path.join(root, 'tailwind.config.js'), CONFIG_JS, 'utf8'); const result = runScan({ root, srcDir: 'src', cssFiles: ['src/app/globals.css'], configFiles: ['tailwind.config.js'], }); expect(result.exitCode).toBe(EXIT_CLEAN); expect(result.counts.sourceFiles).toBe(1); }); }); describe('元测试:规则可达性(N-22 / N-24 / A-6 的直接防线)', () => { it('四条规则各自都有至少一个能触发它的夹具', () => { const fixtures = [ scanFixture({ css: CLEAN_CSS.replace('--transition-fast: 180ms', '--transition-fast: 999ms') }), scanFixture({ source: CLEAN_TSX.replace('duration: 0.28', 'duration: 2.8') }), scanFixture({ css: `${CLEAN_CSS}\n.x { transition: opacity 0.6s var(--ease-ink); }` }), scanFixture({ source: CLEAN_TSX.replace('[0.22, 1, 0.36, 1]', '[0.7, 0.7, 0.7, 0.7]') }), ]; const covered = new Set(); for (const fixture of fixtures) { expect(fixture.exitCode).toBe(EXIT_VIOLATION); for (const violation of fixture.violations) covered.add(violation.rule); } expect([...covered].sort()).toEqual([...MOTION_RULES.map((rule) => rule.id)].sort()); }); it('MOTION_RULES 声明的四条规则 id 唯一且与退出码语义一致', () => { const ids = MOTION_RULES.map((rule) => rule.id); expect(new Set(ids).size).toBe(ids.length); expect([EXIT_CLEAN, EXIT_VIOLATION, EXIT_UNMEASURABLE]).toEqual([0, 1, 2]); }); }); describe('「空扫描 ≠ 干净」:无法度量必须判 2', () => { it('源码目录不存在 → 2', () => { const result = scanFixture({ omitSrc: true }); // 目录仍在(夹具建了 src/app),故这里用空目录场景另证 expect(result.counts.sourceFiles).toBe(0); expect(result.exitCode).toBe(EXIT_UNMEASURABLE); }); it('CSS 令牌文件缺失 → 2', () => { const result = scanFixture({ omitCss: true }); expect(result.exitCode).toBe(EXIT_UNMEASURABLE); expect(result.blockers.join(' ')).toContain('CSS 令牌文件不存在'); }); it('令牌层解析不到任何 --ease-* / --transition-* → 2(不允许静默放行)', () => { const result = scanFixture({ css: '.a { transition: opacity 200ms linear; }' }); expect(result.exitCode).toBe(EXIT_UNMEASURABLE); expect(result.blockers.join(' ')).toContain('--ease-*'); }); it('tailwind.config.js 缺失 → 2', () => { const result = scanFixture({ omitConfig: true }); expect(result.exitCode).toBe(EXIT_UNMEASURABLE); expect(result.blockers.join(' ')).toContain('tailwind.config.js'); }); }); describe('CLI 退出码 plumbing(process.exit 真的接上了判定)', () => { function runCli(root: string, extra: string[] = []) { return spawnSync('npx', ['tsx', SCRIPT_PATH, '--root', root, ...extra], { encoding: 'utf8', cwd: path.resolve(__dirname, '..', '..', '..'), }); } it('合规夹具 0 / 违规夹具 1 / 目标缺失 2', () => { const clean = fs.mkdtempSync(path.join(workspace, 'cli-clean-')); fs.mkdirSync(path.join(clean, 'src', 'app'), { recursive: true }); fs.mkdirSync(path.join(clean, 'src', 'components'), { recursive: true }); fs.writeFileSync(path.join(clean, 'src', 'app', 'globals.css'), CLEAN_CSS, 'utf8'); fs.writeFileSync(path.join(clean, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8'); fs.writeFileSync(path.join(clean, 'tailwind.config.js'), CONFIG_JS, 'utf8'); expect(runCli(clean).status).toBe(EXIT_CLEAN); const dirty = fs.mkdtempSync(path.join(workspace, 'cli-dirty-')); fs.mkdirSync(path.join(dirty, 'src', 'app'), { recursive: true }); fs.mkdirSync(path.join(dirty, 'src', 'components'), { recursive: true }); fs.writeFileSync( path.join(dirty, 'src', 'app', 'globals.css'), `${CLEAN_CSS}\n.bad { transition: opacity 1.2s cubic-bezier(0.9, 0, 0.1, 0.9); }\n`, 'utf8', ); fs.writeFileSync(path.join(dirty, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8'); fs.writeFileSync(path.join(dirty, 'tailwind.config.js'), CONFIG_JS, 'utf8'); const dirtyRun = runCli(dirty); expect(dirtyRun.status).toBe(EXIT_VIOLATION); expect(dirtyRun.stdout).toContain('css-transition-token'); const missing = fs.mkdtempSync(path.join(workspace, 'cli-missing-')); expect(runCli(missing).status).toBe(EXIT_UNMEASURABLE); }, 120000); });