|
| 1 | +// fallow-ignore-file duplication |
| 2 | +/** |
| 3 | + * T6c — acorn write path with magic-string offset-splice. |
| 4 | + * |
| 5 | + * Verifies that each write op touches only the intended byte span and leaves |
| 6 | + * every other character identical to the original source. |
| 7 | + */ |
| 8 | +import { describe, expect, it } from "vitest"; |
| 9 | +import { |
| 10 | + addAnimationToScript, |
| 11 | + addKeyframeToScript, |
| 12 | + removeAnimationFromScript, |
| 13 | + removeKeyframeFromScript, |
| 14 | + updateAnimationInScript, |
| 15 | + updateKeyframeInScript, |
| 16 | +} from "./gsapWriterAcorn.js"; |
| 17 | + |
| 18 | +// --------------------------------------------------------------------------- |
| 19 | +// Fixture scripts |
| 20 | +// --------------------------------------------------------------------------- |
| 21 | + |
| 22 | +const SCRIPT_A = `\ |
| 23 | +var tl = gsap.timeline({ paused: true }); |
| 24 | +tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2); |
| 25 | +window.__timelines["t"] = tl;`; |
| 26 | + |
| 27 | +const SCRIPT_B = `\ |
| 28 | +var tl = gsap.timeline({ paused: true }); |
| 29 | +tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0); |
| 30 | +tl.to("#hero", { opacity: 0, duration: 0.3, ease: "power3.in" }, 1); |
| 31 | +window.__timelines["t"] = tl;`; |
| 32 | + |
| 33 | +const SCRIPT_C = `\ |
| 34 | +var tl = gsap.timeline({ paused: true }); |
| 35 | +tl.from(".a", { opacity: 0, duration: 0.5 }, 0) |
| 36 | + .from(".b", { opacity: 0, duration: 0.3 }, 0.5); |
| 37 | +window.__timelines["t"] = tl;`; |
| 38 | + |
| 39 | +// 3-keyframe script so removal leaves ≥2 kfs (no collapse needed) |
| 40 | +const SCRIPT_D = `\ |
| 41 | +var tl = gsap.timeline({ paused: true }); |
| 42 | +tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); |
| 43 | +window.__timelines["t"] = tl;`; |
| 44 | + |
| 45 | +// --------------------------------------------------------------------------- |
| 46 | +// No-op identity |
| 47 | +// --------------------------------------------------------------------------- |
| 48 | + |
| 49 | +describe("T6c — no-op identity", () => { |
| 50 | + it("updateAnimationInScript with empty updates returns identical script", () => { |
| 51 | + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {}); |
| 52 | + expect(result).toBe(SCRIPT_A); |
| 53 | + }); |
| 54 | + |
| 55 | + it("updateAnimationInScript with unknown ID returns identical script", () => { |
| 56 | + const result = updateAnimationInScript(SCRIPT_A, "not-a-real-id", { ease: "power2.in" }); |
| 57 | + expect(result).toBe(SCRIPT_A); |
| 58 | + }); |
| 59 | +}); |
| 60 | + |
| 61 | +// --------------------------------------------------------------------------- |
| 62 | +// updateAnimationInScript |
| 63 | +// --------------------------------------------------------------------------- |
| 64 | + |
| 65 | +describe("T6c — updateAnimationInScript", () => { |
| 66 | + it("updates ease value in-place", () => { |
| 67 | + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", { |
| 68 | + ease: "power2.in", |
| 69 | + }); |
| 70 | + expect(result).toContain('"power2.in"'); |
| 71 | + expect(result).not.toContain('"power3.out"'); |
| 72 | + // Preamble + postamble unchanged |
| 73 | + expect(result).toContain("var tl = gsap.timeline({ paused: true });"); |
| 74 | + expect(result).toContain('window.__timelines["t"] = tl;'); |
| 75 | + }); |
| 76 | + |
| 77 | + it("updates duration value in-place", () => { |
| 78 | + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", { |
| 79 | + duration: 1.2, |
| 80 | + }); |
| 81 | + expect(result).toContain("duration: 1.2"); |
| 82 | + expect(result).not.toContain("duration: 0.5"); |
| 83 | + expect(result).toContain('"power3.out"'); |
| 84 | + }); |
| 85 | + |
| 86 | + it("updates position arg in-place", () => { |
| 87 | + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", { |
| 88 | + position: 0.5, |
| 89 | + }); |
| 90 | + expect(result).toContain("}, 0.5)"); |
| 91 | + expect(result).not.toContain("}, 0.2)"); |
| 92 | + expect(result).toContain("opacity: 1"); |
| 93 | + }); |
| 94 | + |
| 95 | + it("inserts ease when property was absent", () => { |
| 96 | + const noEase = `\ |
| 97 | +var tl = gsap.timeline({ paused: true }); |
| 98 | +tl.to("#hero", { opacity: 1, duration: 0.5 }, 0.2); |
| 99 | +window.__timelines["t"] = tl;`; |
| 100 | + const result = updateAnimationInScript(noEase, "#hero-to-200-visual", { |
| 101 | + ease: "power3.out", |
| 102 | + }); |
| 103 | + expect(result).toContain('ease: "power3.out"'); |
| 104 | + // Duration, opacity, position unchanged |
| 105 | + expect(result).toContain("duration: 0.5"); |
| 106 | + expect(result).toContain("opacity: 1"); |
| 107 | + expect(result).toContain("}, 0.2)"); |
| 108 | + }); |
| 109 | + |
| 110 | + it("updates fromTo — ease on toVars", () => { |
| 111 | + const fromTo = `\ |
| 112 | +var tl = gsap.timeline({ paused: true }); |
| 113 | +tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1); |
| 114 | +window.__timelines["t"] = tl;`; |
| 115 | + // ID: target="#hero", method="fromTo", pos=0.1 → posKey=100, propertyGroup=visual |
| 116 | + const result = updateAnimationInScript(fromTo, "#hero-fromTo-100-visual", { |
| 117 | + ease: "back.out", |
| 118 | + }); |
| 119 | + expect(result).toContain('"back.out"'); |
| 120 | + expect(result).not.toContain('"power3.out"'); |
| 121 | + expect(result).toContain("opacity: 0"); |
| 122 | + }); |
| 123 | + |
| 124 | + it("byte-identity outside edited ease span", () => { |
| 125 | + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", { |
| 126 | + ease: "power2.in", |
| 127 | + }); |
| 128 | + const oldEaseStart = SCRIPT_A.indexOf('"power3.out"'); |
| 129 | + const newEaseStart = result.indexOf('"power2.in"'); |
| 130 | + // Everything before the ease value is identical |
| 131 | + expect(result.slice(0, newEaseStart)).toBe(SCRIPT_A.slice(0, oldEaseStart)); |
| 132 | + // Everything after the ease value close-quote is identical |
| 133 | + const oldAfter = SCRIPT_A.slice(oldEaseStart + '"power3.out"'.length); |
| 134 | + const newAfter = result.slice(newEaseStart + '"power2.in"'.length); |
| 135 | + expect(newAfter).toBe(oldAfter); |
| 136 | + }); |
| 137 | +}); |
| 138 | + |
| 139 | +// --------------------------------------------------------------------------- |
| 140 | +// removeAnimationFromScript |
| 141 | +// --------------------------------------------------------------------------- |
| 142 | + |
| 143 | +describe("T6c — removeAnimationFromScript", () => { |
| 144 | + it("removes a standalone tween statement", () => { |
| 145 | + const result = removeAnimationFromScript(SCRIPT_B, "#hero-to-0-visual"); |
| 146 | + expect(result).not.toContain("power3.out"); |
| 147 | + expect(result).toContain("power3.in"); |
| 148 | + expect(result).toContain('window.__timelines["t"] = tl;'); |
| 149 | + }); |
| 150 | + |
| 151 | + it("removes last chain link (outer call)", () => { |
| 152 | + // SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5) |
| 153 | + // Remove .b (outermost call = last in source) |
| 154 | + const result = removeAnimationFromScript(SCRIPT_C, ".b-from-500-visual"); |
| 155 | + expect(result).toContain('.from(".a"'); |
| 156 | + expect(result).not.toContain('.from(".b"'); |
| 157 | + // The statement should still end with ; (no dangling chain) |
| 158 | + expect(result).toContain("}, 0);"); |
| 159 | + }); |
| 160 | + |
| 161 | + it("removes inner chain link", () => { |
| 162 | + // SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5) |
| 163 | + // Remove .a (innermost call = first in source) |
| 164 | + const result = removeAnimationFromScript(SCRIPT_C, ".a-from-0-visual"); |
| 165 | + expect(result).not.toContain('.from(".a"'); |
| 166 | + expect(result).toContain('.from(".b"'); |
| 167 | + // Chain is still rooted at tl (whitespace between tl and .from is valid JS) |
| 168 | + expect(result).toMatch(/tl[\s.]*from\("\.b"/); |
| 169 | + }); |
| 170 | + |
| 171 | + it("unknown ID returns script unchanged", () => { |
| 172 | + const result = removeAnimationFromScript(SCRIPT_A, "nonexistent-id"); |
| 173 | + expect(result).toBe(SCRIPT_A); |
| 174 | + }); |
| 175 | +}); |
| 176 | + |
| 177 | +// --------------------------------------------------------------------------- |
| 178 | +// addAnimationToScript |
| 179 | +// --------------------------------------------------------------------------- |
| 180 | + |
| 181 | +describe("T6c — addAnimationToScript", () => { |
| 182 | + it("inserts new tween after last existing tween", () => { |
| 183 | + const { script: result } = addAnimationToScript(SCRIPT_A, { |
| 184 | + targetSelector: "#new", |
| 185 | + method: "to", |
| 186 | + position: 0.5, |
| 187 | + duration: 0.3, |
| 188 | + properties: { x: 100 }, |
| 189 | + }); |
| 190 | + expect(result).toContain('tl.to("#new"'); |
| 191 | + expect(result).toContain("x: 100"); |
| 192 | + expect(result).toContain("duration: 0.3"); |
| 193 | + // Original content preserved |
| 194 | + expect(result).toContain('tl.to("#hero"'); |
| 195 | + expect(result).toContain('window.__timelines["t"] = tl;'); |
| 196 | + // New tween comes after hero tween |
| 197 | + expect(result.indexOf('tl.to("#new"')).toBeGreaterThan(result.indexOf('tl.to("#hero"')); |
| 198 | + }); |
| 199 | + |
| 200 | + it("returns a non-empty stable id for the new animation", () => { |
| 201 | + const { id } = addAnimationToScript(SCRIPT_A, { |
| 202 | + targetSelector: "#new", |
| 203 | + method: "to", |
| 204 | + position: 0.5, |
| 205 | + duration: 0.3, |
| 206 | + properties: { x: 100 }, |
| 207 | + }); |
| 208 | + expect(id).toBeTruthy(); |
| 209 | + expect(typeof id).toBe("string"); |
| 210 | + }); |
| 211 | + |
| 212 | + it("inserts after timeline declaration when script has no tweens", () => { |
| 213 | + const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`; |
| 214 | + const { script: result } = addAnimationToScript(empty, { |
| 215 | + targetSelector: "#hero", |
| 216 | + method: "to", |
| 217 | + position: 0, |
| 218 | + duration: 0.5, |
| 219 | + properties: { opacity: 1 }, |
| 220 | + }); |
| 221 | + expect(result).toContain('tl.to("#hero"'); |
| 222 | + // Inserted after timeline declaration |
| 223 | + expect(result.indexOf('tl.to("#hero"')).toBeGreaterThan(result.indexOf("gsap.timeline")); |
| 224 | + }); |
| 225 | +}); |
| 226 | + |
| 227 | +// --------------------------------------------------------------------------- |
| 228 | +// Keyframe write ops |
| 229 | +// --------------------------------------------------------------------------- |
| 230 | + |
| 231 | +describe("T6c — keyframe write ops", () => { |
| 232 | + it("updateKeyframeInScript replaces keyframe value at given percentage", () => { |
| 233 | + // Update 50% from { opacity: 0.7 } to { opacity: 0.5 } |
| 234 | + const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.5 }); |
| 235 | + expect(result).toContain("opacity: 0.5"); |
| 236 | + expect(result).not.toContain("opacity: 0.7"); |
| 237 | + // Other keyframes unchanged |
| 238 | + expect(result).toContain('"0%": { opacity: 0 }'); |
| 239 | + expect(result).toContain('"100%": { opacity: 1 }'); |
| 240 | + }); |
| 241 | + |
| 242 | + it("updateKeyframeInScript preserves bytes outside the edited value", () => { |
| 243 | + const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 100, { |
| 244 | + opacity: 0.9, |
| 245 | + }); |
| 246 | + // The 50% keyframe is untouched |
| 247 | + expect(result).toContain('"50%": { opacity: 0.7 }'); |
| 248 | + // Duration and position are unchanged |
| 249 | + expect(result).toContain("duration: 0.5"); |
| 250 | + expect(result).toContain("}, 0.2)"); |
| 251 | + }); |
| 252 | + |
| 253 | + it("addKeyframeToScript inserts new percentage in sorted order", () => { |
| 254 | + const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 25, { opacity: 0.3 }); |
| 255 | + expect(result).toContain('"25%"'); |
| 256 | + expect(result).toContain("opacity: 0.3"); |
| 257 | + // Original keyframes preserved |
| 258 | + expect(result).toContain('"0%": { opacity: 0 }'); |
| 259 | + expect(result).toContain('"50%": { opacity: 0.7 }'); |
| 260 | + // 25% appears before 50% in the string |
| 261 | + expect(result.indexOf('"25%"')).toBeLessThan(result.indexOf('"50%"')); |
| 262 | + }); |
| 263 | + |
| 264 | + it("addKeyframeToScript replaces value when percentage already exists", () => { |
| 265 | + const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.99 }); |
| 266 | + expect(result).toContain("opacity: 0.99"); |
| 267 | + expect(result).not.toContain("opacity: 0.7"); |
| 268 | + // Only one "50%" in the result |
| 269 | + expect((result.match(/"50%"/g) ?? []).length).toBe(1); |
| 270 | + }); |
| 271 | + |
| 272 | + it("removeKeyframeFromScript removes the target percentage", () => { |
| 273 | + // Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c) |
| 274 | + const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50); |
| 275 | + expect(result).not.toContain('"50%"'); |
| 276 | + expect(result).toContain('"0%"'); |
| 277 | + expect(result).toContain('"100%"'); |
| 278 | + }); |
| 279 | + |
| 280 | + it("updateKeyframeInScript on unknown id returns script unchanged", () => { |
| 281 | + const result = updateKeyframeInScript(SCRIPT_D, "bad-id", 50, { opacity: 0.5 }); |
| 282 | + expect(result).toBe(SCRIPT_D); |
| 283 | + }); |
| 284 | +}); |
0 commit comments