Skip to content

Commit 930c0a5

Browse files
committed
feat(ui): usePresence 卸载时机内核(transitionend + 兜底)
1 parent 8fd85e5 commit 930c0a5

3 files changed

Lines changed: 466 additions & 0 deletions

File tree

‎app/src/ui/primitives/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ export type { SurfaceLevel, SurfaceProps, SurfaceRadius, SurfaceTag } from "./Su
1818

1919
export { Button } from "./Button";
2020
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
21+
22+
export { usePresence } from "./usePresence";
23+
export type { Presence, PresenceOptions, PresencePhase, TransitionEndLike } from "./usePresence";
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context usePresence.test.tsx — 卸载时机内核的状态机契约(批 0-D Task 6)。
4+
*
5+
* Why jsdom:本 hook 用 `renderHook`(`@testing-library/react` 需 `document`)。**不派发真 DOM 事件** ——
6+
* `TransitionEndLike` 是结构类型(`{ target, currentTarget }`,T7 可直接挂 `onTransitionEnd`),
7+
* 所以出场事件用普通对象构造即可:不需要真元素、不需要真 CSS transition(jsdom 不做布局与动画)。
8+
* 计时一律 `vi.useFakeTimers()`:兜底窗口与「下一 tick 进场」都不真 sleep(整套用例毫秒级完成)。
9+
*
10+
* 计划 Task 6 的 9 行状态机 → 用例名对照(报告同表):
11+
* ① 初始 open=false → "初始 open=false:…"
12+
* ② open 假→真 enter→entered → "open 假→真:…"
13+
* ③ 已 entered 后真→假 → "已 entered 后 open 真→假:…"
14+
* ④ 本节点 transitionend 卸载 → "出场期间收到本节点 transitionend:…"
15+
* ⑤ 出场期间反向(可中断可反向)→ "出场期间 open 又回 true:…"
16+
* ⑥ 兜底计时器到点卸载 → "没有 transitionend 时兜底到点卸载:…" + "默认兜底窗口…"
17+
* ⑦ reducedMotion=true 直跳终态 → "reducedMotion 命中:…"
18+
* ⑧ 子元素冒泡被忽略 → "子元素冒泡:…"
19+
* ⑨ 卸载清理 → "卸载清理:…"
20+
*
21+
* 副作用:无(只挂载 React 树;不写磁盘、不发请求)。每个用例后恢复真实计时器并清桩。
22+
* 边界:断言全部落在状态机上;「有没有真的动起来」是消费方 `[data-phase]` CSS 的职责(T7/T9)。
23+
*/
24+
import { act, cleanup, renderHook } from "@testing-library/react";
25+
import type { TransitionEvent } from "react";
26+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
27+
import { usePresence } from "./usePresence";
28+
import type { PresenceOptions, TransitionEndLike } from "./usePresence";
29+
30+
/** 「本节点自身」的替身:同一引用即 `target === currentTarget`(不需要真元素) */
31+
const SELF: unknown = {};
32+
const OWN_EVENT: TransitionEndLike = { target: SELF, currentTarget: SELF };
33+
/** 子元素冒泡:`target` 是另一个对象 ⇒ 必须被忽略 */
34+
const CHILD_EVENT: TransitionEndLike = { target: {}, currentTarget: SELF };
35+
36+
/** 契约里的默认值(`PresenceOptions` 的注释真源;此处独立写死 = 默认值变更必须过这一关) */
37+
const DEFAULT_EXIT_MS = 160;
38+
const DEFAULT_SLACK_MS = 80;
39+
40+
function setup(initialOpen: boolean, options?: PresenceOptions) {
41+
return renderHook(({ open }: { open: boolean }) => usePresence(open, options), {
42+
initialProps: { open: initialOpen },
43+
});
44+
}
45+
46+
/** 推进假计时器(必须包 `act`:状态更新由计时器回调触发) */
47+
function tick(ms: number): void {
48+
act(() => {
49+
vi.advanceTimersByTime(ms);
50+
});
51+
}
52+
53+
/** 造一个最小 `matchMedia` 实现(jsdom 30 **没有** `window.matchMedia`,故必须自己造) */
54+
function stubMatchMedia(matches: boolean): {
55+
impl: (query: string) => MediaQueryList;
56+
queries: string[];
57+
listeners: Array<(e: { matches: boolean }) => void>;
58+
removed: Array<(e: { matches: boolean }) => void>;
59+
setMatches: (next: boolean) => void;
60+
} {
61+
type Listener = (e: { matches: boolean }) => void;
62+
const queries: string[] = [];
63+
const listeners: Listener[] = [];
64+
const removed: Listener[] = [];
65+
let current = matches;
66+
const mql = {
67+
get matches(): boolean {
68+
return current;
69+
},
70+
media: "",
71+
onchange: null,
72+
addEventListener: (_type: "change", listener: Listener): void => {
73+
listeners.push(listener);
74+
},
75+
removeEventListener: (_type: "change", listener: Listener): void => {
76+
removed.push(listener);
77+
},
78+
};
79+
return {
80+
impl: (query: string): MediaQueryList => {
81+
queries.push(query);
82+
return mql as unknown as MediaQueryList;
83+
},
84+
queries,
85+
listeners,
86+
removed,
87+
setMatches: (next: boolean): void => {
88+
current = next;
89+
for (const listener of listeners) listener({ matches: next });
90+
},
91+
};
92+
}
93+
94+
beforeEach(() => {
95+
vi.useFakeTimers();
96+
});
97+
98+
afterEach(() => {
99+
cleanup();
100+
vi.useRealTimers();
101+
vi.unstubAllGlobals();
102+
});
103+
104+
describe("状态机(计划 Task 6 表 9 行逐行)", () => {
105+
it("① 初始 open=false:mounted=false 且 phase='exit'(什么都不渲染)", () => {
106+
const { result } = setup(false);
107+
expect(result.current.mounted).toBe(false);
108+
expect(result.current.phase).toBe("exit");
109+
});
110+
111+
it("② open 假→真:mounted=true 且 phase='enter';下一 tick 才切 'entered'(transition 需要起点)", () => {
112+
const { result, rerender } = setup(false);
113+
rerender({ open: true });
114+
expect(result.current.mounted).toBe(true);
115+
expect(result.current.phase).toBe("enter");
116+
expect(vi.getTimerCount(), "应挂着一个「下一 tick 进球」计时器").toBe(1);
117+
tick(0);
118+
expect(result.current.phase).toBe("entered");
119+
expect(vi.getTimerCount()).toBe(0);
120+
});
121+
122+
it("③ 已 entered 后 open 真→假:phase='exit' 且仍 mounted=true(不早退,才有出场动效)", () => {
123+
const { result, rerender } = setup(true);
124+
tick(0);
125+
expect(result.current.phase).toBe("entered");
126+
rerender({ open: false });
127+
expect(result.current.phase).toBe("exit");
128+
expect(result.current.mounted, "退场期间必须仍挂载").toBe(true);
129+
});
130+
131+
it("④ 出场期间收到本节点 transitionend:清兜底计时器并立即 mounted=false", () => {
132+
const { result, rerender } = setup(true);
133+
tick(0);
134+
rerender({ open: false });
135+
act(() => result.current.onTransitionEnd(OWN_EVENT));
136+
expect(result.current.mounted).toBe(false);
137+
expect(vi.getTimerCount(), "transitionend 到达后兜底计时器应被清掉").toBe(0);
138+
});
139+
140+
it("⑥ 没有 transitionend 时兜底到点卸载(reduced-motion 下永不触发 transitionend 的防线)", () => {
141+
// 注入 exitMs/timeoutSlackMs ⇒ 兜底窗口可测、不真 sleep
142+
const { result, rerender } = setup(true, { exitMs: 1000, timeoutSlackMs: 20 });
143+
tick(0);
144+
rerender({ open: false });
145+
tick(1019);
146+
expect(result.current.mounted, "窗口未到不得卸载").toBe(true);
147+
tick(1);
148+
expect(result.current.mounted).toBe(false);
149+
expect(result.current.phase).toBe("exit");
150+
});
151+
152+
it("⑥ 默认兜底窗口 = exitMs 160 + timeoutSlackMs 80 = 240ms(默认值即契约)", () => {
153+
const { result, rerender } = setup(true);
154+
tick(0);
155+
rerender({ open: false });
156+
tick(DEFAULT_EXIT_MS + DEFAULT_SLACK_MS - 1);
157+
expect(result.current.mounted).toBe(true);
158+
tick(1);
159+
expect(result.current.mounted).toBe(false);
160+
});
161+
162+
it("⑤ 出场期间 open 又回 true(可中断/可反向):清计时器、直回 'entered'、不重新进场", () => {
163+
const { result, rerender } = setup(true);
164+
tick(0);
165+
rerender({ open: false });
166+
expect(result.current.phase).toBe("exit");
167+
rerender({ open: true });
168+
expect(result.current.mounted).toBe(true);
169+
expect(result.current.phase, "反向必须直回 entered(回 'enter' = 重新进场 = 闪一下)").toBe("entered");
170+
expect(vi.getTimerCount(), "退场兜底计时器必须被清掉(否则到点会把弹层卸掉)").toBe(0);
171+
tick(10_000);
172+
expect(result.current.mounted, "反向接管后不得再被退场流程卸载").toBe(true);
173+
});
174+
175+
it("⑦ reducedMotion 命中:进场立即 'entered'、出场立即 mounted=false(不等 transitionend)", () => {
176+
const stub = stubMatchMedia(true);
177+
vi.stubGlobal("matchMedia", stub.impl);
178+
const { result, rerender } = setup(false);
179+
expect(result.current.reducedMotion).toBe(true);
180+
rerender({ open: true });
181+
expect(result.current.mounted).toBe(true);
182+
expect(result.current.phase).toBe("entered");
183+
expect(vi.getTimerCount(), "reduced-motion 下不排任何计时器").toBe(0);
184+
rerender({ open: false });
185+
expect(result.current.mounted).toBe(false);
186+
});
187+
188+
it("⑧ 子元素冒泡(target !== currentTarget)被忽略:仍挂载,直到兜底到点", () => {
189+
const { result, rerender } = setup(true);
190+
tick(0);
191+
rerender({ open: false });
192+
act(() => result.current.onTransitionEnd(CHILD_EVENT));
193+
expect(result.current.mounted, "子元素的过渡不得卸载本节点").toBe(true);
194+
tick(DEFAULT_EXIT_MS + DEFAULT_SLACK_MS);
195+
expect(result.current.mounted).toBe(false);
196+
});
197+
198+
it("⑧' 进场过渡的 transitionend 不得卸载(phase 不是 'exit' 时忽略)", () => {
199+
const { result } = setup(true);
200+
act(() => result.current.onTransitionEnd(OWN_EVENT));
201+
expect(result.current.mounted).toBe(true);
202+
expect(result.current.phase).toBe("enter");
203+
});
204+
205+
it("⑨ 卸载清理:清掉挂起的兜底计时器(照 useTransientToast 的「cleanup 直接读 ref」写法)", () => {
206+
const { rerender, unmount } = setup(true);
207+
tick(0);
208+
rerender({ open: false });
209+
expect(vi.getTimerCount()).toBe(1);
210+
unmount();
211+
expect(vi.getTimerCount(), "卸载后不得留计时器(防卸载后 setState)").toBe(0);
212+
});
213+
214+
it("②' 初始 open=true 也走 enter → entered 两拍(不是直跳 entered)", () => {
215+
const { result } = setup(true);
216+
expect(result.current.phase).toBe("enter");
217+
tick(0);
218+
expect(result.current.phase).toBe("entered");
219+
});
220+
});
221+
222+
describe("matchMedia 守卫(规格 §8.4:本仓无桩,直调即崩)", () => {
223+
it("环境本身没有 matchMedia(vitest 全局 node + 本仓 jsdom 30 都未实现该 API)", () => {
224+
expect(
225+
typeof window.matchMedia,
226+
"此断言变红说明 jsdom 升级后实现了 matchMedia;守卫对 node 全局环境仍然必需",
227+
).toBe("undefined");
228+
});
229+
230+
it("matchMedia 不存在 ⇒ reducedMotion=false 且不抛错(渲染本身即断言)", () => {
231+
vi.stubGlobal("matchMedia", undefined);
232+
const { result } = setup(true);
233+
expect(result.current.reducedMotion).toBe(false);
234+
});
235+
236+
it("matchMedia 不命中(matches=false)⇒ false;change 改判后跟随变 true", () => {
237+
const stub = stubMatchMedia(false);
238+
vi.stubGlobal("matchMedia", stub.impl);
239+
const { result } = setup(true);
240+
expect(result.current.reducedMotion).toBe(false);
241+
act(() => stub.setMatches(true));
242+
expect(result.current.reducedMotion).toBe(true);
243+
});
244+
245+
it("自定义 reducedMotionQuery 被透传给 matchMedia(调用点可换查询串)", () => {
246+
const stub = stubMatchMedia(true);
247+
vi.stubGlobal("matchMedia", stub.impl);
248+
const { result } = setup(true, { reducedMotionQuery: "(prefers-contrast: more)" });
249+
expect(stub.queries).toContain("(prefers-contrast: more)");
250+
expect(result.current.reducedMotion).toBe(true);
251+
});
252+
253+
it("卸载后移除 change 监听(removeEventListener 收到同一个处理器)", () => {
254+
const stub = stubMatchMedia(false);
255+
vi.stubGlobal("matchMedia", stub.impl);
256+
const { unmount } = setup(true);
257+
expect(stub.listeners).toHaveLength(1);
258+
unmount();
259+
expect(stub.removed).toEqual(stub.listeners);
260+
});
261+
});
262+
263+
describe("契约(T7 / T8 / T9 依赖)", () => {
264+
it("React 的 TransitionEvent 结构上满足 TransitionEndLike(可直接挂 onTransitionEnd)", () => {
265+
const handler: (e: TransitionEvent<HTMLDivElement>) => void = (e: TransitionEndLike): void => {
266+
void e;
267+
};
268+
expect(typeof handler).toBe("function");
269+
});
270+
});

0 commit comments

Comments
 (0)