@pyreon/router 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,467 @@
1
+ import {
2
+ buildNameIndex,
3
+ buildPath,
4
+ findRouteByName,
5
+ matchPath,
6
+ parseQuery,
7
+ parseQueryMulti,
8
+ resolveRoute,
9
+ stringifyQuery,
10
+ } from "../match"
11
+ import type { RouteRecord } from "../types"
12
+
13
+ const Home = () => null
14
+ const About = () => null
15
+ const User = () => null
16
+ const NotFound = () => null
17
+
18
+ // ─── parseQuery — edge cases ─────────────────────────────────────────────────
19
+
20
+ describe("parseQuery — edge cases", () => {
21
+ test("handles URI-encoded keys", () => {
22
+ expect(parseQuery("hello%20world=value")).toEqual({ "hello world": "value" })
23
+ })
24
+
25
+ test("handles multiple equals signs in value", () => {
26
+ // Only the first `=` is the delimiter
27
+ expect(parseQuery("expr=a=b")).toEqual({ expr: "a=b" })
28
+ })
29
+
30
+ test("last value wins for duplicate keys", () => {
31
+ expect(parseQuery("a=1&a=2")).toEqual({ a: "2" })
32
+ })
33
+
34
+ test("handles empty key (skipped)", () => {
35
+ // "=value" has empty key, should be skipped
36
+ expect(parseQuery("=value")).toEqual({})
37
+ })
38
+
39
+ test("handles key-only entry with no equals", () => {
40
+ expect(parseQuery("active")).toEqual({ active: "" })
41
+ })
42
+
43
+ test("handles mixed entries", () => {
44
+ expect(parseQuery("a=1&flag&b=2")).toEqual({ a: "1", flag: "", b: "2" })
45
+ })
46
+
47
+ test("decodes both keys and values", () => {
48
+ expect(parseQuery("na%2Fme=val%26ue")).toEqual({ "na/me": "val&ue" })
49
+ })
50
+ })
51
+
52
+ // ─── parseQueryMulti ─────────────────────────────────────────────────────────
53
+
54
+ describe("parseQueryMulti", () => {
55
+ test("returns empty object for empty string", () => {
56
+ expect(parseQueryMulti("")).toEqual({})
57
+ })
58
+
59
+ test("single value stays as string", () => {
60
+ expect(parseQueryMulti("color=red")).toEqual({ color: "red" })
61
+ })
62
+
63
+ test("duplicate keys become arrays", () => {
64
+ expect(parseQueryMulti("color=red&color=blue")).toEqual({ color: ["red", "blue"] })
65
+ })
66
+
67
+ test("triple duplicate keys become array of three", () => {
68
+ expect(parseQueryMulti("a=1&a=2&a=3")).toEqual({ a: ["1", "2", "3"] })
69
+ })
70
+
71
+ test("mixed single and multi values", () => {
72
+ expect(parseQueryMulti("color=red&color=blue&size=lg")).toEqual({
73
+ color: ["red", "blue"],
74
+ size: "lg",
75
+ })
76
+ })
77
+
78
+ test("key without value", () => {
79
+ expect(parseQueryMulti("flag")).toEqual({ flag: "" })
80
+ })
81
+
82
+ test("key without value duplicated", () => {
83
+ expect(parseQueryMulti("flag&flag")).toEqual({ flag: ["", ""] })
84
+ })
85
+
86
+ test("empty key is skipped", () => {
87
+ expect(parseQueryMulti("=value")).toEqual({})
88
+ })
89
+
90
+ test("decodes URI-encoded keys and values", () => {
91
+ expect(parseQueryMulti("na%2Fme=val%26ue")).toEqual({ "na/me": "val&ue" })
92
+ })
93
+ })
94
+
95
+ // ─── stringifyQuery — edge cases ─────────────────────────────────────────────
96
+
97
+ describe("stringifyQuery — edge cases", () => {
98
+ test("encodes special characters", () => {
99
+ const result = stringifyQuery({ "key with space": "value&more" })
100
+ expect(result).toBe("?key%20with%20space=value%26more")
101
+ })
102
+
103
+ test("handles single key-value pair", () => {
104
+ expect(stringifyQuery({ page: "1" })).toBe("?page=1")
105
+ })
106
+
107
+ test("handles key with empty value", () => {
108
+ expect(stringifyQuery({ debug: "" })).toBe("?debug")
109
+ })
110
+ })
111
+
112
+ // ─── matchPath — edge cases ──────────────────────────────────────────────────
113
+
114
+ describe("matchPath — edge cases", () => {
115
+ test("splat param captures remaining path", () => {
116
+ const result = matchPath("/files/:path*", "/files/a/b/c")
117
+ expect(result).toEqual({ path: "a/b/c" })
118
+ })
119
+
120
+ test("splat param captures single segment", () => {
121
+ const result = matchPath("/files/:path*", "/files/readme.txt")
122
+ expect(result).toEqual({ path: "readme.txt" })
123
+ })
124
+
125
+ test("optional param matches with value", () => {
126
+ const result = matchPath("/user/:id?", "/user/42")
127
+ expect(result).toEqual({ id: "42" })
128
+ })
129
+
130
+ test("optional param matches without value", () => {
131
+ const result = matchPath("/user/:id?", "/user")
132
+ expect(result).toEqual({})
133
+ })
134
+
135
+ test("returns null for too many path segments", () => {
136
+ expect(matchPath("/a/b", "/a/b/c")).toBeNull()
137
+ })
138
+
139
+ test("exact static match returns empty params", () => {
140
+ expect(matchPath("/about", "/about")).toEqual({})
141
+ })
142
+
143
+ test("root path matches root pattern", () => {
144
+ expect(matchPath("/", "/")).toEqual({})
145
+ })
146
+
147
+ test("mismatched static segment returns null", () => {
148
+ expect(matchPath("/foo", "/bar")).toBeNull()
149
+ })
150
+
151
+ test("decodes URI-encoded segments", () => {
152
+ const result = matchPath("/user/:name", "/user/hello%20world")
153
+ expect(result).toEqual({ name: "hello world" })
154
+ })
155
+
156
+ test("multiple params in a row", () => {
157
+ const result = matchPath("/:a/:b/:c", "/x/y/z")
158
+ expect(result).toEqual({ a: "x", b: "y", c: "z" })
159
+ })
160
+ })
161
+
162
+ // ─── resolveRoute — edge cases ───────────────────────────────────────────────
163
+
164
+ describe("resolveRoute — edge cases", () => {
165
+ const routes: RouteRecord[] = [
166
+ { path: "/", component: Home },
167
+ { path: "/about", component: About },
168
+ { path: "/user/:id", component: User },
169
+ {
170
+ path: "/admin",
171
+ component: Home,
172
+ meta: { requiresAuth: true },
173
+ children: [
174
+ { path: "users", component: User },
175
+ { path: "settings", component: About },
176
+ ],
177
+ },
178
+ { path: "*", component: NotFound },
179
+ ]
180
+
181
+ test("resolves root path with empty query", () => {
182
+ const r = resolveRoute("/", routes)
183
+ expect(r.path).toBe("/")
184
+ expect(r.params).toEqual({})
185
+ expect(r.query).toEqual({})
186
+ expect(r.hash).toBe("")
187
+ })
188
+
189
+ test("resolves path with query and hash in path portion", () => {
190
+ // Hash in the path portion (before ?) is extracted from pathAndHash
191
+ const r = resolveRoute("/about#section?key=val", routes)
192
+ expect(r.path).toBe("/about")
193
+ expect(r.hash).toBe("section")
194
+ expect(r.query).toEqual({ key: "val" })
195
+ })
196
+
197
+ test("resolves path with query containing hash (hash after query)", () => {
198
+ // When hash follows query: /about?key=val#section
199
+ // The # is part of the query value since ? comes first
200
+ const r = resolveRoute("/about?key=val#section", routes)
201
+ expect(r.path).toBe("/about")
202
+ // The hash ends up in the query value since it's after the ?
203
+ expect(r.query.key).toContain("val")
204
+ })
205
+
206
+ test("resolves nested route with merged meta", () => {
207
+ const r = resolveRoute("/admin/users", routes)
208
+ expect(r.matched.length).toBe(2)
209
+ expect(r.meta.requiresAuth).toBe(true)
210
+ })
211
+
212
+ test("resolves dynamic param route", () => {
213
+ const r = resolveRoute("/user/123", routes)
214
+ expect(r.params.id).toBe("123")
215
+ expect(r.matched.length).toBeGreaterThan(0)
216
+ })
217
+
218
+ test("wildcard catches unmatched paths", () => {
219
+ const r = resolveRoute("/totally/unknown/path", routes)
220
+ expect(r.matched.length).toBeGreaterThan(0)
221
+ expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
222
+ })
223
+
224
+ test("returns empty matched for no match without wildcard", () => {
225
+ const simpleRoutes: RouteRecord[] = [
226
+ { path: "/", component: Home },
227
+ { path: "/about", component: About },
228
+ ]
229
+ const r = resolveRoute("/nonexistent", simpleRoutes)
230
+ expect(r.matched).toHaveLength(0)
231
+ })
232
+
233
+ test("resolves path with hash before query (edge case)", () => {
234
+ // hash in the path portion (before ?), query is separate
235
+ const r = resolveRoute("/#anchor?key=val", routes)
236
+ expect(r.hash).toBe("anchor")
237
+ })
238
+
239
+ test("resolves deeply nested routes", () => {
240
+ const deepRoutes: RouteRecord[] = [
241
+ {
242
+ path: "/a",
243
+ component: Home,
244
+ children: [
245
+ {
246
+ path: "b",
247
+ component: About,
248
+ children: [{ path: "c", component: User }],
249
+ },
250
+ ],
251
+ },
252
+ ]
253
+ const r = resolveRoute("/a/b/c", deepRoutes)
254
+ expect(r.matched.length).toBe(3)
255
+ })
256
+
257
+ test("optional param route matches with and without param", () => {
258
+ const optRoutes: RouteRecord[] = [{ path: "/page/:slug?", component: Home }]
259
+ const withParam = resolveRoute("/page/about", optRoutes)
260
+ expect(withParam.params.slug).toBe("about")
261
+
262
+ const withoutParam = resolveRoute("/page", optRoutes)
263
+ expect(withoutParam.matched.length).toBeGreaterThan(0)
264
+ })
265
+
266
+ test("splat route captures all remaining segments", () => {
267
+ const splatRoutes: RouteRecord[] = [{ path: "/docs/:rest*", component: Home }]
268
+ const r = resolveRoute("/docs/api/reference/types", splatRoutes)
269
+ expect(r.params.rest).toBe("api/reference/types")
270
+ })
271
+
272
+ test("caches compiled routes (same reference gives same result)", () => {
273
+ const r1 = resolveRoute("/about", routes)
274
+ const r2 = resolveRoute("/about", routes)
275
+ expect(r1.path).toBe(r2.path)
276
+ expect(r1.matched.length).toBe(r2.matched.length)
277
+ })
278
+ })
279
+
280
+ // ─── resolveRoute — alias support ────────────────────────────────────────────
281
+
282
+ describe("resolveRoute — alias", () => {
283
+ test("alias string resolves to same component", () => {
284
+ const aliasRoutes: RouteRecord[] = [
285
+ { path: "/user/:id", alias: "/profile/:id", component: User },
286
+ ]
287
+ const r = resolveRoute("/profile/42", aliasRoutes)
288
+ expect(r.matched.length).toBeGreaterThan(0)
289
+ expect(r.matched[0]?.component).toBe(User)
290
+ expect(r.params.id).toBe("42")
291
+ })
292
+
293
+ test("alias array resolves multiple paths to same component", () => {
294
+ const aliasRoutes: RouteRecord[] = [
295
+ { path: "/home", alias: ["/index", "/main"], component: Home },
296
+ ]
297
+ const r1 = resolveRoute("/index", aliasRoutes)
298
+ const r2 = resolveRoute("/main", aliasRoutes)
299
+ expect(r1.matched[0]?.component).toBe(Home)
300
+ expect(r2.matched[0]?.component).toBe(Home)
301
+ })
302
+
303
+ test("primary path still works with alias defined", () => {
304
+ const aliasRoutes: RouteRecord[] = [{ path: "/home", alias: "/index", component: Home }]
305
+ const r = resolveRoute("/home", aliasRoutes)
306
+ expect(r.matched[0]?.component).toBe(Home)
307
+ })
308
+ })
309
+
310
+ // ─── buildPath — edge cases ──────────────────────────────────────────────────
311
+
312
+ describe("buildPath — edge cases", () => {
313
+ test("omits segment for missing optional param", () => {
314
+ const result = buildPath("/user/:id?", {})
315
+ expect(result).toBe("/user")
316
+ })
317
+
318
+ test("includes segment for provided optional param", () => {
319
+ const result = buildPath("/user/:id?", { id: "42" })
320
+ expect(result).toBe("/user/42")
321
+ })
322
+
323
+ test("splat param preserves slashes", () => {
324
+ // buildPath regex captures the full param name including * via [^/]+
325
+ // so the key in params must match what the regex captures
326
+ const result = buildPath("/docs/:path*", { "path*": "api/reference/types" })
327
+ expect(result).toBe("/docs/api/reference/types")
328
+ })
329
+
330
+ test("encodes special characters in params", () => {
331
+ const result = buildPath("/user/:name", { name: "hello world" })
332
+ expect(result).toBe("/user/hello%20world")
333
+ })
334
+
335
+ test("handles path with no params", () => {
336
+ const result = buildPath("/about", {})
337
+ expect(result).toBe("/about")
338
+ })
339
+
340
+ test("handles root path", () => {
341
+ const result = buildPath("/", {})
342
+ expect(result).toBe("/")
343
+ })
344
+
345
+ test("encodes splat param segments individually", () => {
346
+ // buildPath regex captures full param name including * via [^/]+
347
+ const result = buildPath("/files/:path*", { "path*": "dir/my file.txt" })
348
+ expect(result).toBe("/files/dir/my%20file.txt")
349
+ })
350
+ })
351
+
352
+ // ─── findRouteByName — edge cases ────────────────────────────────────────────
353
+
354
+ describe("findRouteByName — edge cases", () => {
355
+ test("finds deeply nested route", () => {
356
+ const routes: RouteRecord[] = [
357
+ {
358
+ path: "/a",
359
+ component: Home,
360
+ children: [
361
+ {
362
+ path: "b",
363
+ component: About,
364
+ children: [{ path: "c", component: User, name: "deep" }],
365
+ },
366
+ ],
367
+ },
368
+ ]
369
+ const found = findRouteByName("deep", routes)
370
+ expect(found).not.toBeNull()
371
+ expect(found?.path).toBe("c")
372
+ })
373
+
374
+ test("returns first match in definition order", () => {
375
+ const routes: RouteRecord[] = [
376
+ { path: "/first", component: Home, name: "dup" },
377
+ { path: "/second", component: About, name: "dup" },
378
+ ]
379
+ const found = findRouteByName("dup", routes)
380
+ expect(found?.path).toBe("/first")
381
+ })
382
+
383
+ test("returns null for empty routes array", () => {
384
+ expect(findRouteByName("anything", [])).toBeNull()
385
+ })
386
+ })
387
+
388
+ // ─── buildNameIndex — edge cases ─────────────────────────────────────────────
389
+
390
+ describe("buildNameIndex — edge cases", () => {
391
+ test("handles empty routes", () => {
392
+ const index = buildNameIndex([])
393
+ expect(index.size).toBe(0)
394
+ })
395
+
396
+ test("does not index routes without names", () => {
397
+ const routes: RouteRecord[] = [
398
+ { path: "/", component: Home },
399
+ { path: "/about", component: About },
400
+ ]
401
+ const index = buildNameIndex(routes)
402
+ expect(index.size).toBe(0)
403
+ })
404
+
405
+ test("indexes deeply nested named routes", () => {
406
+ const routes: RouteRecord[] = [
407
+ {
408
+ path: "/a",
409
+ component: Home,
410
+ name: "a",
411
+ children: [
412
+ {
413
+ path: "b",
414
+ component: About,
415
+ name: "b",
416
+ children: [{ path: "c", component: User, name: "c" }],
417
+ },
418
+ ],
419
+ },
420
+ ]
421
+ const index = buildNameIndex(routes)
422
+ expect(index.size).toBe(3)
423
+ expect(index.get("c")?.path).toBe("c")
424
+ })
425
+ })
426
+
427
+ // ─── resolveRoute — dynamic first segment ────────────────────────────────────
428
+
429
+ describe("resolveRoute — dynamic first segment routing", () => {
430
+ test("matches route where first segment is a param", () => {
431
+ const routes: RouteRecord[] = [{ path: "/:lang/about", component: About }]
432
+ const r = resolveRoute("/en/about", routes)
433
+ expect(r.matched.length).toBeGreaterThan(0)
434
+ expect(r.params.lang).toBe("en")
435
+ })
436
+
437
+ test("static routes take priority over dynamic first segment", () => {
438
+ const routes: RouteRecord[] = [
439
+ { path: "/about", component: About },
440
+ { path: "/:slug", component: User },
441
+ ]
442
+ const r = resolveRoute("/about", routes)
443
+ expect(r.matched[0]?.component).toBe(About)
444
+ })
445
+ })
446
+
447
+ // ─── resolveRoute — wildcard children ────────────────────────────────────────
448
+
449
+ describe("resolveRoute — wildcard patterns", () => {
450
+ test("(.*) catches any path", () => {
451
+ const routes: RouteRecord[] = [
452
+ { path: "/", component: Home },
453
+ { path: "(.*)", component: NotFound },
454
+ ]
455
+ const r = resolveRoute("/any/path/here", routes)
456
+ expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
457
+ })
458
+
459
+ test("* catches any path", () => {
460
+ const routes: RouteRecord[] = [
461
+ { path: "/", component: Home },
462
+ { path: "*", component: NotFound },
463
+ ]
464
+ const r = resolveRoute("/any/path/here", routes)
465
+ expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
466
+ })
467
+ })