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.
Files changed (72) hide show
  1. package/README.md +39 -29
  2. package/cli/commands/build.js +113 -4
  3. package/cli/commands/bundle.js +392 -29
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +29 -4
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +428 -2
  20. package/cli/commands/dev/server.js +42 -5
  21. package/cli/commands/dev/watcher.js +59 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +16 -23
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
  27. package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
  28. package/cli/scaffold/app/components/home.js +137 -0
  29. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  30. package/cli/scaffold/{scripts → app}/store.js +6 -6
  31. package/cli/scaffold/assets/.gitkeep +0 -0
  32. package/cli/scaffold/{styles/styles.css → global.css} +4 -2
  33. package/cli/scaffold/index.html +12 -11
  34. package/cli/utils.js +111 -6
  35. package/dist/zquery.dist.zip +0 -0
  36. package/dist/zquery.js +1122 -158
  37. package/dist/zquery.min.js +3 -16
  38. package/index.d.ts +129 -1290
  39. package/index.js +15 -10
  40. package/package.json +7 -6
  41. package/src/component.js +172 -49
  42. package/src/core.js +359 -18
  43. package/src/diff.js +256 -58
  44. package/src/expression.js +33 -3
  45. package/src/reactive.js +37 -5
  46. package/src/router.js +243 -7
  47. package/tests/component.test.js +886 -0
  48. package/tests/core.test.js +977 -0
  49. package/tests/diff.test.js +525 -0
  50. package/tests/errors.test.js +162 -0
  51. package/tests/expression.test.js +482 -0
  52. package/tests/http.test.js +289 -0
  53. package/tests/reactive.test.js +339 -0
  54. package/tests/router.test.js +649 -0
  55. package/tests/store.test.js +379 -0
  56. package/tests/utils.test.js +512 -0
  57. package/types/collection.d.ts +383 -0
  58. package/types/component.d.ts +217 -0
  59. package/types/errors.d.ts +103 -0
  60. package/types/http.d.ts +81 -0
  61. package/types/misc.d.ts +179 -0
  62. package/types/reactive.d.ts +76 -0
  63. package/types/router.d.ts +161 -0
  64. package/types/ssr.d.ts +49 -0
  65. package/types/store.d.ts +107 -0
  66. package/types/utils.d.ts +142 -0
  67. package/cli/commands/dev.old.js +0 -520
  68. package/cli/scaffold/scripts/components/home.js +0 -137
  69. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  70. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  71. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  72. /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
+ });