Files
novalon-website/scripts/utils/__tests__/check-motion-constraints.test.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

432 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `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 (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, ease: EASE_OUT }}
className="transition-colors duration-150"
/>
);
}
`;
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<RuleId>(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 = () => <motion.div transition={{ duration: 0.2, ease: [0.1, 0.2, 0.3, 0.4] }} />;
`,
});
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 = () => (
<motion.div
transition={{ duration: 1.4, ease: [0.22, 1, 0.36, 1] }}
className="transition-all duration-900"
style={{ transition: 'opacity 0.9s var(--ease-ink)' }}
/>
);
`,
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 = () => (
<motion.div
transition={{ duration: 0.28, ease: [0.25, 1, 0.5, 1] }}
className="ease-[cubic-bezier(0.5, 0.5, 0.5, 0.5)]"
/>
);
`,
});
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<RuleId>();
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);
});