|
| 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