|
| 1 | +/** |
| 2 | + * @ai-context intensity.test.ts — 三档强度通道的**行为判据**(node 环境,不加 jsdom 指令注释)。 |
| 3 | + * |
| 4 | + * Why 用 node 而不是 jsdom:本文件要判的两条事实**恰好与 jsdom 无关** —— ① 「真环境里没有 |
| 5 | + * `window.matchMedia` 时不抛」(实测 jsdom 30.0.1 没有它,而 T10 会给 jsdom 补桩 ⇒ 用 jsdom 判 |
| 6 | + * 就变成"判桩的默认值",桩一改用例就红);② 三档的值语义与持久化键是纯函数契约(照 |
| 7 | + * `views/useViewMemory.ts` 的范式:注入 `Storage` ⇒ 内存桩即可)。故本文件用**假全局** |
| 8 | + * (`window` / `document` / `localStorage`)逐条注入、逐条 `finally` 还原,不依赖 jsdom 也不污染 |
| 9 | + * 别的文件。 |
| 10 | + * 口径:`useMotionIntensity` 用 `react-dom/server` 的 `renderToString` 真跑 hook(node 下唯一入口)。 |
| 11 | + * ⚠️ 服务端**不执行** `useEffect` ⇒ 「挂载即把档位落到 `<html>`」这条由 T7 的 jsdom 组件判据承担 |
| 12 | + * (T7 V2 断 `documentElement.dataset.motion`);本文件判的是**初值**与 `select` 通道。 |
| 13 | + * 副作用:临时改写 `globalThis` 上的 `window` / `document` / `localStorage`(逐条还原)。 |
| 14 | + * 边界:`el.dataset.motion` 用**假元素**判写入路径;真实 `<html>` 的同一断言见 T7。 |
| 15 | + */ |
| 16 | +import { createElement } from "react"; |
| 17 | +import { renderToString } from "react-dom/server"; |
| 18 | +import { describe, expect, it } from "vitest"; |
| 19 | +import { |
| 20 | + MOTION_INTENSITIES, |
| 21 | + MOTION_INTENSITY_KEY, |
| 22 | + applyIntensity, |
| 23 | + defaultIntensity, |
| 24 | + readIntensity, |
| 25 | + useMotionIntensity, |
| 26 | + writeIntensity, |
| 27 | +} from "./intensity"; |
| 28 | +import type { MotionIntensity } from "./intensity"; |
| 29 | + |
| 30 | +/** 逐条注入假全局并还原(`Reflect.deleteProperty` 处理"本来没有"的情形)。 */ |
| 31 | +function withGlobal<T>(key: "window" | "document" | "localStorage", value: unknown, run: () => T): T { |
| 32 | + const had = Object.getOwnPropertyDescriptor(globalThis, key); |
| 33 | + Object.defineProperty(globalThis, key, { value, configurable: true, writable: true }); |
| 34 | + try { |
| 35 | + return run(); |
| 36 | + } finally { |
| 37 | + if (had) Object.defineProperty(globalThis, key, had); |
| 38 | + else Reflect.deleteProperty(globalThis, key); |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +/** 记录型 `Storage`;`"throw"` ⇒ 每次访问都抛(隐私模式 / 配额拒绝的真实形态)。 */ |
| 43 | +function fakeStorage(kind: "memory" | "throw" = "memory", seed?: string): Storage { |
| 44 | + const map = new Map<string, string>(); |
| 45 | + if (seed !== undefined) map.set(MOTION_INTENSITY_KEY, seed); |
| 46 | + const denied = (): never => { |
| 47 | + throw new Error("storage denied"); |
| 48 | + }; |
| 49 | + return { |
| 50 | + get length(): number { |
| 51 | + return map.size; |
| 52 | + }, |
| 53 | + clear: () => map.clear(), |
| 54 | + getItem: (k: string) => (kind === "throw" ? denied() : (map.get(k) ?? null)), |
| 55 | + key: (i: number) => [...map.keys()][i] ?? null, |
| 56 | + removeItem: (k: string) => void map.delete(k), |
| 57 | + setItem: (k: string, v: string) => (kind === "throw" ? denied() : void map.set(k, v)), |
| 58 | + }; |
| 59 | +} |
| 60 | + |
| 61 | +/** 假元素 / 假 document:只带判据要用的那一层(`applyIntensity` 的唯一写入面 = `dataset.motion`)。 */ |
| 62 | +const fakeEl = (): HTMLElement => ({ dataset: {} } as unknown as HTMLElement); |
| 63 | +const fakeDocument = (el: HTMLElement): Document => ({ documentElement: el } as unknown as Document); |
| 64 | + |
| 65 | +/** 真跑一次 hook(`renderToString`),取回当前档与 `select`。 */ |
| 66 | +function renderProbe(): { value: MotionIntensity; select: (v: MotionIntensity) => void } { |
| 67 | + let seen: MotionIntensity | null = null; |
| 68 | + let select: ((v: MotionIntensity) => void) | null = null; |
| 69 | + const Probe = (): string => { |
| 70 | + const [value, setValue] = useMotionIntensity(); |
| 71 | + seen = value; |
| 72 | + select = setValue; |
| 73 | + return value; |
| 74 | + }; |
| 75 | + const html = renderToString(createElement(Probe)); |
| 76 | + if (seen === null || select === null) throw new Error(`hook 没跑起来(渲染出的 html="${html}")`); |
| 77 | + return { value: seen, select }; |
| 78 | +} |
| 79 | + |
| 80 | +describe("初值跟随系统(§8.5 逐字:reduce ⇒ eco,否则 standard)", () => { |
| 81 | + it("defaultIntensity:reduce ⇒ eco;非 reduce / 无 mql ⇒ standard", () => { |
| 82 | + expect(defaultIntensity({ matches: true })).toBe("eco"); |
| 83 | + expect(defaultIntensity({ matches: false })).toBe("standard"); |
| 84 | + expect(defaultIntensity(null)).toBe("standard"); |
| 85 | + expect(defaultIntensity(undefined)).toBe("standard"); |
| 86 | + }); |
| 87 | + |
| 88 | + it("hook 初值:真 node 环境没有 window(也没有 matchMedia)⇒ standard,且不抛", () => { |
| 89 | + expect(typeof window).toBe("undefined"); |
| 90 | + expect(renderProbe().value).toBe("standard"); |
| 91 | + }); |
| 92 | + |
| 93 | + it("hook 初值:系统 reduce ⇒ eco,且查询串逐字是 prefers-reduced-motion: reduce", () => { |
| 94 | + const asked: string[] = []; |
| 95 | + const stub = { |
| 96 | + matchMedia: (q: string) => { |
| 97 | + asked.push(q); |
| 98 | + return { matches: true }; |
| 99 | + }, |
| 100 | + }; |
| 101 | + expect(withGlobal("window", stub, renderProbe).value).toBe("eco"); |
| 102 | + expect(asked).toEqual(["(prefers-reduced-motion: reduce)"]); |
| 103 | + }); |
| 104 | + |
| 105 | + it("hook 初值:window 在但**没有** matchMedia(实测 jsdom 30.0.1 正是这种)⇒ standard,不抛", () => { |
| 106 | + expect(withGlobal("window", {}, renderProbe).value).toBe("standard"); |
| 107 | + }); |
| 108 | +}); |
| 109 | + |
| 110 | +describe("持久化:键逐字 motion:intensity(照 useViewMemory 范式:注入 Storage + 静默降级)", () => { |
| 111 | + it("三档名册与键的字面量冻结(CSS 侧的取值集合判据在 motion-coverage.test.ts)", () => { |
| 112 | + expect([...MOTION_INTENSITIES]).toEqual(["eco", "standard", "rich"]); |
| 113 | + expect(MOTION_INTENSITY_KEY).toBe("motion:intensity"); |
| 114 | + }); |
| 115 | + |
| 116 | + it("写读往返:写进的就是 motion:intensity,读回的等于写入值", () => { |
| 117 | + const storage = fakeStorage(); |
| 118 | + writeIntensity("rich", storage); |
| 119 | + expect(storage.getItem("motion:intensity")).toBe("rich"); |
| 120 | + expect(readIntensity(storage)).toBe("rich"); |
| 121 | + }); |
| 122 | + |
| 123 | + it("垃圾值(空串 / 大小写不符 / 多余空白 / 旧版遗留 / 不是档位名)一律当作没有记忆 ⇒ null", () => { |
| 124 | + for (const junk of ["", "loud", "RICH", "standard ", " eco", "eco;rich"]) { |
| 125 | + expect(readIntensity(fakeStorage("memory", junk)), `垃圾值 ${JSON.stringify(junk)}`).toBeNull(); |
| 126 | + } |
| 127 | + }); |
| 128 | + |
| 129 | + it("Storage 抛(配额满 / 隐私模式)⇒ 读返回 null、写不抛(静默降级)", () => { |
| 130 | + const denied = fakeStorage("throw"); |
| 131 | + expect(() => writeIntensity("eco", denied)).not.toThrow(); |
| 132 | + expect(readIntensity(denied)).toBeNull(); |
| 133 | + }); |
| 134 | + |
| 135 | + it("hook:已存值优先于系统初值;`select` 同时写状态与持久化", () => { |
| 136 | + const storage = fakeStorage("memory", "rich"); |
| 137 | + const seen = withGlobal("localStorage", storage, () => { |
| 138 | + const { value, select } = renderProbe(); |
| 139 | + select("eco"); |
| 140 | + return value; |
| 141 | + }); |
| 142 | + expect(seen, "已存的选择必须盖过系统初值(此处系统未 reduce ⇒ 初值本会是 standard)").toBe("rich"); |
| 143 | + expect(storage.getItem(MOTION_INTENSITY_KEY)).toBe("eco"); |
| 144 | + }); |
| 145 | +}); |
| 146 | + |
| 147 | +describe("applyIntensity:写真属性 data-motion(CSS 侧 html[data-motion=…] 的唯一入口)", () => { |
| 148 | + it("显式传元素 ⇒ 写在它的 dataset.motion 上;再写一次覆盖旧档", () => { |
| 149 | + const el = fakeEl(); |
| 150 | + applyIntensity("rich", el); |
| 151 | + expect(el.dataset.motion).toBe("rich"); |
| 152 | + applyIntensity("eco", el); |
| 153 | + expect(el.dataset.motion).toBe("eco"); |
| 154 | + }); |
| 155 | + |
| 156 | + it("不传元素 ⇒ 默认目标是 document.documentElement(`<html>`)", () => { |
| 157 | + const el = fakeEl(); |
| 158 | + withGlobal("document", fakeDocument(el), () => applyIntensity("standard")); |
| 159 | + expect(el.dataset.motion).toBe("standard"); |
| 160 | + }); |
| 161 | + |
| 162 | + it("无 document(node 真环境)且不传元素 / 传 null ⇒ 静默返回,不抛", () => { |
| 163 | + expect(typeof document).toBe("undefined"); |
| 164 | + expect(() => applyIntensity("rich")).not.toThrow(); |
| 165 | + expect(() => applyIntensity("rich", null)).not.toThrow(); |
| 166 | + }); |
| 167 | +}); |
0 commit comments