|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Toast.interrupt.test.tsx —— Toast 的**接管与关闭权归属**(批 0-D Task 9;规格 §8.6.1 第 3 条)。 |
| 4 | + * |
| 5 | + * 语义边界:本文件管「一条 toast 的非常规路径」—— |
| 6 | + * ① **出场中被新消息打断** ⇒ 取消出场、回到 `entered`(不重放进场)、旧退场不再卸载; |
| 7 | + * ② **不排队**:连续改 message 时至多一个计时器、DOM 里始终只有一条(§8.6.1 第 3 条 |
| 8 | + * 「下一个输入要能接管当前动效,不排队」的机器判据); |
| 9 | + * ③ **关闭权归属**:父级 `open=false` 是父级已知情的决定 ⇒ 退场结束后**不得**回调 `onDismiss`; |
| 10 | + * ④ **多条并存**:彼此独立、不共用模块级状态(本原语不做队列/堆栈 —— 堆叠偏移是批 6 的 CSS 接缝)。 |
| 11 | + * 「正常生命周期」(渲染/计时/卸载/reduced-motion)在同目录 `Toast.test.tsx`,两份都 ≤300 行; |
| 12 | + * 样式文本判据在 `Toast.style.test.ts`(node 环境)。 |
| 13 | + * |
| 14 | + * 副作用:只挂 React 树(不写文件、不发请求、不读 store)。 |
| 15 | + * 边界:本仓未装 jest-dom ⇒ 一律 `getAttribute` / `textContent` / `queryBy…`;假计时器下 |
| 16 | + * `usePresence` 的 `enter → entered` 需要**推一次 0ms**(`settle()`),否则永远停在 `enter`。 |
| 17 | + */ |
| 18 | +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; |
| 19 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 20 | +import { Toast } from "./Toast"; |
| 21 | +import type { ToastProps } from "./Toast"; |
| 22 | + |
| 23 | +const noop = (): void => {}; |
| 24 | + |
| 25 | +/** 宿主 props:派生自原语契约(每个用例只关心其中几项 ⇒ `Partial` + 必需的 `open`) */ |
| 26 | +type HostProps = Partial<ToastProps> & { open: boolean }; |
| 27 | + |
| 28 | +/** 宿主:受控形态(`open` 由测试 rerender 驱动,与真实调用点一致) */ |
| 29 | +function Host(props: HostProps) { |
| 30 | + return ( |
| 31 | + <Toast |
| 32 | + {...props} |
| 33 | + message={props.message ?? "已保存"} |
| 34 | + onDismiss={props.onDismiss ?? noop} |
| 35 | + testId={props.testId ?? "toast"} |
| 36 | + /> |
| 37 | + ); |
| 38 | +} |
| 39 | + |
| 40 | +const toast = (): HTMLElement => screen.getByTestId("toast"); |
| 41 | +const toastOrNull = (): Element | null => document.body.querySelector(".ed-toast"); |
| 42 | +const phase = (): string | null => toastOrNull()?.getAttribute("data-phase") ?? null; |
| 43 | +const cls = (el: Element): string => el.getAttribute("class") ?? ""; |
| 44 | + |
| 45 | +/** 推进受控时钟(包 act:React 的状态更新必须 flush 后才可断言) */ |
| 46 | +const tick = (ms: number): void => { |
| 47 | + act(() => { |
| 48 | + vi.advanceTimersByTime(ms); |
| 49 | + }); |
| 50 | +}; |
| 51 | +/** 走完 `usePresence` 的 enter → entered(0ms 宏任务) */ |
| 52 | +const settle = (): void => tick(0); |
| 53 | +/** 派发一次「本节点的过渡结束」(usePresence 判 `target === currentTarget` 且 `phase === "exit"`) */ |
| 54 | +const endTransition = (el: Element = toast()): void => { |
| 55 | + fireEvent.transitionEnd(el, { target: el, currentTarget: el }); |
| 56 | +}; |
| 57 | + |
| 58 | +beforeEach(() => { |
| 59 | + vi.useFakeTimers(); |
| 60 | +}); |
| 61 | + |
| 62 | +afterEach(() => { |
| 63 | + cleanup(); |
| 64 | + vi.useRealTimers(); |
| 65 | +}); |
| 66 | + |
| 67 | +describe("① 出场中被打断 ⇒ 接管(不排队、不新开一条)", () => { |
| 68 | + it("出场期间改 message ⇒ 回 entered、仍挂载、旧退场不再卸载(越过兜底窗口仍活着)", () => { |
| 69 | + const onDismiss = vi.fn(); |
| 70 | + const { rerender } = render(<Host open message="第一条" onDismiss={onDismiss} />); |
| 71 | + settle(); |
| 72 | + tick(3000); // 默认 3000 到点 ⇒ exit |
| 73 | + expect(phase()).toBe("exit"); |
| 74 | + rerender(<Host open message="第二条" onDismiss={onDismiss} />); |
| 75 | + expect(phase(), "回 entered 而不是重新进场(不闪一下)").toBe("entered"); |
| 76 | + tick(300); // 越过退场兜底窗口(140+80):旧退场若未被接管,此点已卸载 |
| 77 | + expect(toastOrNull()).not.toBeNull(); |
| 78 | + expect(onDismiss).not.toHaveBeenCalled(); |
| 79 | + tick(2700); // t=6000 = 接管点 + 3000 ⇒ 只退场、仍不回调 |
| 80 | + expect(phase()).toBe("exit"); |
| 81 | + expect(onDismiss).not.toHaveBeenCalled(); |
| 82 | + }); |
| 83 | + |
| 84 | + it("显示中改 message ⇒ 重新计时(旧排期到点不消失)", () => { |
| 85 | + const { rerender } = render(<Host open message="A" durationMs={3000} />); |
| 86 | + settle(); |
| 87 | + tick(2000); |
| 88 | + rerender(<Host open message="B" durationMs={3000} />); |
| 89 | + tick(2000); // t=4000:旧排期(t=3000)若未重排,此刻应已 exit |
| 90 | + expect(phase()).toBe("entered"); |
| 91 | + tick(1000); // t=5000 = 接管点 + 3000 |
| 92 | + expect(phase()).toBe("exit"); |
| 93 | + }); |
| 94 | + |
| 95 | + it("改 kind ⇒ 同一条被接管(类名与 aria-live 随之更新,不新开)", () => { |
| 96 | + const { rerender } = render(<Host open kind="info" message="A" />); |
| 97 | + settle(); |
| 98 | + rerender(<Host open kind="err" message="A" />); |
| 99 | + expect(cls(toast())).toContain("ed-toast--err"); |
| 100 | + expect(toast().getAttribute("aria-live")).toBe("assertive"); |
| 101 | + expect(screen.getAllByRole("status")).toHaveLength(1); |
| 102 | + }); |
| 103 | + |
| 104 | + it("连续改 3 次 message:至多一个计时器、DOM 里始终只有一条 toast(不排队)", () => { |
| 105 | + const { rerender } = render(<Host open message="0" />); |
| 106 | + settle(); |
| 107 | + expect(vi.getTimerCount(), "显示期只有自动消失这一个计时器").toBe(1); |
| 108 | + for (const m of ["1", "2", "3"]) { |
| 109 | + rerender(<Host open message={m} />); |
| 110 | + expect(toast().textContent).toBe(m); |
| 111 | + expect(vi.getTimerCount(), "接管 = 重排一个,不是排队两个").toBe(1); |
| 112 | + } |
| 113 | + expect(screen.getAllByRole("status")).toHaveLength(1); |
| 114 | + }); |
| 115 | +}); |
| 116 | + |
| 117 | +describe("② 关闭权归属:父级 open=false 是父级已知情的决定 ⇒ 不回调", () => { |
| 118 | + it("直接走出场且不调 onDismiss;transitionend 后卸载", () => { |
| 119 | + const onDismiss = vi.fn(); |
| 120 | + const { rerender } = render(<Host open onDismiss={onDismiss} />); |
| 121 | + settle(); |
| 122 | + rerender(<Host open={false} onDismiss={onDismiss} />); |
| 123 | + expect(phase()).toBe("exit"); |
| 124 | + expect(toastOrNull()).not.toBeNull(); |
| 125 | + endTransition(); |
| 126 | + expect(toastOrNull()).toBeNull(); |
| 127 | + tick(10_000); |
| 128 | + expect(onDismiss, "父级主动关闭不该收到自己刚做的决定").not.toHaveBeenCalled(); |
| 129 | + }); |
| 130 | + |
| 131 | + it("退场中反向 open=true ⇒ 不卸载、回 entered、零回调(可反向)", () => { |
| 132 | + const onDismiss = vi.fn(); |
| 133 | + const { rerender } = render(<Host open onDismiss={onDismiss} />); |
| 134 | + settle(); |
| 135 | + rerender(<Host open={false} onDismiss={onDismiss} />); |
| 136 | + expect(phase()).toBe("exit"); |
| 137 | + rerender(<Host open onDismiss={onDismiss} />); |
| 138 | + expect(phase()).toBe("entered"); |
| 139 | + tick(300); // 越过退场兜底窗口 |
| 140 | + expect(toastOrNull()).not.toBeNull(); |
| 141 | + expect(onDismiss).not.toHaveBeenCalled(); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +describe("③ 多条并存(堆叠的确定行为:彼此独立、不共用模块级状态)", () => { |
| 146 | + it("两条各自计时、各自回调:一条到点不影响另一条", () => { |
| 147 | + const a = vi.fn(); |
| 148 | + const b = vi.fn(); |
| 149 | + render( |
| 150 | + <> |
| 151 | + <Toast open message="A" durationMs={1000} onDismiss={a} testId="ta" /> |
| 152 | + <Toast open message="B" durationMs={5000} onDismiss={b} testId="tb" /> |
| 153 | + </>, |
| 154 | + ); |
| 155 | + settle(); |
| 156 | + expect(screen.getAllByRole("status")).toHaveLength(2); |
| 157 | + tick(1000); |
| 158 | + const first = screen.getByTestId("ta"); |
| 159 | + expect(first.getAttribute("data-phase")).toBe("exit"); |
| 160 | + expect(screen.getByTestId("tb").getAttribute("data-phase")).toBe("entered"); |
| 161 | + endTransition(first); |
| 162 | + expect(screen.queryByTestId("ta")).toBeNull(); |
| 163 | + expect(a).toHaveBeenCalledTimes(1); |
| 164 | + expect(b).not.toHaveBeenCalled(); |
| 165 | + expect(screen.getByTestId("tb").textContent).toBe("B"); |
| 166 | + }); |
| 167 | +}); |
0 commit comments