|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Modal.test.tsx —— 弹层唯一实现的契约测试(批 0-D Task 7;本批缺口最大的一类)。 |
| 4 | + * |
| 5 | + * Why jsdom:Modal 走 `createPortal`(需要真容器)+ 焦点陷阱(需要 `document.activeElement`)。 |
| 6 | + * 本文件钉住**三条全仓 0 命中的契约**(`createPortal` / `role="dialog"` / `aria-modal="true"`—— |
| 7 | + * 改造前实测 0/0/0,recon §10)+ ESC 的「最内层唯一响应」+ 焦点归还。时序判据读 CSS 文本而非 |
| 8 | + * `getComputedStyle`:`vitest.config.ts` 的 `css` 默认 false ⇒ 测试环境不加载样式表,只能按范式 C |
| 9 | + * 直接读实现文件(同 `tokens.drift.test.ts`);不用 jest-dom(本仓未装),一律原生属性断言。 |
| 10 | + * |
| 11 | + * 副作用:无(只挂 React 树 + 读 1 个 CSS 文件);假计时器只用在需要推进 presence 计时的 describe。 |
| 12 | + * ⚠️ `usePresence` 的 `enter → entered` 要等**下一个宏任务**(`ENTER_TICK_MS = 0`,为的是让出一次 |
| 13 | + * 绘制机会)⇒ jsdom + 假计时器下必须 `vi.advanceTimersByTime(0)`,否则 `phase` 永远停在 `enter`。 |
| 14 | + */ |
| 15 | +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; |
| 16 | +import { readFileSync } from "node:fs"; |
| 17 | +import { dirname, join } from "node:path"; |
| 18 | +import { fileURLToPath } from "node:url"; |
| 19 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 20 | +import { Z_TIER } from "../zIndex"; |
| 21 | +import { Modal } from "./Modal"; |
| 22 | + |
| 23 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 24 | +const readCss = (name: string): string => readFileSync(join(HERE, name), "utf8"); |
| 25 | +const noop = (): void => undefined; |
| 26 | + |
| 27 | +interface HostProps { |
| 28 | + open: boolean; |
| 29 | + onClose?: () => void; |
| 30 | + closeOnEsc?: boolean; |
| 31 | + closeOnOverlay?: boolean; |
| 32 | + tier?: "modal" | "modalNested"; |
| 33 | + footer?: boolean; |
| 34 | +} |
| 35 | + |
| 36 | +/** 宿主:一个触发按钮 + 一个受控 Modal(`open` 由测试 rerender 驱动) */ |
| 37 | +function Host({ open, onClose = noop, closeOnEsc = true, closeOnOverlay = true, tier = "modal", footer = false }: HostProps) { |
| 38 | + return ( |
| 39 | + <div data-testid="host"> |
| 40 | + <button data-testid="trigger">触发</button> |
| 41 | + <Modal |
| 42 | + open={open} |
| 43 | + onClose={onClose} |
| 44 | + title="标题文本" |
| 45 | + size="m" |
| 46 | + tier={tier} |
| 47 | + closeOnEsc={closeOnEsc} |
| 48 | + closeOnOverlay={closeOnOverlay} |
| 49 | + testId="m" |
| 50 | + footer={footer ? <button data-testid="ok">确定</button> : undefined} |
| 51 | + > |
| 52 | + 正文内容 |
| 53 | + </Modal> |
| 54 | + </div> |
| 55 | + ); |
| 56 | +} |
| 57 | + |
| 58 | +const panel = (): HTMLElement => screen.getByRole("dialog"); |
| 59 | +const panelOrNull = (): Element | null => document.body.querySelector('[role="dialog"]'); |
| 60 | + |
| 61 | +afterEach(() => { |
| 62 | + cleanup(); |
| 63 | + vi.useRealTimers(); |
| 64 | +}); |
| 65 | + |
| 66 | +describe("三条全仓 0→1 的契约", () => { |
| 67 | + it("createPortal:弹层离开原 React 容器,直挂 document.body(否则祖先的 overflow/z-index 会吃掉它)", () => { |
| 68 | + const { container } = render(<Host open />); |
| 69 | + expect(container.querySelector('[role="dialog"]')).toBeNull(); |
| 70 | + expect(screen.getByTestId("m-overlay").parentElement).toBe(document.body); |
| 71 | + }); |
| 72 | + |
| 73 | + it('role="dialog" + aria-modal="true" 成对出现(改造前全仓 0 命中,recon §10)', () => { |
| 74 | + render(<Host open />); |
| 75 | + expect(panel().tagName).toBe("DIV"); |
| 76 | + expect(panel().getAttribute("aria-modal")).toBe("true"); |
| 77 | + }); |
| 78 | + |
| 79 | + it("aria-labelledby 指向的节点文本 === title(不硬编码 useId 的值)", () => { |
| 80 | + render(<Host open />); |
| 81 | + const id = panel().getAttribute("aria-labelledby"); |
| 82 | + expect(id, "缺 aria-labelledby ⇒ 读屏只会念「对话框」").toBeTruthy(); |
| 83 | + expect(document.getElementById(id as string)?.textContent).toBe("标题文本"); |
| 84 | + }); |
| 85 | + |
| 86 | + it("不 open 时不渲染:queryByTestId 为 null 且 body 里没有 portal 节点", () => { |
| 87 | + render(<Host open={false} />); |
| 88 | + expect(screen.queryByTestId("m-overlay")).toBeNull(); |
| 89 | + expect(panelOrNull()).toBeNull(); |
| 90 | + }); |
| 91 | +}); |
| 92 | + |
| 93 | +describe("关闭路径", () => { |
| 94 | + it("ESC → onClose(监听挂在 document、冒泡相)", () => { |
| 95 | + const onClose = vi.fn(); |
| 96 | + render(<Host open onClose={onClose} />); |
| 97 | + fireEvent.keyDown(document, { key: "Escape" }); |
| 98 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 99 | + }); |
| 100 | + |
| 101 | + it("closeOnEsc=false ⇒ 不关闭;长按 ESC(repeat)也不重复触发", () => { |
| 102 | + const off = vi.fn(); |
| 103 | + const { unmount } = render(<Host open onClose={off} closeOnEsc={false} />); |
| 104 | + fireEvent.keyDown(document, { key: "Escape" }); |
| 105 | + expect(off).not.toHaveBeenCalled(); |
| 106 | + unmount(); |
| 107 | + |
| 108 | + const onClose = vi.fn(); |
| 109 | + render(<Host open onClose={onClose} />); |
| 110 | + fireEvent.keyDown(document, { key: "Escape", repeat: true }); |
| 111 | + expect(onClose, "自动重复的 keydown 不得连关多层").not.toHaveBeenCalled(); |
| 112 | + fireEvent.keyDown(document, { key: "Escape" }); |
| 113 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 114 | + }); |
| 115 | + |
| 116 | + it("遮罩 mouseDown → onClose;面板内 mouseDown 不触发(防「面板内按下、遮罩上松开」误关)", () => { |
| 117 | + const onClose = vi.fn(); |
| 118 | + render(<Host open onClose={onClose} />); |
| 119 | + fireEvent.mouseDown(panel()); |
| 120 | + expect(onClose).not.toHaveBeenCalled(); |
| 121 | + fireEvent.mouseDown(screen.getByTestId("m-overlay")); |
| 122 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 123 | + }); |
| 124 | + |
| 125 | + it("closeOnOverlay=false ⇒ 点遮罩不关闭;关闭按钮 → onClose", () => { |
| 126 | + const onClose = vi.fn(); |
| 127 | + render(<Host open onClose={onClose} closeOnOverlay={false} />); |
| 128 | + fireEvent.mouseDown(screen.getByTestId("m-overlay")); |
| 129 | + expect(onClose).not.toHaveBeenCalled(); |
| 130 | + fireEvent.click(screen.getByTestId("m-close")); |
| 131 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 132 | + }); |
| 133 | + |
| 134 | + it("最上层消费:拦住仍挂在 window 上的旧 ESC 监听(仓内 19 处手写弹层全在 window)", () => { |
| 135 | + const legacy = vi.fn(); |
| 136 | + const onClose = vi.fn(); |
| 137 | + window.addEventListener("keydown", legacy); |
| 138 | + render(<Host open onClose={onClose} />); |
| 139 | + fireEvent.keyDown(document, { key: "Tab" }); // 前提:jsdom 的事件确实从 document 冒到 window |
| 140 | + expect(legacy, "前提不成立则本用例空转(Tab 不被 Modal 消费)").toHaveBeenCalledTimes(1); |
| 141 | + legacy.mockClear(); |
| 142 | + fireEvent.keyDown(document, { key: "Escape" }); |
| 143 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 144 | + expect(legacy, "弹层必须消费掉这一次 ESC,不能同时关掉下层的旧面板").not.toHaveBeenCalled(); |
| 145 | + window.removeEventListener("keydown", legacy); |
| 146 | + }); |
| 147 | +}); |
| 148 | + |
| 149 | +describe("进出场([data-phase] 三态 + 卸载时机)", () => { |
| 150 | + beforeEach(() => { |
| 151 | + vi.useFakeTimers(); |
| 152 | + }); |
| 153 | + |
| 154 | + const tick = (ms: number): void => { |
| 155 | + act(() => { |
| 156 | + vi.advanceTimersByTime(ms); |
| 157 | + }); |
| 158 | + }; |
| 159 | + |
| 160 | + it("打开:先 'enter'(CSS 起点),下一宏任务才转 'entered' 触发 transition", () => { |
| 161 | + render(<Host open />); |
| 162 | + expect(panel().getAttribute("data-phase")).toBe("enter"); |
| 163 | + expect(screen.getByTestId("m-overlay").getAttribute("data-phase")).toBe("enter"); |
| 164 | + tick(0); |
| 165 | + expect(panel().getAttribute("data-phase")).toBe("entered"); |
| 166 | + }); |
| 167 | + |
| 168 | + it("关闭:先 'exit' 且仍挂载;兜底窗口(160+80)到点才卸载 ——「关不掉的弹层」防线", () => { |
| 169 | + const { rerender } = render(<Host open />); |
| 170 | + tick(0); |
| 171 | + rerender(<Host open={false} />); |
| 172 | + expect(panel().getAttribute("data-phase")).toBe("exit"); |
| 173 | + tick(239); |
| 174 | + expect(panelOrNull(), "兜底窗口未到不得卸载").not.toBeNull(); |
| 175 | + tick(1); |
| 176 | + expect(panelOrNull()).toBeNull(); |
| 177 | + }); |
| 178 | + |
| 179 | + it("面板收到本节点 transitionend ⇒ 立即卸载(不等兜底)", () => { |
| 180 | + const { rerender } = render(<Host open />); |
| 181 | + tick(0); |
| 182 | + rerender(<Host open={false} />); |
| 183 | + const el = panel(); |
| 184 | + fireEvent.transitionEnd(el, { target: el, currentTarget: el }); |
| 185 | + expect(panelOrNull()).toBeNull(); |
| 186 | + }); |
| 187 | + |
| 188 | + it("可中断/可反向:出场期间 open 回 true ⇒ 不卸载且直回 'entered'(不重播进场)", () => { |
| 189 | + const { rerender } = render(<Host open />); |
| 190 | + tick(0); |
| 191 | + rerender(<Host open={false} />); |
| 192 | + rerender(<Host open />); |
| 193 | + expect(panel().getAttribute("data-phase")).toBe("entered"); |
| 194 | + tick(10_000); |
| 195 | + expect(panelOrNull(), "退场兜底计时器必须已被接管").not.toBeNull(); |
| 196 | + }); |
| 197 | +}); |
| 198 | + |
| 199 | +describe("焦点(无障碍优先于动效,§8.6.1 第 4 条)", () => { |
| 200 | + it("打开时焦点进入面板(首个可聚焦元素 = 关闭按钮)", () => { |
| 201 | + render(<Host open />); |
| 202 | + expect(document.activeElement).toBe(screen.getByTestId("m-close")); |
| 203 | + expect(panel().contains(document.activeElement)).toBe(true); |
| 204 | + }); |
| 205 | + |
| 206 | + it("关闭时焦点归还触发元素(§5.2 能力②)", () => { |
| 207 | + const { rerender } = render(<Host open={false} />); |
| 208 | + screen.getByTestId("trigger").focus(); |
| 209 | + rerender(<Host open />); |
| 210 | + expect(document.activeElement).toBe(screen.getByTestId("m-close")); |
| 211 | + rerender(<Host open={false} />); |
| 212 | + expect(document.activeElement).toBe(screen.getByTestId("trigger")); |
| 213 | + }); |
| 214 | + |
| 215 | + it("Tab 在面板内循环:末元素回卷首元素、Shift+Tab 反向回卷(jsdom 无原生 Tab 导航)", () => { |
| 216 | + render(<Host open footer />); |
| 217 | + const close = screen.getByTestId("m-close"); |
| 218 | + const ok = screen.getByTestId("ok"); |
| 219 | + expect(document.activeElement).toBe(close); |
| 220 | + fireEvent.keyDown(document, { key: "Tab" }); |
| 221 | + expect(document.activeElement).toBe(ok); |
| 222 | + fireEvent.keyDown(document, { key: "Tab" }); |
| 223 | + expect(document.activeElement).toBe(close); |
| 224 | + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); |
| 225 | + expect(document.activeElement).toBe(ok); |
| 226 | + }); |
| 227 | +}); |
| 228 | + |
| 229 | +describe("z-index(出生即用标尺,规格 §4.2①)", () => { |
| 230 | + it("tier 决定遮罩的 zIndex(modal / modalNested),不得写裸数字", () => { |
| 231 | + const first = render(<Host open tier="modal" />); |
| 232 | + expect(screen.getByTestId("m-overlay").style.zIndex).toBe(String(Z_TIER.modal)); |
| 233 | + first.unmount(); |
| 234 | + render(<Host open tier="modalNested" />); |
| 235 | + expect(screen.getByTestId("m-overlay").style.zIndex).toBe(String(Z_TIER.modalNested)); |
| 236 | + }); |
| 237 | +}); |
| 238 | + |
| 239 | +describe("ESC 优先级:最内层唯一响应(与仓内「菜单优先」退出链同语义)", () => { |
| 240 | + /** 两层在**同一次提交**里挂载(最苛刻的情形:effect 自底向上跑,入栈序会把外层当栈顶) */ |
| 241 | + function Nested({ inner, onOuter, onInner }: { inner: boolean; onOuter: () => void; onInner: () => void }) { |
| 242 | + return ( |
| 243 | + <Modal open onClose={onOuter} title="外层" testId="outer" tier="modal"> |
| 244 | + <Modal open={inner} onClose={onInner} title="内层" testId="inner" tier="modalNested"> |
| 245 | + 内层正文 |
| 246 | + </Modal> |
| 247 | + </Modal> |
| 248 | + ); |
| 249 | + } |
| 250 | + |
| 251 | + it("两层同开时一次 ESC 只关内层;内层关上后 ESC 才轮到外层", () => { |
| 252 | + const onOuter = vi.fn(); |
| 253 | + const onInner = vi.fn(); |
| 254 | + const { rerender } = render(<Nested inner onOuter={onOuter} onInner={onInner} />); |
| 255 | + fireEvent.keyDown(document, { key: "Escape" }); |
| 256 | + expect(onInner).toHaveBeenCalledTimes(1); |
| 257 | + expect(onOuter, "一次 ESC 只许关一层(仓内退出链语义)").not.toHaveBeenCalled(); |
| 258 | + rerender(<Nested inner={false} onOuter={onOuter} onInner={onInner} />); |
| 259 | + fireEvent.keyDown(document, { key: "Escape" }); |
| 260 | + expect(onOuter, "内层出栈后外层必须重新成为唯一响应者").toHaveBeenCalledTimes(1); |
| 261 | + expect(onInner, "已关闭的内层不得被再次触发").toHaveBeenCalledTimes(1); |
| 262 | + }); |
| 263 | +}); |
| 264 | + |
| 265 | +describe("进出场时序:出场比进场快(规格 §8.4「弹层 200/160」)", () => { |
| 266 | + /** 取一条 CSS 规则原文(到第一个 `}` 为止;transition 值里没有花括号) */ |
| 267 | + const ruleText = (selector: string): string => { |
| 268 | + const css = readCss("Modal.css"); |
| 269 | + const start = css.indexOf(selector); |
| 270 | + expect(start, `Modal.css 缺少规则「${selector}」`).toBeGreaterThanOrEqual(0); |
| 271 | + return css.slice(start, css.indexOf("}", start) + 1); |
| 272 | + }; |
| 273 | + |
| 274 | + it("遮罩与面板:进场用 in(200)、出场用 out(160),兜底字面量与变量同名同值", () => { |
| 275 | + expect(ruleText(".ed-modal-overlay {")).toContain("var(--ed-dur-overlay-in, 200ms)"); |
| 276 | + expect(ruleText('.ed-modal-overlay[data-phase="exit"]')).toContain( |
| 277 | + "transition-duration: var(--ed-dur-overlay-out, 160ms)", |
| 278 | + ); |
| 279 | + expect(ruleText('.ed-modal[data-phase="exit"]')).toContain("transition-duration: var(--ed-dur-overlay-out, 160ms)"); |
| 280 | + }); |
| 281 | + |
| 282 | + it("fallback 字面量:出场 160 < 进场 200(「出场比进场快」的机器判据)", () => { |
| 283 | + const css = readCss("Modal.css"); |
| 284 | + const inMs = Number(/var\(--ed-dur-overlay-in,\s*(\d+)ms\)/.exec(css)?.[1] ?? "0"); |
| 285 | + const outMs = Number(/var\(--ed-dur-overlay-out,\s*(\d+)ms\)/.exec(css)?.[1] ?? "0"); |
| 286 | + expect(inMs).toBe(200); |
| 287 | + expect(outMs).toBe(160); |
| 288 | + expect(outMs, "出场必须比进场快").toBeLessThan(inMs); |
| 289 | + }); |
| 290 | + |
| 291 | + it("面板的 opacity 与 transform 等时长(presence 以首个 transitionend 收尾,不等长会提前摘掉)", () => { |
| 292 | + const base = ruleText(".ed-modal {"); |
| 293 | + expect(base.match(/var\(--ed-dur-overlay-in, 200ms\)/g) ?? []).toHaveLength(2); |
| 294 | + expect(base, "退出时长只许出现在 [data-phase='exit'] 规则里").not.toContain("--ed-dur-overlay-out"); |
| 295 | + }); |
| 296 | +}); |
0 commit comments