a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import {
2 buildPath,
3 clearRouteCache,
4 compileRoute,
5 extractParams,
6 isMatch,
7 matchRoute,
8 matchRoutes,
9 normalizePath,
10 parseUrl,
11} from "$core/router";
12import { beforeEach, describe, expect, it } from "vitest";
13
14describe("router utilities", () => {
15 beforeEach(() => {
16 clearRouteCache();
17 });
18
19 describe("compileRoute", () => {
20 it("compiles a simple pattern with single parameter", () => {
21 const compiled = compileRoute("/blog/:slug");
22 expect(compiled.pattern).toBe("/blog/:slug");
23 expect(compiled.keys).toHaveLength(1);
24 expect(compiled.keys[0]).toEqual({ name: "slug", optional: false, wildcard: false });
25 });
26
27 it("compiles a pattern with multiple parameters", () => {
28 const compiled = compileRoute("/users/:userId/posts/:postId");
29 expect(compiled.keys).toHaveLength(2);
30 expect(compiled.keys[0]).toEqual({ name: "userId", optional: false, wildcard: false });
31 expect(compiled.keys[1]).toEqual({ name: "postId", optional: false, wildcard: false });
32 });
33
34 it("compiles a pattern with optional parameter", () => {
35 const compiled = compileRoute("/blog/:slug?");
36 expect(compiled.keys).toHaveLength(1);
37 expect(compiled.keys[0]).toEqual({ name: "slug", optional: true, wildcard: false });
38 });
39
40 it("compiles a pattern with wildcard parameter", () => {
41 const compiled = compileRoute("/files/*path");
42 expect(compiled.keys).toHaveLength(1);
43 expect(compiled.keys[0]).toEqual({ name: "path", optional: false, wildcard: true });
44 });
45
46 it("caches compiled routes", () => {
47 const first = compileRoute("/blog/:slug");
48 const second = compileRoute("/blog/:slug");
49 expect(first).toBe(second);
50 });
51
52 it("creates valid regex for pattern matching", () => {
53 const compiled = compileRoute("/blog/:slug");
54 expect(compiled.regex.test("/blog/hello-world")).toBe(true);
55 expect(compiled.regex.test("/about")).toBe(false);
56 });
57 });
58
59 describe("matchRoute", () => {
60 it("matches a simple route with one parameter", () => {
61 const match = matchRoute("/blog/:slug", "/blog/hello-world");
62 expect(match).toEqual({ path: "/blog/hello-world", params: { slug: "hello-world" }, pattern: "/blog/:slug" });
63 });
64
65 it("returns undefined for non-matching routes", () => {
66 const match = matchRoute("/blog/:slug", "/about");
67 expect(match).toBeUndefined();
68 });
69
70 it("matches routes with multiple parameters", () => {
71 const match = matchRoute("/users/:userId/posts/:postId", "/users/42/posts/123");
72 expect(match).toBeDefined();
73 expect(match!.params).toEqual({ userId: "42", postId: "123" });
74 });
75
76 it("matches optional parameters when present", () => {
77 const match = matchRoute("/blog/:category/:slug?", "/blog/tech/hello");
78 expect(match).toBeDefined();
79 expect(match!.params).toEqual({ category: "tech", slug: "hello" });
80 });
81
82 it("matches optional parameters when absent", () => {
83 const match = matchRoute("/blog/:category/:slug?", "/blog/tech");
84 expect(match).toBeDefined();
85 expect(match!.params).toEqual({ category: "tech" });
86 });
87
88 it("matches wildcard parameters", () => {
89 const match = matchRoute("/files/*path", "/files/docs/guide/intro.md");
90 expect(match).toBeDefined();
91 expect(match!.params).toEqual({ path: "docs/guide/intro.md" });
92 });
93
94 it("decodes URI components in parameters", () => {
95 const match = matchRoute("/blog/:slug", "/blog/hello%20world");
96 expect(match).toBeDefined();
97 expect(match!.params.slug).toBe("hello world");
98 });
99
100 it("handles static routes without parameters", () => {
101 const match = matchRoute("/about", "/about");
102 expect(match).toEqual({ path: "/about", params: {}, pattern: "/about" });
103 });
104
105 it("does not match partial paths", () => {
106 const match = matchRoute("/blog/:slug", "/blog/hello/extra");
107 expect(match).toBeUndefined();
108 });
109
110 it("handles routes with special characters", () => {
111 const match = matchRoute("/api/v1/users/:id", "/api/v1/users/42");
112 expect(match).toBeDefined();
113 expect(match!.params).toEqual({ id: "42" });
114 });
115 });
116
117 describe("matchRoutes", () => {
118 it("returns first matching route", () => {
119 const patterns = ["/about", "/blog/:slug", "/users/:id"];
120 const match = matchRoutes(patterns, "/blog/hello-world");
121 expect(match).toBeDefined();
122 expect(match!.pattern).toBe("/blog/:slug");
123 expect(match!.params).toEqual({ slug: "hello-world" });
124 });
125
126 it("tries patterns in order", () => {
127 const patterns = ["/blog/:slug", "/blog/featured"];
128 const match = matchRoutes(patterns, "/blog/featured");
129 expect(match).toBeDefined();
130 expect(match!.pattern).toBe("/blog/:slug");
131 expect(match!.params).toEqual({ slug: "featured" });
132 });
133
134 it("returns undefined when no routes match", () => {
135 const patterns = ["/about", "/blog/:slug"];
136 const match = matchRoutes(patterns, "/products");
137 expect(match).toBeUndefined();
138 });
139
140 it("handles empty pattern array", () => {
141 const match = matchRoutes([], "/any-path");
142 expect(match).toBeUndefined();
143 });
144 });
145
146 describe("extractParams", () => {
147 it("extracts parameters from matching route", () => {
148 const params = extractParams("/blog/:slug", "/blog/hello-world");
149 expect(params).toEqual({ slug: "hello-world" });
150 });
151
152 it("returns empty object for non-matching route", () => {
153 const params = extractParams("/blog/:slug", "/about");
154 expect(params).toEqual({});
155 });
156
157 it("extracts multiple parameters", () => {
158 const params = extractParams("/users/:userId/posts/:postId", "/users/42/posts/123");
159 expect(params).toEqual({ userId: "42", postId: "123" });
160 });
161
162 it("handles optional parameters", () => {
163 const params1 = extractParams("/blog/:category/:slug?", "/blog/tech/hello");
164 expect(params1).toEqual({ category: "tech", slug: "hello" });
165
166 const params2 = extractParams("/blog/:category/:slug?", "/blog/tech");
167 expect(params2).toEqual({ category: "tech" });
168 });
169
170 it("extracts wildcard parameters", () => {
171 const params = extractParams("/files/*path", "/files/docs/guide.md");
172 expect(params).toEqual({ path: "docs/guide.md" });
173 });
174 });
175
176 describe("buildPath", () => {
177 it("builds path from pattern with single parameter", () => {
178 const path = buildPath("/blog/:slug", { slug: "hello-world" });
179 expect(path).toBe("/blog/hello-world");
180 });
181
182 it("builds path from pattern with multiple parameters", () => {
183 const path = buildPath("/users/:userId/posts/:postId", { userId: "42", postId: "123" });
184 expect(path).toBe("/users/42/posts/123");
185 });
186
187 it("URL-encodes parameter values", () => {
188 const path = buildPath("/blog/:slug", { slug: "hello world" });
189 expect(path).toBe("/blog/hello%20world");
190 });
191
192 it("handles optional parameters when provided", () => {
193 const path = buildPath("/blog/:category/:slug?", { category: "tech", slug: "hello" });
194 expect(path).toBe("/blog/tech/hello");
195 });
196
197 it("removes optional parameters when not provided", () => {
198 const path = buildPath("/blog/:category/:slug?", { category: "tech" });
199 expect(path).toBe("/blog/tech/");
200 });
201
202 it("builds path with wildcard parameters", () => {
203 const path = buildPath("/files/*path", { path: "docs/guide.md" });
204 expect(path).toBe("/files/docs/guide.md");
205 });
206
207 it("handles special characters in parameters", () => {
208 const path = buildPath("/search/:query", { query: "hello+world" });
209 expect(path).toBe("/search/hello%2Bworld");
210 });
211
212 it("leaves unmatched placeholders as-is", () => {
213 const path = buildPath("/users/:userId/posts/:postId", { userId: "42" });
214 expect(path).toContain(":postId");
215 });
216 });
217
218 describe("isMatch", () => {
219 it("returns true for matching routes", () => {
220 expect(isMatch("/blog/:slug", "/blog/hello-world")).toBe(true);
221 });
222
223 it("returns false for non-matching routes", () => {
224 expect(isMatch("/blog/:slug", "/about")).toBe(false);
225 });
226
227 it("works with complex patterns", () => {
228 expect(isMatch("/users/:userId/posts/:postId", "/users/42/posts/123")).toBe(true);
229 expect(isMatch("/users/:userId/posts/:postId", "/users/42")).toBe(false);
230 });
231
232 it("handles optional parameters", () => {
233 expect(isMatch("/blog/:category/:slug?", "/blog/tech/hello")).toBe(true);
234 expect(isMatch("/blog/:category/:slug?", "/blog/tech")).toBe(true);
235 });
236
237 it("handles wildcards", () => {
238 expect(isMatch("/files/*path", "/files/docs/guide/intro.md")).toBe(true);
239 });
240 });
241
242 describe("normalizePath", () => {
243 it("ensures leading slash", () => {
244 expect(normalizePath("about")).toBe("/about");
245 expect(normalizePath("blog/hello")).toBe("/blog/hello");
246 });
247
248 it("removes trailing slash", () => {
249 expect(normalizePath("/blog/")).toBe("/blog");
250 expect(normalizePath("/about/us/")).toBe("/about/us");
251 });
252
253 it("preserves root path", () => {
254 expect(normalizePath("/")).toBe("/");
255 });
256
257 it("handles already normalized paths", () => {
258 expect(normalizePath("/blog")).toBe("/blog");
259 });
260
261 it("handles empty string", () => {
262 expect(normalizePath("")).toBe("/");
263 });
264
265 it("normalizes complex paths", () => {
266 expect(normalizePath("users/42/posts/")).toBe("/users/42/posts");
267 });
268 });
269
270 describe("parseUrl", () => {
271 it("parses path from simple URL", () => {
272 const parsed = parseUrl("/blog");
273 expect(parsed.path).toBe("/blog");
274 expect(parsed.searchParams.toString()).toBe("");
275 });
276
277 it("parses path and search params", () => {
278 const parsed = parseUrl("/blog?page=2&sort=date");
279 expect(parsed.path).toBe("/blog");
280 expect(parsed.searchParams.get("page")).toBe("2");
281 expect(parsed.searchParams.get("sort")).toBe("date");
282 });
283
284 it("handles absolute URLs", () => {
285 const parsed = parseUrl("https://example.com/blog?page=2");
286 expect(parsed.path).toBe("/blog");
287 expect(parsed.searchParams.get("page")).toBe("2");
288 });
289
290 it("handles URLs without search params", () => {
291 const parsed = parseUrl("/users/42/posts/123");
292 expect(parsed.path).toBe("/users/42/posts/123");
293 expect(parsed.searchParams.toString()).toBe("");
294 });
295
296 it("handles root path", () => {
297 const parsed = parseUrl("/");
298 expect(parsed.path).toBe("/");
299 });
300
301 it("handles URL with hash", () => {
302 const parsed = parseUrl("/blog?page=2#comments");
303 expect(parsed.path).toBe("/blog");
304 expect(parsed.searchParams.get("page")).toBe("2");
305 });
306
307 it("handles malformed URLs gracefully", () => {
308 const parsed = parseUrl("not-a-url?with=params");
309 expect(parsed.path).toBe("/not-a-url");
310 expect(parsed.searchParams.get("with")).toBe("params");
311 });
312
313 it("handles URL with multiple query params", () => {
314 const parsed = parseUrl("/search?q=test&category=tech&sort=date");
315 expect(parsed.searchParams.get("q")).toBe("test");
316 expect(parsed.searchParams.get("category")).toBe("tech");
317 expect(parsed.searchParams.get("sort")).toBe("date");
318 });
319 });
320
321 describe("clearRouteCache", () => {
322 it("clears the compilation cache", () => {
323 const first = compileRoute("/blog/:slug");
324 clearRouteCache();
325 const second = compileRoute("/blog/:slug");
326
327 expect(first).not.toBe(second);
328 expect(first.pattern).toBe(second.pattern);
329 expect(first.keys).toEqual(second.keys);
330 });
331 });
332
333 describe("complex routing scenarios", () => {
334 it("handles nested optional parameters", () => {
335 const match = matchRoute("/blog/:category?/:slug?", "/blog");
336 expect(match?.params).toEqual({});
337 });
338
339 it("handles routes with dots in path", () => {
340 const match = matchRoute("/files/:filename", "/files/document.pdf");
341 expect(match?.params).toEqual({ filename: "document.pdf" });
342 });
343
344 it("handles routes with hyphens and underscores", () => {
345 const match = matchRoute("/api/:resource_type", "/api/user-profile");
346 expect(match?.params).toEqual({ resource_type: "user-profile" });
347 });
348
349 it("matches exact routes before parameterized routes", () => {
350 const patterns = ["/blog/featured", "/blog/:slug"];
351 const match1 = matchRoutes(patterns, "/blog/featured");
352 expect(match1?.pattern).toBe("/blog/featured");
353
354 const match2 = matchRoutes(patterns, "/blog/other");
355 expect(match2?.pattern).toBe("/blog/:slug");
356 });
357
358 it("handles building paths with missing optional params", () => {
359 const path = buildPath("/blog/:year?/:month?/:slug", { slug: "hello" });
360 expect(path).not.toContain(":year");
361 expect(path).not.toContain(":month");
362 });
363
364 it("handles URL encoding in both directions", () => {
365 const encoded = buildPath("/search/:query", { query: "hello world" });
366 expect(encoded).toBe("/search/hello%20world");
367
368 const match = matchRoute("/search/:query", encoded);
369 expect(match?.params.query).toBe("hello world");
370 });
371 });
372
373 describe("edge cases", () => {
374 it("handles empty pattern", () => {
375 const match = matchRoute("", "/any-path");
376 expect(match).toBeUndefined();
377 });
378
379 it("handles pattern with only slashes", () => {
380 const match = matchRoute("///", "/");
381 expect(match).toBeUndefined();
382 });
383
384 it("handles path with trailing query string", () => {
385 const match = matchRoute("/blog/:slug", "/blog/hello?extra=param");
386 expect(match).toBeUndefined();
387 });
388
389 it("handles numeric parameter values", () => {
390 const match = matchRoute("/users/:id", "/users/12345");
391 expect(match?.params.id).toBe("12345");
392 expect(typeof match?.params.id).toBe("string");
393 });
394
395 it("preserves parameter order", () => {
396 const params = extractParams("/a/:first/b/:second/c/:third", "/a/1/b/2/c/3");
397 expect(Object.keys(params)).toEqual(["first", "second", "third"]);
398 });
399 });
400});