zero-query 1.1.1 → 1.2.0

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.
Files changed (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -220
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -1,2327 +1,2327 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { createRouter, getRouter, matchRoute } from '../src/router.js';
3
- import { component } from '../src/component.js';
4
-
5
- // Register stub components used in route definitions so mount() doesn't throw
6
- component('home-page', { render: () => '<p>home</p>' });
7
- component('about-page', { render: () => '<p>about</p>' });
8
- component('user-page', { render: () => '<p>user</p>' });
9
- component('docs-page', { render: () => '<p>docs</p>' });
10
-
11
-
12
- // ---------------------------------------------------------------------------
13
- // Router creation and basic API
14
- // ---------------------------------------------------------------------------
15
-
16
- describe('Router - creation', () => {
17
- beforeEach(() => {
18
- document.body.innerHTML = '<div id="app"></div>';
19
- });
20
-
21
- it('creates a router and retrieves it with getRouter', () => {
22
- const router = createRouter({
23
- el: '#app',
24
- mode: 'hash',
25
- routes: [
26
- { path: '/', component: 'home-page' },
27
- ],
28
- });
29
- expect(getRouter()).toBe(router);
30
- });
31
-
32
- it('defaults to history mode (unless file:// protocol)', () => {
33
- const router = createRouter({
34
- routes: [{ path: '/', component: 'home-page' }],
35
- });
36
- expect(router._mode).toBe('history');
37
- });
38
-
39
- it('resolves base path from config', () => {
40
- const router = createRouter({
41
- base: '/app',
42
- routes: [],
43
- });
44
- expect(router.base).toBe('/app');
45
- });
46
-
47
- it('strips trailing slash from base', () => {
48
- const router = createRouter({
49
- base: '/app/',
50
- routes: [],
51
- });
52
- expect(router.base).toBe('/app');
53
- });
54
- });
55
-
56
-
57
- // ---------------------------------------------------------------------------
58
- // Route matching
59
- // ---------------------------------------------------------------------------
60
-
61
- describe('Router - route matching', () => {
62
- it('compiles path params', () => {
63
- const router = createRouter({
64
- mode: 'hash',
65
- routes: [
66
- { path: '/user/:id', component: 'user-page' },
67
- ],
68
- });
69
- const route = router._routes[0];
70
- expect(route._regex.test('/user/42')).toBe(true);
71
- expect(route._keys).toEqual(['id']);
72
- });
73
-
74
- it('adds fallback route alias', () => {
75
- const router = createRouter({
76
- mode: 'hash',
77
- routes: [
78
- { path: '/docs/:section', fallback: '/docs', component: 'docs-page' },
79
- ],
80
- });
81
- // Should have two routes: the original + fallback alias
82
- expect(router._routes.length).toBe(2);
83
- expect(router._routes[1]._regex.test('/docs')).toBe(true);
84
- });
85
- });
86
-
87
-
88
- // ---------------------------------------------------------------------------
89
- // Navigation
90
- // ---------------------------------------------------------------------------
91
-
92
- describe('Router - navigation', () => {
93
- let router;
94
-
95
- beforeEach(() => {
96
- document.body.innerHTML = '<div id="app"></div>';
97
- window.location.hash = '#/';
98
- router = createRouter({
99
- el: '#app',
100
- mode: 'hash',
101
- routes: [
102
- { path: '/', component: 'home-page' },
103
- { path: '/about', component: 'about-page' },
104
- ],
105
- });
106
- });
107
-
108
- it('navigate changes hash', () => {
109
- router.navigate('/about');
110
- expect(window.location.hash).toBe('#/about');
111
- });
112
-
113
- it('replace changes hash without pushing history', () => {
114
- router.replace('/about');
115
- expect(window.location.hash).toBe('#/about');
116
- });
117
-
118
- it('navigate interpolates :param placeholders from options.params', () => {
119
- router.navigate('/user/:id', { params: { id: 42 } });
120
- expect(window.location.hash).toBe('#/user/42');
121
- });
122
-
123
- it('navigate interpolates multiple :param placeholders', () => {
124
- router.navigate('/post/:postId/comment/:cid', { params: { postId: 5, cid: 99 } });
125
- expect(window.location.hash).toBe('#/post/5/comment/99');
126
- });
127
-
128
- it('navigate leaves unmatched :params as-is', () => {
129
- router.navigate('/user/:id', { params: { name: 'Alice' } });
130
- expect(window.location.hash).toBe('#/user/:id');
131
- });
132
-
133
- it('navigate URI-encodes param values', () => {
134
- router.navigate('/search/:query', { params: { query: 'hello world' } });
135
- expect(window.location.hash).toBe('#/search/hello%20world');
136
- });
137
-
138
- it('replace interpolates :param placeholders from options.params', () => {
139
- router.replace('/user/:id', { params: { id: 7 } });
140
- expect(window.location.hash).toBe('#/user/7');
141
- });
142
-
143
- it('navigate without params option works as before', () => {
144
- router.navigate('/about');
145
- expect(window.location.hash).toBe('#/about');
146
- });
147
- });
148
-
149
-
150
- // ---------------------------------------------------------------------------
151
- // _interpolateParams
152
- // ---------------------------------------------------------------------------
153
-
154
- describe('Router - _interpolateParams', () => {
155
- let router;
156
-
157
- beforeEach(() => {
158
- router = createRouter({ mode: 'hash', routes: [] });
159
- });
160
-
161
- it('replaces single :param', () => {
162
- expect(router._interpolateParams('/user/:id', { id: 42 })).toBe('/user/42');
163
- });
164
-
165
- it('replaces multiple :params', () => {
166
- expect(router._interpolateParams('/post/:pid/comment/:cid', { pid: 1, cid: 5 }))
167
- .toBe('/post/1/comment/5');
168
- });
169
-
170
- it('leaves unmatched :params in place', () => {
171
- expect(router._interpolateParams('/user/:id', {})).toBe('/user/:id');
172
- });
173
-
174
- it('URI-encodes param values', () => {
175
- expect(router._interpolateParams('/tag/:name', { name: 'foo bar' }))
176
- .toBe('/tag/foo%20bar');
177
- });
178
-
179
- it('returns path unchanged when params is null', () => {
180
- expect(router._interpolateParams('/about', null)).toBe('/about');
181
- });
182
-
183
- it('converts numbers to strings', () => {
184
- expect(router._interpolateParams('/user/:id', { id: 123 })).toBe('/user/123');
185
- });
186
- });
187
-
188
-
189
- // ---------------------------------------------------------------------------
190
- // z-link-params
191
- // ---------------------------------------------------------------------------
192
-
193
- describe('Router - z-link-params', () => {
194
- let router;
195
-
196
- beforeEach(() => {
197
- document.body.innerHTML = '<div id="app"></div>';
198
- window.location.hash = '#/';
199
- router = createRouter({
200
- el: '#app',
201
- mode: 'hash',
202
- routes: [
203
- { path: '/', component: 'home-page' },
204
- { path: '/user/:id', component: 'user-page' },
205
- ],
206
- });
207
- });
208
-
209
- it('interpolates params from z-link-params attribute on click', () => {
210
- document.body.innerHTML += '<a z-link="/user/:id" z-link-params=\'{"id": "42"}\'>User</a>';
211
- const link = document.querySelector('[z-link]');
212
- link.click();
213
- expect(window.location.hash).toBe('#/user/42');
214
- });
215
-
216
- it('works without z-link-params (plain z-link)', () => {
217
- document.body.innerHTML += '<a z-link="/about">About</a>';
218
- const link = document.querySelector('a[z-link="/about"]');
219
- link.click();
220
- expect(window.location.hash).toBe('#/about');
221
- });
222
-
223
- it('ignores malformed z-link-params JSON gracefully', () => {
224
- document.body.innerHTML += '<a z-link="/user/fallback" z-link-params="not json">User</a>';
225
- const link = document.querySelector('a[z-link="/user/fallback"]');
226
- link.click();
227
- expect(window.location.hash).toBe('#/user/fallback');
228
- });
229
- });
230
-
231
-
232
- // ---------------------------------------------------------------------------
233
- // Guards
234
- // ---------------------------------------------------------------------------
235
-
236
- describe('Router - guards', () => {
237
- it('beforeEach registers a guard', () => {
238
- const router = createRouter({
239
- mode: 'hash',
240
- routes: [{ path: '/', component: 'home-page' }],
241
- });
242
- const guard = vi.fn();
243
- router.beforeEach(guard);
244
- expect(router._guards.before).toContain(guard);
245
- });
246
-
247
- it('afterEach registers a guard', () => {
248
- const router = createRouter({
249
- mode: 'hash',
250
- routes: [{ path: '/', component: 'home-page' }],
251
- });
252
- const guard = vi.fn();
253
- router.afterEach(guard);
254
- expect(router._guards.after).toContain(guard);
255
- });
256
- });
257
-
258
-
259
- // ---------------------------------------------------------------------------
260
- // onChange listener
261
- // ---------------------------------------------------------------------------
262
-
263
- describe('Router - onChange', () => {
264
- it('onChange registers and returns unsubscribe', () => {
265
- const router = createRouter({
266
- mode: 'hash',
267
- routes: [],
268
- });
269
- const fn = vi.fn();
270
- const unsub = router.onChange(fn);
271
- expect(router._listeners.has(fn)).toBe(true);
272
- unsub();
273
- expect(router._listeners.has(fn)).toBe(false);
274
- });
275
- });
276
-
277
-
278
- // ---------------------------------------------------------------------------
279
- // Path normalization
280
- // ---------------------------------------------------------------------------
281
-
282
- describe('Router - path normalization', () => {
283
- it('normalizes relative paths', () => {
284
- const router = createRouter({
285
- mode: 'hash',
286
- base: '/app',
287
- routes: [],
288
- });
289
- expect(router._normalizePath('docs')).toBe('/docs');
290
- expect(router._normalizePath('/docs')).toBe('/docs');
291
- });
292
-
293
- it('strips base prefix from path', () => {
294
- const router = createRouter({
295
- mode: 'hash',
296
- base: '/app',
297
- routes: [],
298
- });
299
- expect(router._normalizePath('/app/docs')).toBe('/docs');
300
- expect(router._normalizePath('/app')).toBe('/');
301
- });
302
-
303
- it('resolve() adds base prefix', () => {
304
- const router = createRouter({
305
- mode: 'hash',
306
- base: '/app',
307
- routes: [],
308
- });
309
- expect(router.resolve('/docs')).toBe('/app/docs');
310
- expect(router.resolve('about')).toBe('/app/about');
311
- });
312
- });
313
-
314
-
315
- // ---------------------------------------------------------------------------
316
- // Destroy
317
- // ---------------------------------------------------------------------------
318
-
319
- describe('Router - destroy', () => {
320
- it('clears routes, guards, and listeners', () => {
321
- const router = createRouter({
322
- mode: 'hash',
323
- routes: [{ path: '/', component: 'home-page' }],
324
- });
325
- router.beforeEach(() => {});
326
- router.onChange(() => {});
327
- router.destroy();
328
- expect(router._routes.length).toBe(0);
329
- expect(router._guards.before.length).toBe(0);
330
- expect(router._listeners.size).toBe(0);
331
- });
332
- });
333
-
334
-
335
- // ---------------------------------------------------------------------------
336
- // Wildcard / catch-all routes
337
- // ---------------------------------------------------------------------------
338
-
339
- describe('Router - wildcard routes', () => {
340
- it('compiles wildcard route', () => {
341
- const router = createRouter({
342
- mode: 'hash',
343
- routes: [
344
- { path: '/docs/:section', component: 'docs-page' },
345
- { path: '*', component: 'home-page' },
346
- ],
347
- });
348
- // Wildcard is compiled as last route
349
- expect(router._routes.length).toBeGreaterThanOrEqual(2);
350
- });
351
- });
352
-
353
-
354
- // ---------------------------------------------------------------------------
355
- // Query string handling
356
- // ---------------------------------------------------------------------------
357
-
358
- describe('Router - query parsing', () => {
359
- it('_normalizePath strips query string for route matching', () => {
360
- const router = createRouter({
361
- mode: 'hash',
362
- routes: [],
363
- });
364
- // _normalizePath does not strip query params - it only normalizes slashes and base prefix
365
- const path = router._normalizePath('/docs?section=intro');
366
- expect(path).toBe('/docs?section=intro');
367
- });
368
- });
369
-
370
-
371
- // ---------------------------------------------------------------------------
372
- // Multiple guards
373
- // ---------------------------------------------------------------------------
374
-
375
- describe('Router - multiple guards', () => {
376
- it('registers multiple beforeEach guards', () => {
377
- const router = createRouter({
378
- mode: 'hash',
379
- routes: [{ path: '/', component: 'home-page' }],
380
- });
381
- const g1 = vi.fn();
382
- const g2 = vi.fn();
383
- router.beforeEach(g1);
384
- router.beforeEach(g2);
385
- expect(router._guards.before.length).toBe(2);
386
- expect(router._guards.before).toContain(g1);
387
- expect(router._guards.before).toContain(g2);
388
- });
389
-
390
- it('registers multiple afterEach guards', () => {
391
- const router = createRouter({
392
- mode: 'hash',
393
- routes: [{ path: '/', component: 'home-page' }],
394
- });
395
- const g1 = vi.fn();
396
- const g2 = vi.fn();
397
- router.afterEach(g1);
398
- router.afterEach(g2);
399
- expect(router._guards.after.length).toBe(2);
400
- });
401
- });
402
-
403
-
404
- // ---------------------------------------------------------------------------
405
- // Route with multiple params
406
- // ---------------------------------------------------------------------------
407
-
408
- describe('Router - multi-param routes', () => {
409
- it('compiles route with multiple params', () => {
410
- const router = createRouter({
411
- mode: 'hash',
412
- routes: [
413
- { path: '/post/:pid/comment/:cid', component: 'user-page' },
414
- ],
415
- });
416
- const route = router._routes[0];
417
- expect(route._regex.test('/post/1/comment/5')).toBe(true);
418
- expect(route._keys).toEqual(['pid', 'cid']);
419
- });
420
- });
421
-
422
-
423
- // ---------------------------------------------------------------------------
424
- // Same-path deduplication
425
- // ---------------------------------------------------------------------------
426
-
427
- describe('Router - same-path deduplication', () => {
428
- let router;
429
-
430
- beforeEach(() => {
431
- document.body.innerHTML = '<div id="app"></div>';
432
- window.location.hash = '#/';
433
- router = createRouter({
434
- el: '#app',
435
- mode: 'hash',
436
- routes: [
437
- { path: '/', component: 'home-page' },
438
- { path: '/about', component: 'about-page' },
439
- ],
440
- });
441
- });
442
-
443
- it('skips duplicate hash navigation to the same path', () => {
444
- router.navigate('/about');
445
- expect(window.location.hash).toBe('#/about');
446
- // Navigate to the same path again - should be a no-op
447
- const result = router.navigate('/about');
448
- expect(window.location.hash).toBe('#/about');
449
- expect(result).toBe(router); // still returns the router chain
450
- });
451
-
452
- it('allows forced duplicate navigation with options.force', () => {
453
- router.navigate('/about');
454
- // Force navigation to same path
455
- router.navigate('/about', { force: true });
456
- expect(window.location.hash).toBe('#/about');
457
- });
458
- });
459
-
460
-
461
- // ---------------------------------------------------------------------------
462
- // History mode - same-path / hash-only navigation
463
- // ---------------------------------------------------------------------------
464
-
465
- describe('Router - history mode deduplication', () => {
466
- let router;
467
- let pushSpy, replaceSpy;
468
-
469
- beforeEach(() => {
470
- document.body.innerHTML = '<div id="app"></div>';
471
- pushSpy = vi.spyOn(window.history, 'pushState');
472
- replaceSpy = vi.spyOn(window.history, 'replaceState');
473
- router = createRouter({
474
- el: '#app',
475
- mode: 'history',
476
- routes: [
477
- { path: '/', component: 'home-page' },
478
- { path: '/about', component: 'about-page' },
479
- { path: '/docs', component: 'docs-page' },
480
- ],
481
- });
482
- // Reset spy call counts after initial resolve
483
- pushSpy.mockClear();
484
- replaceSpy.mockClear();
485
- });
486
-
487
- afterEach(() => {
488
- pushSpy.mockRestore();
489
- replaceSpy.mockRestore();
490
- });
491
-
492
- it('uses pushState for different routes', () => {
493
- router.navigate('/about');
494
- expect(pushSpy).toHaveBeenCalledTimes(1);
495
- });
496
-
497
- it('uses replaceState for same-route hash-only change', () => {
498
- // Navigate to /docs first
499
- router.navigate('/docs');
500
- pushSpy.mockClear();
501
- replaceSpy.mockClear();
502
- // Navigate to /docs#section - same route, different hash
503
- router.navigate('/docs#section');
504
- expect(pushSpy).not.toHaveBeenCalled();
505
- expect(replaceSpy).toHaveBeenCalledTimes(1);
506
- });
507
- });
508
-
509
-
510
- // ---------------------------------------------------------------------------
511
- // Sub-route history substates
512
- // ---------------------------------------------------------------------------
513
-
514
- describe('Router - substates', () => {
515
- let router;
516
- let pushSpy;
517
-
518
- beforeEach(() => {
519
- document.body.innerHTML = '<div id="app"></div>';
520
- pushSpy = vi.spyOn(window.history, 'pushState');
521
- router = createRouter({
522
- el: '#app',
523
- mode: 'history',
524
- routes: [
525
- { path: '/', component: 'home-page' },
526
- ],
527
- });
528
- pushSpy.mockClear();
529
- });
530
-
531
- afterEach(() => {
532
- pushSpy.mockRestore();
533
- });
534
-
535
- it('pushSubstate pushes a history entry with substate marker', () => {
536
- router.pushSubstate('modal', { id: 'confirm' });
537
- expect(pushSpy).toHaveBeenCalledTimes(1);
538
- const state = pushSpy.mock.calls[0][0];
539
- expect(state.__zq).toBe('substate');
540
- expect(state.key).toBe('modal');
541
- expect(state.data).toEqual({ id: 'confirm' });
542
- });
543
-
544
- it('onSubstate registers and unregisters listeners', () => {
545
- const fn = vi.fn();
546
- const unsub = router.onSubstate(fn);
547
- expect(router._substateListeners).toContain(fn);
548
- unsub();
549
- expect(router._substateListeners).not.toContain(fn);
550
- });
551
-
552
- it('_fireSubstate calls listeners and returns true if any handles it', () => {
553
- const fn1 = vi.fn(() => false);
554
- const fn2 = vi.fn(() => true);
555
- router.onSubstate(fn1);
556
- router.onSubstate(fn2);
557
- const handled = router._fireSubstate('modal', { id: 'x' }, 'pop');
558
- expect(fn1).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
559
- expect(fn2).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
560
- expect(handled).toBe(true);
561
- });
562
-
563
- it('_fireSubstate returns false if no listener handles it', () => {
564
- const fn = vi.fn(() => undefined);
565
- router.onSubstate(fn);
566
- const handled = router._fireSubstate('tab', { index: 0 }, 'pop');
567
- expect(handled).toBe(false);
568
- });
569
-
570
- it('_fireSubstate catches errors in listeners', () => {
571
- const fn = vi.fn(() => { throw new Error('oops'); });
572
- router.onSubstate(fn);
573
- // Should not throw
574
- expect(() => router._fireSubstate('modal', {}, 'pop')).not.toThrow();
575
- });
576
-
577
- it('destroy clears substate listeners', () => {
578
- router.onSubstate(() => {});
579
- router.onSubstate(() => {});
580
- router.destroy();
581
- expect(router._substateListeners.length).toBe(0);
582
- });
583
-
584
- it('pushSubstate sets _inSubstate flag', () => {
585
- expect(router._inSubstate).toBe(false);
586
- router.pushSubstate('tab', { id: 'a' });
587
- expect(router._inSubstate).toBe(true);
588
- });
589
-
590
- it('popstate past all substates fires reset action', () => {
591
- const fn = vi.fn(() => true);
592
- router.onSubstate(fn);
593
- router.pushSubstate('tab', { id: 'a' });
594
- expect(router._inSubstate).toBe(true);
595
-
596
- // Simulate popstate landing on a non-substate entry
597
- const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
598
- window.dispatchEvent(evt);
599
-
600
- // Should have fired with reset action
601
- expect(fn).toHaveBeenCalledWith(null, null, 'reset');
602
- expect(router._inSubstate).toBe(false);
603
- });
604
-
605
- it('popstate on a substate entry keeps _inSubstate true', () => {
606
- const fn = vi.fn(() => true);
607
- router.onSubstate(fn);
608
- router.pushSubstate('tab', { id: 'a' });
609
- router.pushSubstate('tab', { id: 'b' });
610
-
611
- // Simulate popstate landing on a substate entry
612
- const evt = new PopStateEvent('popstate', {
613
- state: { __zq: 'substate', key: 'tab', data: { id: 'a' } }
614
- });
615
- window.dispatchEvent(evt);
616
-
617
- expect(fn).toHaveBeenCalledWith('tab', { id: 'a' }, 'pop');
618
- expect(router._inSubstate).toBe(true);
619
- });
620
-
621
- it('reset action is not fired when no substates were active', () => {
622
- const fn = vi.fn();
623
- router.onSubstate(fn);
624
- // _inSubstate is false, so popstate on a route entry should not fire reset
625
- const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
626
- window.dispatchEvent(evt);
627
- // fn should NOT have been called with 'reset'
628
- const resetCalls = fn.mock.calls.filter(c => c[2] === 'reset');
629
- expect(resetCalls.length).toBe(0);
630
- });
631
- });
632
-
633
-
634
- // ---------------------------------------------------------------------------
635
- // Edge cases
636
- // ---------------------------------------------------------------------------
637
-
638
- describe('Router - edge cases', () => {
639
- it('handles empty routes array', () => {
640
- const router = createRouter({ mode: 'hash', routes: [] });
641
- expect(router._routes.length).toBe(0);
642
- });
643
-
644
- it('getRouter returns null before creation', () => {
645
- // After the tests create routers, getRouter should return the latest
646
- const r = getRouter();
647
- expect(r).toBeDefined();
648
- });
649
- });
650
-
651
-
652
- // ---------------------------------------------------------------------------
653
- // Route matching priority (first match wins)
654
- // ---------------------------------------------------------------------------
655
-
656
- describe('Router - route matching priority', () => {
657
- it('first matching route wins', () => {
658
- const router = createRouter({
659
- mode: 'hash',
660
- routes: [
661
- { path: '/test', component: 'home-page' },
662
- { path: '/test', component: 'about-page' },
663
- ],
664
- });
665
- // The first route with path '/test' should be matched
666
- const matched = router._routes[0];
667
- expect(matched._regex.test('/test')).toBe(true);
668
- expect(matched.component).toBe('home-page');
669
- });
670
-
671
- it('specific route takes priority over wildcard', () => {
672
- const router = createRouter({
673
- mode: 'hash',
674
- routes: [
675
- { path: '/about', component: 'about-page' },
676
- { path: '*', component: 'home-page' },
677
- ],
678
- });
679
- // /about should match the first route, not wildcard
680
- expect(router._routes[0]._regex.test('/about')).toBe(true);
681
- expect(router._routes[0].component).toBe('about-page');
682
- });
683
-
684
- it('parameterized routes match correctly', () => {
685
- const router = createRouter({
686
- mode: 'hash',
687
- routes: [
688
- { path: '/user/:id', component: 'user-page' },
689
- { path: '/user/settings', component: 'about-page' },
690
- ],
691
- });
692
- // /user/42 should match parameterized route
693
- expect(router._routes[0]._regex.test('/user/42')).toBe(true);
694
- // /user/settings matches first (since :id catches "settings")
695
- expect(router._routes[0]._regex.test('/user/settings')).toBe(true);
696
- });
697
- });
698
-
699
-
700
- // ---------------------------------------------------------------------------
701
- // Route removal
702
- // ---------------------------------------------------------------------------
703
-
704
- describe('Router - route removal', () => {
705
- it('remove() deletes the matching route', () => {
706
- const router = createRouter({
707
- mode: 'hash',
708
- routes: [
709
- { path: '/', component: 'home-page' },
710
- { path: '/about', component: 'about-page' },
711
- ],
712
- });
713
- expect(router._routes.length).toBe(2);
714
- router.remove('/about');
715
- expect(router._routes.length).toBe(1);
716
- expect(router._routes.find(r => r.path === '/about')).toBeUndefined();
717
- });
718
-
719
- it('remove() on non-existent path is a no-op', () => {
720
- const router = createRouter({
721
- mode: 'hash',
722
- routes: [{ path: '/', component: 'home-page' }],
723
- });
724
- router.remove('/nonexistent');
725
- expect(router._routes.length).toBe(1);
726
- });
727
-
728
- it('remove() returns the router for chaining', () => {
729
- const router = createRouter({
730
- mode: 'hash',
731
- routes: [{ path: '/', component: 'home-page' }],
732
- });
733
- const result = router.remove('/');
734
- expect(result).toBe(router);
735
- });
736
- });
737
-
738
-
739
- // ---------------------------------------------------------------------------
740
- // Dynamic route addition
741
- // ---------------------------------------------------------------------------
742
-
743
- describe('Router - dynamic route addition', () => {
744
- it('add() returns the router for chaining', () => {
745
- const router = createRouter({ mode: 'hash', routes: [] });
746
- const result = router.add({ path: '/new', component: 'home-page' });
747
- expect(result).toBe(router);
748
- expect(router._routes.length).toBe(1);
749
- });
750
-
751
- it('add() compiles regex for parameterized routes', () => {
752
- const router = createRouter({ mode: 'hash', routes: [] });
753
- router.add({ path: '/item/:id/detail/:section', component: 'home-page' });
754
- const route = router._routes[0];
755
- expect(route._regex.test('/item/42/detail/overview')).toBe(true);
756
- expect(route._keys).toEqual(['id', 'section']);
757
- });
758
-
759
- it('add() with fallback creates two routes', () => {
760
- const router = createRouter({ mode: 'hash', routes: [] });
761
- router.add({ path: '/docs/:page', fallback: '/docs', component: 'docs-page' });
762
- expect(router._routes.length).toBe(2);
763
- expect(router._routes[0]._regex.test('/docs/intro')).toBe(true);
764
- expect(router._routes[1]._regex.test('/docs')).toBe(true);
765
- });
766
- });
767
-
768
-
769
- // ---------------------------------------------------------------------------
770
- // Navigation chaining
771
- // ---------------------------------------------------------------------------
772
-
773
- describe('Router - navigation chaining', () => {
774
- let router;
775
- beforeEach(() => {
776
- document.body.innerHTML = '<div id="app"></div>';
777
- window.location.hash = '#/';
778
- router = createRouter({
779
- el: '#app',
780
- mode: 'hash',
781
- routes: [
782
- { path: '/', component: 'home-page' },
783
- { path: '/about', component: 'about-page' },
784
- ],
785
- });
786
- });
787
-
788
- it('navigate returns the router for chaining', () => {
789
- const result = router.navigate('/about');
790
- expect(result).toBe(router);
791
- });
792
-
793
- it('replace returns the router for chaining', () => {
794
- const result = router.replace('/about');
795
- expect(result).toBe(router);
796
- });
797
-
798
- it('back() returns the router', () => {
799
- expect(router.back()).toBe(router);
800
- });
801
-
802
- it('forward() returns the router', () => {
803
- expect(router.forward()).toBe(router);
804
- });
805
-
806
- it('go() returns the router', () => {
807
- expect(router.go(0)).toBe(router);
808
- });
809
- });
810
-
811
-
812
- // ---------------------------------------------------------------------------
813
- // matchRoute - standalone route matcher (DOM-free)
814
- // ---------------------------------------------------------------------------
815
-
816
- describe('matchRoute', () => {
817
- const routes = [
818
- { path: '/', component: 'home-page' },
819
- { path: '/blog', component: 'blog-list' },
820
- { path: '/blog/:slug', component: 'blog-post' },
821
- { path: '/user/:id', component: 'user-page' },
822
- { path: '/files/*', component: 'file-browser' },
823
- ];
824
-
825
- it('matches a static route', () => {
826
- expect(matchRoute(routes, '/')).toEqual({ component: 'home-page', params: {} });
827
- expect(matchRoute(routes, '/blog')).toEqual({ component: 'blog-list', params: {} });
828
- });
829
-
830
- it('matches a parameterized route', () => {
831
- expect(matchRoute(routes, '/blog/hello-world')).toEqual({
832
- component: 'blog-post',
833
- params: { slug: 'hello-world' },
834
- });
835
- });
836
-
837
- it('matches multiple params', () => {
838
- const r = [{ path: '/user/:id/post/:pid', component: 'user-post' }];
839
- expect(matchRoute(r, '/user/42/post/7')).toEqual({
840
- component: 'user-post',
841
- params: { id: '42', pid: '7' },
842
- });
843
- });
844
-
845
- it('matches wildcard routes', () => {
846
- const result = matchRoute(routes, '/files/docs/readme.md');
847
- expect(result.component).toBe('file-browser');
848
- });
849
-
850
- it('returns fallback when nothing matches', () => {
851
- expect(matchRoute(routes, '/nope')).toEqual({ component: 'not-found', params: {} });
852
- });
853
-
854
- it('accepts a custom fallback component name', () => {
855
- expect(matchRoute(routes, '/nope', '404-page')).toEqual({ component: '404-page', params: {} });
856
- });
857
-
858
- it('matches first route when multiple could match', () => {
859
- const r = [
860
- { path: '/a', component: 'first' },
861
- { path: '/a', component: 'second' },
862
- ];
863
- expect(matchRoute(r, '/a').component).toBe('first');
864
- });
865
-
866
- it('handles per-route fallback aliases', () => {
867
- const r = [
868
- { path: '/docs/:section', component: 'docs-page', fallback: '/docs' },
869
- ];
870
- expect(matchRoute(r, '/docs/intro')).toEqual({ component: 'docs-page', params: { section: 'intro' } });
871
- expect(matchRoute(r, '/docs')).toEqual({ component: 'docs-page', params: {} });
872
- });
873
- });
874
-
875
-
876
- // ---------------------------------------------------------------------------
877
- // Hash mode path parsing
878
- // ---------------------------------------------------------------------------
879
-
880
- describe('Router - hash mode path parsing', () => {
881
- let router;
882
- beforeEach(() => {
883
- router = createRouter({
884
- mode: 'hash',
885
- routes: [{ path: '/', component: 'home-page' }],
886
- });
887
- });
888
-
889
- it('path returns / when hash is empty', () => {
890
- window.location.hash = '';
891
- expect(router.path).toBe('/');
892
- });
893
-
894
- it('path returns correct value from hash', () => {
895
- window.location.hash = '#/about';
896
- expect(router.path).toBe('/about');
897
- });
898
-
899
- it('path returns / when hash is just #/', () => {
900
- window.location.hash = '#/';
901
- expect(router.path).toBe('/');
902
- });
903
- });
904
-
905
-
906
- // ---------------------------------------------------------------------------
907
- // History mode path handling with base
908
- // ---------------------------------------------------------------------------
909
-
910
- describe('Router - history mode with base path', () => {
911
- it('resolve includes base prefix', () => {
912
- const router = createRouter({
913
- mode: 'history',
914
- base: '/myapp',
915
- routes: [],
916
- });
917
- expect(router.resolve('/page')).toBe('/myapp/page');
918
- expect(router.resolve('/')).toBe('/myapp/');
919
- });
920
-
921
- it('_normalizePath strips double base prefix', () => {
922
- const router = createRouter({
923
- mode: 'history',
924
- base: '/myapp',
925
- routes: [],
926
- });
927
- // If someone accidentally includes the base
928
- expect(router._normalizePath('/myapp/page')).toBe('/page');
929
- });
930
-
931
- it('base without leading slash gets normalized', () => {
932
- const router = createRouter({
933
- mode: 'history',
934
- base: 'app',
935
- routes: [],
936
- });
937
- expect(router.base).toBe('/app');
938
- });
939
- });
940
-
941
-
942
- // ---------------------------------------------------------------------------
943
- // Navigate with query strings in hash mode
944
- // ---------------------------------------------------------------------------
945
-
946
- describe('Router - query string in hash mode', () => {
947
- let router;
948
- beforeEach(() => {
949
- document.body.innerHTML = '<div id="app"></div>';
950
- window.location.hash = '#/';
951
- router = createRouter({
952
- el: '#app',
953
- mode: 'hash',
954
- routes: [
955
- { path: '/', component: 'home-page' },
956
- { path: '/search', component: 'about-page' },
957
- ],
958
- });
959
- });
960
-
961
- it('navigate preserves path for query routing', () => {
962
- router.navigate('/search');
963
- expect(window.location.hash).toBe('#/search');
964
- });
965
- });
966
-
967
-
968
- // ---------------------------------------------------------------------------
969
- // Guard edge cases
970
- // ---------------------------------------------------------------------------
971
-
972
- describe('Router - guard edge cases', () => {
973
- it('beforeEach returns the router for chaining', () => {
974
- const router = createRouter({
975
- mode: 'hash',
976
- routes: [{ path: '/', component: 'home-page' }],
977
- });
978
- const result = router.beforeEach(() => {});
979
- expect(result).toBe(router);
980
- });
981
-
982
- it('afterEach returns the router for chaining', () => {
983
- const router = createRouter({
984
- mode: 'hash',
985
- routes: [{ path: '/', component: 'home-page' }],
986
- });
987
- const result = router.afterEach(() => {});
988
- expect(result).toBe(router);
989
- });
990
-
991
- it('guard cancels navigation when returning false', async () => {
992
- const router = createRouter({
993
- el: '#app',
994
- mode: 'hash',
995
- routes: [
996
- { path: '/', component: 'home-page' },
997
- { path: '/blocked', component: 'about-page' },
998
- ],
999
- });
1000
- document.body.innerHTML = '<div id="app"></div>';
1001
- router.beforeEach(() => false);
1002
- // Manually trigger resolve for /blocked
1003
- window.location.hash = '#/blocked';
1004
- await router._resolve();
1005
- // current should not be updated to /blocked
1006
- expect(router._current === null || router._current.path !== '/blocked').toBe(true);
1007
- });
1008
-
1009
- it('guard redirects to a different route', async () => {
1010
- document.body.innerHTML = '<div id="app"></div>';
1011
- const router = createRouter({
1012
- el: '#app',
1013
- mode: 'hash',
1014
- routes: [
1015
- { path: '/', component: 'home-page' },
1016
- { path: '/login', component: 'about-page' },
1017
- { path: '/dashboard', component: 'docs-page' },
1018
- ],
1019
- });
1020
- router.beforeEach((to) => {
1021
- if (to.path === '/dashboard') return '/login';
1022
- });
1023
- window.location.hash = '#/dashboard';
1024
- await router._resolve();
1025
- expect(window.location.hash).toBe('#/login');
1026
- });
1027
- });
1028
-
1029
-
1030
- // ---------------------------------------------------------------------------
1031
- // onChange with navigation
1032
- // ---------------------------------------------------------------------------
1033
-
1034
- describe('Router - onChange fires on resolve', () => {
1035
- it('fires onChange listener after route resolution', async () => {
1036
- document.body.innerHTML = '<div id="app"></div>';
1037
- const listener = vi.fn();
1038
- const router = createRouter({
1039
- el: '#app',
1040
- mode: 'hash',
1041
- routes: [
1042
- { path: '/', component: 'home-page' },
1043
- { path: '/about', component: 'about-page' },
1044
- ],
1045
- });
1046
- router.onChange(listener);
1047
- // Wait for initial resolve
1048
- await new Promise(r => setTimeout(r, 10));
1049
- listener.mockClear();
1050
-
1051
- window.location.hash = '#/about';
1052
- await router._resolve();
1053
- expect(listener).toHaveBeenCalled();
1054
- const [to, from] = listener.mock.calls[0];
1055
- expect(to.path).toBe('/about');
1056
- });
1057
- });
1058
-
1059
-
1060
- // ---------------------------------------------------------------------------
1061
- // Multi-param extraction
1062
- // ---------------------------------------------------------------------------
1063
-
1064
- describe('Router - multi-param extraction', () => {
1065
- it('extracts multiple params from URL', () => {
1066
- const router = createRouter({
1067
- mode: 'hash',
1068
- routes: [
1069
- { path: '/org/:orgId/team/:teamId/member/:memberId', component: 'user-page' },
1070
- ],
1071
- });
1072
- const route = router._routes[0];
1073
- const match = '/org/acme/team/dev/member/42'.match(route._regex);
1074
- expect(match).not.toBeNull();
1075
- const params = {};
1076
- route._keys.forEach((key, i) => { params[key] = match[i + 1]; });
1077
- expect(params).toEqual({ orgId: 'acme', teamId: 'dev', memberId: '42' });
1078
- });
1079
- });
1080
-
1081
-
1082
- // ---------------------------------------------------------------------------
1083
- // Substate in hash mode
1084
- // ---------------------------------------------------------------------------
1085
-
1086
- describe('Router - substates hash mode', () => {
1087
- let router, pushSpy;
1088
-
1089
- beforeEach(() => {
1090
- document.body.innerHTML = '<div id="app"></div>';
1091
- window.location.hash = '#/';
1092
- pushSpy = vi.spyOn(window.history, 'pushState');
1093
- router = createRouter({
1094
- el: '#app',
1095
- mode: 'hash',
1096
- routes: [{ path: '/', component: 'home-page' }],
1097
- });
1098
- pushSpy.mockClear();
1099
- });
1100
-
1101
- afterEach(() => {
1102
- pushSpy.mockRestore();
1103
- });
1104
-
1105
- it('pushSubstate works in hash mode', () => {
1106
- router.pushSubstate('drawer', { side: 'left' });
1107
- expect(pushSpy).toHaveBeenCalledTimes(1);
1108
- const state = pushSpy.mock.calls[0][0];
1109
- expect(state.__zq).toBe('substate');
1110
- expect(state.key).toBe('drawer');
1111
- });
1112
-
1113
- it('multiple substates can be pushed', () => {
1114
- router.pushSubstate('modal', { id: 'a' });
1115
- router.pushSubstate('modal', { id: 'b' });
1116
- expect(pushSpy).toHaveBeenCalledTimes(2);
1117
- });
1118
- });
1119
-
1120
-
1121
- // ---------------------------------------------------------------------------
1122
- // _interpolateParams edge cases
1123
- // ---------------------------------------------------------------------------
1124
-
1125
- describe('Router - _interpolateParams edge cases', () => {
1126
- let router;
1127
- beforeEach(() => {
1128
- router = createRouter({ mode: 'hash', routes: [] });
1129
- });
1130
-
1131
- it('handles special characters in param values', () => {
1132
- expect(router._interpolateParams('/tag/:name', { name: 'c++' })).toBe('/tag/c%2B%2B');
1133
- });
1134
-
1135
- it('handles empty string param value', () => {
1136
- expect(router._interpolateParams('/user/:id', { id: '' })).toBe('/user/');
1137
- });
1138
-
1139
- it('handles zero as param value', () => {
1140
- expect(router._interpolateParams('/page/:num', { num: 0 })).toBe('/page/0');
1141
- });
1142
-
1143
- it('handles boolean param values', () => {
1144
- expect(router._interpolateParams('/flag/:val', { val: true })).toBe('/flag/true');
1145
- });
1146
-
1147
- it('returns path unchanged when params is non-object', () => {
1148
- expect(router._interpolateParams('/about', 'string')).toBe('/about');
1149
- });
1150
-
1151
- it('handles path with no placeholders', () => {
1152
- expect(router._interpolateParams('/about', { id: 42 })).toBe('/about');
1153
- });
1154
-
1155
- it('handles adjacent params', () => {
1156
- // This is a weird URL but should still work
1157
- expect(router._interpolateParams('/:a/:b', { a: 'x', b: 'y' })).toBe('/x/y');
1158
- });
1159
- });
1160
-
1161
-
1162
- // ---------------------------------------------------------------------------
1163
- // Router.destroy cleans up everything
1164
- // ---------------------------------------------------------------------------
1165
-
1166
- describe('Router - destroy completeness', () => {
1167
- it('clears instance, routes, guards, listeners, and substates', () => {
1168
- document.body.innerHTML = '<div id="app"></div>';
1169
- const router = createRouter({
1170
- el: '#app',
1171
- mode: 'hash',
1172
- routes: [{ path: '/', component: 'home-page' }],
1173
- });
1174
- router.beforeEach(() => {});
1175
- router.afterEach(() => {});
1176
- router.onChange(() => {});
1177
- router.onSubstate(() => {});
1178
-
1179
- router.destroy();
1180
-
1181
- expect(router._routes.length).toBe(0);
1182
- expect(router._guards.before.length).toBe(0);
1183
- expect(router._guards.after.length).toBe(0);
1184
- expect(router._listeners.size).toBe(0);
1185
- expect(router._substateListeners.length).toBe(0);
1186
- expect(router._inSubstate).toBe(false);
1187
- });
1188
-
1189
- it('removes window event listeners on destroy (no leak)', () => {
1190
- document.body.innerHTML = '<div id="app"></div>';
1191
- const removeSpy = vi.spyOn(window, 'removeEventListener');
1192
- const router = createRouter({
1193
- el: '#app',
1194
- mode: 'hash',
1195
- routes: [{ path: '/', component: 'home-page' }],
1196
- });
1197
- // Store the handler reference before destroy
1198
- const navHandler = router._onNavEvent;
1199
- const clickHandler = router._onLinkClick;
1200
- expect(navHandler).toBeDefined();
1201
- expect(clickHandler).toBeDefined();
1202
-
1203
- router.destroy();
1204
-
1205
- expect(removeSpy).toHaveBeenCalledWith('hashchange', navHandler);
1206
- expect(router._onNavEvent).toBeNull();
1207
- expect(router._onLinkClick).toBeNull();
1208
- removeSpy.mockRestore();
1209
- });
1210
-
1211
- it('removes popstate listener in history mode on destroy', () => {
1212
- document.body.innerHTML = '<div id="app"></div>';
1213
- const removeSpy = vi.spyOn(window, 'removeEventListener');
1214
- const router = createRouter({
1215
- el: '#app',
1216
- mode: 'history',
1217
- routes: [{ path: '/', component: 'home-page' }],
1218
- });
1219
- const navHandler = router._onNavEvent;
1220
- router.destroy();
1221
- expect(removeSpy).toHaveBeenCalledWith('popstate', navHandler);
1222
- removeSpy.mockRestore();
1223
- });
1224
-
1225
- it('removes document click listener on destroy', () => {
1226
- document.body.innerHTML = '<div id="app"></div>';
1227
- const removeSpy = vi.spyOn(document, 'removeEventListener');
1228
- const router = createRouter({
1229
- el: '#app',
1230
- mode: 'hash',
1231
- routes: [],
1232
- });
1233
- const clickHandler = router._onLinkClick;
1234
- router.destroy();
1235
- expect(removeSpy).toHaveBeenCalledWith('click', clickHandler);
1236
- removeSpy.mockRestore();
1237
- });
1238
- });
1239
-
1240
-
1241
- // ---------------------------------------------------------------------------
1242
- // PERF: same-route comparison uses shallow equality (no JSON.stringify)
1243
- // ---------------------------------------------------------------------------
1244
-
1245
- describe('Router - same-route shallow equality', () => {
1246
- it('skips re-render when navigating to same route with same params', async () => {
1247
- document.body.innerHTML = '<div id="app"></div>';
1248
- let renderCount = 0;
1249
- const router = createRouter({
1250
- el: '#app',
1251
- mode: 'hash',
1252
- routes: [
1253
- { path: '/user/:id', render: () => '<div>user</div>' },
1254
- ],
1255
- });
1256
- // Mock component mount counting
1257
- router.afterEach(() => { renderCount++; });
1258
-
1259
- router.navigate('/user/42');
1260
- await new Promise(r => setTimeout(r, 50));
1261
- const firstCount = renderCount;
1262
-
1263
- // Navigate to the same route - should skip
1264
- router.navigate('/user/42');
1265
- await new Promise(r => setTimeout(r, 50));
1266
- // Hash mode prevents same-hash navigation at URL level,
1267
- // so renderCount should not increase
1268
- expect(renderCount).toBe(firstCount);
1269
- router.destroy();
1270
- });
1271
- });
1272
-
1273
-
1274
- // ===========================================================================
1275
- // Guard - cancel navigation
1276
- // ===========================================================================
1277
-
1278
- describe('Router - guard returning false cancels navigation', () => {
1279
- it('does not resolve route when guard returns false', async () => {
1280
- document.body.innerHTML = '<div id="app"></div>';
1281
- const router = createRouter({
1282
- el: '#app',
1283
- mode: 'hash',
1284
- routes: [
1285
- { path: '/', component: 'home-page' },
1286
- { path: '/blocked', component: 'about-page' },
1287
- ],
1288
- });
1289
- router.beforeEach(() => false);
1290
- await new Promise(r => setTimeout(r, 10));
1291
- router.navigate('/blocked');
1292
- await router._resolve();
1293
- // Should NOT have navigated to /blocked because guard cancelled
1294
- expect(router.current?.path).not.toBe('/blocked');
1295
- router.destroy();
1296
- });
1297
- });
1298
-
1299
-
1300
- // ===========================================================================
1301
- // Guard - redirect loop detection
1302
- // ===========================================================================
1303
-
1304
- describe('Router - guard redirect loop protection', () => {
1305
- it('stops after more than 10 redirects', async () => {
1306
- document.body.innerHTML = '<div id="app"></div>';
1307
- const router = createRouter({
1308
- el: '#app',
1309
- mode: 'hash',
1310
- routes: [
1311
- { path: '/', component: 'home-page' },
1312
- { path: '/a', component: 'about-page' },
1313
- { path: '/b', component: 'docs-page' },
1314
- ],
1315
- });
1316
- // Guard that keeps bouncing between /a and /b
1317
- router.beforeEach((to) => {
1318
- if (to.path === '/a') return '/b';
1319
- if (to.path === '/b') return '/a';
1320
- });
1321
- await new Promise(r => setTimeout(r, 10));
1322
- // Navigate to /a - should not infinite loop
1323
- window.location.hash = '#/a';
1324
- await router._resolve();
1325
- // Just verify it doesn't hang - the guard count > 10 stops it
1326
- router.destroy();
1327
- });
1328
- });
1329
-
1330
-
1331
- // ===========================================================================
1332
- // Guard - afterEach fires after resolve
1333
- // ===========================================================================
1334
-
1335
- describe('Router - afterEach hook', () => {
1336
- it('fires afterEach with to and from after route resolves', async () => {
1337
- document.body.innerHTML = '<div id="app"></div>';
1338
- const afterFn = vi.fn();
1339
- const router = createRouter({
1340
- el: '#app',
1341
- mode: 'hash',
1342
- routes: [
1343
- { path: '/', component: 'home-page' },
1344
- { path: '/about', component: 'about-page' },
1345
- ],
1346
- });
1347
- router.afterEach(afterFn);
1348
- await new Promise(r => setTimeout(r, 10));
1349
- afterFn.mockClear();
1350
-
1351
- window.location.hash = '#/about';
1352
- await router._resolve();
1353
- expect(afterFn).toHaveBeenCalledTimes(1);
1354
- expect(afterFn.mock.calls[0][0].path).toBe('/about');
1355
- router.destroy();
1356
- });
1357
- });
1358
-
1359
-
1360
- // ===========================================================================
1361
- // Guard - before guard that throws
1362
- // ===========================================================================
1363
-
1364
- describe('Router - before guard that throws', () => {
1365
- it('catches the error and does not crash', async () => {
1366
- document.body.innerHTML = '<div id="app"></div>';
1367
- const router = createRouter({
1368
- el: '#app',
1369
- mode: 'hash',
1370
- routes: [
1371
- { path: '/', component: 'home-page' },
1372
- { path: '/err', component: 'about-page' },
1373
- ],
1374
- });
1375
- router.beforeEach(() => { throw new Error('guard boom'); });
1376
- await new Promise(r => setTimeout(r, 10));
1377
- window.location.hash = '#/err';
1378
- await expect(router._resolve()).resolves.not.toThrow();
1379
- router.destroy();
1380
- });
1381
- });
1382
-
1383
-
1384
- // ===========================================================================
1385
- // Lazy loading via route.load
1386
- // ===========================================================================
1387
-
1388
- describe('Router - lazy loading with route.load', () => {
1389
- it('calls load() before mounting component', async () => {
1390
- const loadFn = vi.fn().mockResolvedValue(undefined);
1391
- document.body.innerHTML = '<div id="app"></div>';
1392
- const router = createRouter({
1393
- el: '#app',
1394
- mode: 'hash',
1395
- routes: [
1396
- { path: '/', component: 'home-page' },
1397
- { path: '/lazy', load: loadFn, component: 'about-page' },
1398
- ],
1399
- });
1400
- await new Promise(r => setTimeout(r, 10));
1401
- window.location.hash = '#/lazy';
1402
- await router._resolve();
1403
- expect(loadFn).toHaveBeenCalledTimes(1);
1404
- router.destroy();
1405
- });
1406
-
1407
- it('does not mount if load() rejects', async () => {
1408
- const loadFn = vi.fn().mockRejectedValue(new Error('load fail'));
1409
- document.body.innerHTML = '<div id="app"></div>';
1410
- const router = createRouter({
1411
- el: '#app',
1412
- mode: 'hash',
1413
- routes: [
1414
- { path: '/', component: 'home-page' },
1415
- { path: '/fail', load: loadFn, component: 'about-page' },
1416
- ],
1417
- });
1418
- await new Promise(r => setTimeout(r, 10));
1419
- window.location.hash = '#/fail';
1420
- await router._resolve();
1421
- // Route should not have resolved to /fail since load() threw
1422
- expect(router.current?.path).not.toBe('/fail');
1423
- router.destroy();
1424
- });
1425
- });
1426
-
1427
-
1428
- // ===========================================================================
1429
- // Fallback / 404 route
1430
- // ===========================================================================
1431
-
1432
- describe('Router - fallback 404 route', () => {
1433
- it('resolves to fallback component for unknown paths', async () => {
1434
- component('notfound-page', { render: () => '<p>404</p>' });
1435
- document.body.innerHTML = '<div id="app"></div>';
1436
- window.location.hash = '#/';
1437
- const router = createRouter({
1438
- el: '#app',
1439
- mode: 'hash',
1440
- routes: [{ path: '/', component: 'home-page' }],
1441
- fallback: 'notfound-page',
1442
- });
1443
- await new Promise(r => setTimeout(r, 10));
1444
- window.location.hash = '#/nonexistent';
1445
- await router._resolve();
1446
- expect(router.current.path).toBe('/nonexistent');
1447
- expect(router.current.route.component).toBe('notfound-page');
1448
- router.destroy();
1449
- });
1450
- });
1451
-
1452
-
1453
- // ===========================================================================
1454
- // replace()
1455
- // ===========================================================================
1456
-
1457
- describe('Router - replace()', () => {
1458
- it('returns router for chaining in hash mode', async () => {
1459
- document.body.innerHTML = '<div id="app"></div>';
1460
- window.location.hash = '#/';
1461
- const router = createRouter({
1462
- el: '#app',
1463
- mode: 'hash',
1464
- routes: [
1465
- { path: '/', component: 'home-page' },
1466
- { path: '/replaced', component: 'about-page' },
1467
- ],
1468
- });
1469
- await new Promise(r => setTimeout(r, 10));
1470
- const result = router.replace('/replaced');
1471
- expect(result).toBe(router);
1472
- router.destroy();
1473
- });
1474
- });
1475
-
1476
-
1477
- // ===========================================================================
1478
- // query getter
1479
- // ===========================================================================
1480
-
1481
- describe('Router - query getter', () => {
1482
- it('returns parsed query params from hash', () => {
1483
- document.body.innerHTML = '<div id="app"></div>';
1484
- const router = createRouter({
1485
- el: '#app',
1486
- mode: 'hash',
1487
- routes: [{ path: '/search', component: 'home-page' }],
1488
- });
1489
- window.location.hash = '#/search?q=hello&page=2';
1490
- expect(router.query).toEqual({ q: 'hello', page: '2' });
1491
- router.destroy();
1492
- });
1493
-
1494
- it('returns empty object for no query params', () => {
1495
- document.body.innerHTML = '<div id="app"></div>';
1496
- const router = createRouter({
1497
- el: '#app',
1498
- mode: 'hash',
1499
- routes: [{ path: '/', component: 'home-page' }],
1500
- });
1501
- window.location.hash = '#/';
1502
- expect(router.query).toEqual({});
1503
- router.destroy();
1504
- });
1505
- });
1506
-
1507
-
1508
- // ===========================================================================
1509
- // resolve() - programmatic link generation
1510
- // ===========================================================================
1511
-
1512
- describe('Router - resolve()', () => {
1513
- it('returns full URL path with base prefix', () => {
1514
- const router = createRouter({
1515
- mode: 'hash',
1516
- base: '/app',
1517
- routes: [{ path: '/', component: 'home-page' }],
1518
- });
1519
- expect(router.resolve('/about')).toBe('/app/about');
1520
- router.destroy();
1521
- });
1522
-
1523
- it('returns path as-is when no base', () => {
1524
- const router = createRouter({
1525
- mode: 'hash',
1526
- routes: [{ path: '/', component: 'home-page' }],
1527
- });
1528
- expect(router.resolve('/about')).toBe('/about');
1529
- router.destroy();
1530
- });
1531
- });
1532
-
1533
-
1534
- // ===========================================================================
1535
- // back/forward/go wrappers
1536
- // ===========================================================================
1537
-
1538
- describe('Router - back/forward/go wrappers', () => {
1539
- it('calls window.history.back', () => {
1540
- const spy = vi.spyOn(window.history, 'back').mockImplementation(() => {});
1541
- const router = createRouter({
1542
- mode: 'hash',
1543
- routes: [{ path: '/', component: 'home-page' }],
1544
- });
1545
- router.back();
1546
- expect(spy).toHaveBeenCalled();
1547
- spy.mockRestore();
1548
- router.destroy();
1549
- });
1550
-
1551
- it('calls window.history.forward', () => {
1552
- const spy = vi.spyOn(window.history, 'forward').mockImplementation(() => {});
1553
- const router = createRouter({
1554
- mode: 'hash',
1555
- routes: [{ path: '/', component: 'home-page' }],
1556
- });
1557
- router.forward();
1558
- expect(spy).toHaveBeenCalled();
1559
- spy.mockRestore();
1560
- router.destroy();
1561
- });
1562
-
1563
- it('calls window.history.go with argument', () => {
1564
- const spy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
1565
- const router = createRouter({
1566
- mode: 'hash',
1567
- routes: [{ path: '/', component: 'home-page' }],
1568
- });
1569
- router.go(-2);
1570
- expect(spy).toHaveBeenCalledWith(-2);
1571
- spy.mockRestore();
1572
- router.destroy();
1573
- });
1574
- });
1575
-
1576
-
1577
- // ===========================================================================
1578
- // Link click interception - modified clicks bypass
1579
- // ===========================================================================
1580
-
1581
- describe('Router - link click interception', () => {
1582
- it('intercepts normal clicks on z-link elements', async () => {
1583
- document.body.innerHTML = '<div id="app"></div><a z-link="/about">About</a>';
1584
- const router = createRouter({
1585
- el: '#app',
1586
- mode: 'hash',
1587
- routes: [
1588
- { path: '/', component: 'home-page' },
1589
- { path: '/about', component: 'about-page' },
1590
- ],
1591
- });
1592
- await new Promise(r => setTimeout(r, 10));
1593
-
1594
- const link = document.querySelector('[z-link]');
1595
- const e = new Event('click', { bubbles: true, cancelable: true });
1596
- link.dispatchEvent(e);
1597
- // Should have navigated
1598
- await new Promise(r => setTimeout(r, 10));
1599
- expect(window.location.hash).toBe('#/about');
1600
- router.destroy();
1601
- });
1602
-
1603
- it('ignores clicks with meta key (does not navigate)', async () => {
1604
- document.body.innerHTML = '<div id="app"></div><a z-link="/about">About</a>';
1605
- window.location.hash = '#/';
1606
- const router = createRouter({
1607
- el: '#app',
1608
- mode: 'hash',
1609
- routes: [
1610
- { path: '/', component: 'home-page' },
1611
- { path: '/about', component: 'about-page' },
1612
- ],
1613
- });
1614
- await new Promise(r => setTimeout(r, 50));
1615
- // Record current route before meta click
1616
- const currentBefore = router.current?.path;
1617
-
1618
- const link = document.querySelector('[z-link]');
1619
- const e = new MouseEvent('click', { bubbles: true, metaKey: true });
1620
- link.dispatchEvent(e);
1621
- await new Promise(r => setTimeout(r, 10));
1622
- // Route should remain unchanged - meta key bypasses SPA navigation
1623
- expect(router.current?.path).toBe(currentBefore);
1624
- router.destroy();
1625
- });
1626
-
1627
- it('ignores clicks with ctrl key', async () => {
1628
- document.body.innerHTML = '<div id="app"></div><a z-link="/about2">About</a>';
1629
- window.location.hash = '#/';
1630
- const router = createRouter({
1631
- el: '#app',
1632
- mode: 'hash',
1633
- routes: [
1634
- { path: '/', component: 'home-page' },
1635
- { path: '/about2', component: 'about-page' },
1636
- ],
1637
- });
1638
- await new Promise(r => setTimeout(r, 50));
1639
- const link = document.querySelector('[z-link]');
1640
- const e = new MouseEvent('click', { bubbles: true, ctrlKey: true });
1641
- link.dispatchEvent(e);
1642
- await new Promise(r => setTimeout(r, 10));
1643
- expect(router.current?.path).not.toBe('/about2');
1644
- router.destroy();
1645
- });
1646
-
1647
- it('ignores links with target=_blank', async () => {
1648
- document.body.innerHTML = '<div id="app"></div><a z-link="/about" target="_blank">About</a>';
1649
- window.location.hash = '#/';
1650
- const router = createRouter({
1651
- el: '#app',
1652
- mode: 'hash',
1653
- routes: [
1654
- { path: '/', component: 'home-page' },
1655
- { path: '/about', component: 'about-page' },
1656
- ],
1657
- });
1658
- await new Promise(r => setTimeout(r, 10));
1659
- const link = document.querySelector('[z-link]');
1660
- link.click();
1661
- await new Promise(r => setTimeout(r, 10));
1662
- expect(window.location.hash).toBe('#/');
1663
- router.destroy();
1664
- });
1665
- });
1666
-
1667
-
1668
- // ===========================================================================
1669
- // Router - remove() route
1670
- // ===========================================================================
1671
-
1672
- describe('Router - remove()', () => {
1673
- it('removes route by path', () => {
1674
- const router = createRouter({
1675
- mode: 'hash',
1676
- routes: [
1677
- { path: '/', component: 'home-page' },
1678
- { path: '/temp', component: 'about-page' },
1679
- ],
1680
- });
1681
- expect(router._routes.length).toBe(2);
1682
- router.remove('/temp');
1683
- expect(router._routes.length).toBe(1);
1684
- expect(router._routes[0].path).toBe('/');
1685
- router.destroy();
1686
- });
1687
- });
1688
-
1689
-
1690
- // ===========================================================================
1691
- // Router - add() chaining
1692
- // ===========================================================================
1693
-
1694
- describe('Router - add() chaining', () => {
1695
- it('supports fluent chaining of add calls', () => {
1696
- const router = createRouter({
1697
- mode: 'hash',
1698
- routes: [],
1699
- });
1700
- const result = router.add({ path: '/', component: 'home-page' })
1701
- .add({ path: '/about', component: 'about-page' });
1702
- expect(result).toBe(router);
1703
- expect(router._routes.length).toBe(2);
1704
- router.destroy();
1705
- });
1706
- });
1707
-
1708
-
1709
- // ===========================================================================
1710
- // Router - onChange unsubscribe
1711
- // ===========================================================================
1712
-
1713
- describe('Router - onChange unsubscribe', () => {
1714
- it('stops calling listener after unsubscribe', async () => {
1715
- document.body.innerHTML = '<div id="app"></div>';
1716
- const listener = vi.fn();
1717
- const router = createRouter({
1718
- el: '#app',
1719
- mode: 'hash',
1720
- routes: [
1721
- { path: '/', component: 'home-page' },
1722
- { path: '/about', component: 'about-page' },
1723
- ],
1724
- });
1725
- const unsub = router.onChange(listener);
1726
- await new Promise(r => setTimeout(r, 10));
1727
- listener.mockClear();
1728
-
1729
- unsub();
1730
- window.location.hash = '#/about';
1731
- await router._resolve();
1732
- expect(listener).not.toHaveBeenCalled();
1733
- router.destroy();
1734
- });
1735
- });
1736
-
1737
-
1738
- // ===========================================================================
1739
- // Router - destroy cleans up
1740
- // ===========================================================================
1741
-
1742
- describe('Router - destroy cleans up', () => {
1743
- it('clears listeners, guards, and routes on destroy', () => {
1744
- document.body.innerHTML = '<div id="app"></div>';
1745
- const router = createRouter({
1746
- el: '#app',
1747
- mode: 'hash',
1748
- routes: [{ path: '/', component: 'home-page' }],
1749
- });
1750
- router.beforeEach(() => {});
1751
- router.afterEach(() => {});
1752
- router.onChange(() => {});
1753
- router.onSubstate(() => {});
1754
- router.destroy();
1755
- expect(router._routes.length).toBe(0);
1756
- expect(router._guards.before.length).toBe(0);
1757
- expect(router._guards.after.length).toBe(0);
1758
- expect(router._listeners.size).toBe(0);
1759
- expect(router._substateListeners.length).toBe(0);
1760
- });
1761
- });
1762
-
1763
-
1764
- // ===========================================================================
1765
- // Router - _interpolateParams
1766
- // ===========================================================================
1767
-
1768
- describe('Router - _interpolateParams', () => {
1769
- it('replaces :param with provided values', () => {
1770
- const router = createRouter({
1771
- mode: 'hash',
1772
- routes: [{ path: '/user/:id', component: 'user-page' }],
1773
- });
1774
- const result = router._interpolateParams('/user/:id/post/:pid', { id: 42, pid: 7 });
1775
- expect(result).toBe('/user/42/post/7');
1776
- router.destroy();
1777
- });
1778
-
1779
- it('keeps :param when value not provided', () => {
1780
- const router = createRouter({
1781
- mode: 'hash',
1782
- routes: [],
1783
- });
1784
- const result = router._interpolateParams('/user/:id', {});
1785
- expect(result).toBe('/user/:id');
1786
- router.destroy();
1787
- });
1788
-
1789
- it('returns path unchanged when params is null', () => {
1790
- const router = createRouter({ mode: 'hash', routes: [] });
1791
- expect(router._interpolateParams('/test', null)).toBe('/test');
1792
- router.destroy();
1793
- });
1794
- });
1795
-
1796
-
1797
- // ===========================================================================
1798
- // Router - _normalizePath with base stripping
1799
- // ===========================================================================
1800
-
1801
- describe('Router - _normalizePath', () => {
1802
- it('strips base prefix if accidentally included', () => {
1803
- const router = createRouter({
1804
- mode: 'hash',
1805
- base: '/app',
1806
- routes: [],
1807
- });
1808
- expect(router._normalizePath('/app/about')).toBe('/about');
1809
- router.destroy();
1810
- });
1811
-
1812
- it('returns / when path matches base exactly', () => {
1813
- const router = createRouter({
1814
- mode: 'hash',
1815
- base: '/app',
1816
- routes: [],
1817
- });
1818
- expect(router._normalizePath('/app')).toBe('/');
1819
- router.destroy();
1820
- });
1821
-
1822
- it('adds leading slash to bare paths', () => {
1823
- const router = createRouter({ mode: 'hash', routes: [] });
1824
- expect(router._normalizePath('about')).toBe('/about');
1825
- router.destroy();
1826
- });
1827
-
1828
- it('returns / for empty/null path', () => {
1829
- const router = createRouter({ mode: 'hash', routes: [] });
1830
- expect(router._normalizePath('')).toBe('/');
1831
- expect(router._normalizePath(null)).toBe('/');
1832
- router.destroy();
1833
- });
1834
- });
1835
-
1836
-
1837
- // ===========================================================================
1838
- // Router - navigate with options.params
1839
- // ===========================================================================
1840
-
1841
- describe('Router - navigate with options.params', () => {
1842
- it('interpolates params in path', async () => {
1843
- document.body.innerHTML = '<div id="app"></div>';
1844
- const router = createRouter({
1845
- el: '#app',
1846
- mode: 'hash',
1847
- routes: [
1848
- { path: '/', component: 'home-page' },
1849
- { path: '/user/:id', component: 'user-page' },
1850
- ],
1851
- });
1852
- await new Promise(r => setTimeout(r, 10));
1853
- router.navigate('/user/:id', { params: { id: '99' } });
1854
- await router._resolve();
1855
- expect(window.location.hash).toBe('#/user/99');
1856
- router.destroy();
1857
- });
1858
- });
1859
-
1860
-
1861
- // ===========================================================================
1862
- // Router - render function components
1863
- // ===========================================================================
1864
-
1865
- describe('Router - render function component', () => {
1866
- it('renders HTML from a function component', async () => {
1867
- document.body.innerHTML = '<div id="app"></div>';
1868
- window.location.hash = '#/';
1869
- const router = createRouter({
1870
- el: '#app',
1871
- mode: 'hash',
1872
- routes: [
1873
- { path: '/', component: (route) => `<p>fn: ${route.path}</p>` },
1874
- ],
1875
- });
1876
- // Wait for initial resolve (queueMicrotask + rendering)
1877
- await new Promise(r => setTimeout(r, 100));
1878
- const p = document.querySelector('#app p');
1879
- expect(p).not.toBeNull();
1880
- expect(p.textContent).toBe('fn: /');
1881
- router.destroy();
1882
- });
1883
- });
1884
-
1885
-
1886
- // ===========================================================================
1887
- // Router - substate onSubstate unsubscribe
1888
- // ===========================================================================
1889
-
1890
- describe('Router - onSubstate unsubscribe', () => {
1891
- it('removes listener after unsubscribe', () => {
1892
- const router = createRouter({ mode: 'hash', routes: [] });
1893
- const fn = vi.fn();
1894
- const unsub = router.onSubstate(fn);
1895
- expect(router._substateListeners.length).toBe(1);
1896
- unsub();
1897
- expect(router._substateListeners.length).toBe(0);
1898
- router.destroy();
1899
- });
1900
- });
1901
-
1902
-
1903
- // ===========================================================================
1904
- // Router - substate history restoration after navigation away
1905
- // ===========================================================================
1906
-
1907
- describe('Router - substate restoration after navigating away and back', () => {
1908
- let router;
1909
-
1910
- beforeEach(() => {
1911
- document.body.innerHTML = '<div id="app"></div>';
1912
- window.location.hash = '#/';
1913
- });
1914
-
1915
- afterEach(() => {
1916
- if (router) router.destroy();
1917
- });
1918
-
1919
- /**
1920
- * Reproduces the bug: tabbing through substates in a component (e.g.
1921
- * compare-page), navigating to a different route (e.g. /about), then
1922
- * pressing back should return to the LAST substate tab — not the default.
1923
- *
1924
- * Steps to reproduce:
1925
- * 1. Navigate to /compare, push substates: tab-a → tab-b → tab-c
1926
- * 2. Navigate to /about (component with listener is destroyed)
1927
- * 3. Simulate popstate back → lands on substate entry for tab-c
1928
- * 4. BUG: no listener exists (it was destroyed), so the substate is
1929
- * ignored and the component remounts with its default state.
1930
- * 5. EXPECTED: the router should re-fire the substate after the new
1931
- * component mounts so the listener can restore the correct tab.
1932
- */
1933
- it('restores last substate when pressing back after navigating away', async () => {
1934
- router = createRouter({
1935
- el: '#app',
1936
- mode: 'history',
1937
- routes: [
1938
- { path: '/', component: 'home-page' },
1939
- { path: '/compare', component: 'about-page' },
1940
- { path: '/about', component: 'user-page' },
1941
- ],
1942
- });
1943
-
1944
- // Wait for initial resolve
1945
- await new Promise(r => setTimeout(r, 10));
1946
-
1947
- // 1. Navigate to /compare and register a substate listener
1948
- // (simulates what compare-page does in its mounted() hook)
1949
- window.history.pushState({ __zq: 'route' }, '', '/compare');
1950
- await router._resolve();
1951
- expect(router.current.path).toBe('/compare');
1952
-
1953
- // Simulate component mounting and registering a substate listener
1954
- let activeTab = 'overview';
1955
- const listener = vi.fn((key, data, action) => {
1956
- if (key === 'compare-tab') { activeTab = data.tab; return true; }
1957
- if (action === 'reset') { activeTab = 'overview'; return true; }
1958
- });
1959
- const unsub = router.onSubstate(listener);
1960
-
1961
- // 2. Push several substates (tab switches)
1962
- router.pushSubstate('compare-tab', { tab: 'components' });
1963
- activeTab = 'components';
1964
- router.pushSubstate('compare-tab', { tab: 'directives' });
1965
- activeTab = 'directives';
1966
- router.pushSubstate('compare-tab', { tab: 'reactivity' });
1967
- activeTab = 'reactivity';
1968
-
1969
- // 3. Navigate away to /about — this destroys compare-page
1970
- // so we unsubscribe the listener (like destroyed() would)
1971
- unsub();
1972
- window.history.pushState({ __zq: 'route' }, '', '/about');
1973
- await router._resolve();
1974
- expect(router.current.path).toBe('/about');
1975
-
1976
- // 4. Register a NEW listener after _resolve, simulating what the freshly
1977
- // mounted compare-page would do. We use a one-time setup that defers
1978
- // registration until _resolve has run (like mounted() in the component).
1979
- let restoredTab = 'overview';
1980
- const freshListener = vi.fn((key, data, action) => {
1981
- if (key === 'compare-tab') { restoredTab = data.tab; return true; }
1982
- if (action === 'reset') { restoredTab = 'overview'; return true; }
1983
- });
1984
-
1985
- // Intercept _resolve to register the listener after mount (simulating
1986
- // the component's mounted() lifecycle hook)
1987
- const origResolve = router.__resolve.bind(router);
1988
- router.__resolve = async function () {
1989
- await origResolve();
1990
- // After resolve mounts the component, register the substate listener
1991
- router.onSubstate(freshListener);
1992
- };
1993
-
1994
- // 5. Simulate pressing back — popstate lands on the last substate
1995
- const evt = new PopStateEvent('popstate', {
1996
- state: { __zq: 'substate', key: 'compare-tab', data: { tab: 'reactivity' } }
1997
- });
1998
- window.dispatchEvent(evt);
1999
-
2000
- // Allow async _resolve to complete and the retry to fire
2001
- await new Promise(r => setTimeout(r, 50));
2002
-
2003
- // EXPECTED: the fresh listener should have been called with the substate
2004
- // data so that restoredTab is 'reactivity', NOT 'overview'
2005
- expect(freshListener).toHaveBeenCalledWith('compare-tab', { tab: 'reactivity' }, 'pop');
2006
- expect(restoredTab).toBe('reactivity');
2007
- });
2008
-
2009
- it('handles second back correctly after substate restoration', async () => {
2010
- router = createRouter({
2011
- el: '#app',
2012
- mode: 'history',
2013
- routes: [
2014
- { path: '/', component: 'home-page' },
2015
- { path: '/compare', component: 'about-page' },
2016
- { path: '/about', component: 'user-page' },
2017
- ],
2018
- });
2019
- await new Promise(r => setTimeout(r, 10));
2020
-
2021
- // Navigate to /compare
2022
- window.history.pushState({ __zq: 'route' }, '', '/compare');
2023
- await router._resolve();
2024
-
2025
- let activeTab = 'overview';
2026
- const listener = vi.fn((key, data, action) => {
2027
- if (key === 'compare-tab') { activeTab = data.tab; return true; }
2028
- if (action === 'reset') { activeTab = 'overview'; return true; }
2029
- });
2030
- const unsub = router.onSubstate(listener);
2031
-
2032
- // Push two substates
2033
- router.pushSubstate('compare-tab', { tab: 'components' });
2034
- router.pushSubstate('compare-tab', { tab: 'directives' });
2035
-
2036
- // Navigate away and unsubscribe (simulating component destroy)
2037
- unsub();
2038
- window.history.pushState({ __zq: 'route' }, '', '/about');
2039
- await router._resolve();
2040
-
2041
- // Back: first pop hits 'directives' substate — no listener yet
2042
- let restoredTab = 'overview';
2043
- const freshListener = vi.fn((key, data, action) => {
2044
- if (key === 'compare-tab') { restoredTab = data.tab; return true; }
2045
- if (action === 'reset') { restoredTab = 'overview'; return true; }
2046
- });
2047
-
2048
- const origResolve = router.__resolve.bind(router);
2049
- let resolveCount = 0;
2050
- router.__resolve = async function () {
2051
- await origResolve();
2052
- if (resolveCount === 0) router.onSubstate(freshListener);
2053
- resolveCount++;
2054
- };
2055
-
2056
- // First back: lands on 'directives' substate
2057
- window.dispatchEvent(new PopStateEvent('popstate', {
2058
- state: { __zq: 'substate', key: 'compare-tab', data: { tab: 'directives' } }
2059
- }));
2060
- await new Promise(r => setTimeout(r, 50));
2061
-
2062
- expect(restoredTab).toBe('directives');
2063
-
2064
- // Second back: lands on 'components' substate — listener IS registered now
2065
- window.dispatchEvent(new PopStateEvent('popstate', {
2066
- state: { __zq: 'substate', key: 'compare-tab', data: { tab: 'components' } }
2067
- }));
2068
- await new Promise(r => setTimeout(r, 50));
2069
-
2070
- expect(restoredTab).toBe('components');
2071
- });
2072
- });
2073
-
2074
-
2075
- // ===========================================================================
2076
- // Router - <z-outlet> auto-detection
2077
- // ===========================================================================
2078
-
2079
- describe('Router - z-outlet auto-detection', () => {
2080
- beforeEach(() => {
2081
- window.location.hash = '#/';
2082
- });
2083
-
2084
- it('auto-detects <z-outlet> when no el: is provided', async () => {
2085
- document.body.innerHTML = '<z-outlet></z-outlet>';
2086
- const router = createRouter({
2087
- mode: 'hash',
2088
- routes: [{ path: '/', component: 'home-page' }],
2089
- });
2090
- expect(router._el).toBe(document.querySelector('z-outlet'));
2091
- // Wait for initial resolve and verify component was mounted
2092
- await new Promise(r => setTimeout(r, 50));
2093
- expect(router._el.innerHTML).not.toBe('');
2094
- router.destroy();
2095
- });
2096
-
2097
- it('prefers explicit el: over <z-outlet>', () => {
2098
- document.body.innerHTML = '<div id="app"></div><z-outlet></z-outlet>';
2099
- const router = createRouter({
2100
- mode: 'hash',
2101
- el: '#app',
2102
- routes: [{ path: '/', component: 'home-page' }],
2103
- });
2104
- expect(router._el).toBe(document.getElementById('app'));
2105
- router.destroy();
2106
- });
2107
-
2108
- it('reads fallback attribute from <z-outlet>', () => {
2109
- document.body.innerHTML = '<z-outlet fallback="about-page"></z-outlet>';
2110
- const router = createRouter({
2111
- mode: 'hash',
2112
- routes: [{ path: '/', component: 'home-page' }],
2113
- });
2114
- expect(router._fallback).toBe('about-page');
2115
- router.destroy();
2116
- });
2117
-
2118
- it('config fallback takes priority over <z-outlet> fallback attribute', () => {
2119
- document.body.innerHTML = '<z-outlet fallback="about-page"></z-outlet>';
2120
- const router = createRouter({
2121
- mode: 'hash',
2122
- routes: [{ path: '/', component: 'home-page' }],
2123
- fallback: 'user-page',
2124
- });
2125
- expect(router._fallback).toBe('user-page');
2126
- router.destroy();
2127
- });
2128
-
2129
- it('reads mode attribute from <z-outlet>', () => {
2130
- document.body.innerHTML = '<z-outlet mode="hash"></z-outlet>';
2131
- const router = createRouter({
2132
- routes: [{ path: '/', component: 'home-page' }],
2133
- });
2134
- expect(router._mode).toBe('hash');
2135
- router.destroy();
2136
- });
2137
-
2138
- it('reads base attribute from <z-outlet>', () => {
2139
- document.body.innerHTML = '<z-outlet base="/my-app"></z-outlet>';
2140
- const router = createRouter({
2141
- mode: 'hash',
2142
- routes: [],
2143
- });
2144
- expect(router._base).toBe('/my-app');
2145
- router.destroy();
2146
- });
2147
-
2148
- it('config base takes priority over <z-outlet> base attribute', () => {
2149
- document.body.innerHTML = '<z-outlet base="/outlet-base"></z-outlet>';
2150
- const router = createRouter({
2151
- mode: 'hash',
2152
- base: '/config-base',
2153
- routes: [],
2154
- });
2155
- expect(router._base).toBe('/config-base');
2156
- router.destroy();
2157
- });
2158
-
2159
- it('falls back gracefully when no <z-outlet> and no el:', () => {
2160
- document.body.innerHTML = '<div>no outlet here</div>';
2161
- const router = createRouter({
2162
- mode: 'hash',
2163
- routes: [{ path: '/', component: 'home-page' }],
2164
- });
2165
- expect(router._el).toBeNull();
2166
- router.destroy();
2167
- });
2168
-
2169
- it('mounts and navigates using <z-outlet>', async () => {
2170
- document.body.innerHTML = '<z-outlet></z-outlet>';
2171
- const router = createRouter({
2172
- mode: 'hash',
2173
- routes: [
2174
- { path: '/', component: 'home-page' },
2175
- { path: '/about', component: 'about-page' },
2176
- ],
2177
- });
2178
- await new Promise(r => setTimeout(r, 50));
2179
- expect(router.current.path).toBe('/');
2180
- router.navigate('/about');
2181
- await router._resolve();
2182
- expect(router.current.path).toBe('/about');
2183
- router.destroy();
2184
- });
2185
- });
2186
-
2187
-
2188
- // ===========================================================================
2189
- // z-active-route directive
2190
- // ===========================================================================
2191
-
2192
- describe('Router - z-active-route', () => {
2193
- let router;
2194
-
2195
- beforeEach(() => {
2196
- document.body.innerHTML = '<div id="app"></div>';
2197
- router = createRouter({
2198
- el: '#app',
2199
- mode: 'hash',
2200
- routes: [
2201
- { path: '/', component: 'home-page' },
2202
- { path: '/about', component: 'about-page' },
2203
- { path: '/docs/:section', component: 'docs-page' },
2204
- ],
2205
- });
2206
- });
2207
-
2208
- afterEach(() => {
2209
- if (router) router.destroy();
2210
- });
2211
-
2212
- it('adds "active" class to matching element (prefix match)', () => {
2213
- document.body.innerHTML += `
2214
- <a z-link="/docs/intro" z-active-route="/docs">Docs</a>
2215
- <a z-link="/about" z-active-route="/about">About</a>
2216
- `;
2217
- router._updateActiveRoutes('/docs/intro');
2218
- const docsLink = document.querySelector('[z-active-route="/docs"]');
2219
- const aboutLink = document.querySelector('[z-active-route="/about"]');
2220
- expect(docsLink.classList.contains('active')).toBe(true);
2221
- expect(aboutLink.classList.contains('active')).toBe(false);
2222
- });
2223
-
2224
- it('removes "active" class when route no longer matches', () => {
2225
- document.body.innerHTML += '<a z-active-route="/about">About</a>';
2226
- const el = document.querySelector('[z-active-route="/about"]');
2227
-
2228
- router._updateActiveRoutes('/about');
2229
- expect(el.classList.contains('active')).toBe(true);
2230
-
2231
- router._updateActiveRoutes('/docs');
2232
- expect(el.classList.contains('active')).toBe(false);
2233
- });
2234
-
2235
- it('supports custom class via z-active-class', () => {
2236
- document.body.innerHTML += '<a z-active-route="/about" z-active-class="selected">About</a>';
2237
- const el = document.querySelector('[z-active-route="/about"]');
2238
-
2239
- router._updateActiveRoutes('/about');
2240
- expect(el.classList.contains('selected')).toBe(true);
2241
- expect(el.classList.contains('active')).toBe(false);
2242
- });
2243
-
2244
- it('supports exact matching with z-active-exact', () => {
2245
- document.body.innerHTML += `
2246
- <a z-active-route="/" z-active-exact>Home</a>
2247
- <a z-active-route="/docs">Docs</a>
2248
- `;
2249
- const home = document.querySelector('[z-active-route="/"]');
2250
- const docs = document.querySelector('[z-active-route="/docs"]');
2251
-
2252
- // At root - home is exact match, docs should not match
2253
- router._updateActiveRoutes('/');
2254
- expect(home.classList.contains('active')).toBe(true);
2255
- expect(docs.classList.contains('active')).toBe(false);
2256
-
2257
- // At /docs - home exact should NOT match, docs prefix should match
2258
- router._updateActiveRoutes('/docs');
2259
- expect(home.classList.contains('active')).toBe(false);
2260
- expect(docs.classList.contains('active')).toBe(true);
2261
- });
2262
-
2263
- it('root "/" only matches itself, not all paths (prefix match)', () => {
2264
- document.body.innerHTML += '<a z-active-route="/">Home</a>';
2265
- const el = document.querySelector('[z-active-route="/"]');
2266
-
2267
- router._updateActiveRoutes('/about');
2268
- expect(el.classList.contains('active')).toBe(false);
2269
-
2270
- router._updateActiveRoutes('/');
2271
- expect(el.classList.contains('active')).toBe(true);
2272
- });
2273
-
2274
- it('handles multiple elements simultaneously', () => {
2275
- document.body.innerHTML += `
2276
- <nav>
2277
- <a z-active-route="/" z-active-exact>Home</a>
2278
- <a z-active-route="/about">About</a>
2279
- <a z-active-route="/docs">Docs</a>
2280
- <a z-active-route="/docs" z-active-class="highlight">Docs Alt</a>
2281
- </nav>
2282
- `;
2283
-
2284
- router._updateActiveRoutes('/docs/getting-started');
2285
-
2286
- const home = document.querySelector('[z-active-route="/"]');
2287
- const about = document.querySelector('[z-active-route="/about"]');
2288
- const docs = document.querySelectorAll('[z-active-route="/docs"]');
2289
-
2290
- expect(home.classList.contains('active')).toBe(false);
2291
- expect(about.classList.contains('active')).toBe(false);
2292
- expect(docs[0].classList.contains('active')).toBe(true);
2293
- expect(docs[1].classList.contains('highlight')).toBe(true);
2294
- });
2295
-
2296
- it('z-active-exact does not match child routes', () => {
2297
- document.body.innerHTML += '<a z-active-route="/docs" z-active-exact>Docs</a>';
2298
- const el = document.querySelector('[z-active-route="/docs"]');
2299
-
2300
- router._updateActiveRoutes('/docs/intro');
2301
- expect(el.classList.contains('active')).toBe(false);
2302
-
2303
- router._updateActiveRoutes('/docs');
2304
- expect(el.classList.contains('active')).toBe(true);
2305
- });
2306
-
2307
- it('toggles class correctly across navigation changes', () => {
2308
- document.body.innerHTML += `
2309
- <a z-active-route="/about">About</a>
2310
- <a z-active-route="/docs">Docs</a>
2311
- `;
2312
- const about = document.querySelector('[z-active-route="/about"]');
2313
- const docs = document.querySelector('[z-active-route="/docs"]');
2314
-
2315
- router._updateActiveRoutes('/about');
2316
- expect(about.classList.contains('active')).toBe(true);
2317
- expect(docs.classList.contains('active')).toBe(false);
2318
-
2319
- router._updateActiveRoutes('/docs/selectors');
2320
- expect(about.classList.contains('active')).toBe(false);
2321
- expect(docs.classList.contains('active')).toBe(true);
2322
-
2323
- router._updateActiveRoutes('/');
2324
- expect(about.classList.contains('active')).toBe(false);
2325
- expect(docs.classList.contains('active')).toBe(false);
2326
- });
2327
- });
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createRouter, getRouter, matchRoute } from '../src/router.js';
3
+ import { component } from '../src/component.js';
4
+
5
+ // Register stub components used in route definitions so mount() doesn't throw
6
+ component('home-page', { render: () => '<p>home</p>' });
7
+ component('about-page', { render: () => '<p>about</p>' });
8
+ component('user-page', { render: () => '<p>user</p>' });
9
+ component('docs-page', { render: () => '<p>docs</p>' });
10
+
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Router creation and basic API
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe('Router - creation', () => {
17
+ beforeEach(() => {
18
+ document.body.innerHTML = '<div id="app"></div>';
19
+ });
20
+
21
+ it('creates a router and retrieves it with getRouter', () => {
22
+ const router = createRouter({
23
+ el: '#app',
24
+ mode: 'hash',
25
+ routes: [
26
+ { path: '/', component: 'home-page' },
27
+ ],
28
+ });
29
+ expect(getRouter()).toBe(router);
30
+ });
31
+
32
+ it('defaults to history mode (unless file:// protocol)', () => {
33
+ const router = createRouter({
34
+ routes: [{ path: '/', component: 'home-page' }],
35
+ });
36
+ expect(router._mode).toBe('history');
37
+ });
38
+
39
+ it('resolves base path from config', () => {
40
+ const router = createRouter({
41
+ base: '/app',
42
+ routes: [],
43
+ });
44
+ expect(router.base).toBe('/app');
45
+ });
46
+
47
+ it('strips trailing slash from base', () => {
48
+ const router = createRouter({
49
+ base: '/app/',
50
+ routes: [],
51
+ });
52
+ expect(router.base).toBe('/app');
53
+ });
54
+ });
55
+
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Route matching
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe('Router - route matching', () => {
62
+ it('compiles path params', () => {
63
+ const router = createRouter({
64
+ mode: 'hash',
65
+ routes: [
66
+ { path: '/user/:id', component: 'user-page' },
67
+ ],
68
+ });
69
+ const route = router._routes[0];
70
+ expect(route._regex.test('/user/42')).toBe(true);
71
+ expect(route._keys).toEqual(['id']);
72
+ });
73
+
74
+ it('adds fallback route alias', () => {
75
+ const router = createRouter({
76
+ mode: 'hash',
77
+ routes: [
78
+ { path: '/docs/:section', fallback: '/docs', component: 'docs-page' },
79
+ ],
80
+ });
81
+ // Should have two routes: the original + fallback alias
82
+ expect(router._routes.length).toBe(2);
83
+ expect(router._routes[1]._regex.test('/docs')).toBe(true);
84
+ });
85
+ });
86
+
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Navigation
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe('Router - navigation', () => {
93
+ let router;
94
+
95
+ beforeEach(() => {
96
+ document.body.innerHTML = '<div id="app"></div>';
97
+ window.location.hash = '#/';
98
+ router = createRouter({
99
+ el: '#app',
100
+ mode: 'hash',
101
+ routes: [
102
+ { path: '/', component: 'home-page' },
103
+ { path: '/about', component: 'about-page' },
104
+ ],
105
+ });
106
+ });
107
+
108
+ it('navigate changes hash', () => {
109
+ router.navigate('/about');
110
+ expect(window.location.hash).toBe('#/about');
111
+ });
112
+
113
+ it('replace changes hash without pushing history', () => {
114
+ router.replace('/about');
115
+ expect(window.location.hash).toBe('#/about');
116
+ });
117
+
118
+ it('navigate interpolates :param placeholders from options.params', () => {
119
+ router.navigate('/user/:id', { params: { id: 42 } });
120
+ expect(window.location.hash).toBe('#/user/42');
121
+ });
122
+
123
+ it('navigate interpolates multiple :param placeholders', () => {
124
+ router.navigate('/post/:postId/comment/:cid', { params: { postId: 5, cid: 99 } });
125
+ expect(window.location.hash).toBe('#/post/5/comment/99');
126
+ });
127
+
128
+ it('navigate leaves unmatched :params as-is', () => {
129
+ router.navigate('/user/:id', { params: { name: 'Alice' } });
130
+ expect(window.location.hash).toBe('#/user/:id');
131
+ });
132
+
133
+ it('navigate URI-encodes param values', () => {
134
+ router.navigate('/search/:query', { params: { query: 'hello world' } });
135
+ expect(window.location.hash).toBe('#/search/hello%20world');
136
+ });
137
+
138
+ it('replace interpolates :param placeholders from options.params', () => {
139
+ router.replace('/user/:id', { params: { id: 7 } });
140
+ expect(window.location.hash).toBe('#/user/7');
141
+ });
142
+
143
+ it('navigate without params option works as before', () => {
144
+ router.navigate('/about');
145
+ expect(window.location.hash).toBe('#/about');
146
+ });
147
+ });
148
+
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // _interpolateParams
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe('Router - _interpolateParams', () => {
155
+ let router;
156
+
157
+ beforeEach(() => {
158
+ router = createRouter({ mode: 'hash', routes: [] });
159
+ });
160
+
161
+ it('replaces single :param', () => {
162
+ expect(router._interpolateParams('/user/:id', { id: 42 })).toBe('/user/42');
163
+ });
164
+
165
+ it('replaces multiple :params', () => {
166
+ expect(router._interpolateParams('/post/:pid/comment/:cid', { pid: 1, cid: 5 }))
167
+ .toBe('/post/1/comment/5');
168
+ });
169
+
170
+ it('leaves unmatched :params in place', () => {
171
+ expect(router._interpolateParams('/user/:id', {})).toBe('/user/:id');
172
+ });
173
+
174
+ it('URI-encodes param values', () => {
175
+ expect(router._interpolateParams('/tag/:name', { name: 'foo bar' }))
176
+ .toBe('/tag/foo%20bar');
177
+ });
178
+
179
+ it('returns path unchanged when params is null', () => {
180
+ expect(router._interpolateParams('/about', null)).toBe('/about');
181
+ });
182
+
183
+ it('converts numbers to strings', () => {
184
+ expect(router._interpolateParams('/user/:id', { id: 123 })).toBe('/user/123');
185
+ });
186
+ });
187
+
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // z-link-params
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('Router - z-link-params', () => {
194
+ let router;
195
+
196
+ beforeEach(() => {
197
+ document.body.innerHTML = '<div id="app"></div>';
198
+ window.location.hash = '#/';
199
+ router = createRouter({
200
+ el: '#app',
201
+ mode: 'hash',
202
+ routes: [
203
+ { path: '/', component: 'home-page' },
204
+ { path: '/user/:id', component: 'user-page' },
205
+ ],
206
+ });
207
+ });
208
+
209
+ it('interpolates params from z-link-params attribute on click', () => {
210
+ document.body.innerHTML += '<a z-link="/user/:id" z-link-params=\'{"id": "42"}\'>User</a>';
211
+ const link = document.querySelector('[z-link]');
212
+ link.click();
213
+ expect(window.location.hash).toBe('#/user/42');
214
+ });
215
+
216
+ it('works without z-link-params (plain z-link)', () => {
217
+ document.body.innerHTML += '<a z-link="/about">About</a>';
218
+ const link = document.querySelector('a[z-link="/about"]');
219
+ link.click();
220
+ expect(window.location.hash).toBe('#/about');
221
+ });
222
+
223
+ it('ignores malformed z-link-params JSON gracefully', () => {
224
+ document.body.innerHTML += '<a z-link="/user/fallback" z-link-params="not json">User</a>';
225
+ const link = document.querySelector('a[z-link="/user/fallback"]');
226
+ link.click();
227
+ expect(window.location.hash).toBe('#/user/fallback');
228
+ });
229
+ });
230
+
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Guards
234
+ // ---------------------------------------------------------------------------
235
+
236
+ describe('Router - guards', () => {
237
+ it('beforeEach registers a guard', () => {
238
+ const router = createRouter({
239
+ mode: 'hash',
240
+ routes: [{ path: '/', component: 'home-page' }],
241
+ });
242
+ const guard = vi.fn();
243
+ router.beforeEach(guard);
244
+ expect(router._guards.before).toContain(guard);
245
+ });
246
+
247
+ it('afterEach registers a guard', () => {
248
+ const router = createRouter({
249
+ mode: 'hash',
250
+ routes: [{ path: '/', component: 'home-page' }],
251
+ });
252
+ const guard = vi.fn();
253
+ router.afterEach(guard);
254
+ expect(router._guards.after).toContain(guard);
255
+ });
256
+ });
257
+
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // onChange listener
261
+ // ---------------------------------------------------------------------------
262
+
263
+ describe('Router - onChange', () => {
264
+ it('onChange registers and returns unsubscribe', () => {
265
+ const router = createRouter({
266
+ mode: 'hash',
267
+ routes: [],
268
+ });
269
+ const fn = vi.fn();
270
+ const unsub = router.onChange(fn);
271
+ expect(router._listeners.has(fn)).toBe(true);
272
+ unsub();
273
+ expect(router._listeners.has(fn)).toBe(false);
274
+ });
275
+ });
276
+
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Path normalization
280
+ // ---------------------------------------------------------------------------
281
+
282
+ describe('Router - path normalization', () => {
283
+ it('normalizes relative paths', () => {
284
+ const router = createRouter({
285
+ mode: 'hash',
286
+ base: '/app',
287
+ routes: [],
288
+ });
289
+ expect(router._normalizePath('docs')).toBe('/docs');
290
+ expect(router._normalizePath('/docs')).toBe('/docs');
291
+ });
292
+
293
+ it('strips base prefix from path', () => {
294
+ const router = createRouter({
295
+ mode: 'hash',
296
+ base: '/app',
297
+ routes: [],
298
+ });
299
+ expect(router._normalizePath('/app/docs')).toBe('/docs');
300
+ expect(router._normalizePath('/app')).toBe('/');
301
+ });
302
+
303
+ it('resolve() adds base prefix', () => {
304
+ const router = createRouter({
305
+ mode: 'hash',
306
+ base: '/app',
307
+ routes: [],
308
+ });
309
+ expect(router.resolve('/docs')).toBe('/app/docs');
310
+ expect(router.resolve('about')).toBe('/app/about');
311
+ });
312
+ });
313
+
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Destroy
317
+ // ---------------------------------------------------------------------------
318
+
319
+ describe('Router - destroy', () => {
320
+ it('clears routes, guards, and listeners', () => {
321
+ const router = createRouter({
322
+ mode: 'hash',
323
+ routes: [{ path: '/', component: 'home-page' }],
324
+ });
325
+ router.beforeEach(() => {});
326
+ router.onChange(() => {});
327
+ router.destroy();
328
+ expect(router._routes.length).toBe(0);
329
+ expect(router._guards.before.length).toBe(0);
330
+ expect(router._listeners.size).toBe(0);
331
+ });
332
+ });
333
+
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Wildcard / catch-all routes
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe('Router - wildcard routes', () => {
340
+ it('compiles wildcard route', () => {
341
+ const router = createRouter({
342
+ mode: 'hash',
343
+ routes: [
344
+ { path: '/docs/:section', component: 'docs-page' },
345
+ { path: '*', component: 'home-page' },
346
+ ],
347
+ });
348
+ // Wildcard is compiled as last route
349
+ expect(router._routes.length).toBeGreaterThanOrEqual(2);
350
+ });
351
+ });
352
+
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Query string handling
356
+ // ---------------------------------------------------------------------------
357
+
358
+ describe('Router - query parsing', () => {
359
+ it('_normalizePath strips query string for route matching', () => {
360
+ const router = createRouter({
361
+ mode: 'hash',
362
+ routes: [],
363
+ });
364
+ // _normalizePath does not strip query params - it only normalizes slashes and base prefix
365
+ const path = router._normalizePath('/docs?section=intro');
366
+ expect(path).toBe('/docs?section=intro');
367
+ });
368
+ });
369
+
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Multiple guards
373
+ // ---------------------------------------------------------------------------
374
+
375
+ describe('Router - multiple guards', () => {
376
+ it('registers multiple beforeEach guards', () => {
377
+ const router = createRouter({
378
+ mode: 'hash',
379
+ routes: [{ path: '/', component: 'home-page' }],
380
+ });
381
+ const g1 = vi.fn();
382
+ const g2 = vi.fn();
383
+ router.beforeEach(g1);
384
+ router.beforeEach(g2);
385
+ expect(router._guards.before.length).toBe(2);
386
+ expect(router._guards.before).toContain(g1);
387
+ expect(router._guards.before).toContain(g2);
388
+ });
389
+
390
+ it('registers multiple afterEach guards', () => {
391
+ const router = createRouter({
392
+ mode: 'hash',
393
+ routes: [{ path: '/', component: 'home-page' }],
394
+ });
395
+ const g1 = vi.fn();
396
+ const g2 = vi.fn();
397
+ router.afterEach(g1);
398
+ router.afterEach(g2);
399
+ expect(router._guards.after.length).toBe(2);
400
+ });
401
+ });
402
+
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Route with multiple params
406
+ // ---------------------------------------------------------------------------
407
+
408
+ describe('Router - multi-param routes', () => {
409
+ it('compiles route with multiple params', () => {
410
+ const router = createRouter({
411
+ mode: 'hash',
412
+ routes: [
413
+ { path: '/post/:pid/comment/:cid', component: 'user-page' },
414
+ ],
415
+ });
416
+ const route = router._routes[0];
417
+ expect(route._regex.test('/post/1/comment/5')).toBe(true);
418
+ expect(route._keys).toEqual(['pid', 'cid']);
419
+ });
420
+ });
421
+
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Same-path deduplication
425
+ // ---------------------------------------------------------------------------
426
+
427
+ describe('Router - same-path deduplication', () => {
428
+ let router;
429
+
430
+ beforeEach(() => {
431
+ document.body.innerHTML = '<div id="app"></div>';
432
+ window.location.hash = '#/';
433
+ router = createRouter({
434
+ el: '#app',
435
+ mode: 'hash',
436
+ routes: [
437
+ { path: '/', component: 'home-page' },
438
+ { path: '/about', component: 'about-page' },
439
+ ],
440
+ });
441
+ });
442
+
443
+ it('skips duplicate hash navigation to the same path', () => {
444
+ router.navigate('/about');
445
+ expect(window.location.hash).toBe('#/about');
446
+ // Navigate to the same path again - should be a no-op
447
+ const result = router.navigate('/about');
448
+ expect(window.location.hash).toBe('#/about');
449
+ expect(result).toBe(router); // still returns the router chain
450
+ });
451
+
452
+ it('allows forced duplicate navigation with options.force', () => {
453
+ router.navigate('/about');
454
+ // Force navigation to same path
455
+ router.navigate('/about', { force: true });
456
+ expect(window.location.hash).toBe('#/about');
457
+ });
458
+ });
459
+
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // History mode - same-path / hash-only navigation
463
+ // ---------------------------------------------------------------------------
464
+
465
+ describe('Router - history mode deduplication', () => {
466
+ let router;
467
+ let pushSpy, replaceSpy;
468
+
469
+ beforeEach(() => {
470
+ document.body.innerHTML = '<div id="app"></div>';
471
+ pushSpy = vi.spyOn(window.history, 'pushState');
472
+ replaceSpy = vi.spyOn(window.history, 'replaceState');
473
+ router = createRouter({
474
+ el: '#app',
475
+ mode: 'history',
476
+ routes: [
477
+ { path: '/', component: 'home-page' },
478
+ { path: '/about', component: 'about-page' },
479
+ { path: '/docs', component: 'docs-page' },
480
+ ],
481
+ });
482
+ // Reset spy call counts after initial resolve
483
+ pushSpy.mockClear();
484
+ replaceSpy.mockClear();
485
+ });
486
+
487
+ afterEach(() => {
488
+ pushSpy.mockRestore();
489
+ replaceSpy.mockRestore();
490
+ });
491
+
492
+ it('uses pushState for different routes', () => {
493
+ router.navigate('/about');
494
+ expect(pushSpy).toHaveBeenCalledTimes(1);
495
+ });
496
+
497
+ it('uses replaceState for same-route hash-only change', () => {
498
+ // Navigate to /docs first
499
+ router.navigate('/docs');
500
+ pushSpy.mockClear();
501
+ replaceSpy.mockClear();
502
+ // Navigate to /docs#section - same route, different hash
503
+ router.navigate('/docs#section');
504
+ expect(pushSpy).not.toHaveBeenCalled();
505
+ expect(replaceSpy).toHaveBeenCalledTimes(1);
506
+ });
507
+ });
508
+
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Sub-route history substates
512
+ // ---------------------------------------------------------------------------
513
+
514
+ describe('Router - substates', () => {
515
+ let router;
516
+ let pushSpy;
517
+
518
+ beforeEach(() => {
519
+ document.body.innerHTML = '<div id="app"></div>';
520
+ pushSpy = vi.spyOn(window.history, 'pushState');
521
+ router = createRouter({
522
+ el: '#app',
523
+ mode: 'history',
524
+ routes: [
525
+ { path: '/', component: 'home-page' },
526
+ ],
527
+ });
528
+ pushSpy.mockClear();
529
+ });
530
+
531
+ afterEach(() => {
532
+ pushSpy.mockRestore();
533
+ });
534
+
535
+ it('pushSubstate pushes a history entry with substate marker', () => {
536
+ router.pushSubstate('modal', { id: 'confirm' });
537
+ expect(pushSpy).toHaveBeenCalledTimes(1);
538
+ const state = pushSpy.mock.calls[0][0];
539
+ expect(state.__zq).toBe('substate');
540
+ expect(state.key).toBe('modal');
541
+ expect(state.data).toEqual({ id: 'confirm' });
542
+ });
543
+
544
+ it('onSubstate registers and unregisters listeners', () => {
545
+ const fn = vi.fn();
546
+ const unsub = router.onSubstate(fn);
547
+ expect(router._substateListeners).toContain(fn);
548
+ unsub();
549
+ expect(router._substateListeners).not.toContain(fn);
550
+ });
551
+
552
+ it('_fireSubstate calls listeners and returns true if any handles it', () => {
553
+ const fn1 = vi.fn(() => false);
554
+ const fn2 = vi.fn(() => true);
555
+ router.onSubstate(fn1);
556
+ router.onSubstate(fn2);
557
+ const handled = router._fireSubstate('modal', { id: 'x' }, 'pop');
558
+ expect(fn1).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
559
+ expect(fn2).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
560
+ expect(handled).toBe(true);
561
+ });
562
+
563
+ it('_fireSubstate returns false if no listener handles it', () => {
564
+ const fn = vi.fn(() => undefined);
565
+ router.onSubstate(fn);
566
+ const handled = router._fireSubstate('tab', { index: 0 }, 'pop');
567
+ expect(handled).toBe(false);
568
+ });
569
+
570
+ it('_fireSubstate catches errors in listeners', () => {
571
+ const fn = vi.fn(() => { throw new Error('oops'); });
572
+ router.onSubstate(fn);
573
+ // Should not throw
574
+ expect(() => router._fireSubstate('modal', {}, 'pop')).not.toThrow();
575
+ });
576
+
577
+ it('destroy clears substate listeners', () => {
578
+ router.onSubstate(() => {});
579
+ router.onSubstate(() => {});
580
+ router.destroy();
581
+ expect(router._substateListeners.length).toBe(0);
582
+ });
583
+
584
+ it('pushSubstate sets _inSubstate flag', () => {
585
+ expect(router._inSubstate).toBe(false);
586
+ router.pushSubstate('tab', { id: 'a' });
587
+ expect(router._inSubstate).toBe(true);
588
+ });
589
+
590
+ it('popstate past all substates fires reset action', () => {
591
+ const fn = vi.fn(() => true);
592
+ router.onSubstate(fn);
593
+ router.pushSubstate('tab', { id: 'a' });
594
+ expect(router._inSubstate).toBe(true);
595
+
596
+ // Simulate popstate landing on a non-substate entry
597
+ const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
598
+ window.dispatchEvent(evt);
599
+
600
+ // Should have fired with reset action
601
+ expect(fn).toHaveBeenCalledWith(null, null, 'reset');
602
+ expect(router._inSubstate).toBe(false);
603
+ });
604
+
605
+ it('popstate on a substate entry keeps _inSubstate true', () => {
606
+ const fn = vi.fn(() => true);
607
+ router.onSubstate(fn);
608
+ router.pushSubstate('tab', { id: 'a' });
609
+ router.pushSubstate('tab', { id: 'b' });
610
+
611
+ // Simulate popstate landing on a substate entry
612
+ const evt = new PopStateEvent('popstate', {
613
+ state: { __zq: 'substate', key: 'tab', data: { id: 'a' } }
614
+ });
615
+ window.dispatchEvent(evt);
616
+
617
+ expect(fn).toHaveBeenCalledWith('tab', { id: 'a' }, 'pop');
618
+ expect(router._inSubstate).toBe(true);
619
+ });
620
+
621
+ it('reset action is not fired when no substates were active', () => {
622
+ const fn = vi.fn();
623
+ router.onSubstate(fn);
624
+ // _inSubstate is false, so popstate on a route entry should not fire reset
625
+ const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
626
+ window.dispatchEvent(evt);
627
+ // fn should NOT have been called with 'reset'
628
+ const resetCalls = fn.mock.calls.filter(c => c[2] === 'reset');
629
+ expect(resetCalls.length).toBe(0);
630
+ });
631
+ });
632
+
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Edge cases
636
+ // ---------------------------------------------------------------------------
637
+
638
+ describe('Router - edge cases', () => {
639
+ it('handles empty routes array', () => {
640
+ const router = createRouter({ mode: 'hash', routes: [] });
641
+ expect(router._routes.length).toBe(0);
642
+ });
643
+
644
+ it('getRouter returns null before creation', () => {
645
+ // After the tests create routers, getRouter should return the latest
646
+ const r = getRouter();
647
+ expect(r).toBeDefined();
648
+ });
649
+ });
650
+
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Route matching priority (first match wins)
654
+ // ---------------------------------------------------------------------------
655
+
656
+ describe('Router - route matching priority', () => {
657
+ it('first matching route wins', () => {
658
+ const router = createRouter({
659
+ mode: 'hash',
660
+ routes: [
661
+ { path: '/test', component: 'home-page' },
662
+ { path: '/test', component: 'about-page' },
663
+ ],
664
+ });
665
+ // The first route with path '/test' should be matched
666
+ const matched = router._routes[0];
667
+ expect(matched._regex.test('/test')).toBe(true);
668
+ expect(matched.component).toBe('home-page');
669
+ });
670
+
671
+ it('specific route takes priority over wildcard', () => {
672
+ const router = createRouter({
673
+ mode: 'hash',
674
+ routes: [
675
+ { path: '/about', component: 'about-page' },
676
+ { path: '*', component: 'home-page' },
677
+ ],
678
+ });
679
+ // /about should match the first route, not wildcard
680
+ expect(router._routes[0]._regex.test('/about')).toBe(true);
681
+ expect(router._routes[0].component).toBe('about-page');
682
+ });
683
+
684
+ it('parameterized routes match correctly', () => {
685
+ const router = createRouter({
686
+ mode: 'hash',
687
+ routes: [
688
+ { path: '/user/:id', component: 'user-page' },
689
+ { path: '/user/settings', component: 'about-page' },
690
+ ],
691
+ });
692
+ // /user/42 should match parameterized route
693
+ expect(router._routes[0]._regex.test('/user/42')).toBe(true);
694
+ // /user/settings matches first (since :id catches "settings")
695
+ expect(router._routes[0]._regex.test('/user/settings')).toBe(true);
696
+ });
697
+ });
698
+
699
+
700
+ // ---------------------------------------------------------------------------
701
+ // Route removal
702
+ // ---------------------------------------------------------------------------
703
+
704
+ describe('Router - route removal', () => {
705
+ it('remove() deletes the matching route', () => {
706
+ const router = createRouter({
707
+ mode: 'hash',
708
+ routes: [
709
+ { path: '/', component: 'home-page' },
710
+ { path: '/about', component: 'about-page' },
711
+ ],
712
+ });
713
+ expect(router._routes.length).toBe(2);
714
+ router.remove('/about');
715
+ expect(router._routes.length).toBe(1);
716
+ expect(router._routes.find(r => r.path === '/about')).toBeUndefined();
717
+ });
718
+
719
+ it('remove() on non-existent path is a no-op', () => {
720
+ const router = createRouter({
721
+ mode: 'hash',
722
+ routes: [{ path: '/', component: 'home-page' }],
723
+ });
724
+ router.remove('/nonexistent');
725
+ expect(router._routes.length).toBe(1);
726
+ });
727
+
728
+ it('remove() returns the router for chaining', () => {
729
+ const router = createRouter({
730
+ mode: 'hash',
731
+ routes: [{ path: '/', component: 'home-page' }],
732
+ });
733
+ const result = router.remove('/');
734
+ expect(result).toBe(router);
735
+ });
736
+ });
737
+
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Dynamic route addition
741
+ // ---------------------------------------------------------------------------
742
+
743
+ describe('Router - dynamic route addition', () => {
744
+ it('add() returns the router for chaining', () => {
745
+ const router = createRouter({ mode: 'hash', routes: [] });
746
+ const result = router.add({ path: '/new', component: 'home-page' });
747
+ expect(result).toBe(router);
748
+ expect(router._routes.length).toBe(1);
749
+ });
750
+
751
+ it('add() compiles regex for parameterized routes', () => {
752
+ const router = createRouter({ mode: 'hash', routes: [] });
753
+ router.add({ path: '/item/:id/detail/:section', component: 'home-page' });
754
+ const route = router._routes[0];
755
+ expect(route._regex.test('/item/42/detail/overview')).toBe(true);
756
+ expect(route._keys).toEqual(['id', 'section']);
757
+ });
758
+
759
+ it('add() with fallback creates two routes', () => {
760
+ const router = createRouter({ mode: 'hash', routes: [] });
761
+ router.add({ path: '/docs/:page', fallback: '/docs', component: 'docs-page' });
762
+ expect(router._routes.length).toBe(2);
763
+ expect(router._routes[0]._regex.test('/docs/intro')).toBe(true);
764
+ expect(router._routes[1]._regex.test('/docs')).toBe(true);
765
+ });
766
+ });
767
+
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // Navigation chaining
771
+ // ---------------------------------------------------------------------------
772
+
773
+ describe('Router - navigation chaining', () => {
774
+ let router;
775
+ beforeEach(() => {
776
+ document.body.innerHTML = '<div id="app"></div>';
777
+ window.location.hash = '#/';
778
+ router = createRouter({
779
+ el: '#app',
780
+ mode: 'hash',
781
+ routes: [
782
+ { path: '/', component: 'home-page' },
783
+ { path: '/about', component: 'about-page' },
784
+ ],
785
+ });
786
+ });
787
+
788
+ it('navigate returns the router for chaining', () => {
789
+ const result = router.navigate('/about');
790
+ expect(result).toBe(router);
791
+ });
792
+
793
+ it('replace returns the router for chaining', () => {
794
+ const result = router.replace('/about');
795
+ expect(result).toBe(router);
796
+ });
797
+
798
+ it('back() returns the router', () => {
799
+ expect(router.back()).toBe(router);
800
+ });
801
+
802
+ it('forward() returns the router', () => {
803
+ expect(router.forward()).toBe(router);
804
+ });
805
+
806
+ it('go() returns the router', () => {
807
+ expect(router.go(0)).toBe(router);
808
+ });
809
+ });
810
+
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // matchRoute - standalone route matcher (DOM-free)
814
+ // ---------------------------------------------------------------------------
815
+
816
+ describe('matchRoute', () => {
817
+ const routes = [
818
+ { path: '/', component: 'home-page' },
819
+ { path: '/blog', component: 'blog-list' },
820
+ { path: '/blog/:slug', component: 'blog-post' },
821
+ { path: '/user/:id', component: 'user-page' },
822
+ { path: '/files/*', component: 'file-browser' },
823
+ ];
824
+
825
+ it('matches a static route', () => {
826
+ expect(matchRoute(routes, '/')).toEqual({ component: 'home-page', params: {} });
827
+ expect(matchRoute(routes, '/blog')).toEqual({ component: 'blog-list', params: {} });
828
+ });
829
+
830
+ it('matches a parameterized route', () => {
831
+ expect(matchRoute(routes, '/blog/hello-world')).toEqual({
832
+ component: 'blog-post',
833
+ params: { slug: 'hello-world' },
834
+ });
835
+ });
836
+
837
+ it('matches multiple params', () => {
838
+ const r = [{ path: '/user/:id/post/:pid', component: 'user-post' }];
839
+ expect(matchRoute(r, '/user/42/post/7')).toEqual({
840
+ component: 'user-post',
841
+ params: { id: '42', pid: '7' },
842
+ });
843
+ });
844
+
845
+ it('matches wildcard routes', () => {
846
+ const result = matchRoute(routes, '/files/docs/readme.md');
847
+ expect(result.component).toBe('file-browser');
848
+ });
849
+
850
+ it('returns fallback when nothing matches', () => {
851
+ expect(matchRoute(routes, '/nope')).toEqual({ component: 'not-found', params: {} });
852
+ });
853
+
854
+ it('accepts a custom fallback component name', () => {
855
+ expect(matchRoute(routes, '/nope', '404-page')).toEqual({ component: '404-page', params: {} });
856
+ });
857
+
858
+ it('matches first route when multiple could match', () => {
859
+ const r = [
860
+ { path: '/a', component: 'first' },
861
+ { path: '/a', component: 'second' },
862
+ ];
863
+ expect(matchRoute(r, '/a').component).toBe('first');
864
+ });
865
+
866
+ it('handles per-route fallback aliases', () => {
867
+ const r = [
868
+ { path: '/docs/:section', component: 'docs-page', fallback: '/docs' },
869
+ ];
870
+ expect(matchRoute(r, '/docs/intro')).toEqual({ component: 'docs-page', params: { section: 'intro' } });
871
+ expect(matchRoute(r, '/docs')).toEqual({ component: 'docs-page', params: {} });
872
+ });
873
+ });
874
+
875
+
876
+ // ---------------------------------------------------------------------------
877
+ // Hash mode path parsing
878
+ // ---------------------------------------------------------------------------
879
+
880
+ describe('Router - hash mode path parsing', () => {
881
+ let router;
882
+ beforeEach(() => {
883
+ router = createRouter({
884
+ mode: 'hash',
885
+ routes: [{ path: '/', component: 'home-page' }],
886
+ });
887
+ });
888
+
889
+ it('path returns / when hash is empty', () => {
890
+ window.location.hash = '';
891
+ expect(router.path).toBe('/');
892
+ });
893
+
894
+ it('path returns correct value from hash', () => {
895
+ window.location.hash = '#/about';
896
+ expect(router.path).toBe('/about');
897
+ });
898
+
899
+ it('path returns / when hash is just #/', () => {
900
+ window.location.hash = '#/';
901
+ expect(router.path).toBe('/');
902
+ });
903
+ });
904
+
905
+
906
+ // ---------------------------------------------------------------------------
907
+ // History mode path handling with base
908
+ // ---------------------------------------------------------------------------
909
+
910
+ describe('Router - history mode with base path', () => {
911
+ it('resolve includes base prefix', () => {
912
+ const router = createRouter({
913
+ mode: 'history',
914
+ base: '/myapp',
915
+ routes: [],
916
+ });
917
+ expect(router.resolve('/page')).toBe('/myapp/page');
918
+ expect(router.resolve('/')).toBe('/myapp/');
919
+ });
920
+
921
+ it('_normalizePath strips double base prefix', () => {
922
+ const router = createRouter({
923
+ mode: 'history',
924
+ base: '/myapp',
925
+ routes: [],
926
+ });
927
+ // If someone accidentally includes the base
928
+ expect(router._normalizePath('/myapp/page')).toBe('/page');
929
+ });
930
+
931
+ it('base without leading slash gets normalized', () => {
932
+ const router = createRouter({
933
+ mode: 'history',
934
+ base: 'app',
935
+ routes: [],
936
+ });
937
+ expect(router.base).toBe('/app');
938
+ });
939
+ });
940
+
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // Navigate with query strings in hash mode
944
+ // ---------------------------------------------------------------------------
945
+
946
+ describe('Router - query string in hash mode', () => {
947
+ let router;
948
+ beforeEach(() => {
949
+ document.body.innerHTML = '<div id="app"></div>';
950
+ window.location.hash = '#/';
951
+ router = createRouter({
952
+ el: '#app',
953
+ mode: 'hash',
954
+ routes: [
955
+ { path: '/', component: 'home-page' },
956
+ { path: '/search', component: 'about-page' },
957
+ ],
958
+ });
959
+ });
960
+
961
+ it('navigate preserves path for query routing', () => {
962
+ router.navigate('/search');
963
+ expect(window.location.hash).toBe('#/search');
964
+ });
965
+ });
966
+
967
+
968
+ // ---------------------------------------------------------------------------
969
+ // Guard edge cases
970
+ // ---------------------------------------------------------------------------
971
+
972
+ describe('Router - guard edge cases', () => {
973
+ it('beforeEach returns the router for chaining', () => {
974
+ const router = createRouter({
975
+ mode: 'hash',
976
+ routes: [{ path: '/', component: 'home-page' }],
977
+ });
978
+ const result = router.beforeEach(() => {});
979
+ expect(result).toBe(router);
980
+ });
981
+
982
+ it('afterEach returns the router for chaining', () => {
983
+ const router = createRouter({
984
+ mode: 'hash',
985
+ routes: [{ path: '/', component: 'home-page' }],
986
+ });
987
+ const result = router.afterEach(() => {});
988
+ expect(result).toBe(router);
989
+ });
990
+
991
+ it('guard cancels navigation when returning false', async () => {
992
+ const router = createRouter({
993
+ el: '#app',
994
+ mode: 'hash',
995
+ routes: [
996
+ { path: '/', component: 'home-page' },
997
+ { path: '/blocked', component: 'about-page' },
998
+ ],
999
+ });
1000
+ document.body.innerHTML = '<div id="app"></div>';
1001
+ router.beforeEach(() => false);
1002
+ // Manually trigger resolve for /blocked
1003
+ window.location.hash = '#/blocked';
1004
+ await router._resolve();
1005
+ // current should not be updated to /blocked
1006
+ expect(router._current === null || router._current.path !== '/blocked').toBe(true);
1007
+ });
1008
+
1009
+ it('guard redirects to a different route', async () => {
1010
+ document.body.innerHTML = '<div id="app"></div>';
1011
+ const router = createRouter({
1012
+ el: '#app',
1013
+ mode: 'hash',
1014
+ routes: [
1015
+ { path: '/', component: 'home-page' },
1016
+ { path: '/login', component: 'about-page' },
1017
+ { path: '/dashboard', component: 'docs-page' },
1018
+ ],
1019
+ });
1020
+ router.beforeEach((to) => {
1021
+ if (to.path === '/dashboard') return '/login';
1022
+ });
1023
+ window.location.hash = '#/dashboard';
1024
+ await router._resolve();
1025
+ expect(window.location.hash).toBe('#/login');
1026
+ });
1027
+ });
1028
+
1029
+
1030
+ // ---------------------------------------------------------------------------
1031
+ // onChange with navigation
1032
+ // ---------------------------------------------------------------------------
1033
+
1034
+ describe('Router - onChange fires on resolve', () => {
1035
+ it('fires onChange listener after route resolution', async () => {
1036
+ document.body.innerHTML = '<div id="app"></div>';
1037
+ const listener = vi.fn();
1038
+ const router = createRouter({
1039
+ el: '#app',
1040
+ mode: 'hash',
1041
+ routes: [
1042
+ { path: '/', component: 'home-page' },
1043
+ { path: '/about', component: 'about-page' },
1044
+ ],
1045
+ });
1046
+ router.onChange(listener);
1047
+ // Wait for initial resolve
1048
+ await new Promise(r => setTimeout(r, 10));
1049
+ listener.mockClear();
1050
+
1051
+ window.location.hash = '#/about';
1052
+ await router._resolve();
1053
+ expect(listener).toHaveBeenCalled();
1054
+ const [to, from] = listener.mock.calls[0];
1055
+ expect(to.path).toBe('/about');
1056
+ });
1057
+ });
1058
+
1059
+
1060
+ // ---------------------------------------------------------------------------
1061
+ // Multi-param extraction
1062
+ // ---------------------------------------------------------------------------
1063
+
1064
+ describe('Router - multi-param extraction', () => {
1065
+ it('extracts multiple params from URL', () => {
1066
+ const router = createRouter({
1067
+ mode: 'hash',
1068
+ routes: [
1069
+ { path: '/org/:orgId/team/:teamId/member/:memberId', component: 'user-page' },
1070
+ ],
1071
+ });
1072
+ const route = router._routes[0];
1073
+ const match = '/org/acme/team/dev/member/42'.match(route._regex);
1074
+ expect(match).not.toBeNull();
1075
+ const params = {};
1076
+ route._keys.forEach((key, i) => { params[key] = match[i + 1]; });
1077
+ expect(params).toEqual({ orgId: 'acme', teamId: 'dev', memberId: '42' });
1078
+ });
1079
+ });
1080
+
1081
+
1082
+ // ---------------------------------------------------------------------------
1083
+ // Substate in hash mode
1084
+ // ---------------------------------------------------------------------------
1085
+
1086
+ describe('Router - substates hash mode', () => {
1087
+ let router, pushSpy;
1088
+
1089
+ beforeEach(() => {
1090
+ document.body.innerHTML = '<div id="app"></div>';
1091
+ window.location.hash = '#/';
1092
+ pushSpy = vi.spyOn(window.history, 'pushState');
1093
+ router = createRouter({
1094
+ el: '#app',
1095
+ mode: 'hash',
1096
+ routes: [{ path: '/', component: 'home-page' }],
1097
+ });
1098
+ pushSpy.mockClear();
1099
+ });
1100
+
1101
+ afterEach(() => {
1102
+ pushSpy.mockRestore();
1103
+ });
1104
+
1105
+ it('pushSubstate works in hash mode', () => {
1106
+ router.pushSubstate('drawer', { side: 'left' });
1107
+ expect(pushSpy).toHaveBeenCalledTimes(1);
1108
+ const state = pushSpy.mock.calls[0][0];
1109
+ expect(state.__zq).toBe('substate');
1110
+ expect(state.key).toBe('drawer');
1111
+ });
1112
+
1113
+ it('multiple substates can be pushed', () => {
1114
+ router.pushSubstate('modal', { id: 'a' });
1115
+ router.pushSubstate('modal', { id: 'b' });
1116
+ expect(pushSpy).toHaveBeenCalledTimes(2);
1117
+ });
1118
+ });
1119
+
1120
+
1121
+ // ---------------------------------------------------------------------------
1122
+ // _interpolateParams edge cases
1123
+ // ---------------------------------------------------------------------------
1124
+
1125
+ describe('Router - _interpolateParams edge cases', () => {
1126
+ let router;
1127
+ beforeEach(() => {
1128
+ router = createRouter({ mode: 'hash', routes: [] });
1129
+ });
1130
+
1131
+ it('handles special characters in param values', () => {
1132
+ expect(router._interpolateParams('/tag/:name', { name: 'c++' })).toBe('/tag/c%2B%2B');
1133
+ });
1134
+
1135
+ it('handles empty string param value', () => {
1136
+ expect(router._interpolateParams('/user/:id', { id: '' })).toBe('/user/');
1137
+ });
1138
+
1139
+ it('handles zero as param value', () => {
1140
+ expect(router._interpolateParams('/page/:num', { num: 0 })).toBe('/page/0');
1141
+ });
1142
+
1143
+ it('handles boolean param values', () => {
1144
+ expect(router._interpolateParams('/flag/:val', { val: true })).toBe('/flag/true');
1145
+ });
1146
+
1147
+ it('returns path unchanged when params is non-object', () => {
1148
+ expect(router._interpolateParams('/about', 'string')).toBe('/about');
1149
+ });
1150
+
1151
+ it('handles path with no placeholders', () => {
1152
+ expect(router._interpolateParams('/about', { id: 42 })).toBe('/about');
1153
+ });
1154
+
1155
+ it('handles adjacent params', () => {
1156
+ // This is a weird URL but should still work
1157
+ expect(router._interpolateParams('/:a/:b', { a: 'x', b: 'y' })).toBe('/x/y');
1158
+ });
1159
+ });
1160
+
1161
+
1162
+ // ---------------------------------------------------------------------------
1163
+ // Router.destroy cleans up everything
1164
+ // ---------------------------------------------------------------------------
1165
+
1166
+ describe('Router - destroy completeness', () => {
1167
+ it('clears instance, routes, guards, listeners, and substates', () => {
1168
+ document.body.innerHTML = '<div id="app"></div>';
1169
+ const router = createRouter({
1170
+ el: '#app',
1171
+ mode: 'hash',
1172
+ routes: [{ path: '/', component: 'home-page' }],
1173
+ });
1174
+ router.beforeEach(() => {});
1175
+ router.afterEach(() => {});
1176
+ router.onChange(() => {});
1177
+ router.onSubstate(() => {});
1178
+
1179
+ router.destroy();
1180
+
1181
+ expect(router._routes.length).toBe(0);
1182
+ expect(router._guards.before.length).toBe(0);
1183
+ expect(router._guards.after.length).toBe(0);
1184
+ expect(router._listeners.size).toBe(0);
1185
+ expect(router._substateListeners.length).toBe(0);
1186
+ expect(router._inSubstate).toBe(false);
1187
+ });
1188
+
1189
+ it('removes window event listeners on destroy (no leak)', () => {
1190
+ document.body.innerHTML = '<div id="app"></div>';
1191
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
1192
+ const router = createRouter({
1193
+ el: '#app',
1194
+ mode: 'hash',
1195
+ routes: [{ path: '/', component: 'home-page' }],
1196
+ });
1197
+ // Store the handler reference before destroy
1198
+ const navHandler = router._onNavEvent;
1199
+ const clickHandler = router._onLinkClick;
1200
+ expect(navHandler).toBeDefined();
1201
+ expect(clickHandler).toBeDefined();
1202
+
1203
+ router.destroy();
1204
+
1205
+ expect(removeSpy).toHaveBeenCalledWith('hashchange', navHandler);
1206
+ expect(router._onNavEvent).toBeNull();
1207
+ expect(router._onLinkClick).toBeNull();
1208
+ removeSpy.mockRestore();
1209
+ });
1210
+
1211
+ it('removes popstate listener in history mode on destroy', () => {
1212
+ document.body.innerHTML = '<div id="app"></div>';
1213
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
1214
+ const router = createRouter({
1215
+ el: '#app',
1216
+ mode: 'history',
1217
+ routes: [{ path: '/', component: 'home-page' }],
1218
+ });
1219
+ const navHandler = router._onNavEvent;
1220
+ router.destroy();
1221
+ expect(removeSpy).toHaveBeenCalledWith('popstate', navHandler);
1222
+ removeSpy.mockRestore();
1223
+ });
1224
+
1225
+ it('removes document click listener on destroy', () => {
1226
+ document.body.innerHTML = '<div id="app"></div>';
1227
+ const removeSpy = vi.spyOn(document, 'removeEventListener');
1228
+ const router = createRouter({
1229
+ el: '#app',
1230
+ mode: 'hash',
1231
+ routes: [],
1232
+ });
1233
+ const clickHandler = router._onLinkClick;
1234
+ router.destroy();
1235
+ expect(removeSpy).toHaveBeenCalledWith('click', clickHandler);
1236
+ removeSpy.mockRestore();
1237
+ });
1238
+ });
1239
+
1240
+
1241
+ // ---------------------------------------------------------------------------
1242
+ // PERF: same-route comparison uses shallow equality (no JSON.stringify)
1243
+ // ---------------------------------------------------------------------------
1244
+
1245
+ describe('Router - same-route shallow equality', () => {
1246
+ it('skips re-render when navigating to same route with same params', async () => {
1247
+ document.body.innerHTML = '<div id="app"></div>';
1248
+ let renderCount = 0;
1249
+ const router = createRouter({
1250
+ el: '#app',
1251
+ mode: 'hash',
1252
+ routes: [
1253
+ { path: '/user/:id', render: () => '<div>user</div>' },
1254
+ ],
1255
+ });
1256
+ // Mock component mount counting
1257
+ router.afterEach(() => { renderCount++; });
1258
+
1259
+ router.navigate('/user/42');
1260
+ await new Promise(r => setTimeout(r, 50));
1261
+ const firstCount = renderCount;
1262
+
1263
+ // Navigate to the same route - should skip
1264
+ router.navigate('/user/42');
1265
+ await new Promise(r => setTimeout(r, 50));
1266
+ // Hash mode prevents same-hash navigation at URL level,
1267
+ // so renderCount should not increase
1268
+ expect(renderCount).toBe(firstCount);
1269
+ router.destroy();
1270
+ });
1271
+ });
1272
+
1273
+
1274
+ // ===========================================================================
1275
+ // Guard - cancel navigation
1276
+ // ===========================================================================
1277
+
1278
+ describe('Router - guard returning false cancels navigation', () => {
1279
+ it('does not resolve route when guard returns false', async () => {
1280
+ document.body.innerHTML = '<div id="app"></div>';
1281
+ const router = createRouter({
1282
+ el: '#app',
1283
+ mode: 'hash',
1284
+ routes: [
1285
+ { path: '/', component: 'home-page' },
1286
+ { path: '/blocked', component: 'about-page' },
1287
+ ],
1288
+ });
1289
+ router.beforeEach(() => false);
1290
+ await new Promise(r => setTimeout(r, 10));
1291
+ router.navigate('/blocked');
1292
+ await router._resolve();
1293
+ // Should NOT have navigated to /blocked because guard cancelled
1294
+ expect(router.current?.path).not.toBe('/blocked');
1295
+ router.destroy();
1296
+ });
1297
+ });
1298
+
1299
+
1300
+ // ===========================================================================
1301
+ // Guard - redirect loop detection
1302
+ // ===========================================================================
1303
+
1304
+ describe('Router - guard redirect loop protection', () => {
1305
+ it('stops after more than 10 redirects', async () => {
1306
+ document.body.innerHTML = '<div id="app"></div>';
1307
+ const router = createRouter({
1308
+ el: '#app',
1309
+ mode: 'hash',
1310
+ routes: [
1311
+ { path: '/', component: 'home-page' },
1312
+ { path: '/a', component: 'about-page' },
1313
+ { path: '/b', component: 'docs-page' },
1314
+ ],
1315
+ });
1316
+ // Guard that keeps bouncing between /a and /b
1317
+ router.beforeEach((to) => {
1318
+ if (to.path === '/a') return '/b';
1319
+ if (to.path === '/b') return '/a';
1320
+ });
1321
+ await new Promise(r => setTimeout(r, 10));
1322
+ // Navigate to /a - should not infinite loop
1323
+ window.location.hash = '#/a';
1324
+ await router._resolve();
1325
+ // Just verify it doesn't hang - the guard count > 10 stops it
1326
+ router.destroy();
1327
+ });
1328
+ });
1329
+
1330
+
1331
+ // ===========================================================================
1332
+ // Guard - afterEach fires after resolve
1333
+ // ===========================================================================
1334
+
1335
+ describe('Router - afterEach hook', () => {
1336
+ it('fires afterEach with to and from after route resolves', async () => {
1337
+ document.body.innerHTML = '<div id="app"></div>';
1338
+ const afterFn = vi.fn();
1339
+ const router = createRouter({
1340
+ el: '#app',
1341
+ mode: 'hash',
1342
+ routes: [
1343
+ { path: '/', component: 'home-page' },
1344
+ { path: '/about', component: 'about-page' },
1345
+ ],
1346
+ });
1347
+ router.afterEach(afterFn);
1348
+ await new Promise(r => setTimeout(r, 10));
1349
+ afterFn.mockClear();
1350
+
1351
+ window.location.hash = '#/about';
1352
+ await router._resolve();
1353
+ expect(afterFn).toHaveBeenCalledTimes(1);
1354
+ expect(afterFn.mock.calls[0][0].path).toBe('/about');
1355
+ router.destroy();
1356
+ });
1357
+ });
1358
+
1359
+
1360
+ // ===========================================================================
1361
+ // Guard - before guard that throws
1362
+ // ===========================================================================
1363
+
1364
+ describe('Router - before guard that throws', () => {
1365
+ it('catches the error and does not crash', async () => {
1366
+ document.body.innerHTML = '<div id="app"></div>';
1367
+ const router = createRouter({
1368
+ el: '#app',
1369
+ mode: 'hash',
1370
+ routes: [
1371
+ { path: '/', component: 'home-page' },
1372
+ { path: '/err', component: 'about-page' },
1373
+ ],
1374
+ });
1375
+ router.beforeEach(() => { throw new Error('guard boom'); });
1376
+ await new Promise(r => setTimeout(r, 10));
1377
+ window.location.hash = '#/err';
1378
+ await expect(router._resolve()).resolves.not.toThrow();
1379
+ router.destroy();
1380
+ });
1381
+ });
1382
+
1383
+
1384
+ // ===========================================================================
1385
+ // Lazy loading via route.load
1386
+ // ===========================================================================
1387
+
1388
+ describe('Router - lazy loading with route.load', () => {
1389
+ it('calls load() before mounting component', async () => {
1390
+ const loadFn = vi.fn().mockResolvedValue(undefined);
1391
+ document.body.innerHTML = '<div id="app"></div>';
1392
+ const router = createRouter({
1393
+ el: '#app',
1394
+ mode: 'hash',
1395
+ routes: [
1396
+ { path: '/', component: 'home-page' },
1397
+ { path: '/lazy', load: loadFn, component: 'about-page' },
1398
+ ],
1399
+ });
1400
+ await new Promise(r => setTimeout(r, 10));
1401
+ window.location.hash = '#/lazy';
1402
+ await router._resolve();
1403
+ expect(loadFn).toHaveBeenCalledTimes(1);
1404
+ router.destroy();
1405
+ });
1406
+
1407
+ it('does not mount if load() rejects', async () => {
1408
+ const loadFn = vi.fn().mockRejectedValue(new Error('load fail'));
1409
+ document.body.innerHTML = '<div id="app"></div>';
1410
+ const router = createRouter({
1411
+ el: '#app',
1412
+ mode: 'hash',
1413
+ routes: [
1414
+ { path: '/', component: 'home-page' },
1415
+ { path: '/fail', load: loadFn, component: 'about-page' },
1416
+ ],
1417
+ });
1418
+ await new Promise(r => setTimeout(r, 10));
1419
+ window.location.hash = '#/fail';
1420
+ await router._resolve();
1421
+ // Route should not have resolved to /fail since load() threw
1422
+ expect(router.current?.path).not.toBe('/fail');
1423
+ router.destroy();
1424
+ });
1425
+ });
1426
+
1427
+
1428
+ // ===========================================================================
1429
+ // Fallback / 404 route
1430
+ // ===========================================================================
1431
+
1432
+ describe('Router - fallback 404 route', () => {
1433
+ it('resolves to fallback component for unknown paths', async () => {
1434
+ component('notfound-page', { render: () => '<p>404</p>' });
1435
+ document.body.innerHTML = '<div id="app"></div>';
1436
+ window.location.hash = '#/';
1437
+ const router = createRouter({
1438
+ el: '#app',
1439
+ mode: 'hash',
1440
+ routes: [{ path: '/', component: 'home-page' }],
1441
+ fallback: 'notfound-page',
1442
+ });
1443
+ await new Promise(r => setTimeout(r, 10));
1444
+ window.location.hash = '#/nonexistent';
1445
+ await router._resolve();
1446
+ expect(router.current.path).toBe('/nonexistent');
1447
+ expect(router.current.route.component).toBe('notfound-page');
1448
+ router.destroy();
1449
+ });
1450
+ });
1451
+
1452
+
1453
+ // ===========================================================================
1454
+ // replace()
1455
+ // ===========================================================================
1456
+
1457
+ describe('Router - replace()', () => {
1458
+ it('returns router for chaining in hash mode', async () => {
1459
+ document.body.innerHTML = '<div id="app"></div>';
1460
+ window.location.hash = '#/';
1461
+ const router = createRouter({
1462
+ el: '#app',
1463
+ mode: 'hash',
1464
+ routes: [
1465
+ { path: '/', component: 'home-page' },
1466
+ { path: '/replaced', component: 'about-page' },
1467
+ ],
1468
+ });
1469
+ await new Promise(r => setTimeout(r, 10));
1470
+ const result = router.replace('/replaced');
1471
+ expect(result).toBe(router);
1472
+ router.destroy();
1473
+ });
1474
+ });
1475
+
1476
+
1477
+ // ===========================================================================
1478
+ // query getter
1479
+ // ===========================================================================
1480
+
1481
+ describe('Router - query getter', () => {
1482
+ it('returns parsed query params from hash', () => {
1483
+ document.body.innerHTML = '<div id="app"></div>';
1484
+ const router = createRouter({
1485
+ el: '#app',
1486
+ mode: 'hash',
1487
+ routes: [{ path: '/search', component: 'home-page' }],
1488
+ });
1489
+ window.location.hash = '#/search?q=hello&page=2';
1490
+ expect(router.query).toEqual({ q: 'hello', page: '2' });
1491
+ router.destroy();
1492
+ });
1493
+
1494
+ it('returns empty object for no query params', () => {
1495
+ document.body.innerHTML = '<div id="app"></div>';
1496
+ const router = createRouter({
1497
+ el: '#app',
1498
+ mode: 'hash',
1499
+ routes: [{ path: '/', component: 'home-page' }],
1500
+ });
1501
+ window.location.hash = '#/';
1502
+ expect(router.query).toEqual({});
1503
+ router.destroy();
1504
+ });
1505
+ });
1506
+
1507
+
1508
+ // ===========================================================================
1509
+ // resolve() - programmatic link generation
1510
+ // ===========================================================================
1511
+
1512
+ describe('Router - resolve()', () => {
1513
+ it('returns full URL path with base prefix', () => {
1514
+ const router = createRouter({
1515
+ mode: 'hash',
1516
+ base: '/app',
1517
+ routes: [{ path: '/', component: 'home-page' }],
1518
+ });
1519
+ expect(router.resolve('/about')).toBe('/app/about');
1520
+ router.destroy();
1521
+ });
1522
+
1523
+ it('returns path as-is when no base', () => {
1524
+ const router = createRouter({
1525
+ mode: 'hash',
1526
+ routes: [{ path: '/', component: 'home-page' }],
1527
+ });
1528
+ expect(router.resolve('/about')).toBe('/about');
1529
+ router.destroy();
1530
+ });
1531
+ });
1532
+
1533
+
1534
+ // ===========================================================================
1535
+ // back/forward/go wrappers
1536
+ // ===========================================================================
1537
+
1538
+ describe('Router - back/forward/go wrappers', () => {
1539
+ it('calls window.history.back', () => {
1540
+ const spy = vi.spyOn(window.history, 'back').mockImplementation(() => {});
1541
+ const router = createRouter({
1542
+ mode: 'hash',
1543
+ routes: [{ path: '/', component: 'home-page' }],
1544
+ });
1545
+ router.back();
1546
+ expect(spy).toHaveBeenCalled();
1547
+ spy.mockRestore();
1548
+ router.destroy();
1549
+ });
1550
+
1551
+ it('calls window.history.forward', () => {
1552
+ const spy = vi.spyOn(window.history, 'forward').mockImplementation(() => {});
1553
+ const router = createRouter({
1554
+ mode: 'hash',
1555
+ routes: [{ path: '/', component: 'home-page' }],
1556
+ });
1557
+ router.forward();
1558
+ expect(spy).toHaveBeenCalled();
1559
+ spy.mockRestore();
1560
+ router.destroy();
1561
+ });
1562
+
1563
+ it('calls window.history.go with argument', () => {
1564
+ const spy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
1565
+ const router = createRouter({
1566
+ mode: 'hash',
1567
+ routes: [{ path: '/', component: 'home-page' }],
1568
+ });
1569
+ router.go(-2);
1570
+ expect(spy).toHaveBeenCalledWith(-2);
1571
+ spy.mockRestore();
1572
+ router.destroy();
1573
+ });
1574
+ });
1575
+
1576
+
1577
+ // ===========================================================================
1578
+ // Link click interception - modified clicks bypass
1579
+ // ===========================================================================
1580
+
1581
+ describe('Router - link click interception', () => {
1582
+ it('intercepts normal clicks on z-link elements', async () => {
1583
+ document.body.innerHTML = '<div id="app"></div><a z-link="/about">About</a>';
1584
+ const router = createRouter({
1585
+ el: '#app',
1586
+ mode: 'hash',
1587
+ routes: [
1588
+ { path: '/', component: 'home-page' },
1589
+ { path: '/about', component: 'about-page' },
1590
+ ],
1591
+ });
1592
+ await new Promise(r => setTimeout(r, 10));
1593
+
1594
+ const link = document.querySelector('[z-link]');
1595
+ const e = new Event('click', { bubbles: true, cancelable: true });
1596
+ link.dispatchEvent(e);
1597
+ // Should have navigated
1598
+ await new Promise(r => setTimeout(r, 10));
1599
+ expect(window.location.hash).toBe('#/about');
1600
+ router.destroy();
1601
+ });
1602
+
1603
+ it('ignores clicks with meta key (does not navigate)', async () => {
1604
+ document.body.innerHTML = '<div id="app"></div><a z-link="/about">About</a>';
1605
+ window.location.hash = '#/';
1606
+ const router = createRouter({
1607
+ el: '#app',
1608
+ mode: 'hash',
1609
+ routes: [
1610
+ { path: '/', component: 'home-page' },
1611
+ { path: '/about', component: 'about-page' },
1612
+ ],
1613
+ });
1614
+ await new Promise(r => setTimeout(r, 50));
1615
+ // Record current route before meta click
1616
+ const currentBefore = router.current?.path;
1617
+
1618
+ const link = document.querySelector('[z-link]');
1619
+ const e = new MouseEvent('click', { bubbles: true, metaKey: true });
1620
+ link.dispatchEvent(e);
1621
+ await new Promise(r => setTimeout(r, 10));
1622
+ // Route should remain unchanged - meta key bypasses SPA navigation
1623
+ expect(router.current?.path).toBe(currentBefore);
1624
+ router.destroy();
1625
+ });
1626
+
1627
+ it('ignores clicks with ctrl key', async () => {
1628
+ document.body.innerHTML = '<div id="app"></div><a z-link="/about2">About</a>';
1629
+ window.location.hash = '#/';
1630
+ const router = createRouter({
1631
+ el: '#app',
1632
+ mode: 'hash',
1633
+ routes: [
1634
+ { path: '/', component: 'home-page' },
1635
+ { path: '/about2', component: 'about-page' },
1636
+ ],
1637
+ });
1638
+ await new Promise(r => setTimeout(r, 50));
1639
+ const link = document.querySelector('[z-link]');
1640
+ const e = new MouseEvent('click', { bubbles: true, ctrlKey: true });
1641
+ link.dispatchEvent(e);
1642
+ await new Promise(r => setTimeout(r, 10));
1643
+ expect(router.current?.path).not.toBe('/about2');
1644
+ router.destroy();
1645
+ });
1646
+
1647
+ it('ignores links with target=_blank', async () => {
1648
+ document.body.innerHTML = '<div id="app"></div><a z-link="/about" target="_blank">About</a>';
1649
+ window.location.hash = '#/';
1650
+ const router = createRouter({
1651
+ el: '#app',
1652
+ mode: 'hash',
1653
+ routes: [
1654
+ { path: '/', component: 'home-page' },
1655
+ { path: '/about', component: 'about-page' },
1656
+ ],
1657
+ });
1658
+ await new Promise(r => setTimeout(r, 10));
1659
+ const link = document.querySelector('[z-link]');
1660
+ link.click();
1661
+ await new Promise(r => setTimeout(r, 10));
1662
+ expect(window.location.hash).toBe('#/');
1663
+ router.destroy();
1664
+ });
1665
+ });
1666
+
1667
+
1668
+ // ===========================================================================
1669
+ // Router - remove() route
1670
+ // ===========================================================================
1671
+
1672
+ describe('Router - remove()', () => {
1673
+ it('removes route by path', () => {
1674
+ const router = createRouter({
1675
+ mode: 'hash',
1676
+ routes: [
1677
+ { path: '/', component: 'home-page' },
1678
+ { path: '/temp', component: 'about-page' },
1679
+ ],
1680
+ });
1681
+ expect(router._routes.length).toBe(2);
1682
+ router.remove('/temp');
1683
+ expect(router._routes.length).toBe(1);
1684
+ expect(router._routes[0].path).toBe('/');
1685
+ router.destroy();
1686
+ });
1687
+ });
1688
+
1689
+
1690
+ // ===========================================================================
1691
+ // Router - add() chaining
1692
+ // ===========================================================================
1693
+
1694
+ describe('Router - add() chaining', () => {
1695
+ it('supports fluent chaining of add calls', () => {
1696
+ const router = createRouter({
1697
+ mode: 'hash',
1698
+ routes: [],
1699
+ });
1700
+ const result = router.add({ path: '/', component: 'home-page' })
1701
+ .add({ path: '/about', component: 'about-page' });
1702
+ expect(result).toBe(router);
1703
+ expect(router._routes.length).toBe(2);
1704
+ router.destroy();
1705
+ });
1706
+ });
1707
+
1708
+
1709
+ // ===========================================================================
1710
+ // Router - onChange unsubscribe
1711
+ // ===========================================================================
1712
+
1713
+ describe('Router - onChange unsubscribe', () => {
1714
+ it('stops calling listener after unsubscribe', async () => {
1715
+ document.body.innerHTML = '<div id="app"></div>';
1716
+ const listener = vi.fn();
1717
+ const router = createRouter({
1718
+ el: '#app',
1719
+ mode: 'hash',
1720
+ routes: [
1721
+ { path: '/', component: 'home-page' },
1722
+ { path: '/about', component: 'about-page' },
1723
+ ],
1724
+ });
1725
+ const unsub = router.onChange(listener);
1726
+ await new Promise(r => setTimeout(r, 10));
1727
+ listener.mockClear();
1728
+
1729
+ unsub();
1730
+ window.location.hash = '#/about';
1731
+ await router._resolve();
1732
+ expect(listener).not.toHaveBeenCalled();
1733
+ router.destroy();
1734
+ });
1735
+ });
1736
+
1737
+
1738
+ // ===========================================================================
1739
+ // Router - destroy cleans up
1740
+ // ===========================================================================
1741
+
1742
+ describe('Router - destroy cleans up', () => {
1743
+ it('clears listeners, guards, and routes on destroy', () => {
1744
+ document.body.innerHTML = '<div id="app"></div>';
1745
+ const router = createRouter({
1746
+ el: '#app',
1747
+ mode: 'hash',
1748
+ routes: [{ path: '/', component: 'home-page' }],
1749
+ });
1750
+ router.beforeEach(() => {});
1751
+ router.afterEach(() => {});
1752
+ router.onChange(() => {});
1753
+ router.onSubstate(() => {});
1754
+ router.destroy();
1755
+ expect(router._routes.length).toBe(0);
1756
+ expect(router._guards.before.length).toBe(0);
1757
+ expect(router._guards.after.length).toBe(0);
1758
+ expect(router._listeners.size).toBe(0);
1759
+ expect(router._substateListeners.length).toBe(0);
1760
+ });
1761
+ });
1762
+
1763
+
1764
+ // ===========================================================================
1765
+ // Router - _interpolateParams
1766
+ // ===========================================================================
1767
+
1768
+ describe('Router - _interpolateParams', () => {
1769
+ it('replaces :param with provided values', () => {
1770
+ const router = createRouter({
1771
+ mode: 'hash',
1772
+ routes: [{ path: '/user/:id', component: 'user-page' }],
1773
+ });
1774
+ const result = router._interpolateParams('/user/:id/post/:pid', { id: 42, pid: 7 });
1775
+ expect(result).toBe('/user/42/post/7');
1776
+ router.destroy();
1777
+ });
1778
+
1779
+ it('keeps :param when value not provided', () => {
1780
+ const router = createRouter({
1781
+ mode: 'hash',
1782
+ routes: [],
1783
+ });
1784
+ const result = router._interpolateParams('/user/:id', {});
1785
+ expect(result).toBe('/user/:id');
1786
+ router.destroy();
1787
+ });
1788
+
1789
+ it('returns path unchanged when params is null', () => {
1790
+ const router = createRouter({ mode: 'hash', routes: [] });
1791
+ expect(router._interpolateParams('/test', null)).toBe('/test');
1792
+ router.destroy();
1793
+ });
1794
+ });
1795
+
1796
+
1797
+ // ===========================================================================
1798
+ // Router - _normalizePath with base stripping
1799
+ // ===========================================================================
1800
+
1801
+ describe('Router - _normalizePath', () => {
1802
+ it('strips base prefix if accidentally included', () => {
1803
+ const router = createRouter({
1804
+ mode: 'hash',
1805
+ base: '/app',
1806
+ routes: [],
1807
+ });
1808
+ expect(router._normalizePath('/app/about')).toBe('/about');
1809
+ router.destroy();
1810
+ });
1811
+
1812
+ it('returns / when path matches base exactly', () => {
1813
+ const router = createRouter({
1814
+ mode: 'hash',
1815
+ base: '/app',
1816
+ routes: [],
1817
+ });
1818
+ expect(router._normalizePath('/app')).toBe('/');
1819
+ router.destroy();
1820
+ });
1821
+
1822
+ it('adds leading slash to bare paths', () => {
1823
+ const router = createRouter({ mode: 'hash', routes: [] });
1824
+ expect(router._normalizePath('about')).toBe('/about');
1825
+ router.destroy();
1826
+ });
1827
+
1828
+ it('returns / for empty/null path', () => {
1829
+ const router = createRouter({ mode: 'hash', routes: [] });
1830
+ expect(router._normalizePath('')).toBe('/');
1831
+ expect(router._normalizePath(null)).toBe('/');
1832
+ router.destroy();
1833
+ });
1834
+ });
1835
+
1836
+
1837
+ // ===========================================================================
1838
+ // Router - navigate with options.params
1839
+ // ===========================================================================
1840
+
1841
+ describe('Router - navigate with options.params', () => {
1842
+ it('interpolates params in path', async () => {
1843
+ document.body.innerHTML = '<div id="app"></div>';
1844
+ const router = createRouter({
1845
+ el: '#app',
1846
+ mode: 'hash',
1847
+ routes: [
1848
+ { path: '/', component: 'home-page' },
1849
+ { path: '/user/:id', component: 'user-page' },
1850
+ ],
1851
+ });
1852
+ await new Promise(r => setTimeout(r, 10));
1853
+ router.navigate('/user/:id', { params: { id: '99' } });
1854
+ await router._resolve();
1855
+ expect(window.location.hash).toBe('#/user/99');
1856
+ router.destroy();
1857
+ });
1858
+ });
1859
+
1860
+
1861
+ // ===========================================================================
1862
+ // Router - render function components
1863
+ // ===========================================================================
1864
+
1865
+ describe('Router - render function component', () => {
1866
+ it('renders HTML from a function component', async () => {
1867
+ document.body.innerHTML = '<div id="app"></div>';
1868
+ window.location.hash = '#/';
1869
+ const router = createRouter({
1870
+ el: '#app',
1871
+ mode: 'hash',
1872
+ routes: [
1873
+ { path: '/', component: (route) => `<p>fn: ${route.path}</p>` },
1874
+ ],
1875
+ });
1876
+ // Wait for initial resolve (queueMicrotask + rendering)
1877
+ await new Promise(r => setTimeout(r, 100));
1878
+ const p = document.querySelector('#app p');
1879
+ expect(p).not.toBeNull();
1880
+ expect(p.textContent).toBe('fn: /');
1881
+ router.destroy();
1882
+ });
1883
+ });
1884
+
1885
+
1886
+ // ===========================================================================
1887
+ // Router - substate onSubstate unsubscribe
1888
+ // ===========================================================================
1889
+
1890
+ describe('Router - onSubstate unsubscribe', () => {
1891
+ it('removes listener after unsubscribe', () => {
1892
+ const router = createRouter({ mode: 'hash', routes: [] });
1893
+ const fn = vi.fn();
1894
+ const unsub = router.onSubstate(fn);
1895
+ expect(router._substateListeners.length).toBe(1);
1896
+ unsub();
1897
+ expect(router._substateListeners.length).toBe(0);
1898
+ router.destroy();
1899
+ });
1900
+ });
1901
+
1902
+
1903
+ // ===========================================================================
1904
+ // Router - substate history restoration after navigation away
1905
+ // ===========================================================================
1906
+
1907
+ describe('Router - substate restoration after navigating away and back', () => {
1908
+ let router;
1909
+
1910
+ beforeEach(() => {
1911
+ document.body.innerHTML = '<div id="app"></div>';
1912
+ window.location.hash = '#/';
1913
+ });
1914
+
1915
+ afterEach(() => {
1916
+ if (router) router.destroy();
1917
+ });
1918
+
1919
+ /**
1920
+ * Reproduces the bug: tabbing through substates in a component (e.g.
1921
+ * compare-page), navigating to a different route (e.g. /about), then
1922
+ * pressing back should return to the LAST substate tab — not the default.
1923
+ *
1924
+ * Steps to reproduce:
1925
+ * 1. Navigate to /compare, push substates: tab-a → tab-b → tab-c
1926
+ * 2. Navigate to /about (component with listener is destroyed)
1927
+ * 3. Simulate popstate back → lands on substate entry for tab-c
1928
+ * 4. BUG: no listener exists (it was destroyed), so the substate is
1929
+ * ignored and the component remounts with its default state.
1930
+ * 5. EXPECTED: the router should re-fire the substate after the new
1931
+ * component mounts so the listener can restore the correct tab.
1932
+ */
1933
+ it('restores last substate when pressing back after navigating away', async () => {
1934
+ router = createRouter({
1935
+ el: '#app',
1936
+ mode: 'history',
1937
+ routes: [
1938
+ { path: '/', component: 'home-page' },
1939
+ { path: '/compare', component: 'about-page' },
1940
+ { path: '/about', component: 'user-page' },
1941
+ ],
1942
+ });
1943
+
1944
+ // Wait for initial resolve
1945
+ await new Promise(r => setTimeout(r, 10));
1946
+
1947
+ // 1. Navigate to /compare and register a substate listener
1948
+ // (simulates what compare-page does in its mounted() hook)
1949
+ window.history.pushState({ __zq: 'route' }, '', '/compare');
1950
+ await router._resolve();
1951
+ expect(router.current.path).toBe('/compare');
1952
+
1953
+ // Simulate component mounting and registering a substate listener
1954
+ let activeTab = 'overview';
1955
+ const listener = vi.fn((key, data, action) => {
1956
+ if (key === 'compare-tab') { activeTab = data.tab; return true; }
1957
+ if (action === 'reset') { activeTab = 'overview'; return true; }
1958
+ });
1959
+ const unsub = router.onSubstate(listener);
1960
+
1961
+ // 2. Push several substates (tab switches)
1962
+ router.pushSubstate('compare-tab', { tab: 'components' });
1963
+ activeTab = 'components';
1964
+ router.pushSubstate('compare-tab', { tab: 'directives' });
1965
+ activeTab = 'directives';
1966
+ router.pushSubstate('compare-tab', { tab: 'reactivity' });
1967
+ activeTab = 'reactivity';
1968
+
1969
+ // 3. Navigate away to /about — this destroys compare-page
1970
+ // so we unsubscribe the listener (like destroyed() would)
1971
+ unsub();
1972
+ window.history.pushState({ __zq: 'route' }, '', '/about');
1973
+ await router._resolve();
1974
+ expect(router.current.path).toBe('/about');
1975
+
1976
+ // 4. Register a NEW listener after _resolve, simulating what the freshly
1977
+ // mounted compare-page would do. We use a one-time setup that defers
1978
+ // registration until _resolve has run (like mounted() in the component).
1979
+ let restoredTab = 'overview';
1980
+ const freshListener = vi.fn((key, data, action) => {
1981
+ if (key === 'compare-tab') { restoredTab = data.tab; return true; }
1982
+ if (action === 'reset') { restoredTab = 'overview'; return true; }
1983
+ });
1984
+
1985
+ // Intercept _resolve to register the listener after mount (simulating
1986
+ // the component's mounted() lifecycle hook)
1987
+ const origResolve = router.__resolve.bind(router);
1988
+ router.__resolve = async function () {
1989
+ await origResolve();
1990
+ // After resolve mounts the component, register the substate listener
1991
+ router.onSubstate(freshListener);
1992
+ };
1993
+
1994
+ // 5. Simulate pressing back — popstate lands on the last substate
1995
+ const evt = new PopStateEvent('popstate', {
1996
+ state: { __zq: 'substate', key: 'compare-tab', data: { tab: 'reactivity' } }
1997
+ });
1998
+ window.dispatchEvent(evt);
1999
+
2000
+ // Allow async _resolve to complete and the retry to fire
2001
+ await new Promise(r => setTimeout(r, 50));
2002
+
2003
+ // EXPECTED: the fresh listener should have been called with the substate
2004
+ // data so that restoredTab is 'reactivity', NOT 'overview'
2005
+ expect(freshListener).toHaveBeenCalledWith('compare-tab', { tab: 'reactivity' }, 'pop');
2006
+ expect(restoredTab).toBe('reactivity');
2007
+ });
2008
+
2009
+ it('handles second back correctly after substate restoration', async () => {
2010
+ router = createRouter({
2011
+ el: '#app',
2012
+ mode: 'history',
2013
+ routes: [
2014
+ { path: '/', component: 'home-page' },
2015
+ { path: '/compare', component: 'about-page' },
2016
+ { path: '/about', component: 'user-page' },
2017
+ ],
2018
+ });
2019
+ await new Promise(r => setTimeout(r, 10));
2020
+
2021
+ // Navigate to /compare
2022
+ window.history.pushState({ __zq: 'route' }, '', '/compare');
2023
+ await router._resolve();
2024
+
2025
+ let activeTab = 'overview';
2026
+ const listener = vi.fn((key, data, action) => {
2027
+ if (key === 'compare-tab') { activeTab = data.tab; return true; }
2028
+ if (action === 'reset') { activeTab = 'overview'; return true; }
2029
+ });
2030
+ const unsub = router.onSubstate(listener);
2031
+
2032
+ // Push two substates
2033
+ router.pushSubstate('compare-tab', { tab: 'components' });
2034
+ router.pushSubstate('compare-tab', { tab: 'directives' });
2035
+
2036
+ // Navigate away and unsubscribe (simulating component destroy)
2037
+ unsub();
2038
+ window.history.pushState({ __zq: 'route' }, '', '/about');
2039
+ await router._resolve();
2040
+
2041
+ // Back: first pop hits 'directives' substate — no listener yet
2042
+ let restoredTab = 'overview';
2043
+ const freshListener = vi.fn((key, data, action) => {
2044
+ if (key === 'compare-tab') { restoredTab = data.tab; return true; }
2045
+ if (action === 'reset') { restoredTab = 'overview'; return true; }
2046
+ });
2047
+
2048
+ const origResolve = router.__resolve.bind(router);
2049
+ let resolveCount = 0;
2050
+ router.__resolve = async function () {
2051
+ await origResolve();
2052
+ if (resolveCount === 0) router.onSubstate(freshListener);
2053
+ resolveCount++;
2054
+ };
2055
+
2056
+ // First back: lands on 'directives' substate
2057
+ window.dispatchEvent(new PopStateEvent('popstate', {
2058
+ state: { __zq: 'substate', key: 'compare-tab', data: { tab: 'directives' } }
2059
+ }));
2060
+ await new Promise(r => setTimeout(r, 50));
2061
+
2062
+ expect(restoredTab).toBe('directives');
2063
+
2064
+ // Second back: lands on 'components' substate — listener IS registered now
2065
+ window.dispatchEvent(new PopStateEvent('popstate', {
2066
+ state: { __zq: 'substate', key: 'compare-tab', data: { tab: 'components' } }
2067
+ }));
2068
+ await new Promise(r => setTimeout(r, 50));
2069
+
2070
+ expect(restoredTab).toBe('components');
2071
+ });
2072
+ });
2073
+
2074
+
2075
+ // ===========================================================================
2076
+ // Router - <z-outlet> auto-detection
2077
+ // ===========================================================================
2078
+
2079
+ describe('Router - z-outlet auto-detection', () => {
2080
+ beforeEach(() => {
2081
+ window.location.hash = '#/';
2082
+ });
2083
+
2084
+ it('auto-detects <z-outlet> when no el: is provided', async () => {
2085
+ document.body.innerHTML = '<z-outlet></z-outlet>';
2086
+ const router = createRouter({
2087
+ mode: 'hash',
2088
+ routes: [{ path: '/', component: 'home-page' }],
2089
+ });
2090
+ expect(router._el).toBe(document.querySelector('z-outlet'));
2091
+ // Wait for initial resolve and verify component was mounted
2092
+ await new Promise(r => setTimeout(r, 50));
2093
+ expect(router._el.innerHTML).not.toBe('');
2094
+ router.destroy();
2095
+ });
2096
+
2097
+ it('prefers explicit el: over <z-outlet>', () => {
2098
+ document.body.innerHTML = '<div id="app"></div><z-outlet></z-outlet>';
2099
+ const router = createRouter({
2100
+ mode: 'hash',
2101
+ el: '#app',
2102
+ routes: [{ path: '/', component: 'home-page' }],
2103
+ });
2104
+ expect(router._el).toBe(document.getElementById('app'));
2105
+ router.destroy();
2106
+ });
2107
+
2108
+ it('reads fallback attribute from <z-outlet>', () => {
2109
+ document.body.innerHTML = '<z-outlet fallback="about-page"></z-outlet>';
2110
+ const router = createRouter({
2111
+ mode: 'hash',
2112
+ routes: [{ path: '/', component: 'home-page' }],
2113
+ });
2114
+ expect(router._fallback).toBe('about-page');
2115
+ router.destroy();
2116
+ });
2117
+
2118
+ it('config fallback takes priority over <z-outlet> fallback attribute', () => {
2119
+ document.body.innerHTML = '<z-outlet fallback="about-page"></z-outlet>';
2120
+ const router = createRouter({
2121
+ mode: 'hash',
2122
+ routes: [{ path: '/', component: 'home-page' }],
2123
+ fallback: 'user-page',
2124
+ });
2125
+ expect(router._fallback).toBe('user-page');
2126
+ router.destroy();
2127
+ });
2128
+
2129
+ it('reads mode attribute from <z-outlet>', () => {
2130
+ document.body.innerHTML = '<z-outlet mode="hash"></z-outlet>';
2131
+ const router = createRouter({
2132
+ routes: [{ path: '/', component: 'home-page' }],
2133
+ });
2134
+ expect(router._mode).toBe('hash');
2135
+ router.destroy();
2136
+ });
2137
+
2138
+ it('reads base attribute from <z-outlet>', () => {
2139
+ document.body.innerHTML = '<z-outlet base="/my-app"></z-outlet>';
2140
+ const router = createRouter({
2141
+ mode: 'hash',
2142
+ routes: [],
2143
+ });
2144
+ expect(router._base).toBe('/my-app');
2145
+ router.destroy();
2146
+ });
2147
+
2148
+ it('config base takes priority over <z-outlet> base attribute', () => {
2149
+ document.body.innerHTML = '<z-outlet base="/outlet-base"></z-outlet>';
2150
+ const router = createRouter({
2151
+ mode: 'hash',
2152
+ base: '/config-base',
2153
+ routes: [],
2154
+ });
2155
+ expect(router._base).toBe('/config-base');
2156
+ router.destroy();
2157
+ });
2158
+
2159
+ it('falls back gracefully when no <z-outlet> and no el:', () => {
2160
+ document.body.innerHTML = '<div>no outlet here</div>';
2161
+ const router = createRouter({
2162
+ mode: 'hash',
2163
+ routes: [{ path: '/', component: 'home-page' }],
2164
+ });
2165
+ expect(router._el).toBeNull();
2166
+ router.destroy();
2167
+ });
2168
+
2169
+ it('mounts and navigates using <z-outlet>', async () => {
2170
+ document.body.innerHTML = '<z-outlet></z-outlet>';
2171
+ const router = createRouter({
2172
+ mode: 'hash',
2173
+ routes: [
2174
+ { path: '/', component: 'home-page' },
2175
+ { path: '/about', component: 'about-page' },
2176
+ ],
2177
+ });
2178
+ await new Promise(r => setTimeout(r, 50));
2179
+ expect(router.current.path).toBe('/');
2180
+ router.navigate('/about');
2181
+ await router._resolve();
2182
+ expect(router.current.path).toBe('/about');
2183
+ router.destroy();
2184
+ });
2185
+ });
2186
+
2187
+
2188
+ // ===========================================================================
2189
+ // z-active-route directive
2190
+ // ===========================================================================
2191
+
2192
+ describe('Router - z-active-route', () => {
2193
+ let router;
2194
+
2195
+ beforeEach(() => {
2196
+ document.body.innerHTML = '<div id="app"></div>';
2197
+ router = createRouter({
2198
+ el: '#app',
2199
+ mode: 'hash',
2200
+ routes: [
2201
+ { path: '/', component: 'home-page' },
2202
+ { path: '/about', component: 'about-page' },
2203
+ { path: '/docs/:section', component: 'docs-page' },
2204
+ ],
2205
+ });
2206
+ });
2207
+
2208
+ afterEach(() => {
2209
+ if (router) router.destroy();
2210
+ });
2211
+
2212
+ it('adds "active" class to matching element (prefix match)', () => {
2213
+ document.body.innerHTML += `
2214
+ <a z-link="/docs/intro" z-active-route="/docs">Docs</a>
2215
+ <a z-link="/about" z-active-route="/about">About</a>
2216
+ `;
2217
+ router._updateActiveRoutes('/docs/intro');
2218
+ const docsLink = document.querySelector('[z-active-route="/docs"]');
2219
+ const aboutLink = document.querySelector('[z-active-route="/about"]');
2220
+ expect(docsLink.classList.contains('active')).toBe(true);
2221
+ expect(aboutLink.classList.contains('active')).toBe(false);
2222
+ });
2223
+
2224
+ it('removes "active" class when route no longer matches', () => {
2225
+ document.body.innerHTML += '<a z-active-route="/about">About</a>';
2226
+ const el = document.querySelector('[z-active-route="/about"]');
2227
+
2228
+ router._updateActiveRoutes('/about');
2229
+ expect(el.classList.contains('active')).toBe(true);
2230
+
2231
+ router._updateActiveRoutes('/docs');
2232
+ expect(el.classList.contains('active')).toBe(false);
2233
+ });
2234
+
2235
+ it('supports custom class via z-active-class', () => {
2236
+ document.body.innerHTML += '<a z-active-route="/about" z-active-class="selected">About</a>';
2237
+ const el = document.querySelector('[z-active-route="/about"]');
2238
+
2239
+ router._updateActiveRoutes('/about');
2240
+ expect(el.classList.contains('selected')).toBe(true);
2241
+ expect(el.classList.contains('active')).toBe(false);
2242
+ });
2243
+
2244
+ it('supports exact matching with z-active-exact', () => {
2245
+ document.body.innerHTML += `
2246
+ <a z-active-route="/" z-active-exact>Home</a>
2247
+ <a z-active-route="/docs">Docs</a>
2248
+ `;
2249
+ const home = document.querySelector('[z-active-route="/"]');
2250
+ const docs = document.querySelector('[z-active-route="/docs"]');
2251
+
2252
+ // At root - home is exact match, docs should not match
2253
+ router._updateActiveRoutes('/');
2254
+ expect(home.classList.contains('active')).toBe(true);
2255
+ expect(docs.classList.contains('active')).toBe(false);
2256
+
2257
+ // At /docs - home exact should NOT match, docs prefix should match
2258
+ router._updateActiveRoutes('/docs');
2259
+ expect(home.classList.contains('active')).toBe(false);
2260
+ expect(docs.classList.contains('active')).toBe(true);
2261
+ });
2262
+
2263
+ it('root "/" only matches itself, not all paths (prefix match)', () => {
2264
+ document.body.innerHTML += '<a z-active-route="/">Home</a>';
2265
+ const el = document.querySelector('[z-active-route="/"]');
2266
+
2267
+ router._updateActiveRoutes('/about');
2268
+ expect(el.classList.contains('active')).toBe(false);
2269
+
2270
+ router._updateActiveRoutes('/');
2271
+ expect(el.classList.contains('active')).toBe(true);
2272
+ });
2273
+
2274
+ it('handles multiple elements simultaneously', () => {
2275
+ document.body.innerHTML += `
2276
+ <nav>
2277
+ <a z-active-route="/" z-active-exact>Home</a>
2278
+ <a z-active-route="/about">About</a>
2279
+ <a z-active-route="/docs">Docs</a>
2280
+ <a z-active-route="/docs" z-active-class="highlight">Docs Alt</a>
2281
+ </nav>
2282
+ `;
2283
+
2284
+ router._updateActiveRoutes('/docs/getting-started');
2285
+
2286
+ const home = document.querySelector('[z-active-route="/"]');
2287
+ const about = document.querySelector('[z-active-route="/about"]');
2288
+ const docs = document.querySelectorAll('[z-active-route="/docs"]');
2289
+
2290
+ expect(home.classList.contains('active')).toBe(false);
2291
+ expect(about.classList.contains('active')).toBe(false);
2292
+ expect(docs[0].classList.contains('active')).toBe(true);
2293
+ expect(docs[1].classList.contains('highlight')).toBe(true);
2294
+ });
2295
+
2296
+ it('z-active-exact does not match child routes', () => {
2297
+ document.body.innerHTML += '<a z-active-route="/docs" z-active-exact>Docs</a>';
2298
+ const el = document.querySelector('[z-active-route="/docs"]');
2299
+
2300
+ router._updateActiveRoutes('/docs/intro');
2301
+ expect(el.classList.contains('active')).toBe(false);
2302
+
2303
+ router._updateActiveRoutes('/docs');
2304
+ expect(el.classList.contains('active')).toBe(true);
2305
+ });
2306
+
2307
+ it('toggles class correctly across navigation changes', () => {
2308
+ document.body.innerHTML += `
2309
+ <a z-active-route="/about">About</a>
2310
+ <a z-active-route="/docs">Docs</a>
2311
+ `;
2312
+ const about = document.querySelector('[z-active-route="/about"]');
2313
+ const docs = document.querySelector('[z-active-route="/docs"]');
2314
+
2315
+ router._updateActiveRoutes('/about');
2316
+ expect(about.classList.contains('active')).toBe(true);
2317
+ expect(docs.classList.contains('active')).toBe(false);
2318
+
2319
+ router._updateActiveRoutes('/docs/selectors');
2320
+ expect(about.classList.contains('active')).toBe(false);
2321
+ expect(docs.classList.contains('active')).toBe(true);
2322
+
2323
+ router._updateActiveRoutes('/');
2324
+ expect(about.classList.contains('active')).toBe(false);
2325
+ expect(docs.classList.contains('active')).toBe(false);
2326
+ });
2327
+ });