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.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -442
- package/cli/commands/build.js +254 -247
- package/cli/commands/bundle.js +1228 -1224
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -220
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +661 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6614
- package/dist/zquery.min.js +8 -631
- package/index.d.ts +570 -371
- package/index.js +311 -240
- package/package.json +76 -70
- package/src/component.js +1709 -1691
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -255
- package/src/router.js +843 -843
- package/src/ssr.js +418 -418
- package/src/store.js +318 -318
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1103
- package/tests/compare.test.js +497 -486
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -489
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -1650
- package/tests/electron-features.test.js +864 -864
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -146
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
package/tests/router.test.js
CHANGED
|
@@ -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
|
+
});
|