a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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});