a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import {
2 applyOverrides,
3 easings,
4 getEasing,
5 getRegisteredTransitions,
6 getTransition,
7 hasTransition,
8 parseTransitionValue,
9 prefersReducedMotion,
10 registerTransition,
11 unregisterTransition,
12} from "$core/transitions";
13import type { TransitionPreset } from "$types/volt";
14import { describe, expect, it, vi } from "vitest";
15
16describe("Transition Preset System", () => {
17 describe("Built-in Presets", () => {
18 it("should have fade preset registered", () => {
19 expect(hasTransition("fade")).toBe(true);
20 const fade = getTransition("fade");
21 expect(fade).toBeDefined();
22 expect(fade?.enter.from).toEqual({ opacity: 0 });
23 expect(fade?.enter.to).toEqual({ opacity: 1 });
24 expect(fade?.leave.from).toEqual({ opacity: 1 });
25 expect(fade?.leave.to).toEqual({ opacity: 0 });
26 });
27
28 it.each([{
29 name: "slide-up",
30 enterFrom: { opacity: 0, transform: "translateY(20px)" },
31 enterTo: { opacity: 1, transform: "translateY(0)" },
32 }, {
33 name: "slide-down",
34 enterFrom: { opacity: 0, transform: "translateY(-20px)" },
35 enterTo: { opacity: 1, transform: "translateY(0)" },
36 }, {
37 name: "slide-left",
38 enterFrom: { opacity: 0, transform: "translateX(20px)" },
39 enterTo: { opacity: 1, transform: "translateX(0)" },
40 }, {
41 name: "slide-right",
42 enterFrom: { opacity: 0, transform: "translateX(-20px)" },
43 enterTo: { opacity: 1, transform: "translateX(0)" },
44 }, {
45 name: "scale",
46 enterFrom: { opacity: 0, transform: "scale(0.95)" },
47 enterTo: { opacity: 1, transform: "scale(1)" },
48 }, { name: "blur", enterFrom: { opacity: 0, filter: "blur(10px)" }, enterTo: { opacity: 1, filter: "blur(0)" } }])(
49 "should have $name preset registered",
50 ({ name, enterFrom, enterTo }) => {
51 expect(hasTransition(name)).toBe(true);
52 const preset = getTransition(name);
53 expect(preset).toBeDefined();
54 expect(preset?.enter.from).toEqual(enterFrom);
55 expect(preset?.enter.to).toEqual(enterTo);
56 },
57 );
58
59 it("should return all built-in preset names", () => {
60 const presets = getRegisteredTransitions();
61
62 for (const preset of ["fade", "slide-up", "slide-down", "slide-left", "slide-right", "scale", "blur"]) {
63 expect(presets).toContain(preset);
64 }
65 });
66 });
67
68 describe("Custom Preset Registration", () => {
69 it("should register a custom transition preset", () => {
70 const customPreset: TransitionPreset = {
71 enter: {
72 from: { opacity: 0, transform: "translateX(-100px)" },
73 to: { opacity: 1, transform: "translateX(0)" },
74 duration: 400,
75 easing: "ease-out",
76 },
77 leave: {
78 from: { opacity: 1, transform: "translateX(0)" },
79 to: { opacity: 0, transform: "translateX(100px)" },
80 duration: 300,
81 easing: "ease-in",
82 },
83 };
84
85 registerTransition("custom-slide", customPreset);
86 expect(hasTransition("custom-slide")).toBe(true);
87
88 const retrieved = getTransition("custom-slide");
89 expect(retrieved).toEqual(customPreset);
90 });
91
92 it("should unregister a custom preset", () => {
93 const customPreset: TransitionPreset = { enter: { from: {}, to: {} }, leave: { from: {}, to: {} } };
94
95 registerTransition("temp-preset", customPreset);
96 expect(hasTransition("temp-preset")).toBe(true);
97
98 const result = unregisterTransition("temp-preset");
99 expect(result).toBe(true);
100 expect(hasTransition("temp-preset")).toBe(false);
101 });
102
103 it("should not unregister built-in presets", () => {
104 const result = unregisterTransition("fade");
105 expect(result).toBe(false);
106 expect(hasTransition("fade")).toBe(true);
107 });
108
109 it("should warn when overriding built-in preset", () => {
110 const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
111 const customPreset: TransitionPreset = { enter: { from: {}, to: {} }, leave: { from: {}, to: {} } };
112
113 registerTransition("fade", customPreset);
114 expect(consoleSpy).toHaveBeenCalledWith("[Volt] Overriding built-in transition preset: \"fade\"");
115
116 consoleSpy.mockRestore();
117 });
118
119 it("should return undefined for unknown preset", () => {
120 const preset = getTransition("nonexistent");
121 expect(preset).toBeUndefined();
122 });
123 });
124
125 describe("Parse Transition Value", () => {
126 it("should parse preset name only", () => {
127 const parsed = parseTransitionValue("fade");
128 expect(parsed).toBeDefined();
129 expect(parsed?.preset).toEqual(getTransition("fade"));
130 expect(parsed?.duration).toBeUndefined();
131 expect(parsed?.delay).toBeUndefined();
132 });
133
134 it("should parse preset name with duration", () => {
135 const parsed = parseTransitionValue("fade.500");
136 expect(parsed).toBeDefined();
137 expect(parsed?.preset).toEqual(getTransition("fade"));
138 expect(parsed?.duration).toBe(500);
139 expect(parsed?.delay).toBeUndefined();
140 });
141
142 it("should parse preset name with duration and delay", () => {
143 const parsed = parseTransitionValue("slide-down.600.100");
144 expect(parsed).toBeDefined();
145 expect(parsed?.preset).toEqual(getTransition("slide-down"));
146 expect(parsed?.duration).toBe(600);
147 expect(parsed?.delay).toBe(100);
148 });
149
150 it("should handle whitespace", () => {
151 const parsed = parseTransitionValue(" fade.500.100 ");
152 expect(parsed).toBeDefined();
153 expect(parsed?.preset).toEqual(getTransition("fade"));
154 expect(parsed?.duration).toBe(500);
155 expect(parsed?.delay).toBe(100);
156 });
157
158 it("should return undefined for empty string", () => {
159 const parsed = parseTransitionValue("");
160 expect(parsed).toBeUndefined();
161 });
162
163 it("should return undefined for unknown preset", () => {
164 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
165
166 const parsed = parseTransitionValue("nonexistent");
167 expect(parsed).toBeUndefined();
168 expect(consoleSpy).toHaveBeenCalled();
169
170 consoleSpy.mockRestore();
171 });
172
173 it("should ignore invalid duration values", () => {
174 const parsed = parseTransitionValue("fade.abc");
175 expect(parsed).toBeDefined();
176 expect(parsed?.duration).toBeUndefined();
177 });
178
179 it("should ignore invalid delay values", () => {
180 const parsed = parseTransitionValue("fade.500.xyz");
181 expect(parsed).toBeDefined();
182 expect(parsed?.duration).toBe(500);
183 expect(parsed?.delay).toBeUndefined();
184 });
185 });
186
187 describe("Easing Functions", () => {
188 it("should return CSS easing for named easings", () => {
189 for (const e of ["linear", "ease", "ease-in", "ease-out", "ease-in-out"]) {
190 const res = getEasing(e);
191 expect(res).toEqual(e);
192 }
193 });
194
195 it("should return cubic-bezier for named easing curves", () => {
196 expect(getEasing("ease-in-sine")).toBe("cubic-bezier(0.12, 0, 0.39, 0)");
197 expect(getEasing("ease-out-sine")).toBe("cubic-bezier(0.61, 1, 0.88, 1)");
198 expect(getEasing("ease-in-quad")).toBe("cubic-bezier(0.11, 0, 0.5, 0)");
199 });
200
201 it("should return custom cubic-bezier as-is", () => {
202 const custom = "cubic-bezier(0.25, 0.1, 0.25, 1)";
203 expect(getEasing(custom)).toBe(custom);
204 });
205
206 it("should have all easing constants defined", () => {
207 for (
208 const prop of [
209 "linear",
210 "ease",
211 "ease-in",
212 "ease-out",
213 "ease-in-out",
214 "ease-in-back",
215 "ease-out-back",
216 "ease-in-out-back",
217 ]
218 ) {
219 expect(easings).toHaveProperty(prop);
220 }
221 });
222 });
223
224 describe("Apply Overrides", () => {
225 it("should apply duration override", () => {
226 const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" };
227 const overridden = applyOverrides(phase, 500);
228 expect(overridden.duration).toBe(500);
229 expect(overridden.delay).toBe(0);
230 expect(overridden.from).toEqual({ opacity: 0 });
231 expect(overridden.to).toEqual({ opacity: 1 });
232 expect(overridden.easing).toBe("ease");
233 });
234
235 it("should apply delay override", () => {
236 const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" };
237 const overridden = applyOverrides(phase, undefined, 100);
238 expect(overridden.duration).toBe(300);
239 expect(overridden.delay).toBe(100);
240 });
241
242 it("should apply both duration and delay overrides", () => {
243 const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" };
244 const overridden = applyOverrides(phase, 600, 200);
245 expect(overridden.duration).toBe(600);
246 expect(overridden.delay).toBe(200);
247 });
248
249 it("should not mutate original phase", () => {
250 const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" };
251 const overridden = applyOverrides(phase, 500, 100);
252 expect(phase.duration).toBe(300);
253 expect(phase.delay).toBe(0);
254 expect(overridden).not.toBe(phase);
255 });
256
257 it("should preserve all properties when no overrides", () => {
258 const phase = {
259 from: { opacity: 0, transform: "translateY(20px)" },
260 to: { opacity: 1, transform: "translateY(0)" },
261 duration: 300,
262 delay: 50,
263 easing: "ease-out",
264 classes: ["entering"],
265 };
266
267 const overridden = applyOverrides(phase);
268 expect(overridden).toEqual(phase);
269 expect(overridden).not.toBe(phase);
270 });
271 });
272
273 describe("Prefers Reduced Motion", () => {
274 it("should return false when matchMedia is not available", () => {
275 const originalMatchMedia = globalThis.matchMedia;
276 // @ts-expect-error - Testing undefined case
277 delete globalThis.matchMedia;
278
279 expect(prefersReducedMotion()).toBe(false);
280
281 globalThis.matchMedia = originalMatchMedia;
282 });
283
284 it("should check prefers-reduced-motion media query", () => {
285 const mockMatchMedia = vi.fn().mockReturnValue({ matches: true });
286 globalThis.matchMedia = mockMatchMedia;
287
288 const result = prefersReducedMotion();
289
290 expect(mockMatchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)");
291 expect(result).toBe(true);
292 });
293
294 it("should return false when user does not prefer reduced motion", () => {
295 const mockMatchMedia = vi.fn().mockReturnValue({ matches: false });
296 globalThis.matchMedia = mockMatchMedia;
297 const result = prefersReducedMotion();
298 expect(result).toBe(false);
299 });
300 });
301});