Skip to content

Commit a53fb50

Browse files
committed
feat(motion): 加可中断动效出口与判据模板
1 parent e3719d0 commit a53fb50

2 files changed

Lines changed: 316 additions & 0 deletions

File tree

‎app/src/motion/controls.test.ts‎

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context controls.test.ts — 可中断 / 可反向出口的**判据模板**(批 6 T11 · 裁决 R8.2 / R8.4 ·
4+
* 计划 Task 11 Step 2 的四条判据,按「一条判据一个用例、各带专属变异体」拆开)。
5+
*
6+
* 这是**波 C 的复制母本**:六个签名动效(#1–#6)与列折叠的用例照本文件写 —— 双断言(状态机 + DOM)、
7+
* 回走逐序、属性集合审计、reduced-motion 降级四条;每条都能在专属变异体下变红(逐条读数见
8+
* `task-11-report.md`)。
9+
*
10+
* 判据纪律:① 推进**只用** `freezeAt`(R8.1 的唯一正解;本文件零 `await`、零真实定时器、零 fake timers);
11+
* ② 双断言**同时**断状态机(`globalTimeline` 计数 / 旧 tween 的 `totalTime()` / `tweenCount`)**与**
12+
* `currentTransform` —— 只看后者会假绿(R8.2);③ **绝不可**把 jsdom 的 `performance` 挂到 `globalThis`;
13+
* ④ `tl.to()` 返回 Timeline 不是 Tween ⇒ tween 句柄一律走 `timeline.getChildren()[0]`;⑤ 所有时间线经
14+
* `track()` 登记、由 `afterEach` 统一 kill ⇒ 每条用例的 `globalTimeline` 计数都是**干净**读数。
15+
*
16+
* `globalTimeline.getChildren()` 的**默认口径是递归**的:一条时间线 + 它的 1 个 tween = **2**
17+
* (`getChildren(false, true, true)` 才是只数直接子项 = 1);两种读数下面都给,与 `motion.md:121` 的
18+
* 写法(不带实参)保持一致。
19+
*
20+
* 边界:① 本文件判的是 jsdom 里的**状态机正确性**(旧 tween 死了、新 tween 掌权),**不是**「用户看到的
21+
* 接管」;② 真实帧率**本批未测**(jsdom 不可达)⇒ 性能面只有属性集合代理判据(R8.4);③ 三档强度
22+
* (`data-motion`)不在出口的判据域(那是 CSS 侧的幅度通道)。
23+
*/
24+
import { afterEach, describe, expect, it } from "vitest";
25+
import { animatedProps, currentTransform, freezeAt, installMatchMediaStub, tweenCount } from "../test/motionHarness";
26+
import { assertAnimatable, startControllable } from "./controls";
27+
import type { Controllable } from "./controls";
28+
import { gsap } from "./engine";
29+
import { ANIMATABLE_PROPERTIES } from "./shift";
30+
31+
/** 本文件建的时间线全部登记在此、`afterEach` 收尸(否则跨用例残留会让精确计数变假)。 */
32+
const live: Controllable[] = [];
33+
const track = (c: Controllable): Controllable => {
34+
live.push(c);
35+
return c;
36+
};
37+
38+
function mkDiv(): HTMLDivElement {
39+
const el = document.createElement("div");
40+
document.body.appendChild(el);
41+
return el;
42+
}
43+
44+
afterEach(() => {
45+
for (const c of live.splice(0)) c.timeline.kill();
46+
for (const el of [...document.body.children]) {
47+
gsap.killTweensOf(el);
48+
el.remove();
49+
}
50+
});
51+
52+
describe("V1 可中断(R8.2 本体:双断言,缺一即假绿)", () => {
53+
it("V1a `interrupt()` 后旧 tween 死透(状态机)**且** DOM 归新 tween", () => {
54+
expect(gsap.globalTimeline.getChildren(), "同文件前置用例有残留 ⇒ 下面的精确计数会被做假").toHaveLength(0);
55+
const el = mkDiv();
56+
const a = track(startControllable(el, { x: 100, duration: 1, ease: "none" }, { paused: true }));
57+
const aTween = a.timeline.getChildren()[0];
58+
freezeAt(a.timeline, 0.3);
59+
expect(currentTransform(el)).toBe("translate3d(30px, 0px, 0px)");
60+
expect(gsap.globalTimeline.getChildren(), "一条时间线 + 它的 1 个 tween(默认口径递归)").toHaveLength(2);
61+
62+
const next: { handle: Controllable | null } = { handle: null };
63+
a.interrupt(() => {
64+
next.handle = track(startControllable(el, { x: 50, duration: 1, ease: "none" }, { paused: true }));
65+
});
66+
const b = next.handle;
67+
if (b === null) throw new Error("`interrupt(next)` 没有**立即**起下一个 —— 探针失效(排队 = 不接管)");
68+
freezeAt(b.timeline, 1);
69+
70+
// ① 状态机半边:旧时间线已从 globalTimeline 摘除;目标上只剩新 tween 一条
71+
expect(gsap.globalTimeline.getChildren(), "旧时间线还在 globalTimeline ⇒ 那是「覆盖」不是「kill」").toHaveLength(2);
72+
expect(gsap.globalTimeline.getChildren(false, true, true), "只数直接子项(规范字面的「1」是这一口径)").toHaveLength(1);
73+
expect(tweenCount(el), "目标上只许剩新 tween 一条").toBe(1);
74+
75+
// ② 继续推**旧**时间线的播放头 = 确定性模拟「旧 tween 仍在跑」(生产里由 ticker 驱动)
76+
freezeAt(a.timeline, 1);
77+
expect(aTween.totalTime(), "旧 tween 的播放头必须冻结在 interrupt 时刻").toBe(0.3);
78+
79+
// ③ DOM 半边:旧 tween 若还活着,它会把值写回 100(只看这一半的实现会漏掉状态机)
80+
expect(currentTransform(el), "旧 tween 仍在写同一属性 ⇒ 只看这一半会假绿(R8.2)").toBe("translate(50px, 0px)");
81+
expect(gsap.getProperty(el, "x")).toBe(50);
82+
83+
expect(() => a.interrupt(), "`interrupt()` 必须幂等(重复调用不得抛)").not.toThrow();
84+
expect(tweenCount(el), "接管语义 = 目标上不再有在飞 tween").toBe(0);
85+
expect(currentTransform(el), "kill 不还原已写出的值(冻结在当前值)").toBe("translate(50px, 0px)");
86+
});
87+
88+
it('V1b 出口强制 `overwrite: "auto"`:新 tween 首次渲染即杀掉同属性的旧 tween(无需显式 interrupt)', () => {
89+
const el = mkDiv();
90+
const a = track(startControllable(el, { x: 100, duration: 1, ease: "none" }, { paused: true }));
91+
const aTween = a.timeline.getChildren()[0];
92+
freezeAt(a.timeline, 0.3);
93+
94+
const b = track(startControllable(el, { x: 50, duration: 1, ease: "none" }, { paused: true }));
95+
expect(tweenCount(el), "两条并存 = GSAP 默认 `overwrite:false` ⇒ 出口没有强制 auto").toBe(2);
96+
97+
freezeAt(b.timeline, 0.5); // 新 tween 的**首次渲染**:auto 覆盖在此刻生效(尖刺 S2.4(b) 表)
98+
expect(tweenCount(el)).toBe(1);
99+
expect(a.timeline.getChildren(), "旧 tween 已从它自己的时间线摘除").toHaveLength(0);
100+
expect(aTween.totalTime(), "旧 tween 死在冻结处,不随新 tween 前进").toBe(0.3);
101+
expect(currentTransform(el), "接管从**接管时刻的当前值**(30px)出发,不跳回起点").toBe("translate3d(40px, 0px, 0px)");
102+
});
103+
});
104+
105+
describe("V2 可反向(计划 Step 2 的第 2 条,拆成三条各有专属变异体的判据)", () => {
106+
it("V2a `reverse()` 真的翻方向并开始回放(「只改 timeScale 不 play」的假反向会红在这里)", () => {
107+
const el = mkDiv();
108+
const c = track(startControllable(el, { x: 100, duration: 1, ease: "none" }, { paused: true }));
109+
freezeAt(c.timeline, 0.6);
110+
111+
c.reverse();
112+
expect(c.timeline.reversed()).toBe(true);
113+
expect(c.timeline.timeScale()).toBe(-1);
114+
expect(c.timeline.paused(), "只改 timeScale 不 play ⇒ 时间线仍停着(看起来反向其实没反向)").toBe(false);
115+
});
116+
117+
it("V2b `reverse()` 只改方向、**不动**播放头(尖刺 S2.5(a):`reverse()` 后 progress 仍 = 1)", () => {
118+
const el = mkDiv();
119+
const c = track(startControllable(el, { x: 100, duration: 1, ease: "none" }, { paused: true }));
120+
freezeAt(c.timeline, 0.6);
121+
const before = c.timeline.progress();
122+
const domBefore = currentTransform(el);
123+
124+
c.reverse();
125+
expect(before, "时间线长度必须非 0,否则「progress 不变」是空真").toBeCloseTo(0.6, 6);
126+
expect(c.timeline.progress(), "把播放头拉回起点 = 反向时从头播(不是从当前状态回退)").toBe(before);
127+
expect(currentTransform(el), "`reverse()` 本身不写 DOM").toBe(domBefore);
128+
});
129+
130+
it("V2c 反向后回走**逐字重走同一条轨迹**(逐序数组相等;防空真:前进序列必须真的在动)", () => {
131+
const el = mkDiv();
132+
const c = track(startControllable(el, { x: 100, duration: 1, ease: "none" }, { paused: true }));
133+
const tween = c.timeline.getChildren()[0];
134+
freezeAt(c.timeline, 0.6); // 先渲染一次:未渲染时 t=0 的读数是空串,不是 `translate(0, 0)`
135+
136+
const pts = [0, 0.25, 0.5, 0.75, 1];
137+
const forward = pts.map((t) => {
138+
freezeAt(c.timeline, t);
139+
return currentTransform(el);
140+
});
141+
expect(forward, "前进序列必须逐字等于 5 个已知读数(否则「逐序相等」是空真)").toEqual([
142+
"translate(0, 0)",
143+
"translate3d(25px, 0px, 0px)",
144+
"translate3d(50px, 0px, 0px)",
145+
"translate3d(75px, 0px, 0px)",
146+
"translate(100px, 0px)",
147+
]);
148+
149+
c.reverse();
150+
const backward = [...pts].reverse().map((t) => {
151+
freezeAt(c.timeline, t);
152+
return currentTransform(el);
153+
});
154+
expect(backward, "回走的值必须逐字等于正向序列的逆序(任何时间→值的映射改写都会在这里现形)").toEqual([...forward].reverse());
155+
expect(c.timeline.getChildren()[0], "`reverse()` 不许重建 / 替换 tween(重建 = 值序列换了来源)").toBe(tween);
156+
});
157+
});
158+
159+
describe("V3 被动画属性集合审计(R8.4 的机器抓手 = 未动 layout 属性)", () => {
160+
it("V3a 出口产出的 tween 只写白名单内的合成属性(逐序数组相等 + ⊆ 白名单 + `assertAnimatable` 不抛)", () => {
161+
const el = mkDiv();
162+
const c = track(startControllable(el, { x: 20, y: 4, opacity: 0.5, duration: 1, ease: "none" }, { paused: true }));
163+
freezeAt(c.timeline, 0.5);
164+
165+
const props = animatedProps(el);
166+
expect(props, "逐序等于 jsdom 实测的 5 个合成属性(换序 / 多写都红)").toEqual([
167+
"opacity",
168+
"rotate",
169+
"scale",
170+
"transform",
171+
"translate",
172+
]);
173+
expect(props.filter((p) => !ANIMATABLE_PROPERTIES.includes(p)), "有 layout 属性混进来").toEqual([]);
174+
expect(ANIMATABLE_PROPERTIES, "白名单真源(T9 交付)逐序").toEqual(["transform", "translate", "rotate", "scale", "opacity", "filter"]);
175+
expect(() => assertAnimatable(props)).not.toThrow();
176+
});
177+
178+
it("V3b 对照(V3a 的牙):动 layout 属性的样本必须写出该属性、且被 `assertAnimatable` 抛", () => {
179+
const el = mkDiv();
180+
const c = track(startControllable(el, { width: 200, duration: 1, ease: "none" }, { paused: true }));
181+
freezeAt(c.timeline, 0.5);
182+
183+
const props = animatedProps(el);
184+
expect(props, "对照组必须真的写出 layout 属性(删掉本句 ⇒ V3a 退化为「任何集合都 ⊆」的空真)").toContain("width");
185+
expect(() => assertAnimatable(props)).toThrow(/width/);
186+
expect(() => assertAnimatable(["height", "margin"])).toThrow(/height, margin/);
187+
});
188+
});
189+
190+
describe("V4 reduced-motion 降级(§8.5:系统偏好优先于档位)", () => {
191+
it("V4a 桩 `{reduce:true}` ⇒ 直接落终态、不建 tween(`tweenCount` = 0)", () => {
192+
const stub = installMatchMediaStub({ reduce: true });
193+
try {
194+
const el = mkDiv();
195+
const c = track(startControllable(el, { x: 50, opacity: 0.5, duration: 1, ease: "none" }, { paused: true }));
196+
expect(tweenCount(el), "降级路径建了 tween ⇒ 系统偏好被无视").toBe(0);
197+
expect(c.timeline.getChildren(), "惰性时间线里不该有任何 tween").toHaveLength(0);
198+
199+
freezeAt(c.timeline, 0);
200+
expect(currentTransform(el), "reduce 下必须**直接落终态**(不是从初值开始动)").toBe("translate(50px, 0px)");
201+
expect(el.style.opacity).toBe("0.5");
202+
expect(gsap.getProperty(el, "x")).toBe(50);
203+
} finally {
204+
stub.restore();
205+
}
206+
});
207+
208+
it("V4b 对照:本环境没有 `matchMedia` ⇒ 未 reduce ⇒ 建 tween 且从初值动起来", () => {
209+
expect(typeof window.matchMedia, "对照成立的前提是 jsdom 没有 matchMedia(装了桩 ⇒ 本用例会静默改道)").toBe("undefined");
210+
const el = mkDiv();
211+
const c = track(startControllable(el, { x: 50, duration: 1, ease: "none" }, { paused: true }));
212+
expect(tweenCount(el), "未 reduce ⇒ 必须走 tween 路径").toBe(1);
213+
214+
freezeAt(c.timeline, 0.5);
215+
expect(currentTransform(el), "从初值 0 起(reduce 才直接落 50)").toBe("translate3d(25px, 0px, 0px)");
216+
});
217+
});

‎app/src/motion/controls.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* @ai-context controls.ts — **可中断 / 可反向动效的唯一出口**(批 6 T11 · 裁决 R8.2 · R5 通则②)。
3+
*
4+
* Why 单独成件:规格 §8.6 的六个签名动效(#1 对齐 · #2 显影编排 · #3 相变凝固 · #4 刻度生长 ·
5+
* #5 时间码回跳 · #6 记忆浮现)与列折叠都要满足 R5 通则②「下一个输入接管,**不排队**」,而各落点
6+
* 自建 timeline 会踩同一个**会假绿**的坑 —— 裁决 R8.2 逐字:「GSAP 3 默认 `overwrite:false` ⇒
7+
* 覆盖同属性时**旧 tween 仍在跑**,**只看 `style.transform` 会假绿**」。⇒ 本出口一次做对两件事:
8+
* ① 每个 tween 强制 `overwrite: "auto"`(新动画首次渲染时杀掉同属性的旧 tween);
9+
* ② `interrupt()` **真 kill**,**不是**「起一条新 tween 覆盖同属性」—— 尖刺 S2.4(b) 实测:默认
10+
* `overwrite:false` 下两条 tween 并存(`globalTimeline` 子项计数 2)、旧句柄 `totalTime()` 一路到 1.0,
11+
* 而 DOM 看起来「已被接管」。
12+
*
13+
* 🔴 `interrupt()` 的两步**顺序不可颠倒**(T11 在 jsdom 实测):必须 `gsap.killTweensOf(target)` **先**、
14+
* `timeline.kill()` **后**。反序时子 tween 变成孤立 tween —— `timeline.kill()` 只把时间线从
15+
* `globalTimeline` 摘除、**不动**其子 tween,而孤立 tween 此后 `killTweensOf` 再也够不着:实测再推那条
16+
* 旧时间线的播放头,它照样写 DOM(`translate3d(30px…)` 冻不住、`totalTime()` 0.3 → 1)。
17+
*
18+
* 🔴 **两条 API 陷阱(R8.3)**:① `timeline.to()` 返回的是 **Timeline 本身、不是 Tween** ⇒ 要 tween 句柄
19+
* 只能用 `timeline.getChildren()[0]`(把返回值当 tween 用会让「旧 tween 冻结」一类断言**恒真 = 假绿**);
20+
* ② **绝不可**把 jsdom 的 `performance` 挂到 `globalThis`(`Performance-impl.js:14` 自调用 ⇒ 栈溢出)。
21+
*
22+
* reduced-motion(§8.5「系统 `prefers-reduced-motion` 优先于档位」):命中时**直接落终态、不建 tween**
23+
* (`tweenCount(target)` = 0),调用方无需分支;`matchMedia` **自带守卫**(尖刺实测 jsdom 30.0.1 没有它
24+
* ⇒ 视为「未 reduce」,不抛)。
25+
*
26+
* 副作用:建 GSAP 时间线 / 写目标元素的内联样式;无存储、无 IPC、无网络。
27+
* 边界:① 本出口**不判**观感与真实帧率 —— R8.4 逐字:真实帧率**本批未测**(jsdom 不可达),机器抓手只有
28+
* 「被动画属性集合 ⊆ 合成属性白名单」(`assertAnimatable`);② 本出口**不**做三档强度(`data-motion`)
29+
* 分支 —— 档位是 CSS 侧的幅度通道(R3.1),在 JS 侧再实现一遍会造成双写真相;③ `assertAnimatable` 只看
30+
* **传进来的属性名**,读数口由调用方给(`test/motionHarness.ts` 的 `animatedProps` 读 `el.style` 内联集合,
31+
* 看不见类规则里的属性);④ 出口**挡不住**绕过它直接调 tween 的写法 —— 那由属性集合审计与评审兜。
32+
*/
33+
import { gsap } from "./engine";
34+
import type { GsapTimeline } from "./engine";
35+
import { ANIMATABLE_PROPERTIES } from "./shift";
36+
37+
/** 系统无障碍查询串(逐字;与 `ui/primitives/motion.css` 的 reduced-motion 块、`motion/intensity.ts` 同串)。 */
38+
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
39+
40+
/** 波 C 六个动效共用的句柄(计划 T11 Interfaces 的冻结快照)。 */
41+
export interface Controllable {
42+
readonly timeline: GsapTimeline;
43+
/** 下一个输入接管:kill 当前 + 可选**立即**起下一个(不是排队)。 */
44+
interrupt(next?: () => void): void;
45+
/** 反向:只改方向、不改播放头(尖刺 S2.5(a))。 */
46+
reverse(): void;
47+
/** 播放头定位(测试用;确定性推进走 `test/motionHarness.ts` 的 `freezeAt`)。 */
48+
seek(t: number): void;
49+
}
50+
51+
/** 带守卫的系统偏好读取(R3.2):无 `window` / 无 `matchMedia`(jsdom 30)⇒ 视为「未 reduce」,不抛。 */
52+
function systemPrefersReducedMotion(): boolean {
53+
if (typeof window === "undefined") return false;
54+
const mm = (window as { matchMedia?: (query: string) => MediaQueryList }).matchMedia;
55+
return typeof mm === "function" && mm(REDUCED_MOTION_QUERY).matches === true;
56+
}
57+
58+
/** 句柄装配(两条路径共用 —— `interrupt` 的 kill 顺序正是本文件的核心语义,不许各写一遍)。 */
59+
function handleFor(timeline: GsapTimeline, target: Element): Controllable {
60+
return {
61+
timeline,
62+
interrupt: (next) => {
63+
gsap.killTweensOf(target); // 🔴 必须**先**摘 tween,再 kill 时间线(见文件头:反序会留下够不着的孤立 tween)
64+
timeline.kill();
65+
if (next !== undefined) next(); // 立即起下一个:接管语义,不是排队
66+
},
67+
reverse: () => {
68+
timeline.reverse();
69+
},
70+
seek: (t) => {
71+
timeline.time(t);
72+
},
73+
};
74+
}
75+
76+
/**
77+
* 建一条**可中断、可反向、可 seek** 的动效。`opts.paused` = 建 paused 时间线(测试的确定性推进需要它)。
78+
* 🔴 调用方给的 `overwrite` 一律被本出口覆盖为 `"auto"`(**覆盖不是合并**)—— 那是「接管」语义的一半。
79+
*/
80+
export function startControllable(target: Element, vars: gsap.TweenVars, opts?: { paused?: boolean }): Controllable {
81+
if (systemPrefersReducedMotion()) {
82+
// 降级:直接落终态。`gsap.set` 是零时长 set(实测不留在场 tween ⇒ `tweenCount` = 0);再摘掉在飞的旧 tween,
83+
// 否则旧动画会继续覆盖终态。返回的**惰性时间线**里没有任何 tween(seek / reverse 皆为空操作)。
84+
gsap.set(target, vars);
85+
gsap.killTweensOf(target);
86+
return handleFor(gsap.timeline({ paused: true }), target);
87+
}
88+
const timeline = gsap.timeline({ paused: opts?.paused ?? false });
89+
timeline.to(target, { ...vars, overwrite: "auto" });
90+
return handleFor(timeline, target);
91+
}
92+
93+
/** 属性集合审计(R8.4 的机器抓手):越出合成属性白名单即**抛**(不静默通过)。 */
94+
export function assertAnimatable(props: readonly string[]): void {
95+
const offenders = props.filter((p) => !ANIMATABLE_PROPERTIES.includes(p));
96+
if (offenders.length > 0) {
97+
throw new Error(`assertAnimatable: 被动画属性越出合成属性白名单(会触发排版):${offenders.join(", ")}`);
98+
}
99+
}

0 commit comments

Comments
 (0)