a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 13 kB view raw
1import { signal } from "$core/signal"; 2import { 3 getAnimation, 4 getRegisteredAnimations, 5 hasAnimation, 6 registerAnimation, 7 shiftPlugin, 8 unregisterAnimation, 9} from "$plugins/shift"; 10import type { AnimationPreset, PluginContext } from "$types/volt"; 11import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 12 13describe("Shift Plugin", () => { 14 let container: HTMLDivElement; 15 let element: HTMLElement; 16 let mockContext: PluginContext; 17 let cleanups: Array<() => void>; 18 let onMountCallbacks: Array<() => void>; 19 20 beforeEach(() => { 21 container = document.createElement("div"); 22 element = document.createElement("div"); 23 element.textContent = "Test Content"; 24 container.append(element); 25 document.body.append(container); 26 27 cleanups = []; 28 onMountCallbacks = []; 29 30 mockContext = { 31 element, 32 scope: {}, 33 addCleanup: (fn) => { 34 cleanups.push(fn); 35 }, 36 findSignal: vi.fn(), 37 evaluate: vi.fn(), 38 lifecycle: { 39 onMount: (cb) => { 40 onMountCallbacks.push(cb); 41 cb(); 42 }, 43 onUnmount: vi.fn(), 44 beforeBinding: vi.fn(), 45 afterBinding: vi.fn(), 46 }, 47 }; 48 49 globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false }); 50 51 element.style.animation = ""; 52 }); 53 54 afterEach(() => { 55 for (const cleanup of cleanups) { 56 cleanup(); 57 } 58 cleanups = []; 59 onMountCallbacks = []; 60 container.remove(); 61 vi.restoreAllMocks(); 62 }); 63 64 describe("Animation Registry", () => { 65 it("should have built-in animation presets", () => { 66 expect(hasAnimation("bounce")).toBe(true); 67 expect(hasAnimation("shake")).toBe(true); 68 expect(hasAnimation("pulse")).toBe(true); 69 expect(hasAnimation("spin")).toBe(true); 70 expect(hasAnimation("flash")).toBe(true); 71 }); 72 73 it("should register custom animation", () => { 74 const customAnimation: AnimationPreset = { 75 keyframes: [{ offset: 0, transform: "scale(1)" }, { offset: 1, transform: "scale(1.5)" }], 76 duration: 500, 77 iterations: 1, 78 timing: "ease", 79 }; 80 81 registerAnimation("custom", customAnimation); 82 expect(hasAnimation("custom")).toBe(true); 83 expect(getAnimation("custom")).toEqual(customAnimation); 84 }); 85 86 it("should unregister custom animation", () => { 87 const customAnimation: AnimationPreset = { 88 keyframes: [{ offset: 0, opacity: "1" }, { offset: 1, opacity: "0" }], 89 duration: 300, 90 iterations: 1, 91 timing: "linear", 92 }; 93 94 registerAnimation("temp", customAnimation); 95 expect(hasAnimation("temp")).toBe(true); 96 97 const result = unregisterAnimation("temp"); 98 expect(result).toBe(true); 99 expect(hasAnimation("temp")).toBe(false); 100 }); 101 102 it("should not unregister built-in animation", () => { 103 const result = unregisterAnimation("bounce"); 104 expect(result).toBe(false); 105 expect(hasAnimation("bounce")).toBe(true); 106 }); 107 108 it("should get all registered animations", () => { 109 const animations = getRegisteredAnimations(); 110 expect(animations).toContain("bounce"); 111 expect(animations).toContain("shake"); 112 expect(animations).toContain("pulse"); 113 expect(animations).toContain("spin"); 114 expect(animations).toContain("flash"); 115 }); 116 117 it("should warn when overriding built-in animation", () => { 118 const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 119 const customAnimation: AnimationPreset = { 120 keyframes: [{ offset: 0, opacity: "1" }], 121 duration: 100, 122 iterations: 1, 123 timing: "ease", 124 }; 125 126 registerAnimation("bounce", customAnimation); 127 expect(consoleSpy).toHaveBeenCalledWith( 128 expect.stringContaining("Overriding built-in animation preset: \"bounce\""), 129 ); 130 131 consoleSpy.mockRestore(); 132 }); 133 }); 134 135 describe("Basic Animation Application", () => { 136 it("should apply animation on mount", async () => { 137 shiftPlugin(mockContext, "bounce"); 138 139 await vi.waitFor(() => { 140 expect(element.style.animationName).toMatch(/^volt-shift-/); 141 }); 142 }); 143 144 it("should use default duration and iterations", async () => { 145 shiftPlugin(mockContext, "bounce"); 146 147 await vi.waitFor(() => { 148 expect(element.style.animationDuration).toBe("100ms"); 149 expect(element.style.animationIterationCount).toBe("1"); 150 }); 151 }); 152 153 it("should apply custom duration", async () => { 154 shiftPlugin(mockContext, "bounce.1000"); 155 156 await vi.waitFor(() => { 157 expect(element.style.animationDuration).toBe("1000ms"); 158 }); 159 }); 160 161 it("should apply custom duration and iterations", async () => { 162 shiftPlugin(mockContext, "bounce.500.3"); 163 164 await vi.waitFor(() => { 165 expect(element.style.animationDuration).toBe("500ms"); 166 expect(element.style.animationIterationCount).toBe("3"); 167 }); 168 }); 169 170 it("should handle unknown animation preset", () => { 171 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 172 173 shiftPlugin(mockContext, "unknown"); 174 175 expect(element.style.animationName).toBe(""); 176 expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown animation preset: \"unknown\"")); 177 178 consoleSpy.mockRestore(); 179 }); 180 181 it("should handle invalid shift value", () => { 182 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 183 184 shiftPlugin(mockContext, ""); 185 186 expect(element.style.animationName).toBe(""); 187 expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid shift value")); 188 189 consoleSpy.mockRestore(); 190 }); 191 192 it("should work when Web Animations API is unavailable", async () => { 193 // @ts-expect-error mutate for test 194 element.animate = undefined; 195 196 shiftPlugin(mockContext, "bounce"); 197 198 await vi.waitFor(() => { 199 expect(element.style.animationName).toMatch(/^volt-shift-/); 200 }); 201 }); 202 203 it("should normalize inline elements for transform animations", async () => { 204 const span = document.createElement("span"); 205 span.textContent = "⚙️"; 206 container.append(span); 207 208 const context: PluginContext = { ...mockContext, element: span }; 209 210 shiftPlugin(context, "spin"); 211 212 await vi.waitFor(() => { 213 expect(span.style.display).toBe("inline-block"); 214 expect(span.dataset.voltShiftDisplayManaged).toBe("infinite"); 215 expect(span.dataset.voltShiftRuns).toBe("1"); 216 expect(span.style.transformOrigin).toBe("center center"); 217 }); 218 }); 219 }); 220 221 describe("Signal-Triggered Animations", () => { 222 it("should trigger animation when signal changes to truthy", () => { 223 const triggerSignal = signal(false); 224 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 225 226 shiftPlugin(mockContext, "trigger:bounce"); 227 228 expect(element.dataset.voltShiftRuns ?? "0").toBe("0"); 229 230 triggerSignal.set(true); 231 232 expect(element.dataset.voltShiftRuns).toBe("1"); 233 }); 234 235 it("should not trigger animation when signal stays truthy", async () => { 236 const triggerSignal = signal(true); 237 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 238 239 shiftPlugin(mockContext, "trigger:bounce"); 240 241 await vi.waitFor(() => { 242 expect(element.dataset.voltShiftRuns).toBe("1"); 243 }); 244 245 triggerSignal.set(true); 246 247 expect(element.dataset.voltShiftRuns).toBe("1"); 248 }); 249 250 it("should trigger animation on initial mount if signal is truthy", async () => { 251 const triggerSignal = signal(true); 252 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 253 254 shiftPlugin(mockContext, "trigger:bounce"); 255 256 await vi.waitFor(() => { 257 expect(element.dataset.voltShiftRuns).toBe("1"); 258 }); 259 }); 260 261 it("should handle signal not found", () => { 262 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 263 mockContext.findSignal = vi.fn().mockReturnValue(void 0); 264 265 shiftPlugin(mockContext, "missing:bounce"); 266 267 expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"missing\" not found")); 268 consoleSpy.mockRestore(); 269 }); 270 271 it("should support custom duration and iterations with signal trigger", () => { 272 const triggerSignal = signal(false); 273 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 274 275 shiftPlugin(mockContext, "trigger:bounce.800.2"); 276 277 triggerSignal.set(true); 278 279 expect(element.dataset.voltShiftRuns).toBe("1"); 280 expect(element.style.animationDuration).toBe("800ms"); 281 expect(element.style.animationIterationCount).toBe("2"); 282 }); 283 284 it("should stop infinite animations when signal becomes falsy", async () => { 285 const spinSignal = signal(true); 286 mockContext.findSignal = vi.fn().mockReturnValue(spinSignal); 287 288 shiftPlugin(mockContext, "spin:spin"); 289 290 await vi.waitFor(() => { 291 expect(element.style.animationName).toMatch(/^volt-shift-/); 292 expect(element.style.animationIterationCount).toBe("infinite"); 293 }); 294 295 spinSignal.set(false); 296 297 expect(element.style.animationName).toBe(""); 298 expect(element.style.animationIterationCount).toBe(""); 299 }); 300 301 it("should not stop finite animations when signal becomes falsy", async () => { 302 const triggerSignal = signal(true); 303 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 304 305 shiftPlugin(mockContext, "trigger:bounce"); 306 307 await vi.waitFor(() => { 308 expect(element.style.animationName).toMatch(/^volt-shift-/); 309 }); 310 311 triggerSignal.set(false); 312 313 expect(element.style.animationName).toMatch(/^volt-shift-/); 314 }); 315 316 it("should restart infinite animation when signal toggles", async () => { 317 const spinSignal = signal(true); 318 mockContext.findSignal = vi.fn().mockReturnValue(spinSignal); 319 320 shiftPlugin(mockContext, "spin:spin"); 321 322 await vi.waitFor(() => { 323 expect(element.dataset.voltShiftRuns).toBe("1"); 324 }); 325 326 spinSignal.set(false); 327 expect(element.style.animationName).toBe(""); 328 329 spinSignal.set(true); 330 331 expect(element.dataset.voltShiftRuns).toBe("2"); 332 }); 333 }); 334 335 describe("Accessibility", () => { 336 it("should respect prefers-reduced-motion", () => { 337 globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true }); 338 339 shiftPlugin(mockContext, "bounce"); 340 341 expect(element.style.animationName).toBe(""); 342 }); 343 344 it("should not animate when prefers-reduced-motion is active and signal triggers", () => { 345 globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true }); 346 347 const triggerSignal = signal(false); 348 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 349 350 shiftPlugin(mockContext, "trigger:bounce"); 351 352 triggerSignal.set(true); 353 354 expect(element.style.animationName).toBe(""); 355 }); 356 }); 357 358 describe("Animation Cleanup", () => { 359 it("should clear inline animation after it completes", async () => { 360 shiftPlugin(mockContext, "bounce"); 361 362 await vi.waitFor(() => { 363 expect(element.style.animationName).toMatch(/^volt-shift-/); 364 }); 365 366 await new Promise((resolve) => setTimeout(resolve, 150)); 367 368 expect(element.style.animationName).toBe(""); 369 expect(element.style.animationFillMode).toBe(""); 370 }); 371 372 it("should cleanup signal subscription", () => { 373 const triggerSignal = signal(false); 374 const unsubscribe = vi.fn(); 375 triggerSignal.subscribe = vi.fn().mockReturnValue(unsubscribe); 376 377 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 378 379 shiftPlugin(mockContext, "trigger:bounce"); 380 381 expect(cleanups).toHaveLength(1); 382 383 cleanups[0](); 384 385 expect(unsubscribe).toHaveBeenCalled(); 386 }); 387 }); 388 389 describe("Built-in Animations", () => { 390 it("should have bounce animation with correct keyframes", () => { 391 const bounce = getAnimation("bounce"); 392 expect(bounce).toBeDefined(); 393 expect(bounce?.keyframes.length).toBeGreaterThan(0); 394 expect(bounce?.duration).toBe(100); 395 expect(bounce?.iterations).toBe(1); 396 }); 397 398 it("should have shake animation with correct keyframes", () => { 399 const shake = getAnimation("shake"); 400 expect(shake).toBeDefined(); 401 expect(shake?.keyframes.length).toBeGreaterThan(0); 402 expect(shake?.duration).toBe(500); 403 }); 404 405 it("should have pulse animation with infinite iterations", () => { 406 const pulse = getAnimation("pulse"); 407 expect(pulse).toBeDefined(); 408 expect(pulse?.iterations).toBe(Number.POSITIVE_INFINITY); 409 }); 410 411 it("should have spin animation with infinite iterations", () => { 412 const spin = getAnimation("spin"); 413 expect(spin).toBeDefined(); 414 expect(spin?.iterations).toBe(Number.POSITIVE_INFINITY); 415 expect(spin?.timing).toBe("linear"); 416 }); 417 418 it("should have flash animation", () => { 419 const flash = getAnimation("flash"); 420 expect(flash).toBeDefined(); 421 expect(flash?.duration).toBe(1000); 422 }); 423 }); 424});