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