Skip to content

Commit 30ee2fa

Browse files
committed
test(ui): usePresence 补真 node 环境用例
1 parent 9d8a767 commit 30ee2fa

3 files changed

Lines changed: 90 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @ai-context usePresence.node.test.ts — **真 node 环境**(**无** jsdom 环境指令)的守卫用例
3+
* (批 0-D Task 6 评审 Important ① 补充)。
4+
*
5+
* Why 单开一个文件:`usePresence.test.tsx` 是 jsdom(`renderHook` 需要 `document`),那里只能把
6+
* `matchMedia` **桩成 `undefined`** 来模拟"无 matchMedia",**覆盖不到** `getMediaQueryList()` 的第一道
7+
* 守卫 `typeof window === "undefined"`(真 node 环境根本不存在 `window`)。而计划 Task 6 的验收项明写
8+
* 「**node 环境下不抛错**」⇒ 本文件就是那条验收的机器判据(评审者独立探针 `tmp/review-t6/node-guard-probe.mjs`
9+
* 已先证明可行,本文件把它固化成随 CI 跑的用例)。
10+
*
11+
* 手段:`react-dom/server` 的 `renderToString` —— node 无 DOM,`@testing-library/react` 用不了;但 SSR 会
12+
* 跑完**渲染阶段**(含 `useState` 的惰性初始化,那正是媒体查询的读取点)。断言全部落在渲染产物上(不靠
13+
* 外部变量捕获,避免 TS 控制流把捕获变量收窄成 `never`)。
14+
* **局限(诚实标注)**:SSR **不执行 `useEffect`** ⇒ 本文件只覆盖渲染路径;effect 路径的守卫由
15+
* 「`getMediaQueryList()` 是模块内唯一读取点、且自带两道守卫」这一点保证(`useReducedMotion` 的 effect 也调它)。
16+
*
17+
* 副作用:无(纯内存渲染;不写磁盘、不发请求、不挂计时器)。
18+
* 边界:**不得**加 jsdom 环境指令(仓规写法是 `@` + `vitest-environment` + 环境名)—— 加了就退化回 jsdom,
19+
* 两道守卫里的第一道 `typeof window === "undefined"` 测不到。⚠️ **该指令是「全文件文本扫描」的**:
20+
* 连注释里写出完整指令串都会被 vitest 当成指令识别(本文件初版就因此在 jsdom 下跑、`typeof window` 变成
21+
* `object`)⇒ 本文件刻意**不出现**该指令串(本段只用拆开的写法指代它)。
22+
* 本文件是 `.ts`(SSR 渲染无需 JSX),故用 `createElement` 而不是 JSX 语法。
23+
*/
24+
import { createElement } from "react";
25+
import type { ReactElement } from "react";
26+
import { renderToString } from "react-dom/server";
27+
import { describe, expect, it } from "vitest";
28+
import { usePresence } from "./usePresence";
29+
30+
/** 把 hook 的返回值全部摊到 DOM 属性上(`renderToString` 不允许外部捕获,属性即观测面) */
31+
function Probe({ open }: { open: boolean }): ReactElement {
32+
const presence = usePresence(open);
33+
return createElement("i", {
34+
"data-phase": presence.phase,
35+
"data-mounted": String(presence.mounted),
36+
"data-reduced": String(presence.reducedMotion),
37+
"data-handler": typeof presence.onTransitionEnd,
38+
});
39+
}
40+
41+
describe("真 node 环境(无 window / document):usePresence 不抛错", () => {
42+
it("环境事实:本文件确实跑在无 DOM 的 node 环境(否则下面的守卫分支根本没被测到)", () => {
43+
expect(typeof window).toBe("undefined");
44+
expect(typeof document).toBe("undefined");
45+
});
46+
47+
it("open=true:renderToString 不抛错;reducedMotion=false(守卫按「不命中」处理,不是抛错)", () => {
48+
const html = renderToString(createElement(Probe, { open: true }));
49+
expect(html).toContain('data-reduced="false"');
50+
expect(html).toContain('data-mounted="true"');
51+
expect(html).toContain('data-phase="enter"');
52+
expect(html, "onTransitionEnd 即使是纯逻辑件也必须可用(T7 挂它,不需要 DOM)").toContain(
53+
'data-handler="function"',
54+
);
55+
});
56+
57+
it("open=false:不抛错;初始态即终态(mounted=false / phase='exit')", () => {
58+
const html = renderToString(createElement(Probe, { open: false }));
59+
expect(html).toContain('data-reduced="false"');
60+
expect(html).toContain('data-mounted="false"');
61+
expect(html).toContain('data-phase="exit"');
62+
});
63+
64+
it("自定义 reducedMotionQuery 在 node 下同样不抛错(查询串照传,只是读不到环境)", () => {
65+
function Custom(): ReactElement {
66+
const presence = usePresence(true, { reducedMotionQuery: "(prefers-contrast: more)" });
67+
return createElement("i", { "data-reduced": String(presence.reducedMotion) });
68+
}
69+
expect(renderToString(createElement(Custom, {}))).toContain('data-reduced="false"');
70+
});
71+
});

