|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context MotionIntensityControl.test.tsx —— 「动效强度」三段控件的行为级契约(批 6 Task 7), |
| 4 | + * 并承担 **R16.3② 的连带硬要求**:T6 的 `intensity.test.ts` 是 **node 环境**文件(不写 jsdom 头) |
| 5 | + * ⇒ 「hook 挂载即写 `<html data-motion>`」这一事实在 node 环境**不可判**(那里只能拿假元素判 DOM |
| 6 | + * 写入)⇒ 由本文件补 jsdom 判据:① 挂载即写 ② 切换即改(属性 + 持久化双写)③ 卸载/复挂载读回。 |
| 7 | + * |
| 8 | + * Why 每条判据都在这里: |
| 9 | + * V1 选中态**恰一段**且等于当前档(受控值不是「看着像」,是可数出来的); |
| 10 | + * V2 点击 ⇒ `<html data-motion>` **与** `motion:intensity` **双写**(只看 DOM 会被「只 setState |
| 11 | + * 不落地」骗过;只看存储会被「只写属性不记忆」骗过 —— 两半各配一个专属变异体); |
| 12 | + * V3 段清单与真源同源:段数 / 文案 / 顺序三项对拍(硬字面量对照,**不从真源派生期望值**,否则 |
| 13 | + * 「真源多一档」这类变异不会被发现 —— 与 T6 V7 的 CSS 侧值域判据双向互补); |
| 14 | + * V3b 每段点击都落到**自己**那个档位(一一对应,防「三段都写同一个值」); |
| 15 | + * V4 **零行内 style**(ADR-033 §4):扫**整棵子树**而不只根节点(防「包一层带 style 的壳」); |
| 16 | + * ⑤ **状态可观测**(控制方 2026-09-13 追加,来自 T6 评审 I-1):T6 的 `intensity.test.ts` 是 node |
| 17 | + * 环境 + SSR 探针(`renderToString` **返回后**才调 `select` ⇒ 不重渲染 ⇒ 探针永远读到切换**前** |
| 18 | + * 的值,删掉 `setValue(next)` 12 条全绿)⇒ 本文件用 jsdom 的 `renderHook` **切换后再读一次**, |
| 19 | + * 断言读到的是**新档**;同一次切换的另两半(`<html data-motion>` / `motion:intensity`)各自单列, |
| 20 | + * 三半互不代偿(专属变异体 m2a / m2b / m7)。 |
| 21 | + * |
| 22 | + * 副作用:只挂 React 树 + 只写 jsdom 的 `<html data-motion>` / `localStorage` 桩 + 局部 |
| 23 | + * `vi.stubGlobal("matchMedia")`(**不动全局 `src/test/setup.ts`**,先例 `usePresence.test.tsx` |
| 24 | + * `:54-93` 与 `Toast.test.tsx:58-66`)。 |
| 25 | + * 边界(诚实登记):本仓**未装 `jest-dom` / `user-event`** ⇒ 断言一律**原生 DOM API**、交互一律 |
| 26 | + * `fireEvent`;jsdom **不是真浏览器** ⇒ 「三档动画看起来不一样」不可机器判据(同 T6 诚实边界①)。 |
| 27 | + */ |
| 28 | +import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react"; |
| 29 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 30 | +import { MotionIntensityControl } from "./MotionIntensityControl"; |
| 31 | +import { MOTION_INTENSITIES, MOTION_INTENSITY_KEY, useMotionIntensity } from "../motion/intensity"; |
| 32 | + |
| 33 | +/** §8.5 三档:段名 ↔ 档位取值。**硬字面量**(判据的期望值不许从被测真源派生) */ |
| 34 | +const TIERS = [ |
| 35 | + { key: "eco", label: "节能" }, |
| 36 | + { key: "standard", label: "标准" }, |
| 37 | + { key: "rich", label: "丰富" }, |
| 38 | +] as const; |
| 39 | +/** T6 `intensity.ts:19` 的唯一入参(逐字) */ |
| 40 | +const REDUCED_QUERY = "(prefers-reduced-motion: reduce)"; |
| 41 | + |
| 42 | +type MqlListener = (event: MediaQueryListEvent) => void; |
| 43 | +interface MatchMediaStub { |
| 44 | + readonly impl: (query: string) => MediaQueryList; |
| 45 | + readonly queries: readonly string[]; |
| 46 | + readonly legacyAdded: readonly MqlListener[]; |
| 47 | + readonly legacyRemoved: readonly MqlListener[]; |
| 48 | +} |
| 49 | + |
| 50 | +/** |
| 51 | + * 造一个最小 `matchMedia` 桩(jsdom 30.0.1 **没有** `window.matchMedia` —— 本文件 ④ 自证)。 |
| 52 | + * 🔴 `addListener` / `removeListener` **必须**实现:GSAP 与部分库走 **legacy 分支**,只实现 |
| 53 | + * `addEventListener` 时它们**永远不会被调用**(R3.2 的连带口径)。 |
| 54 | + */ |
| 55 | +function stubMatchMedia(matches: boolean): MatchMediaStub { |
| 56 | + const queries: string[] = []; |
| 57 | + const legacyAdded: MqlListener[] = []; |
| 58 | + const legacyRemoved: MqlListener[] = []; |
| 59 | + const mql = { |
| 60 | + matches, |
| 61 | + media: REDUCED_QUERY, |
| 62 | + onchange: null, |
| 63 | + addEventListener: (_type: string, _listener: MqlListener): void => undefined, |
| 64 | + removeEventListener: (_type: string, _listener: MqlListener): void => undefined, |
| 65 | + addListener: (listener: MqlListener | null): void => { |
| 66 | + if (listener !== null) legacyAdded.push(listener); |
| 67 | + }, |
| 68 | + removeListener: (listener: MqlListener | null): void => { |
| 69 | + if (listener !== null) legacyRemoved.push(listener); |
| 70 | + }, |
| 71 | + dispatchEvent: (): boolean => true, |
| 72 | + } as unknown as MediaQueryList; // 先例:`usePresence.test.tsx:83`(jsdom 无该 API,只能造) |
| 73 | + return { |
| 74 | + impl: (query: string): MediaQueryList => { |
| 75 | + queries.push(query); |
| 76 | + return mql; |
| 77 | + }, |
| 78 | + queries, |
| 79 | + legacyAdded, |
| 80 | + legacyRemoved, |
| 81 | + }; |
| 82 | +} |
| 83 | + |
| 84 | +/** `<html>` 上的档位属性(判据一律读真 DOM,不读组件内部状态) */ |
| 85 | +const htmlMotion = (): string | null => document.documentElement.getAttribute("data-motion"); |
| 86 | +/** 三段(段类来自 `ViewSwitcher`:`ed-btn ed-btn--segment`) */ |
| 87 | +const segs = (): HTMLButtonElement[] => [...document.querySelectorAll<HTMLButtonElement>(".ed-btn--segment")]; |
| 88 | +const segByLabel = (label: string): HTMLButtonElement => { |
| 89 | + const seg = segs().find((s) => s.textContent === label); |
| 90 | + if (seg === undefined) throw new Error(`找不到文案为「${label}」的段`); |
| 91 | + return seg; |
| 92 | +}; |
| 93 | + |
| 94 | +beforeEach(() => { |
| 95 | + window.localStorage.clear(); |
| 96 | + document.documentElement.removeAttribute("data-motion"); |
| 97 | +}); |
| 98 | +afterEach(() => { |
| 99 | + cleanup(); |
| 100 | + vi.unstubAllGlobals(); |
| 101 | + window.localStorage.clear(); |
| 102 | + document.documentElement.removeAttribute("data-motion"); |
| 103 | +}); |
| 104 | + |
| 105 | +describe("R16.3② ①挂载即写(T6 在 node 环境判不到的那一条)", () => { |
| 106 | + it("挂载前 `<html>` 无 `data-motion`(先证起点干净,防残留属性让断言空真);挂载后 = standard", () => { |
| 107 | + expect(htmlMotion()).toBeNull(); |
| 108 | + render(<MotionIntensityControl />); |
| 109 | + expect(htmlMotion()).toBe("standard"); |
| 110 | + }); |
| 111 | + |
| 112 | + it("`prefers-reduced-motion: reduce` ⇒ 初值 eco,且查询串逐字透传给 matchMedia", () => { |
| 113 | + const stub = stubMatchMedia(true); |
| 114 | + vi.stubGlobal("matchMedia", stub.impl); |
| 115 | + render(<MotionIntensityControl />); |
| 116 | + expect(htmlMotion()).toBe("eco"); |
| 117 | + expect([...stub.queries]).toEqual([REDUCED_QUERY]); |
| 118 | + }); |
| 119 | +}); |
| 120 | + |
| 121 | +describe("R16.3② ④桩自证:本环境没有 `matchMedia`,且桩实现了 legacy 的 addListener/removeListener", () => { |
| 122 | + it("`typeof window.matchMedia === \"undefined\"`(桩不是奢侈品)", () => { |
| 123 | + expect(typeof window.matchMedia).toBe("undefined"); |
| 124 | + }); |
| 125 | + |
| 126 | + it("桩的 addListener/removeListener 真被调用得到(只实现 addEventListener 的桩会漏掉 legacy 分支)", () => { |
| 127 | + const stub = stubMatchMedia(false); |
| 128 | + const mql = stub.impl(REDUCED_QUERY); |
| 129 | + const listener: MqlListener = () => undefined; |
| 130 | + expect(typeof mql.addListener).toBe("function"); |
| 131 | + expect(typeof mql.removeListener).toBe("function"); |
| 132 | + mql.addListener(listener); |
| 133 | + mql.removeListener(listener); |
| 134 | + expect([...stub.legacyAdded]).toEqual([listener]); |
| 135 | + expect([...stub.legacyRemoved]).toEqual([listener]); |
| 136 | + }); |
| 137 | +}); |
| 138 | + |
| 139 | +describe("V1 当前档反映在 DOM:`aria-pressed=\"true\"` 恰一段且等于当前档", () => { |
| 140 | + it("初值 ⇒ 恰「标准」为 true;点「丰富」后 true 段随之搬走", () => { |
| 141 | + render(<MotionIntensityControl />); |
| 142 | + expect(segs().length).toBe(TIERS.length); |
| 143 | + const pressed = (): string[] => |
| 144 | + segs().filter((s) => s.getAttribute("aria-pressed") === "true").map((s) => s.textContent ?? ""); |
| 145 | + expect(pressed()).toEqual(["标准"]); |
| 146 | + fireEvent.click(segByLabel("丰富")); |
| 147 | + expect(pressed()).toEqual(["丰富"]); |
| 148 | + }); |
| 149 | +}); |
| 150 | + |
| 151 | +describe("R16.3② ②切换即改 + V2 双写:`<html data-motion>` 与 `motion:intensity` 同时落地", () => { |
| 152 | + it("点「丰富」⇒ `data-motion=\"rich\"` 且持久化键 == rich", () => { |
| 153 | + render(<MotionIntensityControl />); |
| 154 | + fireEvent.click(segByLabel("丰富")); |
| 155 | + expect(htmlMotion()).toBe("rich"); |
| 156 | + expect(window.localStorage.getItem(MOTION_INTENSITY_KEY)).toBe("rich"); |
| 157 | + }); |
| 158 | +}); |
| 159 | + |
| 160 | +describe("R16.3② ⑤状态可观测(T6 评审 I-1 的盲区:node 探针只在渲染期取值)", () => { |
| 161 | + it("`renderHook`:切到 rich 后**再读一次** ⇒ 读到的是新档(删掉 `setValue(next)` 即红)", () => { |
| 162 | + const { result } = renderHook(() => useMotionIntensity()); |
| 163 | + expect(result.current[0]).toBe("standard"); |
| 164 | + act(() => { |
| 165 | + result.current[1]("rich"); |
| 166 | + }); |
| 167 | + expect(result.current[0], "读到的仍是切换**前**的值 ⇒ state 没被写").toBe("rich"); |
| 168 | + }); |
| 169 | + |
| 170 | + it("组件侧同一事实:点「丰富」后**重新渲染**所得的值 = 新档(`aria-pressed` 由 `value` 算出)", () => { |
| 171 | + render(<MotionIntensityControl />); |
| 172 | + fireEvent.click(segByLabel("丰富")); |
| 173 | + expect(segs().filter((s) => s.getAttribute("aria-pressed") === "true").map((s) => s.textContent)).toEqual(["丰富"]); |
| 174 | + }); |
| 175 | +}); |
| 176 | + |
| 177 | +describe("R16.3② ③卸载 / 复挂载:读回上次档位(持久化真的生效)", () => { |
| 178 | + it("选「丰富」→ 卸载 → 抹掉 `<html>` 上的属性 → 复挂载 ⇒ 又是 rich 且段仍选中", () => { |
| 179 | + const first = render(<MotionIntensityControl />); |
| 180 | + fireEvent.click(segByLabel("丰富")); |
| 181 | + expect(htmlMotion()).toBe("rich"); |
| 182 | + first.unmount(); |
| 183 | + document.documentElement.removeAttribute("data-motion"); // 防「读到上一次的残留」 |
| 184 | + expect(htmlMotion()).toBeNull(); |
| 185 | + render(<MotionIntensityControl />); |
| 186 | + expect(htmlMotion()).toBe("rich"); |
| 187 | + expect(segByLabel("丰富").getAttribute("aria-pressed")).toBe("true"); |
| 188 | + }); |
| 189 | +}); |
| 190 | + |
| 191 | +describe("V3 三段与真源同源(段数 / 文案 / 顺序三项对拍)", () => { |
| 192 | + it("段数 == 3(§8.5)、文案逐字、顺序与 `MOTION_INTENSITIES` 一致", () => { |
| 193 | + render(<MotionIntensityControl />); |
| 194 | + expect(segs().length).toBe(3); |
| 195 | + expect(segs().map((s) => s.textContent)).toEqual(TIERS.map((t) => t.label)); |
| 196 | + expect([...MOTION_INTENSITIES]).toEqual(TIERS.map((t) => t.key)); |
| 197 | + }); |
| 198 | +}); |
| 199 | + |
| 200 | +describe("V3b 每段都落到自己那个档位(一一对应,防「三段写同一个值」)", () => { |
| 201 | + it("逐段点击 ⇒ `data-motion` 依次 = eco / standard / rich(属性与记忆同时跟手)", () => { |
| 202 | + render(<MotionIntensityControl />); |
| 203 | + for (const tier of TIERS) { |
| 204 | + fireEvent.click(segByLabel(tier.label)); |
| 205 | + expect(htmlMotion(), `「${tier.label}」段没有把档位写成 ${tier.key}`).toBe(tier.key); |
| 206 | + expect(window.localStorage.getItem(MOTION_INTENSITY_KEY)).toBe(tier.key); |
| 207 | + } |
| 208 | + }); |
| 209 | +}); |
| 210 | + |
| 211 | +describe("V4 零行内 style(ADR-033 §4:不得用行内 style 覆盖类语义)", () => { |
| 212 | + it("控件**整棵子树**里没有一个元素带 `style` 属性(不只根节点)", () => { |
| 213 | + const { container } = render(<MotionIntensityControl />); |
| 214 | + const all = [...container.querySelectorAll<HTMLElement>("*")]; |
| 215 | + expect(all.length, "子树为空 ⇒ 本判据会空真").toBeGreaterThan(2); |
| 216 | + expect(all.filter((el) => el.getAttribute("style") !== null).map((el) => el.tagName)).toEqual([]); |
| 217 | + }); |
| 218 | +}); |
| 219 | + |
| 220 | +describe("DOM 契约(本件 Produces;计划 Interfaces)", () => { |
| 221 | + it("根 = `data-testid=\"motion-intensity\"` + `role=group` + 可访问名「动效强度」", () => { |
| 222 | + render(<MotionIntensityControl />); |
| 223 | + const root = screen.getByTestId("motion-intensity"); |
| 224 | + expect(root.getAttribute("role")).toBe("group"); |
| 225 | + expect(root.getAttribute("aria-label")).toBe("动效强度"); |
| 226 | + }); |
| 227 | +}); |
0 commit comments