|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context motionHarness.test.ts — T10 底座的**自证**(计划 Task 10 的 Verification V1–V4 · 裁决 |
| 4 | + * R8.1 / R8.2 / R8.3 / R8.4 / R3.2)。被测 = `./motionHarness`(波 B/C 复用的同一支底座)。 |
| 5 | + * |
| 6 | + * 判据纪律(本文件自己也要守): |
| 7 | + * · **零 `await sleep` / 零真实定时器 / 零 fake timers** —— 推进会部走 `freezeAt`(R8.1 的正解), |
| 8 | + * 全套用例同步跑完;`gsap.ticker` 的墙钟推不动 `{ paused: true }` 的 timeline,读数因此可复现。 |
| 9 | + * · **不把 jsdom 的 `performance` 挂到 `globalThis`**(R8.3:`Performance-impl.js:13-15` 的 `now()` |
| 10 | + * 取全局 `performance` ⇒ 自调用栈溢出)。本文件的 V4 是**静态扫描**:两处禁令串按拼接构造,源码 |
| 11 | + * 文本里**不出现**完整禁令串(否则扫描器会扫到自己 —— 同 `motion/engine.guard.test.ts` 的先例)。 |
| 12 | + * · 每条判据各带**专属变异体**(逐条读数见批次报告 `task-10-report.md`);**不设快照**。 |
| 13 | + * |
| 14 | + * 边界:① V1 只证「paused timeline 的 seek 逐字精确」,**不证**真实帧率 / 观感(jsdom 无 paint, |
| 15 | + * `getBoundingClientRect` 恒 0 ⇒ 见 `motion.md` 的「不可用判据」);② V3 读的是 `el.style` 的**内联** |
| 16 | + * 属性集合,看不见类规则;③ V4 是**文本级**扫描(剥注释、无 AST)—— 它只证「禁令串不在代码里」。 |
| 17 | + */ |
| 18 | +import { readFileSync } from "node:fs"; |
| 19 | +import { dirname, join } from "node:path"; |
| 20 | +import { fileURLToPath } from "node:url"; |
| 21 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 22 | +import { gsap } from "../motion/engine"; |
| 23 | +import { ANIMATABLE_PROPERTIES } from "../motion/shift"; |
| 24 | +import { stripComments } from "../ui/primitives/sliceScan"; |
| 25 | +import { animatedProps, currentTransform, freezeAt, installMatchMediaStub, tweenCount } from "./motionHarness"; |
| 26 | + |
| 27 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 28 | +/** reduced-motion 查询串(底座不导出它 ⇒ 这里独立写死:两边分叉时 V2c 的 conditions 当场红)。 */ |
| 29 | +const REDUCED = "(prefers-reduced-motion: reduce)"; |
| 30 | + |
| 31 | +/** 游离元素(挂进 `document.body` 让 inline style 生效)。 */ |
| 32 | +function mkDiv(): HTMLDivElement { |
| 33 | + const el = document.createElement("div"); |
| 34 | + document.body.appendChild(el); |
| 35 | + return el; |
| 36 | +} |
| 37 | + |
| 38 | +afterEach(() => { |
| 39 | + // 局部桩纪律(R3.2):谁装谁 `restore()`;这里再兜一次,防某条用例中途失败留下全局残留 |
| 40 | + vi.unstubAllGlobals(); |
| 41 | + for (const el of [...document.body.children]) { |
| 42 | + gsap.killTweensOf(el); |
| 43 | + el.remove(); |
| 44 | + } |
| 45 | +}); |
| 46 | + |
| 47 | +describe("V1 确定性推进的唯一正解:`gsap.timeline({paused:true})` + `freezeAt`(R8.1)", () => { |
| 48 | + it("V1a 逐字精确:`power2` / dur 0.5 / x:0→100 在 t=0.125 与 0.25 的读数与尖刺实测逐字一致", () => { |
| 49 | + const el = mkDiv(); |
| 50 | + const tl = gsap.timeline({ paused: true }); |
| 51 | + const ret = tl.to(el, { x: 100, duration: 0.5, ease: "power2.out" }); |
| 52 | + |
| 53 | + // R8.3 的陷阱:`tl.to()` 返回 **Timeline 本身**,不是 Tween(把它当 tween 用 ⇒ 断言恒真 = 假绿) |
| 54 | + expect(ret, "`tl.to()` 必须返回 Timeline 本身").toBe(tl); |
| 55 | + expect(tl.getChildren(), "tween 句柄只能从 `getChildren()` 取").toHaveLength(1); |
| 56 | + expect(tweenCount(el)).toBe(1); |
| 57 | + expect(currentTransform(el), "seek 之前不得有内联 transform(防上一用例的残留做假)").toBe(""); |
| 58 | + |
| 59 | + freezeAt(tl, 0.125); |
| 60 | + expect(currentTransform(el)).toBe("translate3d(57.8125px, 0px, 0px)"); |
| 61 | + freezeAt(tl, 0.25); |
| 62 | + expect(currentTransform(el)).toBe("translate3d(87.5px, 0px, 0px)"); |
| 63 | + expect(gsap.getProperty(el, "x"), "数值口径与字符串口径必须同源").toBe(87.5); |
| 64 | + expect(freezeAt(tl, 0.25), "`freezeAt` 必须返回同一个 timeline(可链式)").toBe(tl); |
| 65 | + }); |
| 66 | + |
| 67 | + it("V1b `tweenCount` 是双断言的第一半(R8.2):两个 tween 并存 ⇒ 2,`killTweensOf` 后 ⇒ 0", () => { |
| 68 | + const el = mkDiv(); |
| 69 | + // 注意:这里必须用 `gsap.to`(根级)而不是 `tl.to`(后者返回 timeline) |
| 70 | + gsap.to(el, { x: 10, duration: 1 }); |
| 71 | + gsap.to(el, { x: 20, duration: 1 }); |
| 72 | + expect(tweenCount(el), "默认 `overwrite:false` ⇒ 两个 tween 并存(只看 transform 会假绿)").toBe(2); |
| 73 | + |
| 74 | + gsap.killTweensOf(el); |
| 75 | + expect(tweenCount(el)).toBe(0); |
| 76 | + }); |
| 77 | +}); |
| 78 | + |
| 79 | +describe("V2 `matchMedia` 桩必须走 legacy 分支(R3.2 · 尖刺 S2.8③)", () => { |
| 80 | + it("V2a jsdom 30 本就没有 `window.matchMedia`(桩不是奢侈品;也是 V2d 的读数基线)", () => { |
| 81 | + expect(typeof window.matchMedia).toBe("undefined"); |
| 82 | + }); |
| 83 | + |
| 84 | + it("V2b 直接订阅:`addListener` 增、`removeListener` 减 —— legacy 两方法都**真被调用**(不是摆设)", () => { |
| 85 | + const stub = installMatchMediaStub({ reduce: true }); |
| 86 | + try { |
| 87 | + const mq = window.matchMedia(REDUCED); |
| 88 | + expect(mq.matches, "`matches === true` 必须读得到").toBe(true); |
| 89 | + expect(mq.media, "查询串必须逐字透传").toBe(REDUCED); |
| 90 | + expect(stub.listeners()).toBe(0); |
| 91 | + |
| 92 | + const listener = (): void => undefined; |
| 93 | + mq.addListener(listener); |
| 94 | + expect(stub.listeners(), "`addListener` 没进挂载集合").toBe(1); |
| 95 | + mq.removeListener(listener); |
| 96 | + expect(stub.listeners(), "`removeListener` 没把监听器摘掉(只加不减 = 摆设)").toBe(0); |
| 97 | + } finally { |
| 98 | + stub.restore(); |
| 99 | + } |
| 100 | + }); |
| 101 | + |
| 102 | + it("V2c GSAP 侧真走 legacy:`gsap.matchMedia().add()` 后 `listeners() >= 1`(只实现 addEventListener 会是 0)", () => { |
| 103 | + const stub = installMatchMediaStub({ reduce: true }); |
| 104 | + const mm = gsap.matchMedia(); |
| 105 | + try { |
| 106 | + const seen: unknown[] = []; |
| 107 | + mm.add({ reduce: REDUCED }, (ctx) => { |
| 108 | + seen.push(ctx.conditions); |
| 109 | + }); |
| 110 | + expect(seen, "handler 必须**同步**跑一次(尖刺 S2.8③ 的 handlerRanSynchronously)").toEqual([{ reduce: true }]); |
| 111 | + expect( |
| 112 | + stub.listeners(), |
| 113 | + "GSAP 没走 legacy 分支(`mq.addListener ? … : mq.addEventListener`,gsap-core.js:4078)⇒ 桩的 addListener 是摆设", |
| 114 | + ).toBeGreaterThanOrEqual(1); |
| 115 | + } finally { |
| 116 | + mm.revert(); |
| 117 | + stub.restore(); |
| 118 | + } |
| 119 | + }); |
| 120 | + |
| 121 | + it("V2d `restore()` 复原「原本就没有」:`delete` 回 `undefined`(局部桩不得泄漏到同文件后续用例)", () => { |
| 122 | + const stub = installMatchMediaStub({ reduce: false }); |
| 123 | + try { |
| 124 | + expect(typeof window.matchMedia).toBe("function"); |
| 125 | + expect(window.matchMedia(REDUCED).matches, "`reduce:false` ⇒ matches 必须是 false").toBe(false); |
| 126 | + expect(window.matchMedia("(min-width: 700px)").matches, "非 reduced 查询一律 false(不做「全真」糊桩)").toBe(false); |
| 127 | + } finally { |
| 128 | + stub.restore(); |
| 129 | + } |
| 130 | + expect(typeof window.matchMedia).toBe("undefined"); |
| 131 | + expect("matchMedia" in window, "restore 必须 delete 掉自建属性,而不是留一个 `undefined`").toBe(false); |
| 132 | + }); |
| 133 | +}); |
| 134 | + |
| 135 | +describe("V3 属性集合读取(R8.4 的代理判据口径)", () => { |
| 136 | + it("V3a 动 `x + opacity` ⇒ 内联属性集合恰 5 个(逐序数组相等)且 ⊆ `ANIMATABLE_PROPERTIES`", () => { |
| 137 | + const el = mkDiv(); |
| 138 | + gsap.to(el, { x: 50, opacity: 0.5, duration: 0 }); |
| 139 | + |
| 140 | + const props = animatedProps(el); |
| 141 | + expect(props, "读数必须逐序等于尖刺实测的 5 个合成属性").toEqual(["opacity", "rotate", "scale", "transform", "translate"]); |
| 142 | + expect(ANIMATABLE_PROPERTIES, "白名单真源(T9 交付)逐序").toEqual(["transform", "translate", "rotate", "scale", "opacity", "filter"]); |
| 143 | + expect(props.every((p) => ANIMATABLE_PROPERTIES.includes(p)), "被动画属性越出白名单").toBe(true); |
| 144 | + }); |
| 145 | + |
| 146 | + it("V3b 对照(**这是 V3a 的牙**):动 `width` ⇒ 集合含 `width` ⇒ 落在白名单之外", () => { |
| 147 | + const el = mkDiv(); |
| 148 | + gsap.to(el, { width: 50, duration: 0 }); |
| 149 | + |
| 150 | + const props = animatedProps(el); |
| 151 | + expect(props, "对照组必须真的写出 layout 属性(删掉本句 ⇒ V3a 退化为「任何集合都 ⊆」的空真)").toContain("width"); |
| 152 | + expect(props.some((p) => !ANIMATABLE_PROPERTIES.includes(p)), "layout 属性混进来却没被告发").toBe(true); |
| 153 | + }); |
| 154 | +}); |
| 155 | + |
| 156 | +// 禁令串一律**拼接构造**:本文件也在 V4b 的扫描域内 ⇒ 源码文本里出现完整禁令串会自伤 |
| 157 | +// (同 `motion/engine.guard.test.ts` 的 `fromSpec()` 先例)。 |
| 158 | +const Q = '"'; |
| 159 | +const NEEDLES: readonly string[] = [ |
| 160 | + "globalThis." + "performance =", |
| 161 | + "Object.defineProperty(globalThis, " + Q + "performance" + Q, |
| 162 | + "gsap." + "updateRoot(", |
| 163 | + "." + "ticker.tick(", |
| 164 | + "." + "ticker.sleep(", |
| 165 | + "await " + "sleep(", |
| 166 | +]; |
| 167 | +/** 底座两件(`motionHarness.ts` + 本文件):波 B/C 直接复用的面 + 它自己的自证 */ |
| 168 | +const SCANNED: readonly string[] = [join(HERE, "motionHarness.ts"), join(HERE, "motionHarness.test.ts")]; |
| 169 | + |
| 170 | +/** 一段源码里命中的禁令串(**剥注释后**扫;纯函数,行为由 V4a 双侧自证)。 */ |
| 171 | +const bannedHits = (src: string): string[] => { |
| 172 | + const code = stripComments(src); |
| 173 | + return NEEDLES.filter((n) => code.includes(n)); |
| 174 | +}; |
| 175 | + |
| 176 | +describe("V4 静态禁令扫描(剥注释 · R8.3 的 `performance` 陷阱 + 四个禁用驱动)", () => { |
| 177 | + it("V4a 阳性对照:禁令串出现在**代码**里 6 条全中(逐序数组相等);只出现在注释里 ⇒ 0", () => { |
| 178 | + const asCode = NEEDLES.map((n) => "const a = " + n + " 1;").join("\n"); |
| 179 | + expect(bannedHits(asCode), "扫描器对已知样本必须逐序报全 —— 否则 V4b 的 0 命中可能是扫描器坏了").toEqual([...NEEDLES]); |
| 180 | + |
| 181 | + const asComment = NEEDLES.map((n) => "// " + n + " 1;").join("\n"); |
| 182 | + expect(bannedHits(asComment), "剥注释失效 ⇒ 底座文件头(逐字写了四个禁用项)会自命中").toEqual([]); |
| 183 | + }); |
| 184 | + |
| 185 | + it("V4b 底座两件零命中(防空扫:两文件都必须真读得到、且都在盘上)", () => { |
| 186 | + const texts = SCANNED.map((f) => readFileSync(f, "utf8")); |
| 187 | + expect(texts.map((t) => t.length).every((n) => n > 1000), "扫描域缩到空/读错文件 ⇒ 「0 命中」一文不值").toBe(true); |
| 188 | + for (const [i, text] of texts.entries()) { |
| 189 | + expect(bannedHits(text), `${SCANNED[i]} 出现禁令串:${bannedHits(text).join(" / ")}`).toEqual([]); |
| 190 | + } |
| 191 | + }); |
| 192 | +}); |
0 commit comments