‎app/src/ui/primitives/usePresence.test.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* ② open 假→真 enter→entered → "open 假→真:…"
1313
* ③ 已 entered 后真→假 → "已 entered 后 open 真→假:…"
1414
* ④ 本节点 transitionend 卸载 → "出场期间收到本节点 transitionend:…"
15+
* ④' 未进场就关闭(表外边界④) → "进场未完成(下一 tick 未到)就关闭:…"
1516
* ⑤ 出场期间反向(可中断可反向)→ "出场期间 open 又回 true:…"
1617
* ⑥ 兜底计时器到点卸载 → "没有 transitionend 时兜底到点卸载:…" + "默认兜底窗口…"
1718
* ⑦ reducedMotion=true 直跳终态 → "reducedMotion 命中:…"
@@ -137,6 +138,17 @@ describe("状态机(计划 Task 6 表 9 行逐行)", () => {
137138
expect(vi.getTimerCount(), "transitionend 到达后兜底计时器应被清掉").toBe(0);
138139
});
139140

141+
it("④' 进场未完成(下一 tick 未到)就关闭:不排退场窗口、清掉进场计时器、立即卸载", () => {
142+
const { result, rerender } = setup(false);
143+
rerender({ open: true });
144+
expect(result.current.phase).toBe("enter");
145+
expect(vi.getTimerCount(), "此时只有「下一 tick 进球」计时器在跑").toBe(1);
146+
rerender({ open: false });
147+
expect(result.current.mounted, "一次绘制都没发生过 ⇒ 没有可退的场,直接卸载").toBe(false);
148+
expect(result.current.phase).toBe("exit");
149+
expect(vi.getTimerCount(), "既不排退场窗口,也必须清掉进场计时器").toBe(0);
150+
});
151+
140152
it("⑥ 没有 transitionend 时兜底到点卸载(reduced-motion 下永不触发 transitionend 的防线)", () => {
141153
// 注入 exitMs/timeoutSlackMs ⇒ 兜底窗口可测、不真 sleep
142154
const { result, rerender } = setup(true, { exitMs: 1000, timeoutSlackMs: 20 });

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ export type PresencePhase = "enter" | "entered" | "exit";
3636
* `transitionend` 事件的**最小结构契约**。React 的 `TransitionEvent<T>` 结构上满足它
3737
* (`target` / `currentTarget` 都赋给 `unknown`),故消费方可直接 `onTransitionEnd={onTransitionEnd}`;
3838
* 测试也用普通对象构造事件 —— 不需要真元素、不需要真过渡(类型层断言见 `usePresence.test.tsx`)。
39+
*
40+
* **为什么不需要 `propertyName`**:本 hook 只判两件事 ——「是不是本节点自己的过渡」(`target ===
41+
* currentTarget`)与「当前相位是不是 `exit`」,而"哪个属性结束了"由**同元素等时长**对冲:Modal /
42+
* ConfirmDialog / Toast 的进出场属性都写在**同一个元素**上、时长同取 `--ed-dur-overlay-*` /
43+
* `--ed-dur-toast-*`,故任一属性的 `transitionend` 都等价于「本节点退场结束」,多接一个字段只会让
44+
* 消费方的类型面更脏。⚠️ 若将来出现「同元素多属性、时长不等」的场景(例如遮罩与面板分开计时),
45+
* 才需要把 `propertyName` 纳入契约 —— 那时须连 T7/T9 的挂载点一起改(本注释就是那条触发条件)。
3946
*/
4047
export interface TransitionEndLike {
4148
readonly target: unknown;

0 commit comments

Comments
 (0)