@devp0nt/route0 1.0.0-next.4 → 1.0.0-next.41

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.
package/src/index.test.ts CHANGED
@@ -1,13 +1,43 @@
1
1
  import { describe, expect, expectTypeOf, it } from 'bun:test'
2
- import { Route0 } from './index.js'
2
+ import type {
3
+ AnyRoute,
4
+ AnyRouteOrDefinition,
5
+ CallabelRoute,
6
+ CanInputBeEmpty,
7
+ Extended,
8
+ FlatInput,
9
+ FlatInputStringOnly,
10
+ FlatOutput,
11
+ HasParams,
12
+ HasSearch,
13
+ IsChildren,
14
+ IsParent,
15
+ IsSame,
16
+ IsSameParams,
17
+ ParamsInput,
18
+ ParamsInputStringOnly,
19
+ ParamsOutput,
20
+ SearchInput,
21
+ SearchInputStringOnly,
22
+ SearchOutput,
23
+ StrictFlatInput,
24
+ StrictFlatInputStringOnly,
25
+ StrictFlatOutput,
26
+ StrictSearchInput,
27
+ StrictSearchInputStringOnly,
28
+ StrictSearchOutput,
29
+ } from './index.js'
30
+ import { Route0, Routes } from './index.js'
3
31
 
4
- describe('route0', () => {
32
+ describe('Route0', () => {
5
33
  it('simple', () => {
6
34
  const route0 = Route0.create('/')
7
35
  const path = route0.get()
8
36
  expect(route0).toBeInstanceOf(Route0)
9
37
  expectTypeOf<typeof path>().toEqualTypeOf<'/'>()
10
38
  expect(path).toBe('/')
39
+ expectTypeOf<HasParams<typeof route0>>().toEqualTypeOf<false>()
40
+ expect(path).toBe(route0.flat())
11
41
  })
12
42
 
13
43
  it('simple, callable', () => {
@@ -16,13 +46,15 @@ describe('route0', () => {
16
46
  expect(route0).toBeInstanceOf(Route0)
17
47
  expectTypeOf<typeof path>().toEqualTypeOf<'/'>()
18
48
  expect(path).toBe('/')
49
+ expect(path).toBe(route0.flat())
19
50
  })
20
51
 
21
- it('simple any query', () => {
52
+ it('simple any search', () => {
22
53
  const route0 = Route0.create('/')
23
- const path = route0.get({ query: { q: '1' } })
54
+ const path = route0.get({ search: { q: '1' } })
24
55
  expectTypeOf<typeof path>().toEqualTypeOf<`/?${string}`>()
25
56
  expect(path).toBe('/?q=1')
57
+ expect(path).toBe(route0.flat({ q: '1' }))
26
58
  })
27
59
 
28
60
  it('params', () => {
@@ -30,35 +62,42 @@ describe('route0', () => {
30
62
  const path = route0.get({ x: '1', y: 2, z: '3' })
31
63
  expectTypeOf<typeof path>().toEqualTypeOf<`/prefix/${string}/some/${string}/${string}`>()
32
64
  expect(path).toBe('/prefix/1/some/2/3')
65
+ expectTypeOf<HasParams<typeof route0>>().toEqualTypeOf<true>()
66
+ expect(path).toBe(route0.flat({ x: '1', y: 2, z: '3' }))
33
67
  })
34
68
 
35
- it('params and any query', () => {
69
+ it('params and any search', () => {
36
70
  const route0 = Route0.create('/prefix/:x/some/:y/:z')
37
- const path = route0.get({ x: '1', y: 2, z: '3', query: { q: '1' } })
71
+ const path = route0.get({ x: '1', y: 2, z: '3', search: { q: '1' } })
38
72
  expectTypeOf<typeof path>().toEqualTypeOf<`/prefix/${string}/some/${string}/${string}?${string}`>()
39
73
  expect(path).toBe('/prefix/1/some/2/3?q=1')
74
+ expect(path).toBe(route0.flat({ x: '1', y: 2, z: '3', q: '1' }))
40
75
  })
41
76
 
42
- it('query', () => {
77
+ it('search', () => {
43
78
  const route0 = Route0.create('/prefix&y&z')
44
- expectTypeOf<(typeof route0)['queryDefinition']>().toEqualTypeOf<{ y: true; z: true }>()
45
- const path = route0.get({ query: { y: '1', z: '2' } })
79
+ expectTypeOf<(typeof route0)['searchDefinition']>().toEqualTypeOf<{ y: true; z: true }>()
80
+ const path = route0.get({ search: { y: '1', z: '2' } })
46
81
  expectTypeOf<typeof path>().toEqualTypeOf<`/prefix?${string}`>()
47
82
  expect(path).toBe('/prefix?y=1&z=2')
83
+ expect(path).toBe(route0.flat({ y: '1', z: '2' }))
48
84
  })
49
85
 
50
- it('params and query', () => {
86
+ it('params and search', () => {
51
87
  const route0 = Route0.create('/prefix/:x/some/:y/:z&z&c')
52
- const path = route0.get({ x: '1', y: '2', z: '3', query: { z: '4', c: '5' } })
88
+ const path = route0.get({ x: '1', y: '2', z: '3', search: { z: '4', c: '5' } })
53
89
  expectTypeOf<typeof path>().toEqualTypeOf<`/prefix/${string}/some/${string}/${string}?${string}`>()
54
90
  expect(path).toBe('/prefix/1/some/2/3?z=4&c=5')
91
+ expect(route0.flat({ x: '1', y: '2', z: '4', c: '5' })).toBe('/prefix/1/some/2/4?z=4&c=5')
55
92
  })
56
93
 
57
- it('params and query and any query', () => {
94
+ it('params and search and any search', () => {
58
95
  const route0 = Route0.create('/prefix/:x/some/:y/:z&z&c')
59
- const path = route0.get({ x: '1', y: '2', z: '3', query: { z: '4', c: '5', o: '6' } })
96
+ const path = route0.get({ x: '1', y: '2', z: '3', search: { z: '4', c: '5', o: '6' } })
60
97
  expectTypeOf<typeof path>().toEqualTypeOf<`/prefix/${string}/some/${string}/${string}?${string}`>()
61
98
  expect(path).toBe('/prefix/1/some/2/3?z=4&c=5&o=6')
99
+ // very strange case
100
+ expect(route0.flat({ x: '1', y: '2', z: '4', c: '5', o: '6' })).toBe('/prefix/1/some/2/4?z=4&c=5&o=6')
62
101
  })
63
102
 
64
103
  it('simple extend', () => {
@@ -67,15 +106,19 @@ describe('route0', () => {
67
106
  const path = route1.get()
68
107
  expectTypeOf<typeof path>().toEqualTypeOf<`/prefix/suffix`>()
69
108
  expect(path).toBe('/prefix/suffix')
109
+ expect(path).toBe(route1.flat())
70
110
  })
71
111
 
72
112
  it('simple extend double slash', () => {
73
113
  const route0 = Route0.create('/')
114
+ expect(route0.get()).toBe('/')
74
115
  const route1 = route0.extend('/suffix1/')
116
+ expect(route1.get()).toBe('/suffix1/')
75
117
  const route2 = route1.extend('/suffix2')
76
118
  const path = route2.get()
77
119
  expectTypeOf<typeof path>().toEqualTypeOf<`/suffix1/suffix2`>()
78
120
  expect(path).toBe('/suffix1/suffix2')
121
+ expect(path).toBe(route2.flat())
79
122
  })
80
123
 
81
124
  it('simple extend no slash', () => {
@@ -85,6 +128,37 @@ describe('route0', () => {
85
128
  const path = route2.get()
86
129
  expectTypeOf<typeof path>().toEqualTypeOf<`/suffix1/suffix2`>()
87
130
  expect(path).toBe('/suffix1/suffix2')
131
+ expect(path).toBe(route2.flat())
132
+ })
133
+
134
+ it('simple extend no slash chaos', () => {
135
+ const route0 = Route0.create('/')
136
+ expectTypeOf<(typeof route0)['definition']>().toEqualTypeOf<'/'>()
137
+ expect(route0.get()).toBe('/')
138
+
139
+ const route1 = Route0.create('')
140
+ expectTypeOf<(typeof route1)['definition']>().toEqualTypeOf<''>()
141
+ expect(route1.get()).toBe('')
142
+
143
+ const route2 = route0.extend('/')
144
+ expectTypeOf<(typeof route2)['definition']>().toEqualTypeOf<'/'>()
145
+ expect(route2.get()).toBe('/')
146
+
147
+ const route3 = route1.extend('/')
148
+ expectTypeOf<(typeof route3)['definition']>().toEqualTypeOf<'/'>()
149
+ expect(route3.get()).toBe('/')
150
+
151
+ const route4 = route0.extend('path/')
152
+ expectTypeOf<(typeof route4)['definition']>().toEqualTypeOf<'/path/'>()
153
+ expect(route4.get()).toBe('/path/')
154
+
155
+ const route5 = route1.extend('/path/')
156
+ expectTypeOf<(typeof route5)['definition']>().toEqualTypeOf<'/path/'>()
157
+ expect(route5.get()).toBe('/path/')
158
+
159
+ const route6 = route1.extend('path')
160
+ expectTypeOf<(typeof route6)['definition']>().toEqualTypeOf<'/path'>()
161
+ expect(route6.get()).toBe('/path')
88
162
  })
89
163
 
90
164
  it('extend with params', () => {
@@ -93,13 +167,14 @@ describe('route0', () => {
93
167
  const path = route1.get({ x: '1', y: '2' })
94
168
  expectTypeOf<typeof path>().toEqualTypeOf<`/prefix/${string}/suffix/${string}`>()
95
169
  expect(path).toBe('/prefix/1/suffix/2')
170
+ expect(path).toBe(route1.flat({ x: '1', y: '2' }))
96
171
  })
97
172
 
98
173
  it('extend with search params', () => {
99
174
  const route0 = Route0.create('/prefix&y&z')
100
175
  const route1 = route0.extend('/suffix&z&c')
101
- const path = route1.get({ query: { y: '2', c: '3', a: '4' } })
102
- expectTypeOf<(typeof route1)['queryDefinition']>().toEqualTypeOf<{
176
+ const path = route1.get({ search: { y: '2', c: '3', a: '4' } })
177
+ expectTypeOf<(typeof route1)['searchDefinition']>().toEqualTypeOf<{
103
178
  z: true
104
179
  c: true
105
180
  }>()
@@ -108,13 +183,14 @@ describe('route0', () => {
108
183
  const path1 = route1.get()
109
184
  expectTypeOf<typeof path1>().toEqualTypeOf<`/prefix/suffix`>()
110
185
  expect(path1).toBe('/prefix/suffix')
186
+ expect(path1).toBe(route1.flat())
111
187
  })
112
188
 
113
- it('extend with params and query', () => {
189
+ it('extend with params and search', () => {
114
190
  const route0 = Route0.create('/prefix/:id&y&z')
115
191
  const route1 = route0.extend('/:sn/suffix&z&c')
116
- const path = route1.get({ id: 'myid', sn: 'mysn', query: { y: '2', c: '3', a: '4' } })
117
- expectTypeOf<(typeof route1)['queryDefinition']>().toEqualTypeOf<{
192
+ const path = route1.get({ id: 'myid', sn: 'mysn', search: { y: '2', c: '3', a: '4' } })
193
+ expectTypeOf<(typeof route1)['searchDefinition']>().toEqualTypeOf<{
118
194
  z: true
119
195
  c: true
120
196
  }>()
@@ -123,13 +199,14 @@ describe('route0', () => {
123
199
  const path1 = route1.get({ id: 'myid', sn: 'mysn' })
124
200
  expectTypeOf<typeof path1>().toEqualTypeOf<`/prefix/${string}/${string}/suffix`>()
125
201
  expect(path1).toBe('/prefix/myid/mysn/suffix')
202
+ expect(path1).toBe(route1.flat({ id: 'myid', sn: 'mysn' }))
126
203
  })
127
204
 
128
- it('extend with params and query, callable', () => {
205
+ it('extend with params and search, callable', () => {
129
206
  const route0 = Route0.create('/prefix/:id&y&z')
130
207
  const route1 = route0.extend('/:sn/suffix&z&c')
131
- const path = route1({ id: 'myid', sn: 'mysn', query: { y: '2', c: '3', a: '4' } })
132
- expectTypeOf<(typeof route1)['queryDefinition']>().toEqualTypeOf<{
208
+ const path = route1({ id: 'myid', sn: 'mysn', search: { y: '2', c: '3', a: '4' } })
209
+ expectTypeOf<(typeof route1)['searchDefinition']>().toEqualTypeOf<{
133
210
  z: true
134
211
  c: true
135
212
  }>()
@@ -138,6 +215,7 @@ describe('route0', () => {
138
215
  const path1 = route1({ id: 'myid', sn: 'mysn' })
139
216
  expectTypeOf<typeof path1>().toEqualTypeOf<`/prefix/${string}/${string}/suffix`>()
140
217
  expect(path1).toBe('/prefix/myid/mysn/suffix')
218
+ expect(path1).toBe(route1.flat({ id: 'myid', sn: 'mysn' }))
141
219
  })
142
220
 
143
221
  it('abs default', () => {
@@ -145,6 +223,7 @@ describe('route0', () => {
145
223
  const path = route0.get({ abs: true })
146
224
  expectTypeOf<typeof path>().toEqualTypeOf<`${string}/path`>()
147
225
  expect(path).toBe('https://example.com/path')
226
+ expect(path).toBe(route0.flat({}, true))
148
227
  })
149
228
 
150
229
  it('abs set', () => {
@@ -152,6 +231,7 @@ describe('route0', () => {
152
231
  const path = route0.get({ abs: true })
153
232
  expectTypeOf<typeof path>().toEqualTypeOf<`${string}/path`>()
154
233
  expect(path).toBe('https://x.com/path')
234
+ expect(path).toBe(route0.flat({}, true))
155
235
  })
156
236
 
157
237
  it('abs override', () => {
@@ -160,6 +240,7 @@ describe('route0', () => {
160
240
  const path = route0.get({ abs: true })
161
241
  expectTypeOf<typeof path>().toEqualTypeOf<`${string}/path`>()
162
242
  expect(path).toBe('https://y.com/path')
243
+ expect(path).toBe(route0.flat({}, true))
163
244
  })
164
245
 
165
246
  it('abs override extend', () => {
@@ -169,38 +250,1620 @@ describe('route0', () => {
169
250
  const path = route1.get({ abs: true })
170
251
  expectTypeOf<typeof path>().toEqualTypeOf<`${string}/path/suffix`>()
171
252
  expect(path).toBe('https://y.com/path/suffix')
253
+ expect(path).toBe(route1.flat({}, true))
172
254
  })
173
255
 
174
- it('abs override many', () => {
175
- const route0 = Route0.create('/path', { baseUrl: 'https://x.com' })
176
- const route1 = route0.extend('/suffix')
177
- const routes = {
178
- r0: route0,
179
- r1: route1,
180
- }
181
- const routes2 = Route0.overrideMany(routes, { baseUrl: 'https://z.com' })
182
- const path = routes2.r1.get({ abs: true })
183
- expectTypeOf<typeof path>().toEqualTypeOf<`${string}/path/suffix`>()
184
- expect(path).toBe('https://z.com/path/suffix')
185
- })
256
+ // it('abs override many', () => {
257
+ // const route0 = Route0.create('/path', { baseUrl: 'https://x.com' })
258
+ // const route1 = route0.extend('/suffix')
259
+ // const routes = {
260
+ // r0: route0,
261
+ // r1: route1,
262
+ // }
263
+ // const routes2 = Route0._.overrideMany(routes, { baseUrl: 'https://z.com' })
264
+ // const path = routes2.r1.get({ abs: true })
265
+ // expectTypeOf<typeof path>().toEqualTypeOf<`${string}/path/suffix`>()
266
+ // expect(path).toBe('https://z.com/path/suffix')
267
+ // })
186
268
 
187
269
  it('type errors: require params when defined', () => {
188
270
  const rWith = Route0.create('/a/:id')
189
271
  // @ts-expect-error missing required path params
190
272
  expect(rWith.get()).toBe('/a/undefined')
273
+ // @ts-expect-error missing required path params
274
+ expect(rWith.flat()).toBe('/a/undefined')
191
275
 
192
276
  // @ts-expect-error missing required path params
193
277
  expect(rWith.get({})).toBe('/a/undefined')
278
+ // @ts-expect-error missing required path params
279
+ expect(rWith.flat({})).toBe('/a/undefined')
194
280
  // @ts-expect-error missing required path params (object form abs)
195
281
  expect(rWith.get({ abs: true })).toBe('https://example.com/a/undefined')
196
- // @ts-expect-error missing required path params (object form query)
197
- expect(rWith.get({ query: { q: '1' } })).toBe('/a/undefined?q=1')
282
+ // @ts-expect-error missing required path params (object form abs)
283
+ expect(rWith.flat({}, true)).toBe('https://example.com/a/undefined')
284
+ // @ts-expect-error missing required path params (object form search)
285
+ expect(rWith.get({ search: { q: '1' } })).toBe('/a/undefined?q=1')
286
+ // @ts-expect-error missing required path params (object form search)
287
+ expect(rWith.flat({ q: '1' })).toBe('/a/undefined?q=1')
198
288
 
199
289
  // @ts-expect-error params can not be sent as object value it should be argument
200
290
  rWith.get({ params: { id: '1' } }) // not throw becouse this will not used
291
+ expect(rWith.flat({ id: '1' })).toBe('/a/1')
201
292
 
202
293
  const rNo = Route0.create('/b')
203
294
  // @ts-expect-error no path params allowed for this route (shorthand)
204
295
  expect(rNo.get({ id: '1' })).toBe('/b')
296
+ expect(rNo.flat({ id: '1' })).toBe('/b?id=1')
297
+ })
298
+
299
+ it('really any route assignable to AnyRoute', () => {
300
+ expectTypeOf<Route0<string>>().toExtend<AnyRoute>()
301
+ expectTypeOf<Route0<string>>().toExtend<AnyRouteOrDefinition>()
302
+ expectTypeOf<Route0<'/path'>>().toExtend<AnyRoute>()
303
+ expectTypeOf<Route0<'/path'>>().toExtend<AnyRouteOrDefinition>()
304
+ expectTypeOf<Route0<'/path/:id'>>().toExtend<AnyRoute>()
305
+ expectTypeOf<Route0<'/path/:id'>>().toExtend<AnyRouteOrDefinition>()
306
+ expectTypeOf<Route0<'/path/:id&x'>>().toExtend<AnyRoute>()
307
+ expectTypeOf<CallabelRoute<'/path'>>().toExtend<AnyRouteOrDefinition>()
308
+ expectTypeOf<CallabelRoute<'/path'>>().toExtend<AnyRoute>()
309
+ expectTypeOf<CallabelRoute<'/path'>>().toExtend<AnyRouteOrDefinition>()
310
+ expectTypeOf<CallabelRoute<'/path/:id'>>().toExtend<AnyRoute>()
311
+ expectTypeOf<CallabelRoute<'/path/:id'>>().toExtend<AnyRouteOrDefinition>()
312
+ expectTypeOf<CallabelRoute<'/path/:id&x'>>().toExtend<AnyRoute>()
313
+ expectTypeOf<CallabelRoute<'/path/:id&x'>>().toExtend<AnyRouteOrDefinition>()
314
+ expectTypeOf<CallabelRoute>().toExtend<AnyRoute>()
315
+ expectTypeOf<CallabelRoute>().toExtend<AnyRouteOrDefinition>()
316
+
317
+ const route = Route0.create('/path')
318
+ expectTypeOf<typeof route>().toExtend<AnyRoute>()
319
+ expectTypeOf<typeof route>().toExtend<AnyRouteOrDefinition>()
320
+
321
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
322
+ const route2 = route.extend('/path2')
323
+ expectTypeOf<typeof route2>().toExtend<AnyRoute>()
324
+ expectTypeOf<typeof route2>().toExtend<AnyRouteOrDefinition>()
325
+
326
+ // Test that specific CallabelRoute with literal path IS assignable to AnyRouteOrDefinition
327
+ expectTypeOf<CallabelRoute<'/ideas/best'>>().toExtend<AnyRouteOrDefinition>()
328
+
329
+ // Test actual function parameter assignment scenario
330
+ const testFn = (_route: AnyRouteOrDefinition) => {
331
+ // intentionally empty
332
+ }
333
+ const callableRoute = Route0.create('/ideas/best')
334
+ testFn(callableRoute) // This should work
335
+
336
+ // Test with params
337
+ const routeWithParams = Route0.create('/ideas/:id')
338
+ testFn(routeWithParams) // This should also work
339
+ })
340
+
341
+ it('clone, from, create', () => {
342
+ const route = Route0.create('/path')
343
+ const clonedRoute = route.clone()
344
+ expect(clonedRoute.get()).toBe('/path')
345
+ expect(clonedRoute).not.toBe(route)
346
+ expect(clonedRoute.definition).toBe(route.definition)
347
+
348
+ const createdRoute = Route0.create(route)
349
+ expect(createdRoute.get()).toBe('/path')
350
+ expect(createdRoute).not.toBe(route)
351
+ expect(createdRoute.definition).toBe(route.definition)
352
+
353
+ const fromRoute = Route0.from(route)
354
+ expect(fromRoute.get()).toBe('/path')
355
+ expect(fromRoute).toBe(route)
356
+ expect(fromRoute.definition).toBe(route.definition)
357
+ })
358
+
359
+ it('x', () => {
360
+ const a = Route0.create('/')
361
+ const b = a.extend('/b')
362
+ const c = b.extend('/:c')
363
+ const d = c.extend('/x')
364
+ expect(Route0.from(a.definition).definition).toBe('/')
365
+ expect(Route0.from(b.definition).definition).toBe('/b')
366
+ expect(Route0.from(c.definition).definition).toBe('/b/:c')
367
+ expect(Route0.from(d.definition).definition).toBe('/b/:c/x')
368
+ expect(Route0.from('/').definition).toBe('/')
369
+ expect(Route0.from('/b').definition).toBe('/b')
370
+ expect(Route0.from('/b/:c').definition).toBe('/b/:c')
371
+ expect(Route0.from('/b/:c/x').definition).toBe('/b/:c/x')
372
+ })
373
+ })
374
+
375
+ describe('type utilities', () => {
376
+ it('HasParams', () => {
377
+ expectTypeOf<HasParams<'/path'>>().toEqualTypeOf<false>()
378
+ expectTypeOf<HasParams<'/path/:id'>>().toEqualTypeOf<true>()
379
+ expectTypeOf<HasParams<'/path/:id/:name'>>().toEqualTypeOf<true>()
380
+
381
+ expectTypeOf<HasParams<Route0<'/path'>>>().toEqualTypeOf<false>()
382
+ expectTypeOf<HasParams<Route0<'/path/:id'>>>().toEqualTypeOf<true>()
383
+ })
384
+
385
+ it('HasSearch', () => {
386
+ expectTypeOf<HasSearch<'/path'>>().toEqualTypeOf<false>()
387
+ expectTypeOf<HasSearch<'/path&x'>>().toEqualTypeOf<true>()
388
+ expectTypeOf<HasSearch<'/path&x&y'>>().toEqualTypeOf<true>()
389
+
390
+ expectTypeOf<HasSearch<Route0<'/path'>>>().toEqualTypeOf<false>()
391
+ expectTypeOf<HasSearch<Route0<'/path&x&y'>>>().toEqualTypeOf<true>()
392
+ })
393
+
394
+ it('ParamsInput', () => {
395
+ expectTypeOf<ParamsInput<'/path'>>().toEqualTypeOf<Record<never, never>>()
396
+ expectTypeOf<ParamsInput<'/path/:id'>>().toEqualTypeOf<{ id: string | number }>()
397
+ expectTypeOf<ParamsInput<'/path/:id/:name'>>().toEqualTypeOf<{ id: string | number; name: string | number }>()
398
+
399
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
400
+ const route = Route0.create('/path/:id/:name')
401
+ expectTypeOf<ParamsInput<typeof route>>().toEqualTypeOf<{ id: string | number; name: string | number }>()
402
+ })
403
+
404
+ it('ParamsOutput', () => {
405
+ expectTypeOf<ParamsOutput<'/path/:id'>>().toEqualTypeOf<{ id: string }>()
406
+ expectTypeOf<ParamsOutput<'/path/:id/:name'>>().toEqualTypeOf<{ id: string; name: string }>()
407
+
408
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
409
+ const route = Route0.create('/path/:id/:name')
410
+ expectTypeOf<ParamsOutput<typeof route>>().toEqualTypeOf<{ id: string; name: string }>()
411
+ })
412
+
413
+ it('SearchInput', () => {
414
+ type T1 = SearchInput<'/path'>
415
+ expectTypeOf<T1>().toEqualTypeOf<Record<string, string | number>>()
416
+
417
+ type T2 = SearchInput<'/path&x&y'>
418
+ expectTypeOf<T2>().toEqualTypeOf<
419
+ Partial<{
420
+ x: string | number
421
+ y: string | number
422
+ }> &
423
+ Record<string, string | number>
424
+ >()
425
+ })
426
+
427
+ it('SearchOutput', () => {
428
+ type T1 = SearchOutput<'/path'>
429
+ expectTypeOf<T1>().toEqualTypeOf<{
430
+ [key: string]: string | undefined
431
+ }>()
432
+
433
+ type T2 = SearchOutput<'/path&x&y'>
434
+ expectTypeOf<T2>().toEqualTypeOf<{
435
+ [key: string]: string | undefined
436
+ x?: string | undefined
437
+ y?: string | undefined
438
+ }>()
439
+ })
440
+
441
+ it('StrictSearchInput', () => {
442
+ type T1 = StrictSearchInput<'/path&x&y'>
443
+ expectTypeOf<T1>().toEqualTypeOf<{ x?: string | number; y?: string | number }>()
444
+ })
445
+
446
+ it('StrictSearchOutput', () => {
447
+ type T1 = StrictSearchOutput<'/path&x&y'>
448
+ expectTypeOf<T1>().toEqualTypeOf<{ x?: string | undefined; y?: string | undefined }>()
449
+ })
450
+
451
+ it('FlatInput', () => {
452
+ type T1 = FlatInput<'/path&x&y'>
453
+ expectTypeOf<T1>().toEqualTypeOf<
454
+ Partial<{
455
+ x: string | number
456
+ y: string | number
457
+ }> &
458
+ Record<string, string | number>
459
+ >()
460
+
461
+ type T2 = FlatInput<'/path/:id&x&y'>
462
+ expectTypeOf<T2>().toEqualTypeOf<
463
+ {
464
+ id: string | number
465
+ } & Partial<{
466
+ x: string | number
467
+ y: string | number
468
+ }> &
469
+ Record<string, string | number>
470
+ >()
471
+ })
472
+ it('StrictFlatInput', () => {
473
+ type T1 = StrictFlatInput<'/path&x&y'>
474
+ expectTypeOf<T1>().toEqualTypeOf<{ x?: string | number; y?: string | number }>()
475
+ type T2 = StrictFlatInput<'/path/:id&x&y'>
476
+ expectTypeOf<T2>().toEqualTypeOf<
477
+ Partial<{
478
+ x: string | number
479
+ y: string | number
480
+ }> & {
481
+ id: string | number
482
+ }
483
+ >()
484
+ })
485
+
486
+ it('FlatOutput', () => {
487
+ type T1 = FlatOutput<'/path&x&y'>
488
+ expectTypeOf<T1>().toEqualTypeOf<{
489
+ [x: string]: string | undefined
490
+ x?: string | undefined
491
+ y?: string | undefined
492
+ }>()
493
+
494
+ type T2 = FlatOutput<'/path/:id&x&y'>
495
+ expectTypeOf<T2>().toEqualTypeOf<
496
+ {
497
+ id: string
498
+ } & {
499
+ [x: string]: string | undefined
500
+ x?: string | undefined
501
+ y?: string | undefined
502
+ }
503
+ >()
504
+ })
505
+ it('StrictFlatOutput', () => {
506
+ type T1 = StrictFlatOutput<'/path&x&y'>
507
+ expectTypeOf<T1>().toEqualTypeOf<{ x?: string | undefined; y?: string | undefined }>()
508
+ type T2 = StrictFlatOutput<'/path/:id&x&y'>
509
+ expectTypeOf<T2>().toEqualTypeOf<
510
+ { id: string } & Partial<{
511
+ x?: string | undefined
512
+ y?: string | undefined
513
+ }>
514
+ >()
515
+ })
516
+
517
+ it('ParamsInputStringOnly', () => {
518
+ expectTypeOf<ParamsInputStringOnly<'/path'>>().toEqualTypeOf<Record<never, never>>()
519
+ expectTypeOf<ParamsInputStringOnly<'/path/:id'>>().toEqualTypeOf<{ id: string }>()
520
+ expectTypeOf<ParamsInputStringOnly<'/path/:id/:name'>>().toEqualTypeOf<{ id: string; name: string }>()
521
+
522
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
523
+ const route = Route0.create('/path/:id/:name')
524
+ expectTypeOf<ParamsInputStringOnly<typeof route>>().toEqualTypeOf<{ id: string; name: string }>()
525
+ })
526
+
527
+ it('SearchInputStringOnly', () => {
528
+ type T1 = SearchInputStringOnly<'/path'>
529
+ expectTypeOf<T1>().toEqualTypeOf<Record<string, string>>()
530
+
531
+ type T2 = SearchInputStringOnly<'/path&x&y'>
532
+ expectTypeOf<T2>().toEqualTypeOf<
533
+ Partial<{
534
+ x: string
535
+ y: string
536
+ }> &
537
+ Record<string, string>
538
+ >()
539
+ })
540
+
541
+ it('StrictSearchInputStringOnly', () => {
542
+ type T1 = StrictSearchInputStringOnly<'/path&x&y'>
543
+ expectTypeOf<T1>().toEqualTypeOf<{ x?: string; y?: string }>()
544
+ })
545
+
546
+ it('FlatInputStringOnly', () => {
547
+ type T1 = FlatInputStringOnly<'/path&x&y'>
548
+ expectTypeOf<T1>().toEqualTypeOf<
549
+ Partial<{
550
+ x: string
551
+ y: string
552
+ }> &
553
+ Record<string, string>
554
+ >()
555
+
556
+ type T2 = FlatInputStringOnly<'/path/:id&x&y'>
557
+ expectTypeOf<T2>().toEqualTypeOf<
558
+ {
559
+ id: string
560
+ } & Partial<{
561
+ x: string
562
+ y: string
563
+ }> &
564
+ Record<string, string>
565
+ >()
566
+ })
567
+ it('StrictFlatInputStringOnly', () => {
568
+ type T1 = StrictFlatInputStringOnly<'/path&x&y'>
569
+ expectTypeOf<T1>().toEqualTypeOf<{ x?: string; y?: string }>()
570
+ type T2 = StrictFlatInputStringOnly<'/path/:id&x&y'>
571
+ expectTypeOf<T2>().toEqualTypeOf<
572
+ Partial<{
573
+ x: string
574
+ y: string
575
+ }> & {
576
+ id: string
577
+ }
578
+ >()
579
+ })
580
+
581
+ it('CanInputBeEmpty', () => {
582
+ type T1 = CanInputBeEmpty<'/path'>
583
+ expectTypeOf<T1>().toEqualTypeOf<true>()
584
+ type T2 = CanInputBeEmpty<'/path/:id'>
585
+ expectTypeOf<T2>().toEqualTypeOf<false>()
586
+ type T3 = CanInputBeEmpty<'/path&x&y'>
587
+ expectTypeOf<T3>().toEqualTypeOf<true>()
588
+ type T4 = CanInputBeEmpty<'/path/:id&x&y'>
589
+ expectTypeOf<T4>().toEqualTypeOf<false>()
590
+ })
591
+
592
+ it('IsParent', () => {
593
+ type T1 = IsParent<'/path/child', '/path'>
594
+ type T2 = IsParent<'/path', '/path/child'>
595
+ type T3 = IsParent<'/other', '/path'>
596
+ type T4 = IsParent<'/path', '/path'>
597
+ expectTypeOf<T1>().toEqualTypeOf<true>()
598
+ expectTypeOf<T2>().toEqualTypeOf<false>()
599
+ expectTypeOf<T3>().toEqualTypeOf<false>()
600
+ expectTypeOf<T4>().toEqualTypeOf<false>()
601
+ })
602
+
603
+ it('IsChildren', () => {
604
+ type T1 = IsChildren<'/path', '/path/child'>
605
+ type T2 = IsChildren<'/path/child', '/path'>
606
+ type T3 = IsChildren<'/path', '/other'>
607
+ type T4 = IsChildren<'/path', '/path'>
608
+ expectTypeOf<T1>().toEqualTypeOf<true>()
609
+ expectTypeOf<T2>().toEqualTypeOf<false>()
610
+ expectTypeOf<T3>().toEqualTypeOf<false>()
611
+ expectTypeOf<T4>().toEqualTypeOf<false>()
612
+ })
613
+
614
+ it('IsSame', () => {
615
+ type T1 = IsSame<'/path', '/path'>
616
+ type T2 = IsSame<'/path', '/path/child'>
617
+ type T3 = IsSame<'/path/child', '/path'>
618
+ expectTypeOf<T1>().toEqualTypeOf<true>()
619
+ expectTypeOf<T2>().toEqualTypeOf<false>()
620
+ expectTypeOf<T3>().toEqualTypeOf<false>()
621
+ })
622
+
623
+ it('IsSameParams', () => {
624
+ type T1 = IsSameParams<'/path', '/other'>
625
+ type T2 = IsSameParams<'/path/:id', '/other/:id'>
626
+ type T3 = IsSameParams<'/path/:id', '/other'>
627
+ type T4 = IsSameParams<'/path/:id', '/other/:name'>
628
+ expectTypeOf<T1>().toEqualTypeOf<true>()
629
+ expectTypeOf<T2>().toEqualTypeOf<true>()
630
+ expectTypeOf<T3>().toEqualTypeOf<false>()
631
+ expectTypeOf<T4>().toEqualTypeOf<false>()
632
+ })
633
+
634
+ it('Extended', () => {
635
+ expectTypeOf<Extended<'/path', '/child'>>().toEqualTypeOf<Route0<'/path/child'>>()
636
+ expectTypeOf<Extended<'/path', '/:id'>>().toEqualTypeOf<Route0<'/path/:id'>>()
637
+ expectTypeOf<Extended<'/path', '&x&y'>>().toEqualTypeOf<Route0<'/path&x&y'>>()
638
+ expectTypeOf<Extended<'/path/:id', '/child&x'>>().toEqualTypeOf<Route0<'/path/:id/child&x'>>()
639
+ expectTypeOf<Extended<undefined, '/path'>>().toEqualTypeOf<Route0<'/path'>>()
640
+
641
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
642
+ const parent = Route0.create('/path')
643
+ expectTypeOf<Extended<typeof parent, '/child'>>().toEqualTypeOf<Route0<'/path/child'>>()
644
+ })
645
+ })
646
+
647
+ describe('getLocation', () => {
648
+ describe('Route0', () => {
649
+ it('.getLocation location of url', () => {
650
+ let loc = Route0.getLocation('/prefix/some/suffix')
651
+ expect(loc).toMatchObject({
652
+ hash: '',
653
+ href: undefined,
654
+ hrefRel: '/prefix/some/suffix',
655
+ abs: false,
656
+ origin: undefined,
657
+ params: undefined,
658
+ pathname: '/prefix/some/suffix',
659
+ searchParams: {},
660
+ search: '',
661
+ })
662
+ loc = Route0.getLocation('/prefix/some/suffix?x=1&z=2')
663
+ expect(loc).toMatchObject({
664
+ hash: '',
665
+ href: undefined,
666
+ hrefRel: '/prefix/some/suffix?x=1&z=2',
667
+ abs: false,
668
+ origin: undefined,
669
+ params: undefined,
670
+ pathname: '/prefix/some/suffix',
671
+ searchParams: { x: '1', z: '2' },
672
+ search: '?x=1&z=2',
673
+ })
674
+ loc = Route0.getLocation('https://example.com/prefix/some/suffix?x=1&z=2')
675
+ expect(loc).toMatchObject({
676
+ hash: '',
677
+ href: 'https://example.com/prefix/some/suffix?x=1&z=2',
678
+ hrefRel: '/prefix/some/suffix?x=1&z=2',
679
+ abs: true,
680
+ origin: 'https://example.com',
681
+ params: undefined,
682
+ pathname: '/prefix/some/suffix',
683
+ searchParams: { x: '1', z: '2' },
684
+ search: '?x=1&z=2',
685
+ })
686
+ })
687
+
688
+ it('.toRelLocation', () => {
689
+ const loc = Route0.toRelLocation(Route0.getLocation('https://example.com/prefix/some/suffix?x=1&z=2'))
690
+ expect(loc).toMatchObject({
691
+ hash: '',
692
+ href: undefined,
693
+ hrefRel: '/prefix/some/suffix?x=1&z=2',
694
+ abs: false,
695
+ origin: undefined,
696
+ params: undefined,
697
+ pathname: '/prefix/some/suffix',
698
+ searchParams: { x: '1', z: '2' },
699
+ search: '?x=1&z=2',
700
+ })
701
+ const sameLoc = Route0.toRelLocation(loc)
702
+ expect(sameLoc).toMatchObject(loc)
703
+ })
704
+
705
+ it('.toAbsLocation', () => {
706
+ const loc = Route0.toRelLocation(Route0.getLocation('https://example.com/prefix/some/suffix?x=1&z=2'))
707
+ const absLoc = Route0.toAbsLocation(loc, 'https://example2.com')
708
+ expect(absLoc).toMatchObject({
709
+ hash: '',
710
+ href: 'https://example2.com/prefix/some/suffix?x=1&z=2',
711
+ hrefRel: '/prefix/some/suffix?x=1&z=2',
712
+ abs: true,
713
+ origin: 'https://example2.com',
714
+ params: undefined,
715
+ pathname: '/prefix/some/suffix',
716
+ searchParams: { x: '1', z: '2' },
717
+ search: '?x=1&z=2',
718
+ })
719
+ const sameLoc = Route0.toRelLocation(loc)
720
+ expect(sameLoc).toMatchObject(loc)
721
+ })
722
+
723
+ it('#getLocation() exact match', () => {
724
+ const route0 = Route0.create('/prefix/:x/some/:y/:z/suffix')
725
+ let loc = route0.getLocation('/prefix/some/suffix')
726
+ expect(loc.exact).toBe(false)
727
+ expect(loc.parent).toBe(false)
728
+ expect(loc.children).toBe(false)
729
+ expect(loc.params).toMatchObject({})
730
+ loc = route0.getLocation('/prefix/xxx/some/yyy/zzz/suffix')
731
+ expect(loc.exact).toBe(true)
732
+ expect(loc.parent).toBe(false)
733
+ expect(loc.children).toBe(false)
734
+ if (loc.exact) {
735
+ expectTypeOf<typeof loc.params>().toEqualTypeOf<{ x: string; y: string; z: string }>()
736
+ }
737
+ expect(loc.params).toMatchObject({ x: 'xxx', y: 'yyy', z: 'zzz' })
738
+ })
739
+
740
+ it('#getLocation() parent match', () => {
741
+ expect(Route0.create('/prefix/xxx/some').getLocation('/prefix/xxx/some/extra/path')).toMatchObject({
742
+ exact: false,
743
+ parent: true,
744
+ children: false,
745
+ })
746
+ expect(Route0.create('/prefix/:x/some').getLocation('/prefix/xxx/some/extra/path')).toMatchObject({
747
+ exact: false,
748
+ parent: true,
749
+ children: false,
750
+ })
751
+ expect(Route0.create('/:y/:x/some').getLocation('/prefix/xxx/some/extra/path')).toMatchObject({
752
+ exact: false,
753
+ parent: true,
754
+ children: false,
755
+ })
756
+ })
757
+
758
+ it('#getLocation() children match', () => {
759
+ expect(Route0.create('/prefix/some/extra/path').getLocation('/prefix/some')).toMatchObject({
760
+ exact: false,
761
+ parent: false,
762
+ children: true,
763
+ })
764
+ expect(Route0.create('/prefix/some/extra/:id').getLocation('/prefix/some')).toMatchObject({
765
+ exact: false,
766
+ parent: false,
767
+ children: true,
768
+ })
769
+ expect(Route0.create('/:prefix/some/extra/:id').getLocation('/prefix/some')).toMatchObject({
770
+ exact: false,
771
+ parent: false,
772
+ children: true,
773
+ })
774
+ })
775
+
776
+ it('#getLocation() with host info', () => {
777
+ const route0 = Route0.create('/path')
778
+ const loc = route0.getLocation('https://example.com:8080/path')
779
+ expect(loc.exact).toBe(true)
780
+ expect(loc.origin).toBe('https://example.com:8080')
781
+ expect(loc.host).toBe('example.com:8080')
782
+ expect(loc.hostname).toBe('example.com')
783
+ expect(loc.port).toBe('8080')
784
+ })
785
+
786
+ it('#getLocation() with hash', () => {
787
+ const route0 = Route0.create('/path/:id')
788
+ const loc = route0.getLocation('/path/123#section')
789
+ expect(loc.exact).toBe(true)
790
+ expect(loc.hash).toBe('#section')
791
+ expect(loc.params).toMatchObject({ id: '123' })
792
+ })
793
+
794
+ it('.getLocation accepts URL instance (absolute)', () => {
795
+ const url = new URL('https://example.com/prefix/some/suffix?x=1&z=2#hash')
796
+ const loc = Route0.getLocation(url)
797
+ expect(loc).toMatchObject({
798
+ hash: '#hash',
799
+ href: 'https://example.com/prefix/some/suffix?x=1&z=2#hash',
800
+ hrefRel: '/prefix/some/suffix?x=1&z=2#hash',
801
+ abs: true,
802
+ origin: 'https://example.com',
803
+ pathname: '/prefix/some/suffix',
804
+ searchParams: { x: '1', z: '2' },
805
+ search: '?x=1&z=2',
806
+ })
807
+ })
808
+
809
+ it('.getLocation accepts URL instance (relative with base)', () => {
810
+ const url = new URL('/prefix/some/suffix?x=1&z=2', 'https://example.com')
811
+ const loc = Route0.getLocation(url)
812
+ expect(loc).toMatchObject({
813
+ hash: '',
814
+ href: 'https://example.com/prefix/some/suffix?x=1&z=2',
815
+ hrefRel: '/prefix/some/suffix?x=1&z=2',
816
+ abs: true,
817
+ origin: 'https://example.com',
818
+ pathname: '/prefix/some/suffix',
819
+ searchParams: { x: '1', z: '2' },
820
+ search: '?x=1&z=2',
821
+ })
822
+ })
823
+ })
824
+
825
+ describe('Routes', () => {
826
+ it('exact match returns ExactLocation', () => {
827
+ const routes = Routes.create({
828
+ home: '/',
829
+ users: '/users',
830
+ userDetail: '/users/:id',
831
+ })
832
+
833
+ const loc = routes._.getLocation('/users/123')
834
+ expect(loc.exact).toBe(true)
835
+ expect(loc.parent).toBe(false)
836
+ expect(loc.children).toBe(false)
837
+ expect(loc.pathname).toBe('/users/123')
838
+ expect(Route0.isSame(loc.route, routes.userDetail)).toBe(true)
839
+ if (loc.exact) {
840
+ expect(loc.params).toMatchObject({ id: '123' })
841
+ }
842
+ })
843
+
844
+ it('no exact match returns UnknownLocation (parent case)', () => {
845
+ const routes = Routes.create({
846
+ home: '/',
847
+ users: '/users',
848
+ userDetail: '/users/:id',
849
+ })
850
+
851
+ // '/users/123/posts' is not an exact match for any route
852
+ const loc = routes._.getLocation('/users/123/posts')
853
+ expect(loc.exact).toBe(false)
854
+ expect(loc.parent).toBe(false)
855
+ expect(loc.children).toBe(false)
856
+ expect(loc.pathname).toBe('/users/123/posts')
857
+ })
858
+
859
+ it('no exact match returns UnknownLocation (children case)', () => {
860
+ const routes = Routes.create({
861
+ home: '/',
862
+ users: '/users',
863
+ userDetail: '/users/:id/posts',
864
+ })
865
+
866
+ // '/users/123' is not an exact match for any route
867
+ const loc = routes._.getLocation('/users/123')
868
+ expect(loc.exact).toBe(false)
869
+ expect(loc.parent).toBe(false)
870
+ expect(loc.children).toBe(false)
871
+ expect(loc.pathname).toBe('/users/123')
872
+ })
873
+
874
+ it('no match returns UnknownLocation', () => {
875
+ const routes = Routes.create({
876
+ home: '/',
877
+ users: '/users',
878
+ })
879
+
880
+ const loc = routes._.getLocation('/posts/123')
881
+ expect(loc.exact).toBe(false)
882
+ expect(loc.parent).toBe(false)
883
+ expect(loc.children).toBe(false)
884
+ expect(loc.pathname).toBe('/posts/123')
885
+ expect(loc.params).toBeUndefined()
886
+ })
887
+
888
+ it('matches most specific route', () => {
889
+ const routes = Routes.create({
890
+ userDetail: '/users/:id',
891
+ userPosts: '/users/:id/posts',
892
+ users: '/users',
893
+ })
894
+
895
+ // Should match /users exactly
896
+ const loc1 = routes._.getLocation('/users')
897
+ expect(loc1.exact).toBe(true)
898
+ expect(loc1.pathname).toBe('/users')
899
+
900
+ // Should match /users/:id exactly
901
+ const loc2 = routes._.getLocation('/users/123')
902
+ expect(loc2.exact).toBe(true)
903
+ if (loc2.exact) {
904
+ expect(loc2.params).toMatchObject({ id: '123' })
905
+ }
906
+
907
+ // Should match /users/:id/posts exactly
908
+ const loc3 = routes._.getLocation('/users/123/posts')
909
+ expect(loc3.exact).toBe(true)
910
+ if (loc3.exact) {
911
+ expect(loc3.params).toMatchObject({ id: '123' })
912
+ }
913
+ })
914
+
915
+ it('with search params', () => {
916
+ const routes = Routes.create({
917
+ search: '/search&q&filter',
918
+ users: '/users',
919
+ })
920
+
921
+ const loc = routes._.getLocation('/search?q=test&filter=all')
922
+ expect(loc.exact).toBe(true)
923
+ expect(loc.pathname).toBe('/search')
924
+ expect(loc.search).toBe('?q=test&filter=all')
925
+ expect(loc.searchParams).toMatchObject({ q: 'test', filter: 'all' })
926
+ })
927
+
928
+ it('with absolute URL', () => {
929
+ const routes = Routes.create({
930
+ api: '/api/v1',
931
+ users: '/api/v1/users',
932
+ })
933
+
934
+ const loc = routes._.getLocation('https://example.com/api/v1/users')
935
+ expect(loc.exact).toBe(true)
936
+ expect(loc.abs).toBe(true)
937
+ expect(loc.origin).toBe('https://example.com')
938
+ expect(loc.pathname).toBe('/api/v1/users')
939
+ expect(loc.href).toBe('https://example.com/api/v1/users')
940
+ })
941
+
942
+ it('with hash', () => {
943
+ const routes = Routes.create({
944
+ userDetail: '/users/:id',
945
+ })
946
+
947
+ const loc = routes._.getLocation('/users/123#profile')
948
+ expect(loc.exact).toBe(true)
949
+ expect(loc.hash).toBe('#profile')
950
+ expect(loc.pathname).toBe('/users/123')
951
+ if (loc.exact) {
952
+ expect(loc.params).toMatchObject({ id: '123' })
953
+ }
954
+ })
955
+
956
+ it('with extended routes', () => {
957
+ const api = Route0.create('/api/v1')
958
+ const routes = Routes.create({
959
+ api,
960
+ users: api.extend('/users'),
961
+ userDetail: api.extend('/users/:id'),
962
+ })
963
+
964
+ const loc = routes._.getLocation('/api/v1/users/456')
965
+ expect(loc.exact).toBe(true)
966
+ if (loc.exact) {
967
+ expect(loc.params).toMatchObject({ id: '456' })
968
+ }
969
+ })
970
+
971
+ it('root route', () => {
972
+ const routes = Routes.create({
973
+ home: '/',
974
+ about: '/about',
975
+ })
976
+
977
+ const loc = routes._.getLocation('/')
978
+ expect(loc.exact).toBe(true)
979
+ expect(loc.pathname).toBe('/')
980
+ })
981
+
982
+ it('with AnyLocation object as input', () => {
983
+ const routes = Routes.create({
984
+ userDetail: '/users/:id',
985
+ })
986
+
987
+ const inputLoc = Route0.getLocation('/users/789')
988
+ const loc = routes._.getLocation(inputLoc)
989
+ expect(loc.exact).toBe(true)
990
+ if (loc.exact) {
991
+ expect(loc.params).toMatchObject({ id: '789' })
992
+ }
993
+ })
994
+
995
+ it('complex routing with params and search', () => {
996
+ const api = Route0.create('/api/v1')
997
+ const routes = Routes.create({
998
+ api,
999
+ users: api.extend('/users'),
1000
+ userDetail: api.extend('/users/:id'),
1001
+ userPosts: api.extend('/users/:id/posts&sort&filter'),
1002
+ })
1003
+
1004
+ const loc = routes._.getLocation('/api/v1/users/42/posts?sort=date&filter=published&extra=value')
1005
+ expect(loc.exact).toBe(true)
1006
+ expect(loc.pathname).toBe('/api/v1/users/42/posts')
1007
+ expect(loc.searchParams).toMatchObject({
1008
+ sort: 'date',
1009
+ filter: 'published',
1010
+ extra: 'value',
1011
+ })
1012
+ if (loc.exact) {
1013
+ expect(loc.params).toMatchObject({ id: '42' })
1014
+ }
1015
+ })
1016
+
1017
+ it('get location for extedned routes', () => {
1018
+ const a = Route0.create('/')
1019
+ const b = a.extend('/b')
1020
+ const c = b.extend('/:c')
1021
+ const d = c.extend('/x')
1022
+ const routes = Routes.create({
1023
+ a,
1024
+ b,
1025
+ c,
1026
+ d,
1027
+ })
1028
+
1029
+ const loc = routes._.getLocation('/b/test')
1030
+ expect(loc.exact).toBe(true)
1031
+ expect(loc.route).toBe('/b/:c')
1032
+ })
1033
+ })
1034
+ })
1035
+
1036
+ describe('Routes', () => {
1037
+ it('create with string routes', () => {
1038
+ const collection = Routes.create({
1039
+ home: '/',
1040
+ about: '/about',
1041
+ contact: '/contact',
1042
+ })
1043
+
1044
+ expect(collection).toBeInstanceOf(Routes)
1045
+ const home = collection.home
1046
+ const about = collection.about
1047
+ const contact = collection.contact
1048
+
1049
+ expect(home).toBeInstanceOf(Route0)
1050
+ expect(about).toBeInstanceOf(Route0)
1051
+ expect(contact).toBeInstanceOf(Route0)
1052
+
1053
+ expect(home.get()).toBe('/')
1054
+ expect(about.get()).toBe('/about')
1055
+ expect(contact.get()).toBe('/contact')
1056
+ })
1057
+
1058
+ it('create with Route0 instances', () => {
1059
+ const homeRoute = Route0.create('/')
1060
+ const aboutRoute = Route0.create('/about')
1061
+
1062
+ const collection = Routes.create({
1063
+ home: homeRoute,
1064
+ about: aboutRoute,
1065
+ })
1066
+
1067
+ expect(collection.home.get()).toBe('/')
1068
+ expect(collection.about.get()).toBe('/about')
1069
+ })
1070
+
1071
+ it('create with mixed string and Route0', () => {
1072
+ const aboutRoute = Route0.create('/about')
1073
+
1074
+ const collection = Routes.create({
1075
+ home: '/',
1076
+ about: aboutRoute,
1077
+ contact: '/contact',
1078
+ })
1079
+
1080
+ expect(collection.home.get()).toBe('/')
1081
+ expect(collection.about.get()).toBe('/about')
1082
+ expect(collection.contact.get()).toBe('/contact')
1083
+ })
1084
+
1085
+ it('create with params and search', () => {
1086
+ const collection = Routes.create({
1087
+ user: '/user/:id',
1088
+ search: '/search&q&filter',
1089
+ userWithSearch: '/user/:id&tab',
1090
+ })
1091
+
1092
+ const user = collection.user
1093
+ expect(user.get({ id: '123' } as any)).toBe('/user/123')
1094
+
1095
+ const search = collection.search
1096
+ expect(search.get({ search: { q: 'test', filter: 'all' } })).toBe('/search?q=test&filter=all')
1097
+
1098
+ const userWithSearch = collection.userWithSearch
1099
+ expect(userWithSearch.get({ id: '456', search: { tab: 'posts' } } as any)).toBe('/user/456?tab=posts')
1100
+ })
1101
+
1102
+ it('get maintains route definitions', () => {
1103
+ const collection = Routes.create({
1104
+ home: '/',
1105
+ user: '/user/:id',
1106
+ })
1107
+
1108
+ const home = collection.home
1109
+ const user = collection.user
1110
+
1111
+ // Verify route definitions are preserved
1112
+ expect(home.definition).toBe('/')
1113
+ expect(user.definition).toBe('/user/:id')
1114
+ expect(home.pathDefinition).toBe('/')
1115
+ expect(user.pathDefinition).toBe('/user/:id')
1116
+
1117
+ // Verify params work correctly
1118
+ expect(user.get({ id: '123' })).toBe('/user/123')
1119
+ })
1120
+
1121
+ it('override with baseUrl', () => {
1122
+ const collection = Routes.create({
1123
+ home: '/',
1124
+ about: '/about',
1125
+ })
1126
+
1127
+ const overridden = collection._.override({ baseUrl: 'https://example.com' })
1128
+
1129
+ const home = overridden.home
1130
+ const about = overridden.about
1131
+
1132
+ expect(home.get({ abs: true })).toBe('https://example.com')
1133
+ expect(about.get({ abs: true })).toBe('https://example.com/about')
1134
+ })
1135
+
1136
+ it('override does not mutate original', () => {
1137
+ const collection = Routes.create({
1138
+ home: '/',
1139
+ })
1140
+
1141
+ const original = collection.home
1142
+ expect(original.get({ abs: true })).toBe('https://example.com')
1143
+
1144
+ const overridden = collection._.override({ baseUrl: 'https://newdomain.com' })
1145
+ const newRoute = overridden.home
1146
+
1147
+ expect(original.get({ abs: true })).toBe('https://example.com')
1148
+ expect(newRoute.get({ abs: true })).toBe('https://newdomain.com')
1149
+ })
1150
+
1151
+ it('override with extended routes', () => {
1152
+ const apiRoute = Route0.create('/api', { baseUrl: 'https://api.example.com' })
1153
+ const usersRoute = apiRoute.extend('/users')
1154
+
1155
+ const collection = Routes.create({
1156
+ api: apiRoute,
1157
+ users: usersRoute,
1158
+ })
1159
+
1160
+ expect(collection.api.get({ abs: true })).toBe('https://api.example.com/api')
1161
+ expect(collection.api({ abs: true })).toBe('https://api.example.com/api')
1162
+ expect(collection.users.get({ abs: true })).toBe('https://api.example.com/api/users')
1163
+
1164
+ const overridden = collection._.override({ baseUrl: 'https://new-api.example.com' })
1165
+
1166
+ expect(overridden.api.get({ abs: true })).toBe('https://new-api.example.com/api')
1167
+ expect(overridden.users.get({ abs: true })).toBe('https://new-api.example.com/api/users')
1168
+ })
1169
+
1170
+ it('hydrate static method', () => {
1171
+ const hydrated = Routes._.hydrate({
1172
+ home: '/',
1173
+ user: '/user/:id',
1174
+ about: Route0.create('/about'),
1175
+ })
1176
+
1177
+ expect(hydrated.home).toBeInstanceOf(Route0)
1178
+ expect(hydrated.user).toBeInstanceOf(Route0)
1179
+ expect(hydrated.about).toBeInstanceOf(Route0)
1180
+
1181
+ expect(hydrated.home.get()).toBe('/')
1182
+ expect(hydrated.user.get({ id: '123' })).toBe('/user/123')
1183
+ expect(hydrated.about.get()).toBe('/about')
1184
+ })
1185
+
1186
+ it('works with callable routes', () => {
1187
+ const collection = Routes.create({
1188
+ home: '/',
1189
+ user: '/user/:id',
1190
+ })
1191
+
1192
+ const home = collection.home
1193
+ const user = collection.user
1194
+
1195
+ // Routes should be callable
1196
+ expect(typeof home).toBe('function')
1197
+ expect(typeof user).toBe('function')
1198
+ expect(home()).toBe('/')
1199
+ expect(user({ id: '789' })).toBe('/user/789')
1200
+ })
1201
+
1202
+ it('complex nested structure', () => {
1203
+ const api = Route0.create('/api/v1', { baseUrl: 'https://api.example.com' })
1204
+
1205
+ const collection = Routes.create({
1206
+ root: '/',
1207
+ api,
1208
+ users: api.extend('/users'),
1209
+ userDetail: api.extend('/users/:id'),
1210
+ userPosts: api.extend('/users/:id/posts&sort&filter'),
1211
+ })
1212
+
1213
+ expect(collection.root.get()).toBe('/')
1214
+ expect(collection.api({ abs: true })).toBe('https://api.example.com/api/v1')
1215
+ expect(collection.users.get({ abs: true })).toBe('https://api.example.com/api/v1/users')
1216
+
1217
+ const userDetailPath: any = collection.userDetail.get({ id: '42', abs: true })
1218
+ expect(userDetailPath).toBe('https://api.example.com/api/v1/users/42')
1219
+
1220
+ const userPostsPath: any = collection.userPosts.get({
1221
+ id: '42',
1222
+ search: { sort: 'date', filter: 'published' },
1223
+ abs: true,
1224
+ })
1225
+ expect(userPostsPath).toBe('https://api.example.com/api/v1/users/42/posts?sort=date&filter=published')
1226
+ })
1227
+ })
1228
+
1229
+ describe('specificity', () => {
1230
+ it('isMoreSpecificThan: static vs param', () => {
1231
+ const static1 = Route0.create('/a/b')
1232
+ const param1 = Route0.create('/a/:id')
1233
+
1234
+ expect(static1.isMoreSpecificThan(param1)).toBe(true)
1235
+ expect(param1.isMoreSpecificThan(static1)).toBe(false)
1236
+ })
1237
+
1238
+ it('isMoreSpecificThan: more static segments wins', () => {
1239
+ const twoStatic = Route0.create('/a/b/c')
1240
+ const oneStatic = Route0.create('/a/:id/c')
1241
+ const noStatic = Route0.create('/a/:id/:name')
1242
+
1243
+ expect(twoStatic.isMoreSpecificThan(oneStatic)).toBe(true)
1244
+ expect(oneStatic.isMoreSpecificThan(twoStatic)).toBe(false)
1245
+
1246
+ expect(oneStatic.isMoreSpecificThan(noStatic)).toBe(true)
1247
+ expect(noStatic.isMoreSpecificThan(oneStatic)).toBe(false)
1248
+
1249
+ expect(twoStatic.isMoreSpecificThan(noStatic)).toBe(true)
1250
+ expect(noStatic.isMoreSpecificThan(twoStatic)).toBe(false)
1251
+ })
1252
+
1253
+ it('isMoreSpecificThan: compares overlapping segments then lexicographically', () => {
1254
+ const longer = Route0.create('/a/:id/b/:name')
1255
+ const shorter = Route0.create('/a/:id')
1256
+
1257
+ // Both have same pattern for overlapping segments: static then param
1258
+ // Falls back to lexicographic: '/a/:id' < '/a/:id/b/:name'
1259
+ expect(longer.isMoreSpecificThan(shorter)).toBe(false)
1260
+ expect(shorter.isMoreSpecificThan(longer)).toBe(true)
1261
+ })
1262
+
1263
+ it('isMoreSpecificThan: static at earlier position wins', () => {
1264
+ const route1 = Route0.create('/a/static/:param')
1265
+ const route2 = Route0.create('/a/:param/static')
1266
+
1267
+ // Both have 2 static segments and same length
1268
+ // route1 has static at position 1, route2 has param at position 1
1269
+ expect(route1.isMoreSpecificThan(route2)).toBe(true)
1270
+ expect(route2.isMoreSpecificThan(route1)).toBe(false)
1271
+ })
1272
+
1273
+ it('isMoreSpecificThan: lexicographic when completely equal', () => {
1274
+ const route1 = Route0.create('/aaa/:id')
1275
+ const route2 = Route0.create('/bbb/:id')
1276
+
1277
+ // Same specificity, lexicographic comparison
1278
+ expect(route1.isMoreSpecificThan(route2)).toBe(true)
1279
+ expect(route2.isMoreSpecificThan(route1)).toBe(false)
1280
+ })
1281
+
1282
+ it('isMoreSpecificThan: identical routes', () => {
1283
+ const route1 = Route0.create('/a/:id')
1284
+ const route2 = Route0.create('/a/:id')
1285
+
1286
+ // Identical routes, lexicographic comparison returns false for equal strings
1287
+ expect(route1.isMoreSpecificThan(route2)).toBe(false)
1288
+ expect(route2.isMoreSpecificThan(route1)).toBe(false)
1289
+ })
1290
+
1291
+ it('isMoreSpecificThan: root vs other routes', () => {
1292
+ const root = Route0.create('/')
1293
+ const other = Route0.create('/a')
1294
+ const param = Route0.create('/:id')
1295
+
1296
+ // /a (1 static) vs / (1 static) - both static, lexicographic order
1297
+ expect(other.isMoreSpecificThan(root)).toBe(false) // '/' < '/a' lexicographically
1298
+ expect(root.isMoreSpecificThan(other)).toBe(true)
1299
+
1300
+ // /a (1 static) vs /:id (0 static) - static beats param
1301
+ expect(other.isMoreSpecificThan(param)).toBe(true)
1302
+ expect(param.isMoreSpecificThan(other)).toBe(false)
1303
+
1304
+ // /:id (0 static) vs / (1 static) - static beats param
1305
+ expect(param.isMoreSpecificThan(root)).toBe(false)
1306
+ expect(root.isMoreSpecificThan(param)).toBe(true)
1307
+ })
1308
+
1309
+ it('isConflict: checks if routes overlap', () => {
1310
+ const routeA = Route0.create('/a/:x')
1311
+ const routeB = Route0.create('/a/b')
1312
+ const routeC = Route0.create('/a/:c')
1313
+ const routeD = Route0.create('/a/d')
1314
+ const routeE = Route0.create('/a/b/c')
1315
+
1316
+ // Same depth, can match
1317
+ expect(routeA.isConflict(routeB)).toBe(true)
1318
+ expect(routeA.isConflict(routeC)).toBe(true)
1319
+ expect(routeA.isConflict(routeD)).toBe(true)
1320
+ expect(routeB.isConflict(routeC)).toBe(true)
1321
+
1322
+ // Different depth, no conflict
1323
+ expect(routeA.isConflict(routeE)).toBe(false)
1324
+ expect(routeB.isConflict(routeE)).toBe(false)
1325
+ })
1326
+
1327
+ it('isConflict: non-overlapping static routes', () => {
1328
+ const route1 = Route0.create('/users')
1329
+ const route2 = Route0.create('/posts')
1330
+
1331
+ // Same depth but different static segments
1332
+ expect(route1.isConflict(route2)).toBe(false)
1333
+ })
1334
+ })
1335
+
1336
+ describe('regex', () => {
1337
+ it('getRegexString: simple route', () => {
1338
+ const route = Route0.create('/')
1339
+ const regex = route.getRegexString()
1340
+ expect(regex).toBe('^/?$')
1341
+ expect(new RegExp(regex).test('/')).toBe(true)
1342
+ expect(new RegExp(regex).test('/other')).toBe(false)
1343
+ })
1344
+
1345
+ it('getRegexString: static route', () => {
1346
+ const route = Route0.create('/users')
1347
+ const regex = route.getRegexString()
1348
+ expect(regex).toBe('^/users/?$')
1349
+ expect(new RegExp(regex).test('/users')).toBe(true)
1350
+ expect(new RegExp(regex).test('/users/123')).toBe(false)
1351
+ })
1352
+
1353
+ it('getRegexString: route with single param', () => {
1354
+ const route = Route0.create('/users/:id')
1355
+ const regex = route.getRegexString()
1356
+ expect(regex).toBe('^/users/([^/]+)/?$')
1357
+ expect(new RegExp(regex).test('/users/123')).toBe(true)
1358
+ expect(new RegExp(regex).test('/users/abc')).toBe(true)
1359
+ expect(new RegExp(regex).test('/users/123/posts')).toBe(false)
1360
+ expect(new RegExp(regex).test('/users')).toBe(false)
1361
+ })
1362
+
1363
+ it('getRegexString: route with multiple params', () => {
1364
+ const route = Route0.create('/users/:userId/posts/:postId')
1365
+ const regex = route.getRegexString()
1366
+ expect(regex).toBe('^/users/([^/]+)/posts/([^/]+)/?$')
1367
+ expect(new RegExp(regex).test('/users/123/posts/456')).toBe(true)
1368
+ expect(new RegExp(regex).test('/users/123/posts')).toBe(false)
1369
+ })
1370
+
1371
+ it('getRegexString: route with special regex chars', () => {
1372
+ const route = Route0.create('/api/v1.0')
1373
+ const regex = route.getRegexString()
1374
+ // The dot should be escaped
1375
+ expect(regex).toBe('^/api/v1\\.0/?$')
1376
+ expect(new RegExp(regex).test('/api/v1.0')).toBe(true)
1377
+ expect(new RegExp(regex).test('/api/v100')).toBe(false)
1378
+ })
1379
+
1380
+ it('getRegexString: handles trailing slash', () => {
1381
+ const route = Route0.create('/users/')
1382
+ const regex = route.getRegexString()
1383
+ // Trailing slash should be removed from pattern, but optional slash added in regex
1384
+ expect(regex).toBe('^/users/?$')
1385
+ })
1386
+
1387
+ it('getRegexString: root with trailing slash', () => {
1388
+ const route = Route0.create('/')
1389
+ const regex = route.getRegexString()
1390
+ // Root returns pattern for empty string with optional slash
1391
+ expect(regex).toBe('^/?$')
1392
+ })
1393
+
1394
+ it('getRegex: simple route', () => {
1395
+ const route = Route0.create('/users')
1396
+ const regex = route.getRegex()
1397
+ expect(regex.test('/users')).toBe(true)
1398
+ expect(regex.test('/users/123')).toBe(false)
1399
+ expect(regex.test('/other')).toBe(false)
1400
+ })
1401
+
1402
+ it('getRegex: route with params', () => {
1403
+ const route = Route0.create('/users/:id')
1404
+ const regex = route.getRegex()
1405
+ expect(regex.test('/users/123')).toBe(true)
1406
+ expect(regex.test('/users/abc')).toBe(true)
1407
+ expect(regex.test('/users/123/posts')).toBe(false)
1408
+ })
1409
+
1410
+ it('static getRegexString: multiple routes', () => {
1411
+ const routes = [Route0.create('/users'), Route0.create('/posts/:id'), Route0.create('/')]
1412
+ const regex = Route0.getRegexStringGroup(routes)
1413
+ expect(regex).toBe('(^/users/?$|^/posts/([^/]+)/?$|^/?$)')
1414
+ })
1415
+
1416
+ it('static getRegexGroup: multiple routes', () => {
1417
+ const routes = [Route0.create('/users'), Route0.create('/posts/:id'), Route0.create('/')]
1418
+ const regex = Route0.getRegexGroup(routes)
1419
+ expect(regex.test('/users')).toBe(true)
1420
+ expect(regex.test('/posts/123')).toBe(true)
1421
+ expect(regex.test('/')).toBe(true)
1422
+ expect(regex.test('/other')).toBe(false)
1423
+ })
1424
+
1425
+ it('static getRegexGroup: matches in order', () => {
1426
+ const routes = [Route0.create('/users/special'), Route0.create('/users/:id')]
1427
+ const regex = Route0.getRegexGroup(routes)
1428
+ const match = '/users/special'.match(regex)
1429
+ expect(match).toBeTruthy()
1430
+ // Both could match, but first one should win
1431
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1432
+ expect(match![0]).toBe('/users/special')
1433
+ })
1434
+
1435
+ it('getRegexString works with getLocation', () => {
1436
+ const route = Route0.create('/users/:id/posts/:postId')
1437
+ const loc = route.getLocation('/users/123/posts/456')
1438
+ expect(loc.exact).toBe(true)
1439
+ expect(loc.params).toMatchObject({ id: '123', postId: '456' })
1440
+ })
1441
+
1442
+ it('regex matches what getLocation uses', () => {
1443
+ const route = Route0.create('/api/:version/users/:id')
1444
+ const testPath = '/api/v1/users/42'
1445
+
1446
+ // Test using getLocation
1447
+ const loc = route.getLocation(testPath)
1448
+ expect(loc.exact).toBe(true)
1449
+
1450
+ // Test using getRegex
1451
+ const regex = route.getRegex()
1452
+ expect(regex.test(testPath)).toBe(true)
1453
+ })
1454
+
1455
+ it('static getRegexGroup: complex routing scenario', () => {
1456
+ const api = Route0.create('/api/v1')
1457
+ const routes = [
1458
+ Route0.create('/'),
1459
+ api,
1460
+ api.extend('/users'),
1461
+ api.extend('/users/:id'),
1462
+ api.extend('/posts/:postId'),
1463
+ Route0.create('/:slug'),
1464
+ ]
1465
+
1466
+ const regex = Route0.getRegexGroup(routes)
1467
+
1468
+ expect(regex.test('/')).toBe(true)
1469
+ expect(regex.test('/api/v1')).toBe(true)
1470
+ expect(regex.test('/api/v1/users')).toBe(true)
1471
+ expect(regex.test('/api/v1/users/123')).toBe(true)
1472
+ expect(regex.test('/api/v1/posts/456')).toBe(true)
1473
+ expect(regex.test('/about')).toBe(true) // matches /:slug
1474
+ expect(regex.test('/api/v1/users/123/extra')).toBe(false)
1475
+ })
1476
+
1477
+ it('getRegex: handles trailing slash correctly', () => {
1478
+ const route = Route0.create('/users')
1479
+ const regex = route.getRegex()
1480
+ expect(regex.test('/users')).toBe(true)
1481
+ expect(regex.test('/users/')).toBe(true) // trailing slash should match
1482
+ expect(regex.test('/users//')).toBe(false) // double slash should not match
1483
+ expect(regex.test('/users/abc')).toBe(false) // additional segment should not match
1484
+ })
1485
+
1486
+ it('getRegex: route with params and trailing slash', () => {
1487
+ const route = Route0.create('/users/:id')
1488
+ const regex = route.getRegex()
1489
+ expect(regex.test('/users/123')).toBe(true)
1490
+ expect(regex.test('/users/123/')).toBe(true) // trailing slash should match
1491
+ expect(regex.test('/users/123/abc')).toBe(false) // additional segment should not match
1492
+ expect(regex.test('/users/')).toBe(false) // missing param
1493
+ })
1494
+
1495
+ it('getRegex: root route edge cases', () => {
1496
+ const route = Route0.create('/')
1497
+ const regex = route.getRegex()
1498
+ expect(regex.test('/')).toBe(true)
1499
+ expect(regex.test('')).toBe(true) // empty string should match root
1500
+ expect(regex.test('//')).toBe(false) // double slash should not match
1501
+ expect(regex.test('/users')).toBe(false) // non-root should not match
1502
+ })
1503
+
1504
+ it('getRegexString: handles multiple special regex characters', () => {
1505
+ const route1 = Route0.create('/api/v1.0')
1506
+ const route2 = Route0.create('/path(with)parens')
1507
+ const route3 = Route0.create('/path[with]brackets')
1508
+ const route4 = Route0.create('/path*with*asterisks')
1509
+ const route5 = Route0.create('/path+with+pluses')
1510
+ const route6 = Route0.create('/path?with?question')
1511
+ const route7 = Route0.create('/path^with^caret')
1512
+ const route8 = Route0.create('/path$with$dollar')
1513
+
1514
+ expect(route1.getRegexString()).toBe('^/api/v1\\.0/?$')
1515
+ expect(route2.getRegexString()).toBe('^/path\\(with\\)parens/?$')
1516
+ expect(route3.getRegexString()).toBe('^/path\\[with\\]brackets/?$')
1517
+ expect(route4.getRegexString()).toBe('^/path\\*with\\*asterisks/?$')
1518
+ expect(route5.getRegexString()).toBe('^/path\\+with\\+pluses/?$')
1519
+ expect(route6.getRegexString()).toBe('^/path\\?with\\?question/?$')
1520
+ expect(route7.getRegexString()).toBe('^/path\\^with\\^caret/?$')
1521
+ expect(route8.getRegexString()).toBe('^/path\\$with\\$dollar/?$')
1522
+ })
1523
+
1524
+ it('getRegex: works with escaped special characters', () => {
1525
+ const route = Route0.create('/api/v1.0/users')
1526
+ const regex = route.getRegex()
1527
+ expect(regex.test('/api/v1.0/users')).toBe(true)
1528
+ expect(regex.test('/api/v1.0/users/')).toBe(true)
1529
+ expect(regex.test('/api/v100/users')).toBe(false) // dot must be literal
1530
+ expect(regex.test('/api/v1.0/users/extra')).toBe(false)
1531
+ })
1532
+
1533
+ it('static getRegexGroup: handles routes with overlapping patterns', () => {
1534
+ const routes = [
1535
+ Route0.create('/users/special'),
1536
+ Route0.create('/users/:id'),
1537
+ Route0.create('/users/:id/edit'),
1538
+ Route0.create('/users'),
1539
+ ]
1540
+ const regex = Route0.getRegexGroup(routes)
1541
+
1542
+ // Should match in order - first specific route wins
1543
+ expect(regex.test('/users/special')).toBe(true)
1544
+ expect(regex.test('/users/123')).toBe(true)
1545
+ expect(regex.test('/users/123/edit')).toBe(true)
1546
+ expect(regex.test('/users')).toBe(true)
1547
+ expect(regex.test('/users/special/extra')).toBe(false)
1548
+ expect(regex.test('/users/123/extra')).toBe(false)
1549
+ })
1550
+
1551
+ it('static getRegexGroup: prevents partial matches', () => {
1552
+ const routes = [Route0.create('/api'), Route0.create('/api/v1'), Route0.create('/api/v1/users')]
1553
+ const regex = Route0.getRegexGroup(routes)
1554
+
1555
+ expect(regex.test('/api')).toBe(true)
1556
+ expect(regex.test('/api/')).toBe(true)
1557
+ expect(regex.test('/api/v1')).toBe(true)
1558
+ expect(regex.test('/api/v1/')).toBe(true)
1559
+ expect(regex.test('/api/v1/users')).toBe(true)
1560
+ expect(regex.test('/api/v1/users/')).toBe(true)
1561
+ expect(regex.test('/api/v2')).toBe(false)
1562
+ expect(regex.test('/api/v1/users/123')).toBe(false) // should not match /api/v1/users
1563
+ })
1564
+
1565
+ it('static getRegexGroup: handles root with other routes', () => {
1566
+ const routes = [Route0.create('/'), Route0.create('/home'), Route0.create('/about')]
1567
+ const regex = Route0.getRegexGroup(routes)
1568
+
1569
+ expect(regex.test('/')).toBe(true)
1570
+ expect(regex.test('')).toBe(true)
1571
+ expect(regex.test('/home')).toBe(true)
1572
+ expect(regex.test('/about')).toBe(true)
1573
+ expect(regex.test('/other')).toBe(false)
1574
+ })
1575
+
1576
+ it('static getRegexGroup: multiple routes with params', () => {
1577
+ const routes = [
1578
+ Route0.create('/posts/:id'),
1579
+ Route0.create('/users/:id'),
1580
+ Route0.create('/categories/:category/posts/:id'),
1581
+ ]
1582
+ const regex = Route0.getRegexGroup(routes)
1583
+
1584
+ expect(regex.test('/posts/123')).toBe(true)
1585
+ expect(regex.test('/posts/123/')).toBe(true)
1586
+ expect(regex.test('/users/456')).toBe(true)
1587
+ expect(regex.test('/categories/tech/posts/789')).toBe(true)
1588
+ expect(regex.test('/posts/123/comments')).toBe(false)
1589
+ expect(regex.test('/users/456/posts')).toBe(false)
1590
+ })
1591
+
1592
+ it('getRegex: prevents matching beyond route definition', () => {
1593
+ const route = Route0.create('/users/:id/posts/:postId')
1594
+ const regex = route.getRegex()
1595
+
1596
+ expect(regex.test('/users/1/posts/2')).toBe(true)
1597
+ expect(regex.test('/users/1/posts/2/')).toBe(true)
1598
+ expect(regex.test('/users/1/posts/2/comments')).toBe(false)
1599
+ expect(regex.test('/users/1/posts/2/comments/3')).toBe(false)
1600
+ expect(regex.test('/users/1/posts')).toBe(false)
1601
+ expect(regex.test('/users/1')).toBe(false)
1602
+ })
1603
+
1604
+ it('static getRegexGroup: routes should not match partial segments', () => {
1605
+ const routes = [Route0.create('/admin'), Route0.create('/admin/users'), Route0.create('/admin/settings')]
1606
+ const regex = Route0.getRegexGroup(routes)
1607
+
1608
+ expect(regex.test('/admin')).toBe(true)
1609
+ expect(regex.test('/admin/users')).toBe(true)
1610
+ expect(regex.test('/admin/settings')).toBe(true)
1611
+ expect(regex.test('/admin/users/123')).toBe(false) // /admin/users should not match longer paths
1612
+ expect(regex.test('/admins')).toBe(false) // should not match partial word
1613
+ expect(regex.test('/admin-extra')).toBe(false)
1614
+ })
1615
+
1616
+ it('getRegex: handles consecutive slashes correctly', () => {
1617
+ const route = Route0.create('/users')
1618
+ const regex = route.getRegex()
1619
+
1620
+ expect(regex.test('/users')).toBe(true)
1621
+ expect(regex.test('/users/')).toBe(true)
1622
+ expect(regex.test('//users')).toBe(false) // double leading slash
1623
+ expect(regex.test('/users//')).toBe(false) // double trailing slash
1624
+ expect(regex.test('///users')).toBe(false) // triple slash
1625
+ })
1626
+
1627
+ it('static getRegexGroup: handles very similar route patterns', () => {
1628
+ const routes = [
1629
+ Route0.create('/a/b'),
1630
+ Route0.create('/a/:param'),
1631
+ Route0.create('/a/b/c'),
1632
+ Route0.create('/a/b/:param'),
1633
+ ]
1634
+ const regex = Route0.getRegexGroup(routes)
1635
+
1636
+ expect(regex.test('/a/b')).toBe(true)
1637
+ expect(regex.test('/a/xyz')).toBe(true) // matches /a/:param
1638
+ expect(regex.test('/a/b/c')).toBe(true)
1639
+ expect(regex.test('/a/b/xyz')).toBe(true) // matches /a/b/:param
1640
+ expect(regex.test('/a/b/c/d')).toBe(false)
1641
+ })
1642
+
1643
+ it('getRegex: handles params with special characters', () => {
1644
+ const route = Route0.create('/users/:id')
1645
+ const regex = route.getRegex()
1646
+
1647
+ // Params should match URL-encoded characters
1648
+ expect(regex.test('/users/123')).toBe(true)
1649
+ expect(regex.test('/users/user-123')).toBe(true)
1650
+ expect(regex.test('/users/user_123')).toBe(true)
1651
+ expect(regex.test('/users/user.123')).toBe(true)
1652
+ expect(regex.test('/users/user%20123')).toBe(true) // URL encoded space
1653
+ expect(regex.test('/users/user%2F123')).toBe(true) // URL encoded slash (but this is a param value)
1654
+ })
1655
+
1656
+ it('static getRegexGroup: ensures exact match boundaries', () => {
1657
+ const routes = [
1658
+ Route0.create('/test'),
1659
+ Route0.create('/testing'),
1660
+ Route0.create('/testing/:id'),
1661
+ Route0.create('/testing/:id/xxx'),
1662
+ ]
1663
+ const regex = Route0.getRegexGroup(routes)
1664
+
1665
+ expect(regex.test('/test')).toBe(true)
1666
+ expect(regex.test('/test/')).toBe(true)
1667
+ expect(regex.test('/testing')).toBe(true)
1668
+ expect(regex.test('/testing/')).toBe(true)
1669
+ expect(regex.test('/testing/123')).toBe(true)
1670
+ expect(regex.test('/testing/123/xxx')).toBe(true)
1671
+ expect(regex.test('/test/ing')).toBe(false) // should not partially match
1672
+ expect(regex.test('/tested')).toBe(false) // should not match longer word
1673
+ })
1674
+
1675
+ it('getRegexString: handles empty segments', () => {
1676
+ const route = Route0.create('/users//posts')
1677
+ const regex = route.getRegexString()
1678
+ // Double slashes should be normalized to single slash
1679
+ expect(regex).toContain('/users')
1680
+ expect(regex).toContain('/posts')
1681
+ })
1682
+
1683
+ it('static getRegexGroup: root route should not interfere with other routes', () => {
1684
+ const routes = [
1685
+ Route0.create('/'),
1686
+ Route0.create('/root'),
1687
+ Route0.create('/root/:id'),
1688
+ Route0.create('/root/:id/xxx'),
1689
+ ]
1690
+ const regex = Route0.getRegexGroup(routes)
1691
+
1692
+ expect(regex.test('/')).toBe(true)
1693
+ expect(regex.test('')).toBe(true)
1694
+ expect(regex.test('/root')).toBe(true)
1695
+ expect(regex.test('/root/')).toBe(true)
1696
+ expect(regex.test('/rootx')).toBe(false)
1697
+ expect(regex.test('/root/123')).toBe(true)
1698
+ expect(regex.test('/root/123/xxx')).toBe(true)
1699
+ expect(regex.test('/root/123/yyy')).toBe(false)
1700
+ expect(regex.test('/rooting')).toBe(false)
1701
+ })
1702
+ })
1703
+
1704
+ describe('ordering', () => {
1705
+ it('_makeOrdering: orders routes by specificity', () => {
1706
+ const routes = {
1707
+ root: '/',
1708
+ userDetail: '/users/:id',
1709
+ users: '/users',
1710
+ userPosts: '/users/:id/posts',
1711
+ catchAll: '/:slug',
1712
+ }
1713
+
1714
+ const { pathsOrdering: ordering } = Routes._.makeOrdering(routes)
1715
+
1716
+ // Expected order:
1717
+ // Depth 1: / then /users (static) then /:slug (param)
1718
+ // Depth 2: /users/:id
1719
+ // Depth 3: /users/:id/posts
1720
+
1721
+ expect(ordering).toEqual(['/', '/users', '/:slug', '/users/:id', '/users/:id/posts'])
1722
+ })
1723
+
1724
+ it('_makeOrdering: handles routes with same specificity', () => {
1725
+ const routes = {
1726
+ about: '/about',
1727
+ contact: '/contact',
1728
+ home: '/home',
1729
+ }
1730
+
1731
+ const { pathsOrdering: ordering } = Routes._.makeOrdering(routes)
1732
+
1733
+ // All have same depth and don't conflict
1734
+ // Ordered alphabetically
1735
+ expect(ordering).toEqual(['/about', '/contact', '/home'])
1736
+ })
1737
+
1738
+ it('_makeOrdering: complex nested structure', () => {
1739
+ const api = Route0.create('/api/v1')
1740
+ const routes = {
1741
+ root: '/',
1742
+ api,
1743
+ usersStatic: '/api/v1/users/all',
1744
+ users: api.extend('/users'),
1745
+ userDetail: api.extend('/users/:id'),
1746
+ userPosts: api.extend('/users/:id/posts'),
1747
+ adminUser: '/api/v1/admin/:id',
1748
+ catchAll: '/:slug',
1749
+ }
1750
+
1751
+ const { pathsOrdering: ordering } = Routes._.makeOrdering(routes)
1752
+
1753
+ // Expected order:
1754
+ // Depth 1: / (static), /:slug (param)
1755
+ // Depth 2: /api/v1
1756
+ // Depth 3: /api/v1/users (all static)
1757
+ // Depth 4: /api/v1/admin/:id (has param), /api/v1/users/all (all static), /api/v1/users/:id (has param)
1758
+ // Depth 5: /api/v1/users/:id/posts
1759
+
1760
+ expect(ordering).toEqual([
1761
+ '/',
1762
+ '/:slug',
1763
+ '/api/v1',
1764
+ '/api/v1/users',
1765
+ '/api/v1/admin/:id',
1766
+ '/api/v1/users/all',
1767
+ '/api/v1/users/:id',
1768
+ '/api/v1/users/:id/posts',
1769
+ ])
1770
+ })
1771
+
1772
+ it('Routes instance has ordering property', () => {
1773
+ const routes = Routes.create({
1774
+ home: '/',
1775
+ users: '/users',
1776
+ userDetail: '/users/:id',
1777
+ })
1778
+
1779
+ expect(routes._.pathsOrdering).toBeDefined()
1780
+ expect(Array.isArray(routes._.pathsOrdering)).toBe(true)
1781
+ // Depth 1: /, /users (alphabetically)
1782
+ // Depth 2: /users/:id
1783
+ expect(routes._.pathsOrdering).toEqual(['/', '/users', '/users/:id'])
1784
+ })
1785
+
1786
+ it('ordering is preserved after override', () => {
1787
+ const routes = Routes.create({
1788
+ home: '/',
1789
+ users: '/users',
1790
+ userDetail: '/users/:id',
1791
+ })
1792
+
1793
+ const originalOrdering = routes._.pathsOrdering
1794
+
1795
+ const overridden = routes._.override({ baseUrl: 'https://example.com' })
1796
+
1797
+ expect(overridden._.pathsOrdering).toEqual(originalOrdering)
1798
+ expect(overridden._.pathsOrdering).toEqual(['/', '/users', '/users/:id'])
1799
+ })
1800
+
1801
+ it('_makeOrdering: handles single route', () => {
1802
+ const routes = {
1803
+ home: '/',
1804
+ }
1805
+
1806
+ const { pathsOrdering: ordering } = Routes._.makeOrdering(routes)
1807
+ expect(ordering).toEqual(['/'])
1808
+ })
1809
+
1810
+ it('_makeOrdering: handles empty object', () => {
1811
+ const routes = {}
1812
+
1813
+ const { pathsOrdering: ordering } = Routes._.makeOrdering(routes)
1814
+ expect(ordering).toEqual([])
1815
+ })
1816
+ })
1817
+
1818
+ describe('relations: isSame, isParent, isChildren', () => {
1819
+ it('isSame: same static path', () => {
1820
+ const a = Route0.create('/a')
1821
+ const b = Route0.create('/a')
1822
+ expect(a.isSame(b)).toBe(true)
1823
+ })
1824
+
1825
+ it('isSame: ignores param names but respects structure', () => {
1826
+ const r1 = Route0.create('/users/:id')
1827
+ const r2 = Route0.create('/users/:userId')
1828
+ const r3 = Route0.create('/users')
1829
+ const r4 = Route0.create('/users/:id/posts')
1830
+ expect((r1 as any).isSame(r2 as any)).toBe(true)
1831
+ expect((r1 as any).isSame(r3 as any)).toBe(false)
1832
+ expect((r1 as any).isSame(r4 as any)).toBe(false)
1833
+ })
1834
+
1835
+ it('isParent: true when left is ancestor of right', () => {
1836
+ expect(Route0.create('/').isParent(Route0.create('/path/child'))).toBe(true)
1837
+ expect(Route0.create('/path').isParent(Route0.create('/path/child'))).toBe(true)
1838
+ expect(Route0.create('/users/:id').isParent('/users/:id/posts')).toBe(true)
1839
+ expect(Route0.create('/').isParent(Route0.create('/users/:id/posts'))).toBe(true)
1840
+ expect(Route0.create('/').isParent(Route0.create('/users/:id'))).toBe(true)
1841
+ })
1842
+
1843
+ it('isParent: false for reverse, equal, or unrelated', () => {
1844
+ expect(Route0.create('/path/child').isParent(Route0.create('/path'))).toBe(false)
1845
+ expect(Route0.create('/path').isParent(Route0.create('/path'))).toBe(false)
1846
+ expect(Route0.create('/a').isParent(Route0.create('/b'))).toBe(false)
1847
+ })
1848
+
1849
+ it('isChildren: true when left is descendant of right', () => {
1850
+ expect(Route0.create('/path/child').isChildren(Route0.create('/path'))).toBe(true)
1851
+ expect(Route0.create('/users/:id/posts').isChildren(Route0.create('/users/:id'))).toBe(true)
1852
+ expect(Route0.create('/users/:id/posts').isChildren(Route0.create('/'))).toBe(true)
1853
+ })
1854
+
1855
+ it('isChildren: false for reverse, equal, or unrelated', () => {
1856
+ expect(Route0.create('/path').isChildren(Route0.create('/path/child'))).toBe(false)
1857
+ expect(Route0.create('/path').isChildren(Route0.create('/path'))).toBe(false)
1858
+ expect(Route0.create('/a').isChildren(Route0.create('/b'))).toBe(false)
1859
+ })
1860
+
1861
+ it('static isSame: works with strings and undefined', () => {
1862
+ expect(Route0.isSame('/a/:id', Route0.create('/a/:name'))).toBe(true)
1863
+ expect(Route0.isSame('/a', '/a')).toBe(true)
1864
+ expect(Route0.isSame('/a', '/b')).toBe(false)
1865
+ expect(Route0.isSame(undefined, undefined)).toBe(true)
1866
+ expect(Route0.isSame(undefined, '/a')).toBe(false)
1867
+ expect(Route0.isSame('/a', undefined)).toBe(false)
205
1868
  })
206
1869
  })