@devp0nt/route0 1.0.0-next.3 → 1.0.0-next.31

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