a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 415 lines 13 kB view raw
1import { signal } from "$core/signal"; 2import { registerTransition } from "$core/transitions"; 3import { executeSurgeEnter, executeSurgeLeave, hasSurge, surgePlugin } from "$plugins/surge"; 4import type { TransitionPreset } from "$types/volt"; 5import type { PluginContext } from "$types/volt"; 6import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 7 8describe("Surge Plugin", () => { 9 let container: HTMLDivElement; 10 let element: HTMLElement; 11 let mockContext: PluginContext; 12 let cleanups: Array<() => void>; 13 14 beforeEach(() => { 15 container = document.createElement("div"); 16 element = document.createElement("div"); 17 element.textContent = "Test Content"; 18 container.append(element); 19 document.body.append(container); 20 21 cleanups = []; 22 23 mockContext = { 24 element, 25 scope: {}, 26 addCleanup: (fn) => { 27 cleanups.push(fn); 28 }, 29 findSignal: vi.fn(), 30 evaluate: vi.fn(), 31 lifecycle: { onMount: vi.fn(), onUnmount: vi.fn(), beforeBinding: vi.fn(), afterBinding: vi.fn() }, 32 }; 33 34 globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false }); 35 }); 36 37 afterEach(() => { 38 for (const cleanup of cleanups) { 39 cleanup(); 40 } 41 cleanups = []; 42 container.remove(); 43 vi.restoreAllMocks(); 44 }); 45 46 describe("Configuration Storage", () => { 47 it("should store config when no signal path provided", () => { 48 surgePlugin(mockContext, "fade"); 49 expect(hasSurge(element as HTMLElement)).toBe(true); 50 }); 51 52 it("should detect surge attributes before plugin execution", async () => { 53 vi.useFakeTimers(); 54 55 element.dataset.voltSurge = "fade"; 56 expect(hasSurge(element as HTMLElement)).toBe(true); 57 58 const enterPromise = executeSurgeEnter(element as HTMLElement); 59 await vi.advanceTimersByTimeAsync(400); 60 await enterPromise; 61 expect(element.style.opacity).toBe("1"); 62 63 element.dataset["voltSurge:leave"] = "fade"; 64 const leavePromise = executeSurgeLeave(element as HTMLElement); 65 await vi.advanceTimersByTimeAsync(400); 66 await leavePromise; 67 expect(element.style.opacity).toBe("0"); 68 69 vi.useRealTimers(); 70 }); 71 72 it("should store enter-specific config", () => { 73 surgePlugin(mockContext, "enter:slide-down"); 74 const stored = (element as HTMLElement & { _vxSurgeEnter?: unknown })._vxSurgeEnter; 75 expect(stored).toBeDefined(); 76 }); 77 78 it("should store leave-specific config", () => { 79 surgePlugin(mockContext, "leave:fade.300"); 80 const stored = (element as HTMLElement & { _vxSurgeLeave?: unknown })._vxSurgeLeave; 81 expect(stored).toBeDefined(); 82 }); 83 }); 84 85 describe("Signal Watching (Explicit Mode)", () => { 86 it("should watch signal and show/hide element", async () => { 87 vi.useFakeTimers(); 88 89 const showSignal = signal(false); 90 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 91 mockContext.scope = { show: showSignal }; 92 93 surgePlugin(mockContext, "show:fade"); 94 95 expect(element.style.display).toBe("none"); 96 97 showSignal.set(true); 98 await vi.advanceTimersByTimeAsync(400); 99 expect(element.style.display).not.toBe("none"); 100 101 showSignal.set(false); 102 await vi.advanceTimersByTimeAsync(400); 103 expect(element.style.display).toBe("none"); 104 105 vi.useRealTimers(); 106 }); 107 108 it("should apply transitions when showing element", async () => { 109 const showSignal = signal(false); 110 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 111 112 surgePlugin(mockContext, "show:fade"); 113 114 showSignal.set(true); 115 116 await new Promise((resolve) => { 117 setTimeout(resolve, 50); 118 }); 119 120 expect(element.style.display).not.toBe("none"); 121 }); 122 123 it("should cleanup subscription on unmount", () => { 124 const showSignal = signal(true); 125 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 126 127 surgePlugin(mockContext, "show:fade"); 128 129 expect(cleanups.length).toBeGreaterThan(0); 130 131 for (const cleanup of cleanups) { 132 cleanup(); 133 } 134 135 const initialDisplay = element.style.display; 136 showSignal.set(false); 137 138 expect(element.style.display).toBe(initialDisplay); 139 }); 140 141 it("should error when signal not found", () => { 142 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 143 mockContext.findSignal = vi.fn().mockReturnValue(void 0); 144 145 surgePlugin(mockContext, "nonexistent:fade"); 146 expect(consoleSpy).toHaveBeenCalledWith("[Volt] Signal \"nonexistent\" not found for surge binding"); 147 148 consoleSpy.mockRestore(); 149 }); 150 151 it("should not transition if already in target state", async () => { 152 const showSignal = signal(true); 153 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 154 155 surgePlugin(mockContext, "show:fade"); 156 expect(element.style.display).not.toBe("none"); 157 158 const initialStyles = element.style.cssText; 159 showSignal.set(true); 160 161 await new Promise((resolve) => { 162 setTimeout(resolve, 50); 163 }); 164 165 expect(element.style.cssText).toBe(initialStyles); 166 }); 167 }); 168 169 describe("Custom Presets", () => { 170 it("should use custom registered preset", async () => { 171 const customPreset: TransitionPreset = { 172 enter: { 173 from: { opacity: 0, transform: "scale(0.5)" }, 174 to: { opacity: 1, transform: "scale(1)" }, 175 duration: 200, 176 easing: "ease-out", 177 }, 178 leave: { 179 from: { opacity: 1, transform: "scale(1)" }, 180 to: { opacity: 0, transform: "scale(0.5)" }, 181 duration: 200, 182 easing: "ease-in", 183 }, 184 }; 185 186 registerTransition("custom-scale", customPreset); 187 188 const showSignal = signal(false); 189 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 190 191 surgePlugin(mockContext, "show:custom-scale"); 192 193 showSignal.set(true); 194 195 await new Promise((resolve) => { 196 setTimeout(resolve, 50); 197 }); 198 199 expect(element.style.display).not.toBe("none"); 200 }); 201 }); 202 203 describe("Duration and Delay Overrides", () => { 204 it("should parse duration override", () => { 205 const showSignal = signal(false); 206 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 207 208 surgePlugin(mockContext, "show:fade.500"); 209 210 expect(mockContext.findSignal).toHaveBeenCalledWith("show"); 211 }); 212 213 it("should parse duration and delay overrides", () => { 214 const showSignal = signal(false); 215 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 216 217 surgePlugin(mockContext, "show:slide-down.600.100"); 218 219 expect(mockContext.findSignal).toHaveBeenCalledWith("show"); 220 }); 221 }); 222 223 describe("Error Handling", () => { 224 it("should error on invalid surge value", () => { 225 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 226 surgePlugin(mockContext, "nonexistent-preset"); 227 expect(consoleSpy).toHaveBeenCalledWith("[Volt] Unknown transition preset: \"nonexistent-preset\""); 228 consoleSpy.mockRestore(); 229 }); 230 231 it("should error on invalid enter value", () => { 232 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 233 surgePlugin(mockContext, "enter:nonexistent"); 234 expect(consoleSpy).toHaveBeenCalled(); 235 consoleSpy.mockRestore(); 236 }); 237 238 it("should error on invalid leave value", () => { 239 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 240 surgePlugin(mockContext, "leave:nonexistent"); 241 expect(consoleSpy).toHaveBeenCalled(); 242 consoleSpy.mockRestore(); 243 }); 244 }); 245 246 describe("Helper Functions", () => { 247 describe("hasSurge", () => { 248 it("should return true when surge config exists", () => { 249 surgePlugin(mockContext, "fade"); 250 expect(hasSurge(element as HTMLElement)).toBe(true); 251 }); 252 253 it("should return true when custom enter exists", () => { 254 surgePlugin(mockContext, "enter:slide-down"); 255 expect(hasSurge(element as HTMLElement)).toBe(true); 256 }); 257 258 it("should return true when custom leave exists", () => { 259 surgePlugin(mockContext, "leave:fade"); 260 expect(hasSurge(element as HTMLElement)).toBe(true); 261 }); 262 263 it("should return false when no surge config exists", () => { 264 expect(hasSurge(element as HTMLElement)).toBe(false); 265 }); 266 }); 267 268 describe("executeSurgeEnter", () => { 269 it("should execute enter transition", async () => { 270 surgePlugin(mockContext, "fade"); 271 await executeSurgeEnter(element as HTMLElement); 272 expect(element).toBeDefined(); 273 }); 274 275 it("should use custom enter if available", async () => { 276 surgePlugin(mockContext, "enter:slide-down"); 277 surgePlugin(mockContext, "leave:fade"); 278 279 await executeSurgeEnter(element as HTMLElement); 280 281 expect(element).toBeDefined(); 282 }); 283 284 it("should do nothing if no enter config", async () => { 285 await executeSurgeEnter(element as HTMLElement); 286 expect(element).toBeDefined(); 287 }); 288 }); 289 290 describe("executeSurgeLeave", () => { 291 it("should execute leave transition", async () => { 292 surgePlugin(mockContext, "fade"); 293 await executeSurgeLeave(element as HTMLElement); 294 expect(element).toBeDefined(); 295 }); 296 297 it("should use custom leave if available", async () => { 298 surgePlugin(mockContext, "enter:fade"); 299 surgePlugin(mockContext, "leave:slide-up"); 300 301 await executeSurgeLeave(element as HTMLElement); 302 303 expect(element).toBeDefined(); 304 }); 305 306 it("should do nothing if no leave config", async () => { 307 await executeSurgeLeave(element as HTMLElement); 308 expect(element).toBeDefined(); 309 }); 310 }); 311 }); 312 313 describe("Accessibility", () => { 314 it("should skip animations when prefers-reduced-motion is enabled", async () => { 315 globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true }); 316 317 const showSignal = signal(false); 318 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 319 320 surgePlugin(mockContext, "show:fade"); 321 322 showSignal.set(true); 323 324 await new Promise((resolve) => { 325 setTimeout(resolve, 50); 326 }); 327 328 expect(element.style.display).not.toBe("none"); 329 }); 330 }); 331 332 describe("Transition Lifecycle", () => { 333 it("should not start overlapping transitions", async () => { 334 const showSignal = signal(false); 335 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 336 337 surgePlugin(mockContext, "show:fade"); 338 339 showSignal.set(true); 340 showSignal.set(false); 341 showSignal.set(true); 342 343 await new Promise((resolve) => { 344 setTimeout(resolve, 100); 345 }); 346 347 expect(element).toBeDefined(); 348 }); 349 350 it("should cleanup transition styles after completion", async () => { 351 const showSignal = signal(false); 352 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 353 354 registerTransition("test-fast", { 355 enter: { from: { opacity: 0 }, to: { opacity: 1 }, duration: 10 }, 356 leave: { from: { opacity: 1 }, to: { opacity: 0 }, duration: 10 }, 357 }); 358 359 surgePlugin(mockContext, "show:test-fast"); 360 361 showSignal.set(true); 362 363 await new Promise((resolve) => { 364 setTimeout(resolve, 100); 365 }); 366 367 expect(element.style.transition).toBe(""); 368 }); 369 }); 370 371 describe("View Transitions API", () => { 372 it("should use View Transitions API when available", async () => { 373 const mockStartViewTransition = vi.fn((callback) => { 374 callback(); 375 }); 376 377 // @ts-expect-error - Adding View Transitions API mock 378 document.startViewTransition = mockStartViewTransition; 379 380 const showSignal = signal(false); 381 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 382 383 surgePlugin(mockContext, "show:fade"); 384 385 showSignal.set(true); 386 387 await new Promise((resolve) => { 388 setTimeout(resolve, 50); 389 }); 390 391 expect(mockStartViewTransition).toHaveBeenCalled(); 392 393 // @ts-expect-error - Cleanup mock 394 delete document.startViewTransition; 395 }); 396 397 it("should fallback to CSS when View Transitions API not available", async () => { 398 // @ts-expect-error - Ensure View Transitions API is not available 399 delete document.startViewTransition; 400 401 const showSignal = signal(false); 402 mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 403 404 surgePlugin(mockContext, "show:fade"); 405 406 showSignal.set(true); 407 408 await new Promise((resolve) => { 409 setTimeout(resolve, 50); 410 }); 411 412 expect(element.style.display).not.toBe("none"); 413 }); 414 }); 415});