a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 400 lines 14 kB view raw
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});