zero-query 0.6.3 → 0.7.5
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/README.md +6 -6
- package/cli/commands/build.js +3 -3
- package/cli/commands/bundle.js +286 -8
- package/cli/commands/dev/index.js +2 -2
- package/cli/commands/dev/overlay.js +51 -2
- package/cli/commands/dev/server.js +34 -5
- package/cli/commands/dev/watcher.js +33 -0
- package/cli/scaffold/index.html +1 -0
- package/cli/scaffold/scripts/app.js +15 -22
- package/cli/scaffold/scripts/components/contacts/contacts.css +0 -7
- package/cli/scaffold/scripts/components/contacts/contacts.html +3 -3
- package/cli/scaffold/styles/styles.css +1 -0
- package/cli/utils.js +111 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +379 -27
- package/dist/zquery.min.js +3 -16
- package/index.d.ts +127 -1290
- package/package.json +5 -5
- package/src/component.js +11 -1
- package/src/core.js +305 -10
- package/src/router.js +49 -2
- package/tests/component.test.js +304 -0
- package/tests/core.test.js +726 -0
- package/tests/diff.test.js +194 -0
- package/tests/errors.test.js +162 -0
- package/tests/expression.test.js +334 -0
- package/tests/http.test.js +181 -0
- package/tests/reactive.test.js +191 -0
- package/tests/router.test.js +332 -0
- package/tests/store.test.js +253 -0
- package/tests/utils.test.js +353 -0
- package/types/collection.d.ts +368 -0
- package/types/component.d.ts +210 -0
- package/types/errors.d.ts +103 -0
- package/types/http.d.ts +81 -0
- package/types/misc.d.ts +166 -0
- package/types/reactive.d.ts +76 -0
- package/types/router.d.ts +132 -0
- package/types/ssr.d.ts +49 -0
- package/types/store.d.ts +107 -0
- package/types/utils.d.ts +142 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { http } from '../src/http.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mock fetch
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
let fetchSpy;
|
|
9
|
+
|
|
10
|
+
function mockFetch(response = {}, ok = true, status = 200) {
|
|
11
|
+
const body = typeof response === 'string' ? response : JSON.stringify(response);
|
|
12
|
+
const contentType = typeof response === 'string' ? 'text/plain' : 'application/json';
|
|
13
|
+
|
|
14
|
+
fetchSpy = vi.fn(() =>
|
|
15
|
+
Promise.resolve({
|
|
16
|
+
ok,
|
|
17
|
+
status,
|
|
18
|
+
statusText: ok ? 'OK' : 'Error',
|
|
19
|
+
headers: {
|
|
20
|
+
get: (h) => h.toLowerCase() === 'content-type' ? contentType : null,
|
|
21
|
+
entries: () => [[`content-type`, contentType]],
|
|
22
|
+
},
|
|
23
|
+
json: () => Promise.resolve(typeof response === 'object' ? response : JSON.parse(response)),
|
|
24
|
+
text: () => Promise.resolve(body),
|
|
25
|
+
blob: () => Promise.resolve(new Blob([body])),
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
globalThis.fetch = fetchSpy;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
http.configure({ baseURL: '', headers: { 'Content-Type': 'application/json' }, timeout: 30000 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// GET requests
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe('http.get', () => {
|
|
45
|
+
it('makes a GET request', async () => {
|
|
46
|
+
mockFetch({ users: [] });
|
|
47
|
+
const result = await http.get('https://api.test.com/users');
|
|
48
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
49
|
+
expect(result.data).toEqual({ users: [] });
|
|
50
|
+
expect(result.ok).toBe(true);
|
|
51
|
+
expect(result.status).toBe(200);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('appends query params for GET', async () => {
|
|
55
|
+
mockFetch({});
|
|
56
|
+
await http.get('https://api.test.com/search', { q: 'hello', page: '1' });
|
|
57
|
+
const url = fetchSpy.mock.calls[0][0];
|
|
58
|
+
expect(url).toContain('q=hello');
|
|
59
|
+
expect(url).toContain('page=1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('uses baseURL', async () => {
|
|
63
|
+
http.configure({ baseURL: 'https://api.test.com' });
|
|
64
|
+
mockFetch({});
|
|
65
|
+
await http.get('/users');
|
|
66
|
+
expect(fetchSpy.mock.calls[0][0]).toBe('https://api.test.com/users');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// POST requests
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('http.post', () => {
|
|
76
|
+
it('sends JSON body', async () => {
|
|
77
|
+
mockFetch({ id: 1 });
|
|
78
|
+
const result = await http.post('https://api.test.com/users', { name: 'Tony' });
|
|
79
|
+
const opts = fetchSpy.mock.calls[0][1];
|
|
80
|
+
expect(opts.method).toBe('POST');
|
|
81
|
+
expect(opts.body).toBe(JSON.stringify({ name: 'Tony' }));
|
|
82
|
+
expect(result.data).toEqual({ id: 1 });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('handles FormData body', async () => {
|
|
86
|
+
mockFetch({});
|
|
87
|
+
const form = new FormData();
|
|
88
|
+
form.append('file', 'data');
|
|
89
|
+
await http.post('https://api.test.com/upload', form);
|
|
90
|
+
const opts = fetchSpy.mock.calls[0][1];
|
|
91
|
+
expect(opts.body).toBe(form);
|
|
92
|
+
// Content-Type should be removed for FormData
|
|
93
|
+
expect(opts.headers['Content-Type']).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Error handling
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('http — error handling', () => {
|
|
103
|
+
it('throws on non-ok response', async () => {
|
|
104
|
+
mockFetch({ error: 'Not Found' }, false, 404);
|
|
105
|
+
await expect(http.get('https://api.test.com/missing')).rejects.toThrow('HTTP 404');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('error includes response data', async () => {
|
|
109
|
+
mockFetch({ message: 'Unauthorized' }, false, 401);
|
|
110
|
+
try {
|
|
111
|
+
await http.get('https://api.test.com/protected');
|
|
112
|
+
} catch (err) {
|
|
113
|
+
expect(err.response).toBeDefined();
|
|
114
|
+
expect(err.response.status).toBe(401);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('throws on invalid URL', async () => {
|
|
119
|
+
await expect(http.get(null)).rejects.toThrow('requires a URL string');
|
|
120
|
+
await expect(http.get(undefined)).rejects.toThrow('requires a URL string');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// PUT / PATCH / DELETE
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
describe('http — other methods', () => {
|
|
130
|
+
it('PUT sends correct method', async () => {
|
|
131
|
+
mockFetch({});
|
|
132
|
+
await http.put('https://api.test.com/users/1', { name: 'Updated' });
|
|
133
|
+
expect(fetchSpy.mock.calls[0][1].method).toBe('PUT');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('PATCH sends correct method', async () => {
|
|
137
|
+
mockFetch({});
|
|
138
|
+
await http.patch('https://api.test.com/users/1', { name: 'Patched' });
|
|
139
|
+
expect(fetchSpy.mock.calls[0][1].method).toBe('PATCH');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('DELETE sends correct method', async () => {
|
|
143
|
+
mockFetch({});
|
|
144
|
+
await http.delete('https://api.test.com/users/1');
|
|
145
|
+
expect(fetchSpy.mock.calls[0][1].method).toBe('DELETE');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// configure
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe('http.configure', () => {
|
|
155
|
+
it('sets baseURL', async () => {
|
|
156
|
+
http.configure({ baseURL: 'https://example.com/api' });
|
|
157
|
+
mockFetch({});
|
|
158
|
+
await http.get('/data');
|
|
159
|
+
expect(fetchSpy.mock.calls[0][0]).toBe('https://example.com/api/data');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('merges headers', async () => {
|
|
163
|
+
http.configure({ headers: { Authorization: 'Bearer token' } });
|
|
164
|
+
mockFetch({});
|
|
165
|
+
await http.get('https://api.test.com/me');
|
|
166
|
+
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer token');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Text response
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
describe('http — text response', () => {
|
|
176
|
+
it('parses text response', async () => {
|
|
177
|
+
mockFetch('Hello World');
|
|
178
|
+
const result = await http.get('https://api.test.com/text');
|
|
179
|
+
expect(result.data).toBe('Hello World');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { reactive, Signal, signal, computed, effect } from '../src/reactive.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// reactive()
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
describe('reactive', () => {
|
|
10
|
+
it('triggers onChange when a property is set', () => {
|
|
11
|
+
const fn = vi.fn();
|
|
12
|
+
const obj = reactive({ count: 0 }, fn);
|
|
13
|
+
obj.count = 5;
|
|
14
|
+
expect(fn).toHaveBeenCalledWith('count', 5, 0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('does not trigger onChange when value is the same', () => {
|
|
18
|
+
const fn = vi.fn();
|
|
19
|
+
const obj = reactive({ count: 0 }, fn);
|
|
20
|
+
obj.count = 0;
|
|
21
|
+
expect(fn).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns non-objects as-is', () => {
|
|
25
|
+
expect(reactive(42, vi.fn())).toBe(42);
|
|
26
|
+
expect(reactive('hello', vi.fn())).toBe('hello');
|
|
27
|
+
expect(reactive(null, vi.fn())).toBe(null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('supports deep nested property access', () => {
|
|
31
|
+
const fn = vi.fn();
|
|
32
|
+
const obj = reactive({ user: { name: 'Tony' } }, fn);
|
|
33
|
+
obj.user.name = 'Sam';
|
|
34
|
+
expect(fn).toHaveBeenCalledWith('name', 'Sam', 'Tony');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('tracks __isReactive and __raw', () => {
|
|
38
|
+
const fn = vi.fn();
|
|
39
|
+
const raw = { x: 1 };
|
|
40
|
+
const obj = reactive(raw, fn);
|
|
41
|
+
expect(obj.__isReactive).toBe(true);
|
|
42
|
+
expect(obj.__raw).toBe(raw);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('triggers onChange on deleteProperty', () => {
|
|
46
|
+
const fn = vi.fn();
|
|
47
|
+
const obj = reactive({ x: 1 }, fn);
|
|
48
|
+
delete obj.x;
|
|
49
|
+
expect(fn).toHaveBeenCalledWith('x', undefined, 1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('caches child proxies (same reference on repeated access)', () => {
|
|
53
|
+
const fn = vi.fn();
|
|
54
|
+
const obj = reactive({ nested: { a: 1 } }, fn);
|
|
55
|
+
const first = obj.nested;
|
|
56
|
+
const second = obj.nested;
|
|
57
|
+
expect(first).toBe(second);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles onChange gracefully when onChange is not a function', () => {
|
|
61
|
+
// Should not throw — error is reported and a no-op is used
|
|
62
|
+
expect(() => {
|
|
63
|
+
const obj = reactive({ x: 1 }, 'not a function');
|
|
64
|
+
obj.x = 2;
|
|
65
|
+
}).not.toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not crash when onChange callback throws', () => {
|
|
69
|
+
const bad = vi.fn(() => { throw new Error('boom'); });
|
|
70
|
+
const obj = reactive({ x: 1 }, bad);
|
|
71
|
+
expect(() => { obj.x = 2; }).not.toThrow();
|
|
72
|
+
expect(bad).toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Signal
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
describe('Signal', () => {
|
|
82
|
+
it('stores and retrieves a value', () => {
|
|
83
|
+
const s = new Signal(10);
|
|
84
|
+
expect(s.value).toBe(10);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('notifies subscribers on value change', () => {
|
|
88
|
+
const s = new Signal(0);
|
|
89
|
+
const fn = vi.fn();
|
|
90
|
+
s.subscribe(fn);
|
|
91
|
+
s.value = 1;
|
|
92
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not notify when value is the same', () => {
|
|
96
|
+
const s = new Signal(5);
|
|
97
|
+
const fn = vi.fn();
|
|
98
|
+
s.subscribe(fn);
|
|
99
|
+
s.value = 5;
|
|
100
|
+
expect(fn).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('subscribe returns an unsubscribe function', () => {
|
|
104
|
+
const s = new Signal(0);
|
|
105
|
+
const fn = vi.fn();
|
|
106
|
+
const unsub = s.subscribe(fn);
|
|
107
|
+
unsub();
|
|
108
|
+
s.value = 1;
|
|
109
|
+
expect(fn).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('peek() returns value without tracking', () => {
|
|
113
|
+
const s = new Signal(42);
|
|
114
|
+
expect(s.peek()).toBe(42);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('toString() returns string representation of value', () => {
|
|
118
|
+
const s = new Signal(123);
|
|
119
|
+
expect(s.toString()).toBe('123');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('does not crash when a subscriber throws', () => {
|
|
123
|
+
const s = new Signal(0);
|
|
124
|
+
s.subscribe(() => { throw new Error('oops'); });
|
|
125
|
+
expect(() => { s.value = 1; }).not.toThrow();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// signal() factory
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
describe('signal()', () => {
|
|
135
|
+
it('returns a Signal instance', () => {
|
|
136
|
+
const s = signal(0);
|
|
137
|
+
expect(s).toBeInstanceOf(Signal);
|
|
138
|
+
expect(s.value).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// computed()
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe('computed()', () => {
|
|
148
|
+
it('derives value from other signals', () => {
|
|
149
|
+
const count = signal(2);
|
|
150
|
+
const doubled = computed(() => count.value * 2);
|
|
151
|
+
expect(doubled.value).toBe(4);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('updates when dependency changes', () => {
|
|
155
|
+
const a = signal(1);
|
|
156
|
+
const b = signal(2);
|
|
157
|
+
const sum = computed(() => a.value + b.value);
|
|
158
|
+
expect(sum.value).toBe(3);
|
|
159
|
+
a.value = 10;
|
|
160
|
+
expect(sum.value).toBe(12);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// effect()
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
describe('effect()', () => {
|
|
170
|
+
it('runs the effect function immediately', () => {
|
|
171
|
+
const fn = vi.fn();
|
|
172
|
+
effect(fn);
|
|
173
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('re-runs when a tracked signal changes', () => {
|
|
177
|
+
const s = signal(0);
|
|
178
|
+
const log = vi.fn();
|
|
179
|
+
effect(() => { log(s.value); });
|
|
180
|
+
expect(log).toHaveBeenCalledWith(0);
|
|
181
|
+
s.value = 1;
|
|
182
|
+
expect(log).toHaveBeenCalledWith(1);
|
|
183
|
+
expect(log).toHaveBeenCalledTimes(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('does not crash when effect function throws', () => {
|
|
187
|
+
expect(() => {
|
|
188
|
+
effect(() => { throw new Error('fail'); });
|
|
189
|
+
}).not.toThrow();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createRouter, getRouter } 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
|
+
});
|