|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Modal.scroll-lock.test.tsx —— `Modal` 的 **body 滚动锁**行为契约(批 4 T2;B6 缺口 A)。 |
| 4 | + * |
| 5 | + * Why 单列一个文件:`Modal.test.tsx` 296/300、`Modal.exit.test.tsx` 91,两者都在 300 硬红线附近; |
| 6 | + * 而滚动锁是**行为**(不是 prop、不是类),判据形态与它们都不同。 |
| 7 | + * |
| 8 | + * Why 这五条(B6 裁决「20 个调用点共用 ⇒ 改原语」的可失败判据): |
| 9 | + * ① 开弹层 ⇒ `body` 被锁(`overflow: hidden`); |
| 10 | + * ② 锁随 `presence.mounted` 而不是 `open` —— `open=false` 之后还有 160ms 退场(面板仍在屏上), |
| 11 | + * 提前解锁会让背景在弹层还在时就能滚(与 §5.2 第 2 条「退场相位禁指针事件」同源); |
| 12 | + * ③ **嵌套**:弹层内再弹(`tier="modalNested"`)时关内层**不得**解锁(否则背景跳一下); |
| 13 | + * ④ 恢复的是**原值快照**而不是空串 —— 宿主页自己设过的 `overflow` 被抹掉是"看不见的破坏"; |
| 14 | + * ⑤ 无 `transitionend` 的兜底路径(reduced-motion / jsdom 不派发事件)到点也必须恢复。 |
| 15 | + * |
| 16 | + * 副作用:无(只挂 React 树);假计时器只用在第 ⑤ 条。 |
| 17 | + * 边界:不用 jest-dom(本仓未装),一律原生 `style` 断言;`document.body.style.overflow` 是 |
| 18 | + * **既有的 app 级共享状态** ⇒ 每个用例前后都复位,防用例间串味。 |
| 19 | + */ |
| 20 | +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; |
| 21 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 22 | +import { Modal } from "./Modal"; |
| 23 | +import type { ReactElement } from "react"; |
| 24 | + |
| 25 | +const noop = (): void => undefined; |
| 26 | +/** 与 `Modal.tsx` 的 `EXIT_MS` / `usePresence` 的 `timeoutSlackMs` 同源:兜底窗口 = 160 + 80 */ |
| 27 | +const EXIT_FALLBACK_MS = 240; |
| 28 | + |
| 29 | +const overflow = (): string => document.body.style.overflow; |
| 30 | +const panel = (): HTMLElement => screen.getByRole("dialog"); |
| 31 | +/** 「当前有没有面板」的判据 —— 多条用例用它当**前提断言**(防判据在空转) */ |
| 32 | +const panelOrNull = (): Element | null => document.body.querySelector('[role="dialog"]'); |
| 33 | + |
| 34 | +/** |
| 35 | + * 让弹层真正走到 `entered`:`usePresence` 的 `enter → entered` 要等**下一个宏任务** |
| 36 | + * (`ENTER_TICK_MS = 0`,为的是让出一次绘制机会)—— 没走到 `entered` 时关闭是**直接卸载** |
| 37 | + * (`usePresence` 边界④:一次绘制都没发生过就不排退场窗口),退场相位根本不会出现。 |
| 38 | + */ |
| 39 | +const tick = (ms: number): void => { |
| 40 | + act(() => { |
| 41 | + vi.advanceTimersByTime(ms); |
| 42 | + }); |
| 43 | +}; |
| 44 | + |
| 45 | +/** 卸载整个树(含 portal)并把共享的 `body` 样式复位 */ |
| 46 | +afterEach(() => { |
| 47 | + cleanup(); |
| 48 | + vi.useRealTimers(); |
| 49 | + document.body.style.overflow = ""; |
| 50 | +}); |
| 51 | + |
| 52 | +function Host({ open, testId = "m" }: { open: boolean; testId?: string }) { |
| 53 | + return ( |
| 54 | + <Modal open={open} onClose={noop} title="标题文本" testId={testId}> |
| 55 | + 正文内容 |
| 56 | + </Modal> |
| 57 | + ); |
| 58 | +} |
| 59 | + |
| 60 | +/** 两层弹层:外层由 `outer` 驱动、内层由 `inner` 驱动(`tier` 各按真实层级的语义给) */ |
| 61 | +function Nested({ outer, inner }: { outer: boolean; inner: boolean }) { |
| 62 | + return ( |
| 63 | + <Modal open={outer} onClose={noop} title="外层" testId="outer" tier="modal"> |
| 64 | + <Modal open={inner} onClose={noop} title="内层" testId="inner" tier="modalNested"> |
| 65 | + 内层正文 |
| 66 | + </Modal> |
| 67 | + </Modal> |
| 68 | + ); |
| 69 | +} |
| 70 | + |
| 71 | +/** 关掉一层:rerender 到 `open=false` 再派发该面板自己的 `transitionend`(= 退场真结束) */ |
| 72 | +function closeAndSettle(rerender: (ui: ReactElement) => void, ui: ReactElement, testId = "m"): void { |
| 73 | + rerender(ui); |
| 74 | + const el = screen.getByTestId(testId); |
| 75 | + fireEvent.transitionEnd(el, { target: el, currentTarget: el }); |
| 76 | +} |
| 77 | + |
| 78 | +describe("① 开弹层 ⇒ body 被锁(B6:20 个调用点共用,故锁在 Modal 里)", () => { |
| 79 | + it("未打开时不动 body;open=true 且已挂载 ⇒ overflow:'hidden'", () => { |
| 80 | + const { rerender } = render(<Host open={false} />); |
| 81 | + expect(overflow(), "没打开弹层时不得碰 body(原语不许有隐藏副作用)").toBe(""); |
| 82 | + |
| 83 | + rerender(<Host open />); |
| 84 | + expect(screen.getByTestId("m-overlay").parentElement).toBe(document.body); // 前提:确实挂上了 portal |
| 85 | + expect(overflow()).toBe("hidden"); |
| 86 | + }); |
| 87 | +}); |
| 88 | + |
| 89 | +describe("② 锁随 presence.mounted,不随 open(退场窗口内仍锁)", () => { |
| 90 | + it("open=false 且退场未结束(未派发 transitionend)⇒ 仍锁;退场结束 ⇒ 恢复", () => { |
| 91 | + vi.useFakeTimers(); |
| 92 | + const { rerender } = render(<Host open />); |
| 93 | + tick(0); // enter → entered(见 tick 的注释) |
| 94 | + expect(overflow()).toBe("hidden"); |
| 95 | + |
| 96 | + rerender(<Host open={false} />); |
| 97 | + expect(panel().getAttribute("data-phase"), "前提:此时正处于退场相位").toBe("exit"); |
| 98 | + expect(overflow(), "面板还在屏上就解锁 ⇒ 背景在弹层未消失时就能滚").toBe("hidden"); |
| 99 | + |
| 100 | + const el = panel(); |
| 101 | + fireEvent.transitionEnd(el, { target: el, currentTarget: el }); |
| 102 | + expect(panelOrNull(), "前提:退场确实结束了").toBeNull(); |
| 103 | + expect(overflow()).toBe(""); |
| 104 | + }); |
| 105 | +}); |
| 106 | + |
| 107 | +describe("③ 嵌套:关内层不解锁,关外层才恢复(引用计数)", () => { |
| 108 | + it("两层同开 ⇒ 关内层仍锁;关外层 ⇒ 恢复", () => { |
| 109 | + vi.useFakeTimers(); |
| 110 | + const { rerender } = render(<Nested outer={false} inner={false} />); |
| 111 | + tick(0); |
| 112 | + rerender(<Nested outer inner />); |
| 113 | + tick(0); |
| 114 | + expect(overflow()).toBe("hidden"); |
| 115 | + |
| 116 | + // 关内层:它自己的 transitionend 到达 ⇒ 内层被卸载,但外层还在屏上 |
| 117 | + closeAndSettle(rerender, <Nested outer inner={false} />, "inner"); |
| 118 | + expect(screen.queryByTestId("inner"), "前提:内层确实已卸载").toBeNull(); |
| 119 | + expect(screen.getByTestId("outer"), "前提:外层仍在屏上").not.toBeNull(); |
| 120 | + expect(overflow(), "内层关闭时外层还在 ⇒ 解锁会让背景跳一下").toBe("hidden"); |
| 121 | + |
| 122 | + closeAndSettle(rerender, <Nested outer={false} inner={false} />, "outer"); |
| 123 | + expect(panelOrNull(), "前提:外层退场结束").toBeNull(); |
| 124 | + expect(overflow(), "最后一层关闭必须恢复").toBe(""); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe("④ 恢复的是原值快照,不是空串", () => { |
| 129 | + it("开工前 body 已有 overflow:'auto' ⇒ 关完后仍是 'auto'", () => { |
| 130 | + vi.useFakeTimers(); |
| 131 | + document.body.style.overflow = "auto"; |
| 132 | + const { rerender } = render(<Host open />); |
| 133 | + tick(0); |
| 134 | + expect(overflow(), "锁期间必须是 hidden").toBe("hidden"); |
| 135 | + |
| 136 | + closeAndSettle(rerender, <Host open={false} />); |
| 137 | + expect(overflow(), "恢复成空串 = 抹掉宿主页设过的值(看不见的破坏)").toBe("auto"); |
| 138 | + }); |
| 139 | +}); |
| 140 | + |
| 141 | +describe("⑤ 无 transitionend 的兜底路径(reduced-motion / jsdom 不派发)", () => { |
| 142 | + it("推进 EXIT_MS + slack + 1 ms ⇒ 恢复(不派发任何事件)", () => { |
| 143 | + vi.useFakeTimers(); |
| 144 | + const { rerender } = render(<Host open />); |
| 145 | + tick(0); |
| 146 | + expect(overflow()).toBe("hidden"); |
| 147 | + |
| 148 | + rerender(<Host open={false} />); |
| 149 | + tick(EXIT_FALLBACK_MS - 1); |
| 150 | + expect(panelOrNull(), "前提:兜底窗口未到,面板仍在").not.toBeNull(); |
| 151 | + expect(overflow(), "面板仍在时不得提前解锁").toBe("hidden"); |
| 152 | + |
| 153 | + tick(1); |
| 154 | + expect(panelOrNull(), "前提:兜底窗口到点后确实卸载了").toBeNull(); |
| 155 | + expect(overflow()).toBe(""); |
| 156 | + }); |
| 157 | +}); |
| 158 | + |
| 159 | +describe("⑥ 原值快照属于**第一个**持锁者(弹层交接时不许被改写成 'hidden')", () => { |
| 160 | + /** |
| 161 | + * 交接场景:A 正在退场(`open=false` 但未收到 `transitionend`)而 B 已打开。 |
| 162 | + * A 与 B **必须是两个独立实例** —— 同一个 `Modal` 只改 `open`/`testId` 是"同一个持锁者", |
| 163 | + * 测不出快照归属(第一版正是这样写的,被"`a` 找不到"当场证伪)。 |
| 164 | + */ |
| 165 | + function Handoff({ a, b }: { a: boolean; b: boolean }) { |
| 166 | + return ( |
| 167 | + <> |
| 168 | + <Modal open={a} onClose={noop} title="弹层 A" testId="a"> |
| 169 | + A |
| 170 | + </Modal> |
| 171 | + <Modal open={b} onClose={noop} title="弹层 B" testId="b"> |
| 172 | + B |
| 173 | + </Modal> |
| 174 | + </> |
| 175 | + ); |
| 176 | + } |
| 177 | + |
| 178 | + it("A 退场未结束时 B 接手 ⇒ 全程锁住,且最终恢复成最初的原值 'auto'", () => { |
| 179 | + vi.useFakeTimers(); |
| 180 | + document.body.style.overflow = "auto"; |
| 181 | + const { rerender } = render(<Handoff a b={false} />); |
| 182 | + tick(0); |
| 183 | + |
| 184 | + // A 开始退场(未收到 transitionend)⇒ 仍持锁;B 随即打开 ⇒ 两个持锁者并存 |
| 185 | + rerender(<Handoff a={false} b={false} />); |
| 186 | + expect(screen.getByTestId("a").getAttribute("data-phase")).toBe("exit"); |
| 187 | + rerender(<Handoff a={false} b />); |
| 188 | + tick(0); // B 是**新挂载**的实例:它也要走一次 enter → entered(见 tick 的注释) |
| 189 | + expect(overflow(), "交接期间不得出现「背景能滚」的窗口").toBe("hidden"); |
| 190 | + |
| 191 | + // A 的退场结束 ⇒ 释放一次;B 仍持锁 ⇒ 仍 hidden |
| 192 | + const panelA = screen.getByTestId("a"); |
| 193 | + fireEvent.transitionEnd(panelA, { target: panelA, currentTarget: panelA }); |
| 194 | + expect(screen.queryByTestId("a"), "前提:A 已卸载").toBeNull(); |
| 195 | + expect(screen.getByTestId("b"), "前提:B 仍在屏上").not.toBeNull(); |
| 196 | + expect(overflow(), "B 仍在 ⇒ 不许解锁").toBe("hidden"); |
| 197 | + |
| 198 | + // B 也退场 ⇒ 恢复的必须是**最初**的原值(若第二个持锁者改写了快照,这里会是 'hidden') |
| 199 | + closeAndSettle(rerender, <Handoff a={false} b={false} />, "b"); |
| 200 | + expect(overflow(), "快照被后来者改写 ⇒ 宿主页的 'auto' 永久丢失").toBe("auto"); |
| 201 | + }); |
| 202 | +}); |
0 commit comments