zero-query 0.6.3 → 0.8.6
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 +39 -29
- package/cli/commands/build.js +113 -4
- package/cli/commands/bundle.js +392 -29
- package/cli/commands/create.js +1 -1
- package/cli/commands/dev/devtools/index.js +56 -0
- package/cli/commands/dev/devtools/js/components.js +49 -0
- package/cli/commands/dev/devtools/js/core.js +409 -0
- package/cli/commands/dev/devtools/js/elements.js +413 -0
- package/cli/commands/dev/devtools/js/network.js +166 -0
- package/cli/commands/dev/devtools/js/performance.js +73 -0
- package/cli/commands/dev/devtools/js/router.js +105 -0
- package/cli/commands/dev/devtools/js/source.js +132 -0
- package/cli/commands/dev/devtools/js/stats.js +35 -0
- package/cli/commands/dev/devtools/js/tabs.js +79 -0
- package/cli/commands/dev/devtools/panel.html +95 -0
- package/cli/commands/dev/devtools/styles.css +244 -0
- package/cli/commands/dev/index.js +29 -4
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +428 -2
- package/cli/commands/dev/server.js +42 -5
- package/cli/commands/dev/watcher.js +59 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +16 -23
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
- package/cli/scaffold/app/components/home.js +137 -0
- package/cli/scaffold/{scripts → app}/routes.js +1 -1
- package/cli/scaffold/{scripts → app}/store.js +6 -6
- package/cli/scaffold/assets/.gitkeep +0 -0
- package/cli/scaffold/{styles/styles.css → global.css} +4 -2
- package/cli/scaffold/index.html +12 -11
- package/cli/utils.js +111 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +1122 -158
- package/dist/zquery.min.js +3 -16
- package/index.d.ts +129 -1290
- package/index.js +15 -10
- package/package.json +7 -6
- package/src/component.js +172 -49
- package/src/core.js +359 -18
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +243 -7
- package/tests/component.test.js +886 -0
- package/tests/core.test.js +977 -0
- package/tests/diff.test.js +525 -0
- package/tests/errors.test.js +162 -0
- package/tests/expression.test.js +482 -0
- package/tests/http.test.js +289 -0
- package/tests/reactive.test.js +339 -0
- package/tests/router.test.js +649 -0
- package/tests/store.test.js +379 -0
- package/tests/utils.test.js +512 -0
- package/types/collection.d.ts +383 -0
- package/types/component.d.ts +217 -0
- package/types/errors.d.ts +103 -0
- package/types/http.d.ts +81 -0
- package/types/misc.d.ts +179 -0
- package/types/reactive.d.ts +76 -0
- package/types/router.d.ts +161 -0
- package/types/ssr.d.ts +49 -0
- package/types/store.d.ts +107 -0
- package/types/utils.d.ts +142 -0
- package/cli/commands/dev.old.js +0 -520
- package/cli/scaffold/scripts/components/home.js +0 -137
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
});
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Interceptors
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
describe('http — interceptors', () => {
|
|
189
|
+
it('request interceptor via onRequest', async () => {
|
|
190
|
+
http.configure({ baseURL: '' });
|
|
191
|
+
http.onRequest((fetchOpts, url) => {
|
|
192
|
+
fetchOpts.headers['X-Custom'] = 'test';
|
|
193
|
+
});
|
|
194
|
+
mockFetch({});
|
|
195
|
+
await http.get('https://api.test.com/data');
|
|
196
|
+
const opts = fetchSpy.mock.calls[0][1];
|
|
197
|
+
expect(opts.headers['X-Custom']).toBe('test');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('response interceptor via onResponse', async () => {
|
|
201
|
+
http.onResponse((result) => {
|
|
202
|
+
result.intercepted = true;
|
|
203
|
+
});
|
|
204
|
+
mockFetch({ x: 1 });
|
|
205
|
+
const result = await http.get('https://api.test.com/data');
|
|
206
|
+
expect(result.intercepted).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Timeout / abort
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
describe('http — abort signal', () => {
|
|
216
|
+
it('passes signal through options', async () => {
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
mockFetch({});
|
|
219
|
+
await http.get('https://api.test.com/data', null, { signal: controller.signal });
|
|
220
|
+
const opts = fetchSpy.mock.calls[0][1];
|
|
221
|
+
expect(opts.signal).toBe(controller.signal);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Blob response
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
describe('http — blob response', () => {
|
|
231
|
+
it('can request blob responses', async () => {
|
|
232
|
+
mockFetch('binary data');
|
|
233
|
+
const result = await http.get('https://api.test.com/file', null, { responseType: 'blob' });
|
|
234
|
+
// Should have a data field (blob or fallback text)
|
|
235
|
+
expect(result.data).toBeDefined();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// HEAD and OPTIONS methods
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
describe('http — raw fetch pass-through', () => {
|
|
245
|
+
it('raw() delegates to native fetch', async () => {
|
|
246
|
+
mockFetch({ ok: true });
|
|
247
|
+
await http.raw('https://api.test.com/ping', { method: 'HEAD' });
|
|
248
|
+
expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com/ping', { method: 'HEAD' });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Request with custom headers
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
describe('http — custom per-request headers', () => {
|
|
258
|
+
it('merges per-request headers', async () => {
|
|
259
|
+
mockFetch({});
|
|
260
|
+
await http.get('https://api.test.com/data', null, {
|
|
261
|
+
headers: { 'X-Request-Id': '123' },
|
|
262
|
+
});
|
|
263
|
+
const opts = fetchSpy.mock.calls[0][1];
|
|
264
|
+
expect(opts.headers['X-Request-Id']).toBe('123');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Response metadata
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
describe('http — response metadata', () => {
|
|
274
|
+
it('includes status and ok in result', async () => {
|
|
275
|
+
mockFetch({ data: 'yes' }, true, 200);
|
|
276
|
+
const result = await http.get('https://api.test.com/data');
|
|
277
|
+
expect(result.ok).toBe(true);
|
|
278
|
+
expect(result.status).toBe(200);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('includes statusText in error', async () => {
|
|
282
|
+
mockFetch({ error: 'bad' }, false, 500);
|
|
283
|
+
try {
|
|
284
|
+
await http.get('https://api.test.com/fail');
|
|
285
|
+
} catch (err) {
|
|
286
|
+
expect(err.message).toContain('500');
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
|
|
192
|
+
it('dispose stops re-running on signal change', () => {
|
|
193
|
+
const s = signal(0);
|
|
194
|
+
const log = vi.fn();
|
|
195
|
+
const dispose = effect(() => { log(s.value); });
|
|
196
|
+
expect(log).toHaveBeenCalledTimes(1);
|
|
197
|
+
dispose();
|
|
198
|
+
s.value = 1;
|
|
199
|
+
expect(log).toHaveBeenCalledTimes(1); // no additional call
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('dispose removes effect from signal subscribers', () => {
|
|
203
|
+
const s = signal(0);
|
|
204
|
+
const log = vi.fn();
|
|
205
|
+
const dispose = effect(() => { log(s.value); });
|
|
206
|
+
dispose();
|
|
207
|
+
// After disposing, the signal should not hold a reference to the effect
|
|
208
|
+
s.value = 99;
|
|
209
|
+
expect(log).toHaveBeenCalledTimes(1); // only the initial run
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('tracks multiple signals', () => {
|
|
213
|
+
const a = signal(1);
|
|
214
|
+
const b = signal(2);
|
|
215
|
+
const log = vi.fn();
|
|
216
|
+
effect(() => { log(a.value + b.value); });
|
|
217
|
+
expect(log).toHaveBeenCalledWith(3);
|
|
218
|
+
a.value = 10;
|
|
219
|
+
expect(log).toHaveBeenCalledWith(12);
|
|
220
|
+
b.value = 20;
|
|
221
|
+
expect(log).toHaveBeenCalledWith(30);
|
|
222
|
+
expect(log).toHaveBeenCalledTimes(3);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('handles conditional dependency tracking', () => {
|
|
226
|
+
const toggle = signal(true);
|
|
227
|
+
const a = signal('A');
|
|
228
|
+
const b = signal('B');
|
|
229
|
+
const log = vi.fn();
|
|
230
|
+
effect(() => {
|
|
231
|
+
log(toggle.value ? a.value : b.value);
|
|
232
|
+
});
|
|
233
|
+
expect(log).toHaveBeenCalledWith('A');
|
|
234
|
+
// Change b — should NOT re-run because b is not tracked when toggle=true
|
|
235
|
+
b.value = 'B2';
|
|
236
|
+
// After toggle switches, b becomes tracked
|
|
237
|
+
toggle.value = false;
|
|
238
|
+
expect(log).toHaveBeenCalledWith('B2');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// reactive — array mutations
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
describe('reactive — arrays', () => {
|
|
248
|
+
it('detects push on a reactive array', () => {
|
|
249
|
+
const fn = vi.fn();
|
|
250
|
+
const obj = reactive({ items: [1, 2, 3] }, fn);
|
|
251
|
+
obj.items.push(4);
|
|
252
|
+
expect(fn).toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('detects index assignment', () => {
|
|
256
|
+
const fn = vi.fn();
|
|
257
|
+
const obj = reactive({ items: ['a', 'b'] }, fn);
|
|
258
|
+
obj.items[0] = 'z';
|
|
259
|
+
expect(fn).toHaveBeenCalledWith('0', 'z', 'a');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// computed — advanced
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
describe('computed — advanced', () => {
|
|
269
|
+
it('chains computed signals', () => {
|
|
270
|
+
const count = signal(2);
|
|
271
|
+
const doubled = computed(() => count.value * 2);
|
|
272
|
+
const quadrupled = computed(() => doubled.value * 2);
|
|
273
|
+
expect(quadrupled.value).toBe(8);
|
|
274
|
+
count.value = 3;
|
|
275
|
+
expect(quadrupled.value).toBe(12);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('does not recompute when dependencies unchanged (diamond)', () => {
|
|
279
|
+
const s = signal(1);
|
|
280
|
+
const a = computed(() => s.value + 1);
|
|
281
|
+
const b = computed(() => s.value + 2);
|
|
282
|
+
const spy = vi.fn(() => a.value + b.value);
|
|
283
|
+
const c = computed(spy);
|
|
284
|
+
expect(c.value).toBe(5); // (1+1)+(1+2)
|
|
285
|
+
spy.mockClear();
|
|
286
|
+
s.value = 10;
|
|
287
|
+
expect(c.value).toBe(23); // (10+1)+(10+2)
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('peek does not create dependency', () => {
|
|
291
|
+
const s = signal(0);
|
|
292
|
+
const log = vi.fn();
|
|
293
|
+
effect(() => {
|
|
294
|
+
log(s.peek());
|
|
295
|
+
});
|
|
296
|
+
expect(log).toHaveBeenCalledWith(0);
|
|
297
|
+
s.value = 1;
|
|
298
|
+
// peek doesn't track, so effect should NOT re-run
|
|
299
|
+
expect(log).toHaveBeenCalledTimes(1);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Signal — batch behavior
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
describe('Signal — multiple subscribers', () => {
|
|
309
|
+
it('notifies all subscribers', () => {
|
|
310
|
+
const s = signal(0);
|
|
311
|
+
const fn1 = vi.fn();
|
|
312
|
+
const fn2 = vi.fn();
|
|
313
|
+
s.subscribe(fn1);
|
|
314
|
+
s.subscribe(fn2);
|
|
315
|
+
s.value = 1;
|
|
316
|
+
expect(fn1).toHaveBeenCalledOnce();
|
|
317
|
+
expect(fn2).toHaveBeenCalledOnce();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('unsubscribing one does not affect others', () => {
|
|
321
|
+
const s = signal(0);
|
|
322
|
+
const fn1 = vi.fn();
|
|
323
|
+
const fn2 = vi.fn();
|
|
324
|
+
const unsub1 = s.subscribe(fn1);
|
|
325
|
+
s.subscribe(fn2);
|
|
326
|
+
unsub1();
|
|
327
|
+
s.value = 1;
|
|
328
|
+
expect(fn1).not.toHaveBeenCalled();
|
|
329
|
+
expect(fn2).toHaveBeenCalledOnce();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('handles rapid sequential updates', () => {
|
|
333
|
+
const s = signal(0);
|
|
334
|
+
const log = vi.fn();
|
|
335
|
+
s.subscribe(log);
|
|
336
|
+
for (let i = 1; i <= 10; i++) s.value = i;
|
|
337
|
+
expect(log).toHaveBeenCalledTimes(10);
|
|
338
|
+
});
|
|
339
|
+
});
|