Skip to content

Commit 869b9f0

Browse files
committed
test(motion): 加确定性推进底座与 matchMedia 桩
1 parent 9e94d94 commit 869b9f0

4 files changed

Lines changed: 359 additions & 0 deletions

File tree

‎app/src/motion/engine.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,10 @@ gsap.registerPlugin(useGSAP, Flip, ScrollToPlugin, CustomEase);
5050
CustomEase.create("ed-paper-bleed", "M0,0 C0.215,0.61 0.355,1 1,1");
5151

5252
export { gsap, useGSAP };
53+
54+
/**
55+
* `gsap.core.Timeline` 的**转出**(计划 T10 Interfaces 逐字:`export type { gsap }` 转不出命名空间成员)。
56+
* 为什么必须由本文件转出:`import { gsap } from "gsap"` 绑定的**值**会遮蔽 `gsap` 这个**全局命名空间**,
57+
* 使用方直接写 `gsap.core.Timeline` 过不了类型检查。测试底座只以 `import type` 取它(不进产物)。
58+
*/
59+
export type GsapTimeline = gsap.core.Timeline;

‎app/src/test/motionHarness.test.ts‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context motionHarness.test.ts — T10 底座的**自证**(计划 Task 10 的 Verification V1–V4 · 裁决
4+
* R8.1 / R8.2 / R8.3 / R8.4 / R3.2)。被测 = `./motionHarness`(波 B/C 复用的同一支底座)。
5+
*
6+
* 判据纪律(本文件自己也要守):
7+
* · **零 `await sleep` / 零真实定时器 / 零 fake timers** —— 推进会部走 `freezeAt`(R8.1 的正解),
8+
* 全套用例同步跑完;`gsap.ticker` 的墙钟推不动 `{ paused: true }` 的 timeline,读数因此可复现。
9+
* · **不把 jsdom 的 `performance` 挂到 `globalThis`**(R8.3:`Performance-impl.js:13-15` 的 `now()`
10+
* 取全局 `performance` ⇒ 自调用栈溢出)。本文件的 V4 是**静态扫描**:两处禁令串按拼接构造,源码
11+
* 文本里**不出现**完整禁令串(否则扫描器会扫到自己 —— 同 `motion/engine.guard.test.ts` 的先例)。
12+
* · 每条判据各带**专属变异体**(逐条读数见批次报告 `task-10-report.md`);**不设快照**。
13+
*
14+
* 边界:① V1 只证「paused timeline 的 seek 逐字精确」,**不证**真实帧率 / 观感(jsdom 无 paint,
15+
* `getBoundingClientRect` 恒 0 ⇒ 见 `motion.md` 的「不可用判据」);② V3 读的是 `el.style` 的**内联**
16+
* 属性集合,看不见类规则;③ V4 是**文本级**扫描(剥注释、无 AST)—— 它只证「禁令串不在代码里」。
17+
*/
18+
import { readFileSync } from "node:fs";
19+
import { dirname, join } from "node:path";
20+
import { fileURLToPath } from "node:url";
21+
import { afterEach, describe, expect, it, vi } from "vitest";
22+
import { gsap } from "../motion/engine";
23+
import { ANIMATABLE_PROPERTIES } from "../motion/shift";
24+
import { stripComments } from "../ui/primitives/sliceScan";
25+
import { animatedProps, currentTransform, freezeAt, installMatchMediaStub, tweenCount } from "./motionHarness";
26+
27+
const HERE = dirname(fileURLToPath(import.meta.url));
28+
/** reduced-motion 查询串(底座不导出它 ⇒ 这里独立写死:两边分叉时 V2c 的 conditions 当场红)。 */
29+
const REDUCED = "(prefers-reduced-motion: reduce)";
30+
31+
/** 游离元素(挂进 `document.body` 让 inline style 生效)。 */
32+
function mkDiv(): HTMLDivElement {
33+
const el = document.createElement("div");
34+
document.body.appendChild(el);
35+
return el;
36+
}
37+
38+
afterEach(() => {
39+
// 局部桩纪律(R3.2):谁装谁 `restore()`;这里再兜一次,防某条用例中途失败留下全局残留
40+
vi.unstubAllGlobals();
41+
for (const el of [...document.body.children]) {
42+
gsap.killTweensOf(el);
43+
el.remove();
44+
}
45+
});
46+
47+
describe("V1 确定性推进的唯一正解:`gsap.timeline({paused:true})` + `freezeAt`(R8.1)", () => {
48+
it("V1a 逐字精确:`power2` / dur 0.5 / x:0→100 在 t=0.125 与 0.25 的读数与尖刺实测逐字一致", () => {
49+
const el = mkDiv();
50+
const tl = gsap.timeline({ paused: true });
51+
const ret = tl.to(el, { x: 100, duration: 0.5, ease: "power2.out" });
52+
53+
// R8.3 的陷阱:`tl.to()` 返回 **Timeline 本身**,不是 Tween(把它当 tween 用 ⇒ 断言恒真 = 假绿)
54+
expect(ret, "`tl.to()` 必须返回 Timeline 本身").toBe(tl);
55+
expect(tl.getChildren(), "tween 句柄只能从 `getChildren()` 取").toHaveLength(1);
56+
expect(tweenCount(el)).toBe(1);
57+
expect(currentTransform(el), "seek 之前不得有内联 transform(防上一用例的残留做假)").toBe("");
58+
59+
freezeAt(tl, 0.125);
60+
expect(currentTransform(el)).toBe("translate3d(57.8125px, 0px, 0px)");
61+
freezeAt(tl, 0.25);
62+
expect(currentTransform(el)).toBe("translate3d(87.5px, 0px, 0px)");
63+
expect(gsap.getProperty(el, "x"), "数值口径与字符串口径必须同源").toBe(87.5);
64+
expect(freezeAt(tl, 0.25), "`freezeAt` 必须返回同一个 timeline(可链式)").toBe(tl);
65+
});
66+
67+
it("V1b `tweenCount` 是双断言的第一半(R8.2):两个 tween 并存 ⇒ 2,`killTweensOf` 后 ⇒ 0", () => {
68+
const el = mkDiv();
69+
// 注意:这里必须用 `gsap.to`(根级)而不是 `tl.to`(后者返回 timeline)
70+
gsap.to(el, { x: 10, duration: 1 });
71+
gsap.to(el, { x: 20, duration: 1 });
72+
expect(tweenCount(el), "默认 `overwrite:false` ⇒ 两个 tween 并存(只看 transform 会假绿)").toBe(2);
73+
74+
gsap.killTweensOf(el);
75+
expect(tweenCount(el)).toBe(0);
76+
});
77+
});
78+
79+
describe("V2 `matchMedia` 桩必须走 legacy 分支(R3.2 · 尖刺 S2.8③)", () => {
80+
it("V2a jsdom 30 本就没有 `window.matchMedia`(桩不是奢侈品;也是 V2d 的读数基线)", () => {
81+
expect(typeof window.matchMedia).toBe("undefined");
82+
});
83+
84+
it("V2b 直接订阅:`addListener` 增、`removeListener` 减 —— legacy 两方法都**真被调用**(不是摆设)", () => {
85+
const stub = installMatchMediaStub({ reduce: true });
86+
try {
87+
const mq = window.matchMedia(REDUCED);
88+
expect(mq.matches, "`matches === true` 必须读得到").toBe(true);
89+
expect(mq.media, "查询串必须逐字透传").toBe(REDUCED);
90+
expect(stub.listeners()).toBe(0);
91+
92+
const listener = (): void => undefined;
93+
mq.addListener(listener);
94+
expect(stub.listeners(), "`addListener` 没进挂载集合").toBe(1);
95+
mq.removeListener(listener);
96+
expect(stub.listeners(), "`removeListener` 没把监听器摘掉(只加不减 = 摆设)").toBe(0);
97+
} finally {
98+
stub.restore();
99+
}
100+
});
101+
102+
it("V2c GSAP 侧真走 legacy:`gsap.matchMedia().add()` 后 `listeners() >= 1`(只实现 addEventListener 会是 0)", () => {
103+
const stub = installMatchMediaStub({ reduce: true });
104+
const mm = gsap.matchMedia();
105+
try {
106+
const seen: unknown[] = [];
107+
mm.add({ reduce: REDUCED }, (ctx) => {
108+
seen.push(ctx.conditions);
109+
});
110+
expect(seen, "handler 必须**同步**跑一次(尖刺 S2.8③ 的 handlerRanSynchronously)").toEqual([{ reduce: true }]);
111+
expect(
112+
stub.listeners(),
113+
"GSAP 没走 legacy 分支(`mq.addListener ? … : mq.addEventListener`,gsap-core.js:4078)⇒ 桩的 addListener 是摆设",
114+
).toBeGreaterThanOrEqual(1);
115+
} finally {
116+
mm.revert();
117+
stub.restore();
118+
}
119+
});
120+
121+
it("V2d `restore()` 复原「原本就没有」:`delete` 回 `undefined`(局部桩不得泄漏到同文件后续用例)", () => {
122+
const stub = installMatchMediaStub({ reduce: false });
123+
try {
124+
expect(typeof window.matchMedia).toBe("function");
125+
expect(window.matchMedia(REDUCED).matches, "`reduce:false` ⇒ matches 必须是 false").toBe(false);
126+
expect(window.matchMedia("(min-width: 700px)").matches, "非 reduced 查询一律 false(不做「全真」糊桩)").toBe(false);
127+
} finally {
128+
stub.restore();
129+
}
130+
expect(typeof window.matchMedia).toBe("undefined");
131+
expect("matchMedia" in window, "restore 必须 delete 掉自建属性,而不是留一个 `undefined`").toBe(false);
132+
});
133+
});
134+
135+
describe("V3 属性集合读取(R8.4 的代理判据口径)", () => {
136+
it("V3a 动 `x + opacity` ⇒ 内联属性集合恰 5 个(逐序数组相等)且 ⊆ `ANIMATABLE_PROPERTIES`", () => {
137+
const el = mkDiv();
138+
gsap.to(el, { x: 50, opacity: 0.5, duration: 0 });
139+
140+
const props = animatedProps(el);
141+
expect(props, "读数必须逐序等于尖刺实测的 5 个合成属性").toEqual(["opacity", "rotate", "scale", "transform", "translate"]);
142+
expect(ANIMATABLE_PROPERTIES, "白名单真源(T9 交付)逐序").toEqual(["transform", "translate", "rotate", "scale", "opacity", "filter"]);
143+
expect(props.every((p) => ANIMATABLE_PROPERTIES.includes(p)), "被动画属性越出白名单").toBe(true);
144+
});
145+
146+
it("V3b 对照(**这是 V3a 的牙**):动 `width` ⇒ 集合含 `width` ⇒ 落在白名单之外", () => {
147+
const el = mkDiv();
148+
gsap.to(el, { width: 50, duration: 0 });
149+
150+
const props = animatedProps(el);
151+
expect(props, "对照组必须真的写出 layout 属性(删掉本句 ⇒ V3a 退化为「任何集合都 ⊆」的空真)").toContain("width");
152+
expect(props.some((p) => !ANIMATABLE_PROPERTIES.includes(p)), "layout 属性混进来却没被告发").toBe(true);
153+
});
154+
});
155+
156+
// 禁令串一律**拼接构造**:本文件也在 V4b 的扫描域内 ⇒ 源码文本里出现完整禁令串会自伤
157+
// (同 `motion/engine.guard.test.ts` 的 `fromSpec()` 先例)。
158+
const Q = '"';
159+
const NEEDLES: readonly string[] = [
160+
"globalThis." + "performance =",
161+
"Object.defineProperty(globalThis, " + Q + "performance" + Q,
162+
"gsap." + "updateRoot(",
163+
"." + "ticker.tick(",
164+
"." + "ticker.sleep(",
165+
"await " + "sleep(",
166+
];
167+
/** 底座两件(`motionHarness.ts` + 本文件):波 B/C 直接复用的面 + 它自己的自证 */
168+
const SCANNED: readonly string[] = [join(HERE, "motionHarness.ts"), join(HERE, "motionHarness.test.ts")];
169+
170+
/** 一段源码里命中的禁令串(**剥注释后**扫;纯函数,行为由 V4a 双侧自证)。 */
171+
const bannedHits = (src: string): string[] => {
172+
const code = stripComments(src);
173+
return NEEDLES.filter((n) => code.includes(n));
174+
};
175+
176+
describe("V4 静态禁令扫描(剥注释 · R8.3 的 `performance` 陷阱 + 四个禁用驱动)", () => {
177+
it("V4a 阳性对照:禁令串出现在**代码**里 6 条全中(逐序数组相等);只出现在注释里 ⇒ 0", () => {
178+
const asCode = NEEDLES.map((n) => "const a = " + n + " 1;").join("\n");
179+
expect(bannedHits(asCode), "扫描器对已知样本必须逐序报全 —— 否则 V4b 的 0 命中可能是扫描器坏了").toEqual([...NEEDLES]);
180+
181+
const asComment = NEEDLES.map((n) => "// " + n + " 1;").join("\n");
182+
expect(bannedHits(asComment), "剥注释失效 ⇒ 底座文件头(逐字写了四个禁用项)会自命中").toEqual([]);
183+
});
184+
185+
it("V4b 底座两件零命中(防空扫:两文件都必须真读得到、且都在盘上)", () => {
186+
const texts = SCANNED.map((f) => readFileSync(f, "utf8"));
187+
expect(texts.map((t) => t.length).every((n) => n > 1000), "扫描域缩到空/读错文件 ⇒ 「0 命中」一文不值").toBe(true);
188+
for (const [i, text] of texts.entries()) {
189+
expect(bannedHits(text), `${SCANNED[i]} 出现禁令串:${bannedHits(text).join(" / ")}`).toEqual([]);
190+
}
191+
});
192+
});

0 commit comments

Comments
 (0)