zero-query 0.9.0 → 0.9.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.
@@ -0,0 +1,4030 @@
1
+ /**
2
+ * Comprehensive audit test suite.
3
+ * Re-auditing most critical areas with intensive edge case coverage.
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import { morph, morphElement } from '../src/diff.js';
8
+ import { reactive, Signal, signal, computed, effect } from '../src/reactive.js';
9
+ import { safeEval } from '../src/expression.js';
10
+ import {
11
+ debounce, throttle, pipe, once, sleep,
12
+ escapeHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
13
+ deepClone, deepMerge, isEqual, param, parseQuery,
14
+ storage, session, EventBus, bus,
15
+ } from '../src/utils.js';
16
+ import { createRouter, getRouter } from '../src/router.js';
17
+ import { component, mount, mountAll, destroy, prefetch, getInstance } from '../src/component.js';
18
+ import { createStore, getStore } from '../src/store.js';
19
+
20
+
21
+ // ===========================================================================
22
+ // Register dummy components used by router tests
23
+ // ===========================================================================
24
+ component('home-page', { render: () => '<p>home</p>' });
25
+ component('about-page', { render: () => '<p>about</p>' });
26
+ component('user-page', { render: () => '<p>user</p>' });
27
+ component('docs-page', { render: () => '<p>docs</p>' });
28
+
29
+ // ===========================================================================
30
+ // Helpers
31
+ // ===========================================================================
32
+ function el(html) {
33
+ const div = document.createElement('div');
34
+ div.innerHTML = html;
35
+ return div;
36
+ }
37
+
38
+ function morphAndGet(oldHTML, newHTML) {
39
+ const root = el(oldHTML);
40
+ morph(root, newHTML);
41
+ return root;
42
+ }
43
+
44
+ const eval_ = (expr, ...scopes) => safeEval(expr, scopes.length ? scopes : [{}]);
45
+
46
+
47
+ // ===========================================================================
48
+ // 1. LIS ALGORITHM & KEYED RECONCILIATION - AUDIT
49
+ // ===========================================================================
50
+
51
+ describe('LIS & keyed diff - advanced audit', () => {
52
+
53
+ it('correctly handles LIS with all elements already in order', () => {
54
+ // If all keys are in order, LIS = full set, no moves needed
55
+ const root = el(
56
+ '<div z-key="1">1</div><div z-key="2">2</div><div z-key="3">3</div><div z-key="4">4</div>'
57
+ );
58
+ const nodes = [...root.children];
59
+ morph(root, '<div z-key="1">1u</div><div z-key="2">2u</div><div z-key="3">3u</div><div z-key="4">4u</div>');
60
+ // All nodes should be same identity (no moves)
61
+ for (let i = 0; i < 4; i++) {
62
+ expect(root.children[i]).toBe(nodes[i]);
63
+ expect(root.children[i].textContent).toBe(`${i + 1}u`);
64
+ }
65
+ });
66
+
67
+ it('correctly handles LIS with single element out of place', () => {
68
+ // [a, b, c, d] → [a, c, d, b] - only b needs to move
69
+ const root = el(
70
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div><div z-key="d">D</div>'
71
+ );
72
+ const nodeA = root.children[0];
73
+ const nodeB = root.children[1];
74
+ const nodeC = root.children[2];
75
+ const nodeD = root.children[3];
76
+ morph(root, '<div z-key="a">A</div><div z-key="c">C</div><div z-key="d">D</div><div z-key="b">B</div>');
77
+ expect(root.children[0]).toBe(nodeA);
78
+ expect(root.children[1]).toBe(nodeC);
79
+ expect(root.children[2]).toBe(nodeD);
80
+ expect(root.children[3]).toBe(nodeB);
81
+ });
82
+
83
+ it('LIS handles fully reversed order correctly', () => {
84
+ const keys = ['a', 'b', 'c', 'd', 'e'];
85
+ const root = el(keys.map(k => `<div z-key="${k}">${k}</div>`).join(''));
86
+ const reversed = [...keys].reverse();
87
+ morph(root, reversed.map(k => `<div z-key="${k}">${k}</div>`).join(''));
88
+ const result = [...root.children].map(c => c.getAttribute('z-key'));
89
+ expect(result).toEqual(reversed);
90
+ });
91
+
92
+ it('LIS handles interleaved insert and remove', () => {
93
+ // Old: [a, b, c, d, e] → New: [f, a, c, g, e]
94
+ const root = el(
95
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>' +
96
+ '<div z-key="d">D</div><div z-key="e">E</div>'
97
+ );
98
+ const nodeA = root.children[0];
99
+ const nodeC = root.children[2];
100
+ const nodeE = root.children[4];
101
+ morph(root,
102
+ '<div z-key="f">F</div><div z-key="a">A</div><div z-key="c">C</div>' +
103
+ '<div z-key="g">G</div><div z-key="e">E</div>'
104
+ );
105
+ expect(root.children.length).toBe(5);
106
+ expect(root.children[0].getAttribute('z-key')).toBe('f');
107
+ expect(root.children[1]).toBe(nodeA);
108
+ expect(root.children[2]).toBe(nodeC);
109
+ expect(root.children[3].getAttribute('z-key')).toBe('g');
110
+ expect(root.children[4]).toBe(nodeE);
111
+ });
112
+
113
+ it('LIS with no overlapping keys - full replacement', () => {
114
+ const root = el(
115
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>'
116
+ );
117
+ morph(root,
118
+ '<div z-key="x">X</div><div z-key="y">Y</div><div z-key="z">Z</div>'
119
+ );
120
+ expect(root.children.length).toBe(3);
121
+ expect([...root.children].map(c => c.getAttribute('z-key'))).toEqual(['x', 'y', 'z']);
122
+ });
123
+
124
+ it('keyed reorder with attribute and content updates simultaneously', () => {
125
+ const root = el(
126
+ '<div z-key="a" class="old-a" data-x="1">A-old</div>' +
127
+ '<div z-key="b" class="old-b" data-x="2">B-old</div>' +
128
+ '<div z-key="c" class="old-c" data-x="3">C-old</div>'
129
+ );
130
+ const nodeA = root.children[0];
131
+ const nodeB = root.children[1];
132
+ morph(root,
133
+ '<div z-key="c" class="new-c" data-x="30">C-new</div>' +
134
+ '<div z-key="a" class="new-a" data-x="10">A-new</div>' +
135
+ '<div z-key="b" class="new-b" data-x="20">B-new</div>'
136
+ );
137
+ expect(root.children[1]).toBe(nodeA);
138
+ expect(root.children[2]).toBe(nodeB);
139
+ expect(nodeA.className).toBe('new-a');
140
+ expect(nodeA.textContent).toBe('A-new');
141
+ expect(nodeA.dataset.x).toBe('10');
142
+ expect(nodeB.className).toBe('new-b');
143
+ });
144
+
145
+ it('auto-key via id interleaved with unkeyed nodes', () => {
146
+ const root = el('<div id="a">A</div><p>text1</p><div id="b">B</div><p>text2</p>');
147
+ const nodeA = root.children[0];
148
+ const nodeB = root.children[2];
149
+ morph(root, '<div id="b">B2</div><p>text3</p><div id="a">A2</div><p>text4</p>');
150
+ expect(root.children[0]).toBe(nodeB);
151
+ expect(root.children[2]).toBe(nodeA);
152
+ expect(nodeA.textContent).toBe('A2');
153
+ expect(nodeB.textContent).toBe('B2');
154
+ });
155
+
156
+ it('z-key takes priority over id for reconciliation', () => {
157
+ const root = el('<div z-key="k1" id="x">A</div><div z-key="k2" id="y">B</div>');
158
+ const ref1 = root.children[0];
159
+ const ref2 = root.children[1];
160
+ morph(root, '<div z-key="k2" id="y">B2</div><div z-key="k1" id="x">A2</div>');
161
+ expect(root.children[0]).toBe(ref2);
162
+ expect(root.children[1]).toBe(ref1);
163
+ expect(ref1.textContent).toBe('A2');
164
+ expect(ref2.textContent).toBe('B2');
165
+ });
166
+
167
+ it('handles LIS edge case with large list and one insertion at front', () => {
168
+ const count = 20;
169
+ const keys = Array.from({ length: count }, (_, i) => String(i));
170
+ const root = el(keys.map(k => `<div z-key="${k}">${k}</div>`).join(''));
171
+ const originalFirst = root.children[0];
172
+ const newKeys = ['new', ...keys];
173
+ morph(root, newKeys.map(k => `<div z-key="${k}">${k}</div>`).join(''));
174
+ expect(root.children.length).toBe(count + 1);
175
+ expect(root.children[0].getAttribute('z-key')).toBe('new');
176
+ expect(root.children[1]).toBe(originalFirst);
177
+ });
178
+
179
+ it('handles consecutive moves (rotate left)', () => {
180
+ // [a, b, c, d] → [b, c, d, a]
181
+ const root = el(
182
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div><div z-key="d">D</div>'
183
+ );
184
+ const refs = [...root.children];
185
+ morph(root, '<div z-key="b">B</div><div z-key="c">C</div><div z-key="d">D</div><div z-key="a">A</div>');
186
+ expect(root.children[0]).toBe(refs[1]);
187
+ expect(root.children[1]).toBe(refs[2]);
188
+ expect(root.children[2]).toBe(refs[3]);
189
+ expect(root.children[3]).toBe(refs[0]);
190
+ });
191
+
192
+ it('handles pairwise swap', () => {
193
+ // [a, b, c, d] → [b, a, d, c]
194
+ const root = el(
195
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div><div z-key="d">D</div>'
196
+ );
197
+ const refs = [...root.children];
198
+ morph(root, '<div z-key="b">B</div><div z-key="a">A</div><div z-key="d">D</div><div z-key="c">C</div>');
199
+ expect(root.children[0]).toBe(refs[1]);
200
+ expect(root.children[1]).toBe(refs[0]);
201
+ expect(root.children[2]).toBe(refs[3]);
202
+ expect(root.children[3]).toBe(refs[2]);
203
+ });
204
+ });
205
+
206
+
207
+ // ===========================================================================
208
+ // 2. MORPH ENGINE & SELECTOR ENGINE CONSISTENCY - AUDIT
209
+ // ===========================================================================
210
+
211
+ describe('morph engine consistency - audit', () => {
212
+
213
+ it('morphing preserves event listeners attached via addEventListener', () => {
214
+ const root = el('<button id="btn">Click</button>');
215
+ const btn = root.querySelector('#btn');
216
+ const handler = vi.fn();
217
+ btn.addEventListener('click', handler);
218
+ morph(root, '<button id="btn">Click Updated</button>');
219
+ // Same node should be preserved (id-keyed auto-reconciliation)
220
+ const newBtn = root.querySelector('#btn');
221
+ expect(newBtn).toBe(btn);
222
+ newBtn.click();
223
+ expect(handler).toHaveBeenCalledOnce();
224
+ });
225
+
226
+ it('morphing preserves CSS classes added programmatically', () => {
227
+ const root = el('<div id="target">content</div>');
228
+ const target = root.querySelector('#target');
229
+ target.classList.add('programmatic');
230
+ // Morph with same attributes - should preserve via isEqualNode
231
+ morph(root, '<div id="target">content updated</div>');
232
+ const newTarget = root.querySelector('#target');
233
+ expect(newTarget).toBe(target);
234
+ // Programmatic class should be gone because the morph syncs attributes from newNode
235
+ // (which doesn't have 'programmatic' class). This is correct behavior.
236
+ expect(newTarget.classList.contains('programmatic')).toBe(false);
237
+ });
238
+
239
+ it('morphing handles inline style changes', () => {
240
+ const root = el('<div style="color: red;">text</div>');
241
+ morph(root, '<div style="color: blue;">text</div>');
242
+ expect(root.children[0].style.color).toBe('blue');
243
+ });
244
+
245
+ it('morphing handles class addition and removal simultaneously', () => {
246
+ const root = el('<div class="a b c">text</div>');
247
+ morph(root, '<div class="b c d">text</div>');
248
+ const el_ = root.children[0];
249
+ expect(el_.classList.contains('a')).toBe(false);
250
+ expect(el_.classList.contains('b')).toBe(true);
251
+ expect(el_.classList.contains('c')).toBe(true);
252
+ expect(el_.classList.contains('d')).toBe(true);
253
+ });
254
+
255
+ it('morph handles SVG elements', () => {
256
+ const root = document.createElement('div');
257
+ root.innerHTML = '<svg><circle cx="50" cy="50" r="40"/></svg>';
258
+ morph(root, '<svg><circle cx="100" cy="100" r="50"/></svg>');
259
+ const circle = root.querySelector('circle');
260
+ expect(circle.getAttribute('cx')).toBe('100');
261
+ expect(circle.getAttribute('r')).toBe('50');
262
+ });
263
+
264
+ it('morph handles data-* attributes', () => {
265
+ const root = el('<div data-value="old" data-type="text">content</div>');
266
+ morph(root, '<div data-value="new" data-extra="added">content</div>');
267
+ const d = root.children[0];
268
+ expect(d.dataset.value).toBe('new');
269
+ expect(d.dataset.extra).toBe('added');
270
+ expect(d.dataset.type).toBeUndefined();
271
+ });
272
+
273
+ it('morph handles empty attribute properly', () => {
274
+ const root = el('<input type="text">');
275
+ morph(root, '<input type="text" disabled>');
276
+ expect(root.querySelector('input').disabled).toBe(true);
277
+ });
278
+
279
+ it('morph correctly handles table structures', () => {
280
+ const root = el(
281
+ '<table><tbody><tr><td>old</td></tr></tbody></table>'
282
+ );
283
+ morph(root,
284
+ '<table><tbody><tr><td>new</td></tr><tr><td>added</td></tr></tbody></table>'
285
+ );
286
+ expect(root.querySelectorAll('td').length).toBe(2);
287
+ expect(root.querySelectorAll('td')[0].textContent).toBe('new');
288
+ expect(root.querySelectorAll('td')[1].textContent).toBe('added');
289
+ });
290
+
291
+ it('morphElement handles whitespace and text content changes', () => {
292
+ const root = el('<div> old text </div>');
293
+ const target = root.children[0];
294
+ const result = morphElement(target, '<div> new text </div>');
295
+ expect(result).toBe(target);
296
+ expect(target.textContent).toBe(' new text ');
297
+ });
298
+ });
299
+
300
+
301
+ // ===========================================================================
302
+ // 3. REACTIVITY - SIGNALS, COMPUTED, EFFECTS - AUDIT
303
+ // ===========================================================================
304
+
305
+ describe('reactivity audit - edge cases', () => {
306
+
307
+ it('effect re-tracks dependencies properly after conditional branch change', () => {
308
+ const flag = signal(true);
309
+ const a = signal('A');
310
+ const b = signal('B');
311
+ const log = vi.fn();
312
+
313
+ effect(() => {
314
+ if (flag.value) {
315
+ log('a:' + a.value);
316
+ } else {
317
+ log('b:' + b.value);
318
+ }
319
+ });
320
+ expect(log).toHaveBeenCalledWith('a:A');
321
+ log.mockClear();
322
+
323
+ // Changing b while flag=true should NOT trigger
324
+ b.value = 'B2';
325
+ expect(log).not.toHaveBeenCalled();
326
+
327
+ // Switch branch
328
+ flag.value = false;
329
+ expect(log).toHaveBeenCalledWith('b:B2');
330
+ log.mockClear();
331
+
332
+ // Now changing a should NOT trigger (stale dep removed)
333
+ a.value = 'A2';
334
+ expect(log).not.toHaveBeenCalled();
335
+
336
+ // Changing b SHOULD trigger
337
+ b.value = 'B3';
338
+ expect(log).toHaveBeenCalledWith('b:B3');
339
+ });
340
+
341
+ it('computed with boolean stability does not over-notify', () => {
342
+ const n = signal(5);
343
+ const isPositive = computed(() => n.value > 0);
344
+ const spy = vi.fn();
345
+ isPositive.subscribe(spy);
346
+
347
+ n.value = 10; // still positive, no change
348
+ expect(spy).not.toHaveBeenCalled();
349
+
350
+ n.value = -1; // now negative, should notify
351
+ expect(spy).toHaveBeenCalledTimes(1);
352
+
353
+ spy.mockClear();
354
+ n.value = -5; // still negative, no change
355
+ expect(spy).not.toHaveBeenCalled();
356
+ });
357
+
358
+ it('multiple effects on same signal run in subscription order', () => {
359
+ const s = signal(0);
360
+ const order = [];
361
+ effect(() => { s.value; order.push('first'); });
362
+ effect(() => { s.value; order.push('second'); });
363
+ order.length = 0;
364
+ s.value = 1;
365
+ expect(order).toEqual(['first', 'second']);
366
+ });
367
+
368
+ it('effect handles signal updated inside effect (glitch-free reads)', () => {
369
+ const a = signal(1);
370
+ const b = signal(1);
371
+ const results = [];
372
+
373
+ effect(() => {
374
+ results.push(a.value + b.value);
375
+ });
376
+
377
+ expect(results).toEqual([2]);
378
+ a.value = 2;
379
+ expect(results).toEqual([2, 3]);
380
+ });
381
+
382
+ it('reactive proxy handles Object.keys correctly', () => {
383
+ const fn = vi.fn();
384
+ const obj = reactive({ a: 1, b: 2, c: 3 }, fn);
385
+ expect(Object.keys(obj)).toEqual(['a', 'b', 'c']);
386
+ });
387
+
388
+ it('reactive proxy handles "in" operator', () => {
389
+ const fn = vi.fn();
390
+ const obj = reactive({ a: 1 }, fn);
391
+ expect('a' in obj).toBe(true);
392
+ expect('b' in obj).toBe(false);
393
+ });
394
+
395
+ it('reactive proxy handles for...in loop', () => {
396
+ const fn = vi.fn();
397
+ const obj = reactive({ x: 1, y: 2 }, fn);
398
+ const keys = [];
399
+ for (const k in obj) keys.push(k);
400
+ expect(keys).toEqual(['x', 'y']);
401
+ });
402
+
403
+ it('reactive handles deeply nested object replacement', () => {
404
+ const fn = vi.fn();
405
+ const obj = reactive({ a: { b: { c: 1 } } }, fn);
406
+ obj.a = { b: { c: 2 } };
407
+ expect(fn).toHaveBeenCalled();
408
+ expect(obj.a.b.c).toBe(2);
409
+ });
410
+
411
+ it('reactive handles setting property to null', () => {
412
+ const fn = vi.fn();
413
+ const obj = reactive({ value: { nested: true } }, fn);
414
+ obj.value = null;
415
+ expect(fn).toHaveBeenCalledWith('value', null, expect.any(Object));
416
+ expect(obj.value).toBeNull();
417
+ });
418
+
419
+ it('signal with undefined initial value works', () => {
420
+ const s = signal(undefined);
421
+ expect(s.value).toBeUndefined();
422
+ s.value = 42;
423
+ expect(s.value).toBe(42);
424
+ });
425
+
426
+ it('computed with undefined result works', () => {
427
+ const s = signal(null);
428
+ const c = computed(() => s.value?.name);
429
+ expect(c.value).toBeUndefined();
430
+ s.value = { name: 'test' };
431
+ expect(c.value).toBe('test');
432
+ });
433
+
434
+ it('disposing an effect that was already disposed is safe', () => {
435
+ const s = signal(0);
436
+ const fn = vi.fn(() => s.value);
437
+ const dispose = effect(fn);
438
+ dispose();
439
+ dispose(); // second dispose should not throw
440
+ s.value = 1;
441
+ expect(fn).toHaveBeenCalledTimes(1); // only initial
442
+ });
443
+ });
444
+
445
+
446
+ // ===========================================================================
447
+ // 4. UTILITIES - THOROUGH BUG HUNTING
448
+ // ===========================================================================
449
+
450
+ describe('utils audit - edge cases', () => {
451
+
452
+ // --- debounce edge cases ---
453
+ it('debounce handles zero delay', () => {
454
+ vi.useFakeTimers();
455
+ const fn = vi.fn();
456
+ const d = debounce(fn, 0);
457
+ d('a');
458
+ vi.advanceTimersByTime(0);
459
+ expect(fn).toHaveBeenCalledWith('a');
460
+ vi.useRealTimers();
461
+ });
462
+
463
+ it('debounce passes all arguments', () => {
464
+ vi.useFakeTimers();
465
+ const fn = vi.fn();
466
+ const d = debounce(fn, 50);
467
+ d(1, 'two', { three: 3 });
468
+ vi.advanceTimersByTime(50);
469
+ expect(fn).toHaveBeenCalledWith(1, 'two', { three: 3 });
470
+ vi.useRealTimers();
471
+ });
472
+
473
+ // --- throttle edge cases ---
474
+ it('throttle allows call after interval expires', () => {
475
+ vi.useFakeTimers();
476
+ const fn = vi.fn();
477
+ const t = throttle(fn, 100);
478
+ t('first');
479
+ expect(fn).toHaveBeenCalledWith('first');
480
+ vi.advanceTimersByTime(100);
481
+ // After interval, next call should fire immediately
482
+ fn.mockClear();
483
+ t('second');
484
+ expect(fn).toHaveBeenCalledWith('second');
485
+ vi.useRealTimers();
486
+ });
487
+
488
+ it('throttle trailing call fires with correct arguments', () => {
489
+ vi.useFakeTimers();
490
+ const fn = vi.fn();
491
+ const t = throttle(fn, 100);
492
+ t('first');
493
+ t('second');
494
+ t('third'); // should overwrite 'second' as the trailing call
495
+ vi.advanceTimersByTime(100);
496
+ expect(fn).toHaveBeenLastCalledWith('third');
497
+ vi.useRealTimers();
498
+ });
499
+
500
+ // --- once edge cases ---
501
+ it('once preserves the return value across calls', () => {
502
+ let counter = 0;
503
+ const fn = once(() => ++counter);
504
+ expect(fn()).toBe(1);
505
+ expect(fn()).toBe(1);
506
+ expect(fn()).toBe(1);
507
+ expect(counter).toBe(1);
508
+ });
509
+
510
+ // --- escapeHtml edge cases ---
511
+ it('escapeHtml handles all special characters mixed with regular text', () => {
512
+ expect(escapeHtml('Hello & "World" <script>\'s</script>'))
513
+ .toBe('Hello &amp; &quot;World&quot; &lt;script&gt;&#39;s&lt;/script&gt;');
514
+ });
515
+
516
+ it('escapeHtml handles numbers and booleans', () => {
517
+ expect(escapeHtml(0)).toBe('0');
518
+ expect(escapeHtml(false)).toBe('false');
519
+ expect(escapeHtml(undefined)).toBe('undefined');
520
+ });
521
+
522
+ // --- html template tag edge cases ---
523
+ it('html template tag handles multiple interpolations', () => {
524
+ const a = '<b>bold</b>';
525
+ const b = '"quoted"';
526
+ const result = html`<p>${a} and ${b}</p>`;
527
+ expect(result).toContain('&lt;b&gt;bold&lt;/b&gt;');
528
+ expect(result).toContain('&quot;quoted&quot;');
529
+ expect(result).toContain('<p>');
530
+ expect(result).toContain('</p>');
531
+ });
532
+
533
+ it('html template tag handles zero and empty string interpolations', () => {
534
+ const result = html`<span>${0}</span>`;
535
+ expect(result).toBe('<span>0</span>');
536
+ });
537
+
538
+ // --- uuid ---
539
+ it('uuid generates valid v4 format consistently', () => {
540
+ for (let i = 0; i < 20; i++) {
541
+ expect(uuid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
542
+ }
543
+ });
544
+
545
+ // --- camelCase / kebabCase edge cases ---
546
+ it('camelCase handles multiple consecutive hyphens', () => {
547
+ expect(camelCase('my--component')).toBe('my-Component');
548
+ });
549
+
550
+ it('kebabCase handles consecutive uppercase letters', () => {
551
+ expect(kebabCase('HTMLParser')).toBe('html-parser');
552
+ });
553
+
554
+ it('camelCase handles already camelCased string', () => {
555
+ expect(camelCase('myComponent')).toBe('myComponent');
556
+ });
557
+
558
+ it('kebabCase handles single lowercase word', () => {
559
+ expect(kebabCase('hello')).toBe('hello');
560
+ });
561
+
562
+ // --- deepClone edge cases ---
563
+ it('deepClone handles Date objects', () => {
564
+ const original = { date: new Date('2024-01-15') };
565
+ const clone = deepClone(original);
566
+ expect(clone.date).toBeInstanceOf(Date);
567
+ expect(clone.date.getTime()).toBe(original.date.getTime());
568
+ clone.date.setFullYear(2026);
569
+ expect(original.date.getFullYear()).toBe(2024);
570
+ });
571
+
572
+ it('deepClone handles nested arrays within objects', () => {
573
+ const original = { data: [{ id: 1, tags: ['a', 'b'] }, { id: 2, tags: ['c'] }] };
574
+ const clone = deepClone(original);
575
+ clone.data[0].tags.push('new');
576
+ expect(original.data[0].tags).toEqual(['a', 'b']);
577
+ });
578
+
579
+ it('deepClone handles null values', () => {
580
+ const clone = deepClone({ a: null, b: undefined });
581
+ expect(clone.a).toBeNull();
582
+ // Note: JSON.parse/JSON.stringify loses undefined
583
+ });
584
+
585
+ // --- deepMerge edge cases ---
586
+ it('deepMerge does not merge arrays deeply (replaces)', () => {
587
+ const target = { list: [1, 2, 3] };
588
+ const source = { list: [4, 5] };
589
+ deepMerge(target, source);
590
+ expect(target.list).toEqual([4, 5]);
591
+ });
592
+
593
+ it('deepMerge handles empty source', () => {
594
+ const target = { a: 1 };
595
+ deepMerge(target, {});
596
+ expect(target).toEqual({ a: 1 });
597
+ });
598
+
599
+ it('deepMerge handles multiple sources', () => {
600
+ const result = deepMerge({ a: 1 }, { b: 2 }, { c: 3 });
601
+ expect(result).toEqual({ a: 1, b: 2, c: 3 });
602
+ });
603
+
604
+ it('deepMerge handles nested conflict', () => {
605
+ const target = { config: { debug: true, level: 'info' } };
606
+ const source = { config: { level: 'error', verbose: false } };
607
+ deepMerge(target, source);
608
+ expect(target.config).toEqual({ debug: true, level: 'error', verbose: false });
609
+ });
610
+
611
+ // --- isEqual edge cases ---
612
+ it('isEqual handles nested arrays', () => {
613
+ expect(isEqual([1, [2, 3]], [1, [2, 3]])).toBe(true);
614
+ expect(isEqual([1, [2, 3]], [1, [2, 4]])).toBe(false);
615
+ });
616
+
617
+ it('isEqual handles mixed types', () => {
618
+ expect(isEqual(1, '1')).toBe(false);
619
+ expect(isEqual(true, 1)).toBe(false);
620
+ expect(isEqual(null, undefined)).toBe(false);
621
+ expect(isEqual(0, false)).toBe(false);
622
+ });
623
+
624
+ it('isEqual handles empty objects and arrays', () => {
625
+ expect(isEqual({}, {})).toBe(true);
626
+ expect(isEqual([], [])).toBe(true);
627
+ expect(isEqual({}, [])).toBe(false);
628
+ });
629
+
630
+ // --- param edge cases ---
631
+ it('param handles special characters', () => {
632
+ const result = param({ query: 'hello world', tag: 'a&b' });
633
+ expect(result).toContain('hello+world');
634
+ expect(result).toContain('a%26b');
635
+ });
636
+
637
+ it('param handles numeric values', () => {
638
+ expect(param({ page: 1, limit: 20 })).toBe('page=1&limit=20');
639
+ });
640
+
641
+ // --- parseQuery edge cases ---
642
+ it('parseQuery handles encoded values', () => {
643
+ const result = parseQuery('name=hello%20world&tag=a%26b');
644
+ expect(result.name).toBe('hello world');
645
+ expect(result.tag).toBe('a&b');
646
+ });
647
+
648
+ it('parseQuery handles duplicate keys (last wins)', () => {
649
+ const result = parseQuery('a=1&a=2');
650
+ expect(result.a).toBe('2');
651
+ });
652
+
653
+ // --- bus event bus edge cases ---
654
+ it('bus.emit does nothing for unregistered events', () => {
655
+ expect(() => bus.emit('nonexistent', 'data')).not.toThrow();
656
+ });
657
+
658
+ it('bus.once fires only once', () => {
659
+ const fn = vi.fn();
660
+ bus.once('test-once', fn);
661
+ bus.emit('test-once', 'a');
662
+ bus.emit('test-once', 'b');
663
+ expect(fn).toHaveBeenCalledOnce();
664
+ expect(fn).toHaveBeenCalledWith('a');
665
+ });
666
+
667
+ it('bus.off removes specific handler', () => {
668
+ const fn1 = vi.fn();
669
+ const fn2 = vi.fn();
670
+ bus.on('test-off', fn1);
671
+ bus.on('test-off', fn2);
672
+ bus.off('test-off', fn1);
673
+ bus.emit('test-off', 'data');
674
+ expect(fn1).not.toHaveBeenCalled();
675
+ expect(fn2).toHaveBeenCalledWith('data');
676
+ bus.clear();
677
+ });
678
+
679
+ it('bus.on returns unsubscribe function', () => {
680
+ const fn = vi.fn();
681
+ const unsub = bus.on('test-unsub', fn);
682
+ unsub();
683
+ bus.emit('test-unsub');
684
+ expect(fn).not.toHaveBeenCalled();
685
+ bus.clear();
686
+ });
687
+
688
+ it('bus.clear removes all handlers', () => {
689
+ const fn = vi.fn();
690
+ bus.on('test-clear', fn);
691
+ bus.clear();
692
+ bus.emit('test-clear');
693
+ expect(fn).not.toHaveBeenCalled();
694
+ });
695
+
696
+ // --- storage edge cases ---
697
+ it('storage.set and get handles various types', () => {
698
+ localStorage.clear();
699
+ storage.set('num', 42);
700
+ storage.set('str', 'hello');
701
+ storage.set('bool', true);
702
+ storage.set('arr', [1, 2, 3]);
703
+ storage.set('obj', { a: 1 });
704
+ expect(storage.get('num')).toBe(42);
705
+ expect(storage.get('str')).toBe('hello');
706
+ expect(storage.get('bool')).toBe(true);
707
+ expect(storage.get('arr')).toEqual([1, 2, 3]);
708
+ expect(storage.get('obj')).toEqual({ a: 1 });
709
+ });
710
+
711
+ it('storage.get returns fallback when JSON parse fails', () => {
712
+ localStorage.setItem('bad', 'not valid json {');
713
+ expect(storage.get('bad', 'fallback')).toBe('fallback');
714
+ });
715
+ });
716
+
717
+
718
+ // ===========================================================================
719
+ // 5. DIRECTIVES - @ and z-on INTERCHANGEABILITY
720
+ // ===========================================================================
721
+
722
+ describe('directives - @ and z-on audit', () => {
723
+
724
+ beforeEach(() => {
725
+ document.body.innerHTML = '<div id="app"></div>';
726
+ });
727
+
728
+ it('@ shorthand and z-on: are equivalent for click', () => {
729
+ // Register component with both syntaxes
730
+ component('dir-test-at', {
731
+ state: () => ({ count1: 0, count2: 0 }),
732
+ inc1() { this.state.count1++; },
733
+ inc2() { this.state.count2++; },
734
+ render() {
735
+ return `
736
+ <button id="btn1" @click="inc1">@ button</button>
737
+ <button id="btn2" z-on:click="inc2">z-on button</button>
738
+ <span id="c1">${this.state.count1}</span>
739
+ <span id="c2">${this.state.count2}</span>
740
+ `;
741
+ }
742
+ });
743
+
744
+ const el = document.createElement('dir-test-at');
745
+ document.getElementById('app').appendChild(el);
746
+ const inst = mount(el, 'dir-test-at');
747
+
748
+ const btn1 = el.querySelector('#btn1');
749
+ const btn2 = el.querySelector('#btn2');
750
+
751
+ expect(btn1).not.toBeNull();
752
+ expect(btn2).not.toBeNull();
753
+
754
+ btn1.click();
755
+ // Wait for microtask (state update is batched)
756
+ return new Promise(resolve => {
757
+ queueMicrotask(() => {
758
+ expect(el.querySelector('#c1').textContent).toBe('1');
759
+ btn2.click();
760
+ queueMicrotask(() => {
761
+ expect(el.querySelector('#c2').textContent).toBe('1');
762
+ inst.destroy();
763
+ resolve();
764
+ });
765
+ });
766
+ });
767
+ });
768
+
769
+ it('@ shorthand works with modifiers like .prevent', () => {
770
+ component('dir-test-prevent', {
771
+ state: () => ({ submitted: false }),
772
+ handleSubmit() { this.state.submitted = true; },
773
+ render() {
774
+ return `
775
+ <form id="frm" @submit.prevent="handleSubmit">
776
+ <button type="submit" id="btn">Submit</button>
777
+ </form>
778
+ <span id="result">${this.state.submitted}</span>
779
+ `;
780
+ }
781
+ });
782
+
783
+ const el = document.createElement('dir-test-prevent');
784
+ document.getElementById('app').appendChild(el);
785
+ const inst = mount(el, 'dir-test-prevent');
786
+
787
+ const form = el.querySelector('#frm');
788
+ expect(form).not.toBeNull();
789
+
790
+ // Dispatch submit event
791
+ const event = new Event('submit', { bubbles: true, cancelable: true });
792
+ form.dispatchEvent(event);
793
+
794
+ return new Promise(resolve => {
795
+ queueMicrotask(() => {
796
+ // The submit should have been prevented (defaultPrevented)
797
+ // and handleSubmit should have been called
798
+ expect(el.querySelector('#result').textContent).toBe('true');
799
+ inst.destroy();
800
+ resolve();
801
+ });
802
+ });
803
+ });
804
+
805
+ it('z-on:event and @event both support .stop modifier', async () => {
806
+ component('dir-test-stop', {
807
+ state: () => ({ inner: 0, outer: 0 }),
808
+ innerClick() { this.state.inner++; },
809
+ outerClick() { this.state.outer++; },
810
+ render() {
811
+ return `
812
+ <div @click="outerClick">
813
+ <button id="inner" @click.stop="innerClick">Inner</button>
814
+ </div>
815
+ <span id="innerCount">${this.state.inner}</span>
816
+ <span id="outerCount">${this.state.outer}</span>
817
+ `;
818
+ }
819
+ });
820
+
821
+ const el = document.createElement('dir-test-stop');
822
+ document.getElementById('app').appendChild(el);
823
+ const inst = mount(el, 'dir-test-stop');
824
+
825
+ el.querySelector('#inner').click();
826
+
827
+ await new Promise(r => queueMicrotask(r));
828
+ expect(el.querySelector('#innerCount').textContent).toBe('1');
829
+ // Outer should NOT have fired due to .stop
830
+ expect(el.querySelector('#outerCount').textContent).toBe('0');
831
+ inst.destroy();
832
+ });
833
+ });
834
+
835
+
836
+ // ===========================================================================
837
+ // 6. EXPRESSION PARSER / INTERPOLATION - AUDIT
838
+ // ===========================================================================
839
+
840
+ describe('expression parser audit - edge cases', () => {
841
+
842
+ it('handles deeply nested property access', () => {
843
+ expect(eval_('a.b.c.d.e', { a: { b: { c: { d: { e: 42 } } } } })).toBe(42);
844
+ });
845
+
846
+ it('handles chained method calls', () => {
847
+ expect(eval_("'hello world'.split(' ').join('-')")).toBe('hello-world');
848
+ });
849
+
850
+ it('handles optional chaining with method call', () => {
851
+ expect(eval_('arr?.map(x => x * 2)', { arr: [1, 2, 3] })).toEqual([2, 4, 6]);
852
+ expect(eval_('arr?.map(x => x * 2)', { arr: null })).toBeUndefined();
853
+ });
854
+
855
+ it('handles template literals with expressions', () => {
856
+ expect(eval_('`Hello ${name}!`', { name: 'World' })).toBe('Hello World!');
857
+ });
858
+
859
+ it('handles template literals with complex expressions', () => {
860
+ expect(eval_('`${a + b} = ${a} + ${b}`', { a: 1, b: 2 })).toBe('3 = 1 + 2');
861
+ });
862
+
863
+ it('handles nested ternary operators', () => {
864
+ expect(eval_("x > 5 ? 'high' : x > 2 ? 'mid' : 'low'", { x: 6 })).toBe('high');
865
+ expect(eval_("x > 5 ? 'high' : x > 2 ? 'mid' : 'low'", { x: 3 })).toBe('mid');
866
+ expect(eval_("x > 5 ? 'high' : x > 2 ? 'mid' : 'low'", { x: 1 })).toBe('low');
867
+ });
868
+
869
+ it('handles array literal with expressions', () => {
870
+ expect(eval_('[a, b, a + b]', { a: 1, b: 2 })).toEqual([1, 2, 3]);
871
+ });
872
+
873
+ it('handles object literal with expressions', () => {
874
+ expect(eval_('{ x: a, y: b }', { a: 1, b: 2 })).toEqual({ x: 1, y: 2 });
875
+ });
876
+
877
+ it('handles arrow function in filter', () => {
878
+ expect(eval_('items.filter(x => x > 2)', { items: [1, 2, 3, 4] })).toEqual([3, 4]);
879
+ });
880
+
881
+ it('handles arrow function in map', () => {
882
+ expect(eval_('items.map(x => x * 2)', { items: [1, 2, 3] })).toEqual([2, 4, 6]);
883
+ });
884
+
885
+ it('handles typeof operator', () => {
886
+ expect(eval_("typeof x", { x: 42 })).toBe('number');
887
+ expect(eval_("typeof x", { x: 'hello' })).toBe('string');
888
+ expect(eval_("typeof x", {})).toBe('undefined');
889
+ });
890
+
891
+ it('handles instanceof operator', () => {
892
+ expect(eval_('arr instanceof Array', { arr: [1, 2] })).toBe(true);
893
+ });
894
+
895
+ it('handles new Date()', () => {
896
+ const result = eval_("new Date('2024-01-15')");
897
+ expect(result).toBeInstanceOf(Date);
898
+ });
899
+
900
+ it('handles Math globals', () => {
901
+ expect(eval_('Math.min(1, 2, 3)')).toBe(1);
902
+ expect(eval_('Math.floor(3.7)')).toBe(3);
903
+ expect(eval_('Math.ceil(3.2)')).toBe(4);
904
+ });
905
+
906
+ it('blocks constructor access for security', () => {
907
+ expect(eval_('obj.constructor', { obj: {} })).toBeUndefined();
908
+ });
909
+
910
+ it('blocks __proto__ access for security', () => {
911
+ expect(eval_('obj.__proto__', { obj: {} })).toBeUndefined();
912
+ });
913
+
914
+ it('handles shorthand object properties', () => {
915
+ expect(eval_('{ x, y }', { x: 1, y: 2 })).toEqual({ x: 1, y: 2 });
916
+ });
917
+
918
+ it('handles string comparison', () => {
919
+ expect(eval_("name === 'Tony'", { name: 'Tony' })).toBe(true);
920
+ expect(eval_("name === 'Tony'", { name: 'Sam' })).toBe(false);
921
+ });
922
+
923
+ it('handles nullish coalescing with undefined', () => {
924
+ expect(eval_('x ?? "default"', {})).toBe('default');
925
+ });
926
+
927
+ it('handles complex scope resolution with multiple layers', () => {
928
+ const result = safeEval('x + y', [{ x: 1 }, { y: 2 }]);
929
+ expect(result).toBe(3);
930
+ });
931
+
932
+ it('first scope layer wins for duplicate keys', () => {
933
+ const result = safeEval('x', [{ x: 'first' }, { x: 'second' }]);
934
+ expect(result).toBe('first');
935
+ });
936
+
937
+ it('handles empty array and object access gracefully', () => {
938
+ expect(eval_('arr.length', { arr: [] })).toBe(0);
939
+ expect(eval_('obj.missing', { obj: {} })).toBeUndefined();
940
+ });
941
+
942
+ it('handles chaining with null intermediate', () => {
943
+ expect(eval_('a?.b?.c', { a: null })).toBeUndefined();
944
+ expect(eval_('a?.b?.c', { a: { b: null } })).toBeUndefined();
945
+ });
946
+
947
+ it('handles multiline expressions (from templates)', () => {
948
+ // Expression parser should handle whitespace/newlines
949
+ expect(eval_(`
950
+ items.length > 0
951
+ ? 'has items'
952
+ : 'empty'
953
+ `, { items: [1] })).toBe('has items');
954
+ });
955
+ });
956
+
957
+
958
+ // ===========================================================================
959
+ // 7. ROUTER - BASE HREF AND PATH HANDLING - AUDIT
960
+ // ===========================================================================
961
+
962
+ describe('router - base href audit', () => {
963
+
964
+ let router;
965
+
966
+ afterEach(() => {
967
+ if (router) {
968
+ router.destroy();
969
+ router = null;
970
+ }
971
+ });
972
+
973
+ it('_normalizePath strips base prefix if accidentally included', () => {
974
+ router = createRouter({
975
+ base: '/app',
976
+ mode: 'hash',
977
+ routes: []
978
+ });
979
+ expect(router._normalizePath('/app/docs')).toBe('/docs');
980
+ expect(router._normalizePath('/app')).toBe('/');
981
+ });
982
+
983
+ it('_normalizePath handles paths without leading slash', () => {
984
+ router = createRouter({
985
+ base: '/app',
986
+ mode: 'hash',
987
+ routes: []
988
+ });
989
+ expect(router._normalizePath('docs')).toBe('/docs');
990
+ expect(router._normalizePath('')).toBe('/');
991
+ });
992
+
993
+ it('resolve prepends base to path', () => {
994
+ router = createRouter({
995
+ base: '/app',
996
+ mode: 'hash',
997
+ routes: []
998
+ });
999
+ expect(router.resolve('/docs')).toBe('/app/docs');
1000
+ expect(router.resolve('/')).toBe('/app/');
1001
+ });
1002
+
1003
+ it('base path does not double-prefix on navigate', () => {
1004
+ router = createRouter({
1005
+ el: '#app',
1006
+ base: '/app',
1007
+ mode: 'hash',
1008
+ routes: [
1009
+ { path: '/', component: 'home-page' },
1010
+ { path: '/about', component: 'about-page' },
1011
+ ]
1012
+ });
1013
+ // User navigates with path that already includes base
1014
+ router.navigate('/app/about');
1015
+ // Should normalize to /about, not /app/app/about
1016
+ expect(window.location.hash).toBe('#/about');
1017
+ });
1018
+
1019
+ it('navigate with empty path goes to root', () => {
1020
+ document.body.innerHTML = '<div id="app"></div>';
1021
+ router = createRouter({
1022
+ el: '#app',
1023
+ mode: 'hash',
1024
+ routes: [
1025
+ { path: '/', component: 'home-page' },
1026
+ ]
1027
+ });
1028
+ router.navigate('');
1029
+ expect(window.location.hash).toBe('#/');
1030
+ });
1031
+
1032
+ it('hash mode handles fragment in route correctly', () => {
1033
+ document.body.innerHTML = '<div id="app"></div>';
1034
+ router = createRouter({
1035
+ el: '#app',
1036
+ mode: 'hash',
1037
+ routes: [
1038
+ { path: '/', component: 'home-page' },
1039
+ { path: '/docs', component: 'docs-page' },
1040
+ ]
1041
+ });
1042
+ // Navigate with fragment
1043
+ router.navigate('/docs#section1');
1044
+ // Fragment should be stored as scroll target, path is /docs
1045
+ expect(window.location.hash).toBe('#/docs');
1046
+ });
1047
+
1048
+ it('query string is parsed correctly', () => {
1049
+ document.body.innerHTML = '<div id="app"></div>';
1050
+ window.location.hash = '#/?foo=bar&num=42';
1051
+ router = createRouter({
1052
+ el: '#app',
1053
+ mode: 'hash',
1054
+ routes: [{ path: '/', component: 'home-page' }]
1055
+ });
1056
+ const query = router.query;
1057
+ expect(query.foo).toBe('bar');
1058
+ expect(query.num).toBe('42');
1059
+ });
1060
+
1061
+ it('route params are extracted correctly', () => {
1062
+ router = createRouter({
1063
+ mode: 'hash',
1064
+ routes: [
1065
+ { path: '/user/:id/post/:postId', component: 'user-page' }
1066
+ ]
1067
+ });
1068
+ const route = router._routes[0];
1069
+ const match = '/user/42/post/7'.match(route._regex);
1070
+ expect(match).not.toBeNull();
1071
+ const params = {};
1072
+ route._keys.forEach((key, i) => { params[key] = match[i + 1]; });
1073
+ expect(params).toEqual({ id: '42', postId: '7' });
1074
+ });
1075
+
1076
+ it('wildcard route matches any path', () => {
1077
+ router = createRouter({
1078
+ mode: 'hash',
1079
+ routes: [
1080
+ { path: '/files/*', component: 'user-page' }
1081
+ ]
1082
+ });
1083
+ const route = router._routes[0];
1084
+ expect(route._regex.test('/files/some/deep/path')).toBe(true);
1085
+ expect(route._regex.test('/files/')).toBe(true);
1086
+ expect(route._regex.test('/other')).toBe(false);
1087
+ });
1088
+
1089
+ it('fallback route is registered as alias', () => {
1090
+ router = createRouter({
1091
+ mode: 'hash',
1092
+ routes: [
1093
+ { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
1094
+ ]
1095
+ });
1096
+ expect(router._routes.length).toBe(2);
1097
+ expect(router._routes[0]._regex.test('/docs/intro')).toBe(true);
1098
+ expect(router._routes[1]._regex.test('/docs')).toBe(true);
1099
+ });
1100
+
1101
+ it('navigation guards can cancel navigation', async () => {
1102
+ document.body.innerHTML = '<div id="app"></div>';
1103
+ window.location.hash = '#/';
1104
+ router = createRouter({
1105
+ el: '#app',
1106
+ mode: 'hash',
1107
+ routes: [
1108
+ { path: '/', component: 'home-page' },
1109
+ { path: '/protected', component: 'about-page' },
1110
+ ]
1111
+ });
1112
+ router.beforeEach((to) => {
1113
+ if (to.path === '/protected') return false;
1114
+ });
1115
+ router.navigate('/protected');
1116
+ // Wait for resolution
1117
+ await new Promise(r => setTimeout(r, 50));
1118
+ // Guard returned false, so navigation should be cancelled
1119
+ // Current route should still be what it was before
1120
+ });
1121
+
1122
+ it('onChange listener fires on navigation', async () => {
1123
+ document.body.innerHTML = '<div id="app"></div>';
1124
+ window.location.hash = '#/';
1125
+ // Register dummy components so the router can mount them
1126
+ component('home-onchange', { render() { return '<p>Home</p>'; } });
1127
+ component('about-onchange', { render() { return '<p>About</p>'; } });
1128
+ router = createRouter({
1129
+ el: '#app',
1130
+ mode: 'hash',
1131
+ routes: [
1132
+ { path: '/', component: 'home-onchange' },
1133
+ { path: '/about', component: 'about-onchange' },
1134
+ ]
1135
+ });
1136
+ const listener = vi.fn();
1137
+ router.onChange(listener);
1138
+ router.navigate('/about');
1139
+ await new Promise(r => setTimeout(r, 100));
1140
+ expect(listener).toHaveBeenCalled();
1141
+ });
1142
+
1143
+ it('destroy removes event listeners', () => {
1144
+ router = createRouter({
1145
+ mode: 'hash',
1146
+ routes: [{ path: '/', component: 'home-page' }]
1147
+ });
1148
+ router.destroy();
1149
+ expect(router._onNavEvent).toBeNull();
1150
+ expect(router._onLinkClick).toBeNull();
1151
+ router = null;
1152
+ });
1153
+ });
1154
+
1155
+
1156
+ // ===========================================================================
1157
+ // 8. ROUTING + LAZY LOADING - AUDIT
1158
+ // ===========================================================================
1159
+
1160
+ describe('router - lazy loading and morph interaction', () => {
1161
+
1162
+ afterEach(() => {
1163
+ const r = getRouter();
1164
+ if (r) r.destroy();
1165
+ });
1166
+
1167
+ it('route.load function is called before mounting', async () => {
1168
+ document.body.innerHTML = '<div id="app"></div>';
1169
+ window.location.hash = '#/';
1170
+
1171
+ const loadSpy = vi.fn(() => Promise.resolve());
1172
+
1173
+ const router = createRouter({
1174
+ el: '#app',
1175
+ mode: 'hash',
1176
+ routes: [
1177
+ { path: '/', component: 'home-page' },
1178
+ { path: '/lazy', load: loadSpy, component: 'about-page' },
1179
+ ]
1180
+ });
1181
+
1182
+ router.navigate('/lazy');
1183
+ await new Promise(r => setTimeout(r, 100));
1184
+ expect(loadSpy).toHaveBeenCalled();
1185
+ router.destroy();
1186
+ });
1187
+
1188
+ it('route.load failure does not crash the router', async () => {
1189
+ document.body.innerHTML = '<div id="app"></div>';
1190
+ window.location.hash = '#/';
1191
+
1192
+ const router = createRouter({
1193
+ el: '#app',
1194
+ mode: 'hash',
1195
+ routes: [
1196
+ { path: '/', component: 'home-page' },
1197
+ { path: '/fail', load: () => Promise.reject(new Error('load error')), component: 'about-page' },
1198
+ ]
1199
+ });
1200
+
1201
+ // Should not throw
1202
+ router.navigate('/fail');
1203
+ await new Promise(r => setTimeout(r, 100));
1204
+ router.destroy();
1205
+ });
1206
+
1207
+ it('navigating rapidly between routes does not cause errors', async () => {
1208
+ document.body.innerHTML = '<div id="app"></div>';
1209
+ window.location.hash = '#/';
1210
+
1211
+ const router = createRouter({
1212
+ el: '#app',
1213
+ mode: 'hash',
1214
+ routes: [
1215
+ { path: '/', component: 'home-page' },
1216
+ { path: '/about', component: 'about-page' },
1217
+ { path: '/user/:id', component: 'user-page' },
1218
+ ]
1219
+ });
1220
+
1221
+ // Rapid navigation
1222
+ router.navigate('/about');
1223
+ router.navigate('/');
1224
+ router.navigate('/user/1');
1225
+ router.navigate('/about');
1226
+ router.navigate('/');
1227
+
1228
+ await new Promise(r => setTimeout(r, 200));
1229
+ // Should not throw or leave the UI in a broken state
1230
+ router.destroy();
1231
+ });
1232
+
1233
+ it('same-route navigation is skipped (no duplicate mount)', async () => {
1234
+ document.body.innerHTML = '<div id="app"></div>';
1235
+ window.location.hash = '#/about';
1236
+
1237
+ const router = createRouter({
1238
+ el: '#app',
1239
+ mode: 'hash',
1240
+ routes: [
1241
+ { path: '/', component: 'home-page' },
1242
+ { path: '/about', component: 'about-page' },
1243
+ ]
1244
+ });
1245
+
1246
+ await new Promise(r => setTimeout(r, 50));
1247
+ const firstChild = document.querySelector('#app').firstElementChild;
1248
+
1249
+ // Navigate to same route
1250
+ router.navigate('/about');
1251
+ await new Promise(r => setTimeout(r, 50));
1252
+ // Should be the same component instance
1253
+ router.destroy();
1254
+ });
1255
+ });
1256
+
1257
+
1258
+ // ===========================================================================
1259
+ // 9. COMPONENT DIRECTIVES - z-for, z-if, z-show, z-bind, z-class, z-style
1260
+ // ===========================================================================
1261
+
1262
+ describe('component directives audit', () => {
1263
+
1264
+ beforeEach(() => {
1265
+ document.body.innerHTML = '<div id="app"></div>';
1266
+ });
1267
+
1268
+ it('z-for with array renders correct number of items', () => {
1269
+ component('zfor-list', {
1270
+ state: () => ({ items: ['a', 'b', 'c'] }),
1271
+ render() {
1272
+ return `<ul><li z-for="item in items">{{item}}</li></ul>`;
1273
+ }
1274
+ });
1275
+ const el = document.createElement('zfor-list');
1276
+ document.getElementById('app').appendChild(el);
1277
+ const inst = mount(el, 'zfor-list');
1278
+ expect(el.querySelectorAll('li').length).toBe(3);
1279
+ expect(el.querySelectorAll('li')[0].textContent).toBe('a');
1280
+ expect(el.querySelectorAll('li')[2].textContent).toBe('c');
1281
+ inst.destroy();
1282
+ });
1283
+
1284
+ it('z-for with index variable', () => {
1285
+ component('zfor-index', {
1286
+ state: () => ({ items: ['x', 'y', 'z'] }),
1287
+ render() {
1288
+ return `<ul><li z-for="(item, i) in items">{{i}}: {{item}}</li></ul>`;
1289
+ }
1290
+ });
1291
+ const el = document.createElement('zfor-index');
1292
+ document.getElementById('app').appendChild(el);
1293
+ const inst = mount(el, 'zfor-index');
1294
+ expect(el.querySelectorAll('li')[0].textContent).toBe('0: x');
1295
+ expect(el.querySelectorAll('li')[1].textContent).toBe('1: y');
1296
+ inst.destroy();
1297
+ });
1298
+
1299
+ it('z-for with number range', () => {
1300
+ component('zfor-range', {
1301
+ state: () => ({ count: 3 }),
1302
+ render() {
1303
+ return `<ul><li z-for="n in count">{{n}}</li></ul>`;
1304
+ }
1305
+ });
1306
+ const el = document.createElement('zfor-range');
1307
+ document.getElementById('app').appendChild(el);
1308
+ const inst = mount(el, 'zfor-range');
1309
+ expect(el.querySelectorAll('li').length).toBe(3);
1310
+ expect(el.querySelectorAll('li')[0].textContent).toBe('1');
1311
+ expect(el.querySelectorAll('li')[2].textContent).toBe('3');
1312
+ inst.destroy();
1313
+ });
1314
+
1315
+ it('z-if conditionally renders elements', () => {
1316
+ component('zif-test', {
1317
+ state: () => ({ visible: true }),
1318
+ render() {
1319
+ return `
1320
+ <div z-if="visible" id="shown">Visible</div>
1321
+ <div z-else id="hidden">Hidden</div>
1322
+ `;
1323
+ }
1324
+ });
1325
+ const el = document.createElement('zif-test');
1326
+ document.getElementById('app').appendChild(el);
1327
+ const inst = mount(el, 'zif-test');
1328
+ expect(el.querySelector('#shown')).not.toBeNull();
1329
+ expect(el.querySelector('#hidden')).toBeNull();
1330
+ inst.destroy();
1331
+ });
1332
+
1333
+ it('z-show toggles display style', () => {
1334
+ component('zshow-test', {
1335
+ state: () => ({ visible: false }),
1336
+ render() {
1337
+ return `<div z-show="visible" id="target">Content</div>`;
1338
+ }
1339
+ });
1340
+ const el = document.createElement('zshow-test');
1341
+ document.getElementById('app').appendChild(el);
1342
+ const inst = mount(el, 'zshow-test');
1343
+ expect(el.querySelector('#target').style.display).toBe('none');
1344
+ inst.destroy();
1345
+ });
1346
+
1347
+ it('z-bind dynamically sets attributes', () => {
1348
+ component('zbind-test', {
1349
+ state: () => ({ href: 'https://example.com', isDisabled: true }),
1350
+ render() {
1351
+ return `
1352
+ <a z-bind:href="href" id="link">Link</a>
1353
+ <button :disabled="isDisabled" id="btn">Button</button>
1354
+ `;
1355
+ }
1356
+ });
1357
+ const el = document.createElement('zbind-test');
1358
+ document.getElementById('app').appendChild(el);
1359
+ const inst = mount(el, 'zbind-test');
1360
+ expect(el.querySelector('#link').getAttribute('href')).toBe('https://example.com');
1361
+ expect(el.querySelector('#btn').hasAttribute('disabled')).toBe(true);
1362
+ inst.destroy();
1363
+ });
1364
+
1365
+ it('z-class with object syntax', () => {
1366
+ component('zclass-test', {
1367
+ state: () => ({ isActive: true, isError: false }),
1368
+ render() {
1369
+ return `<div z-class="{ active: isActive, error: isError }" id="target">Content</div>`;
1370
+ }
1371
+ });
1372
+ const el = document.createElement('zclass-test');
1373
+ document.getElementById('app').appendChild(el);
1374
+ const inst = mount(el, 'zclass-test');
1375
+ const target = el.querySelector('#target');
1376
+ expect(target.classList.contains('active')).toBe(true);
1377
+ expect(target.classList.contains('error')).toBe(false);
1378
+ inst.destroy();
1379
+ });
1380
+
1381
+ it('z-style with object syntax', () => {
1382
+ component('zstyle-test', {
1383
+ state: () => ({ color: 'red', fontSize: '16px' }),
1384
+ render() {
1385
+ return `<div z-style="{ color: color, fontSize: fontSize }" id="target">Styled</div>`;
1386
+ }
1387
+ });
1388
+ const el = document.createElement('zstyle-test');
1389
+ document.getElementById('app').appendChild(el);
1390
+ const inst = mount(el, 'zstyle-test');
1391
+ const target = el.querySelector('#target');
1392
+ expect(target.style.color).toBe('red');
1393
+ expect(target.style.fontSize).toBe('16px');
1394
+ inst.destroy();
1395
+ });
1396
+ });
1397
+
1398
+
1399
+ // ===========================================================================
1400
+ // 10. MORPH ENGINE - APPEND, CSS, EVENT LISTENER PRESERVATION
1401
+ // ===========================================================================
1402
+
1403
+ describe('morph engine - DOM operations interplay', () => {
1404
+
1405
+ it('morph after programmatic addClass preserves new class from morph', () => {
1406
+ const root = el('<div class="original">text</div>');
1407
+ root.children[0].classList.add('custom');
1408
+ morph(root, '<div class="updated">text</div>');
1409
+ // After morph, class should match the new HTML
1410
+ expect(root.children[0].className).toBe('updated');
1411
+ });
1412
+
1413
+ it('morph preserves input focus state conceptually (same node)', () => {
1414
+ const root = el('<input id="myinput" value="hello">');
1415
+ const input = root.querySelector('input');
1416
+ morph(root, '<input id="myinput" value="hello updated">');
1417
+ // same node via auto-key (id)
1418
+ expect(root.querySelector('#myinput')).toBe(input);
1419
+ expect(input.value).toBe('hello updated');
1420
+ });
1421
+
1422
+ it('morph handles script tags (should not execute)', () => {
1423
+ const root = el('<div>content</div>');
1424
+ morph(root, '<div>content</div><script>window.__test = true;</script>');
1425
+ // Script should exist in DOM but not execute
1426
+ expect(window.__test).toBeUndefined();
1427
+ delete window.__test;
1428
+ });
1429
+
1430
+ it('morph handles style elements correctly', () => {
1431
+ const root = el('<style>.a { color: red; }</style>');
1432
+ morph(root, '<style>.a { color: blue; }</style>');
1433
+ expect(root.querySelector('style').textContent).toBe('.a { color: blue; }');
1434
+ });
1435
+
1436
+ it('morph handles img src changes', () => {
1437
+ const root = el('<img src="old.png" alt="image">');
1438
+ morph(root, '<img src="new.png" alt="image updated">');
1439
+ const img = root.querySelector('img');
1440
+ expect(img.getAttribute('src')).toBe('new.png');
1441
+ expect(img.getAttribute('alt')).toBe('image updated');
1442
+ });
1443
+
1444
+ it('unkeyed morph with same content at different positions', () => {
1445
+ const root = el('<div>A</div><div>B</div><div>C</div>');
1446
+ morph(root, '<div>C</div><div>B</div><div>A</div>');
1447
+ // Unkeyed - positional matching updates content in place
1448
+ expect(root.children[0].textContent).toBe('C');
1449
+ expect(root.children[1].textContent).toBe('B');
1450
+ expect(root.children[2].textContent).toBe('A');
1451
+ });
1452
+
1453
+ it('morph handles nested keyed and unkeyed mix correctly', () => {
1454
+ const root = el(`
1455
+ <div z-key="container">
1456
+ <p>Static text</p>
1457
+ <ul>
1458
+ <li z-key="item-1">Item 1</li>
1459
+ <li z-key="item-2">Item 2</li>
1460
+ </ul>
1461
+ </div>
1462
+ `);
1463
+ morph(root, `
1464
+ <div z-key="container">
1465
+ <p>Updated text</p>
1466
+ <ul>
1467
+ <li z-key="item-2">Item 2 updated</li>
1468
+ <li z-key="item-1">Item 1 updated</li>
1469
+ <li z-key="item-3">Item 3 new</li>
1470
+ </ul>
1471
+ </div>
1472
+ `);
1473
+ const lis = root.querySelectorAll('li');
1474
+ expect(lis.length).toBe(3);
1475
+ expect(lis[0].textContent).toBe('Item 2 updated');
1476
+ expect(lis[1].textContent).toBe('Item 1 updated');
1477
+ expect(lis[2].textContent).toBe('Item 3 new');
1478
+ expect(root.querySelector('p').textContent).toBe('Updated text');
1479
+ });
1480
+
1481
+ it('morph handles select with dynamic options', () => {
1482
+ const root = el(`
1483
+ <select>
1484
+ <option value="a">A</option>
1485
+ <option value="b" selected>B</option>
1486
+ </select>
1487
+ `);
1488
+ morph(root, `
1489
+ <select>
1490
+ <option value="a" selected>A</option>
1491
+ <option value="b">B</option>
1492
+ <option value="c">C</option>
1493
+ </select>
1494
+ `);
1495
+ const options = root.querySelectorAll('option');
1496
+ expect(options.length).toBe(3);
1497
+ });
1498
+ });
1499
+
1500
+
1501
+ // ===========================================================================
1502
+ // 11. INTERPOLATION & PARSING - ALL CONTEXTS
1503
+ // ===========================================================================
1504
+
1505
+ describe('interpolation and parsing - audit', () => {
1506
+
1507
+ it('safeEval handles string concatenation with +', () => {
1508
+ expect(eval_("'hello' + ' ' + 'world'")).toBe('hello world');
1509
+ });
1510
+
1511
+ it('safeEval handles numeric string coercion', () => {
1512
+ expect(eval_("'value: ' + 42")).toBe('value: 42');
1513
+ });
1514
+
1515
+ it('safeEval handles array index with variable', () => {
1516
+ expect(eval_('items[idx]', { items: ['a', 'b', 'c'], idx: 1 })).toBe('b');
1517
+ });
1518
+
1519
+ it('safeEval handles Object.keys-like access', () => {
1520
+ // Can't call Object.keys directly (blocked), but can access properties
1521
+ const result = eval_('items.length', { items: [1, 2, 3] });
1522
+ expect(result).toBe(3);
1523
+ });
1524
+
1525
+ it('safeEval handles reduce with arrow function', () => {
1526
+ const result = eval_('items.reduce((sum, x) => sum + x, 0)', { items: [1, 2, 3, 4] });
1527
+ expect(result).toBe(10);
1528
+ });
1529
+
1530
+ it('safeEval handles chained array operations', () => {
1531
+ const result = eval_('items.filter(x => x > 2).map(x => x * 10)', { items: [1, 2, 3, 4] });
1532
+ expect(result).toEqual([30, 40]);
1533
+ });
1534
+
1535
+ it('safeEval handles comparison chain', () => {
1536
+ expect(eval_('a > b && b > c', { a: 3, b: 2, c: 1 })).toBe(true);
1537
+ expect(eval_('a > b && b > c', { a: 3, b: 2, c: 5 })).toBe(false);
1538
+ });
1539
+
1540
+ it('safeEval handles in operator', () => {
1541
+ expect(eval_("'a' in obj", { obj: { a: 1, b: 2 } })).toBe(true);
1542
+ expect(eval_("'c' in obj", { obj: { a: 1 } })).toBe(false);
1543
+ });
1544
+
1545
+ it('safeEval handles Array.isArray check', () => {
1546
+ // Note: Array.isArray may not be accessible directly, let's test instanceof
1547
+ expect(eval_('arr instanceof Array', { arr: [1, 2] })).toBe(true);
1548
+ });
1549
+
1550
+ it('safeEval caches AST for repeated expressions', () => {
1551
+ // Call the same expression multiple times to test caching
1552
+ for (let i = 0; i < 10; i++) {
1553
+ expect(eval_('x + 1', { x: i })).toBe(i + 1);
1554
+ }
1555
+ });
1556
+
1557
+ it('safeEval returns undefined for unparseable expressions gracefully', () => {
1558
+ expect(eval_('{')).toBeUndefined();
1559
+ expect(eval_('if (true) {}')).toBeUndefined();
1560
+ });
1561
+
1562
+ it('template literal with nested template literal', () => {
1563
+ // This is a complex edge case for the tokenizer
1564
+ expect(eval_('`outer ${`inner ${x}`}`', { x: 42 })).toBe('outer inner 42');
1565
+ });
1566
+
1567
+ it('handles hex and scientific notation numbers', () => {
1568
+ expect(eval_('0xFF')).toBe(255);
1569
+ expect(eval_('1e3')).toBe(1000);
1570
+ expect(eval_('2.5e2')).toBe(250);
1571
+ });
1572
+
1573
+ it('handles escape sequences in strings', () => {
1574
+ expect(eval_("'line1\\nline2'")).toBe('line1\nline2');
1575
+ expect(eval_("'tab\\there'")).toBe('tab\there');
1576
+ });
1577
+
1578
+ it('handles object method call in scope', () => {
1579
+ const obj = {
1580
+ items: [3, 1, 4, 1, 5],
1581
+ getMax() { return Math.max(...this.items); }
1582
+ };
1583
+ // Direct method call won't work because of `this` context
1584
+ // But we can test accessing properties and calling array methods
1585
+ expect(eval_('items.indexOf(4)', { items: [3, 1, 4, 1, 5] })).toBe(2);
1586
+ });
1587
+ });
1588
+
1589
+
1590
+ // ===========================================================================
1591
+ // 12. COMPONENT LIFECYCLE & STATE REACTIVITY
1592
+ // ===========================================================================
1593
+
1594
+ describe('component lifecycle audit', () => {
1595
+
1596
+ beforeEach(() => {
1597
+ document.body.innerHTML = '<div id="app"></div>';
1598
+ });
1599
+
1600
+ it('component state changes trigger re-render via morph', () => {
1601
+ component('state-render', {
1602
+ state: () => ({ count: 0 }),
1603
+ inc() { this.state.count++; },
1604
+ render() {
1605
+ return `<span id="val">${this.state.count}</span><button @click="inc">+</button>`;
1606
+ }
1607
+ });
1608
+ const el = document.createElement('state-render');
1609
+ document.getElementById('app').appendChild(el);
1610
+ const inst = mount(el, 'state-render');
1611
+ expect(el.querySelector('#val').textContent).toBe('0');
1612
+
1613
+ inst.state.count = 5;
1614
+
1615
+ return new Promise(resolve => {
1616
+ queueMicrotask(() => {
1617
+ expect(el.querySelector('#val').textContent).toBe('5');
1618
+ inst.destroy();
1619
+ resolve();
1620
+ });
1621
+ });
1622
+ });
1623
+
1624
+ it('computed properties are available in templates', () => {
1625
+ component('comp-computed', {
1626
+ state: () => ({ firstName: 'John', lastName: 'Doe' }),
1627
+ computed: {
1628
+ fullName(state) { return `${state.firstName} ${state.lastName}`; }
1629
+ },
1630
+ render() {
1631
+ return `<span id="name">${this.computed.fullName}</span>`;
1632
+ }
1633
+ });
1634
+ const el = document.createElement('comp-computed');
1635
+ document.getElementById('app').appendChild(el);
1636
+ const inst = mount(el, 'comp-computed');
1637
+ expect(el.querySelector('#name').textContent).toBe('John Doe');
1638
+ inst.destroy();
1639
+ });
1640
+
1641
+ it('destroying a component clears its innerHTML', () => {
1642
+ component('destroy-test', {
1643
+ render() { return `<p>Content</p>`; }
1644
+ });
1645
+ const el = document.createElement('destroy-test');
1646
+ document.getElementById('app').appendChild(el);
1647
+ const inst = mount(el, 'destroy-test');
1648
+ expect(el.innerHTML).toContain('Content');
1649
+ inst.destroy();
1650
+ expect(el.innerHTML).toBe('');
1651
+ });
1652
+
1653
+ it('z-ref sets element references', () => {
1654
+ component('ref-test', {
1655
+ render() {
1656
+ return `<input z-ref="myInput" type="text">`;
1657
+ },
1658
+ mounted() {
1659
+ expect(this.refs.myInput).toBeTruthy();
1660
+ expect(this.refs.myInput.tagName).toBe('INPUT');
1661
+ }
1662
+ });
1663
+ const el = document.createElement('ref-test');
1664
+ document.getElementById('app').appendChild(el);
1665
+ const inst = mount(el, 'ref-test');
1666
+ expect(inst.refs.myInput).toBeTruthy();
1667
+ inst.destroy();
1668
+ });
1669
+
1670
+ it('props are frozen and available in component', () => {
1671
+ component('props-test', {
1672
+ render() {
1673
+ return `<span id="prop">${this.props.name || 'none'}</span>`;
1674
+ }
1675
+ });
1676
+ const el = document.createElement('props-test');
1677
+ document.getElementById('app').appendChild(el);
1678
+ const inst = mount(el, 'props-test', { name: 'Tony' });
1679
+ expect(el.querySelector('#prop').textContent).toBe('Tony');
1680
+ expect(Object.isFrozen(inst.props)).toBe(true);
1681
+ inst.destroy();
1682
+ });
1683
+
1684
+ it('setState triggers re-render', () => {
1685
+ component('setstate-test', {
1686
+ state: () => ({ val: 'initial' }),
1687
+ render() {
1688
+ return `<span id="val">${this.state.val}</span>`;
1689
+ }
1690
+ });
1691
+ const el = document.createElement('setstate-test');
1692
+ document.getElementById('app').appendChild(el);
1693
+ const inst = mount(el, 'setstate-test');
1694
+ inst.setState({ val: 'updated' });
1695
+
1696
+ return new Promise(resolve => {
1697
+ queueMicrotask(() => {
1698
+ expect(el.querySelector('#val').textContent).toBe('updated');
1699
+ inst.destroy();
1700
+ resolve();
1701
+ });
1702
+ });
1703
+ });
1704
+ });
1705
+
1706
+
1707
+ // ===========================================================================
1708
+ // 13. $ selector + ZQueryCollection + Morph engine integration
1709
+ // ===========================================================================
1710
+ import { query, queryAll, ZQueryCollection } from '../src/core.js';
1711
+ import $, { $ as $named } from '../index.js';
1712
+
1713
+ describe('$ selector + ZQueryCollection + morph engine', () => {
1714
+ let root;
1715
+
1716
+ beforeEach(() => {
1717
+ root = document.createElement('div');
1718
+ root.id = 'zq-root';
1719
+ document.body.appendChild(root);
1720
+ });
1721
+
1722
+ afterEach(() => {
1723
+ root.remove();
1724
+ });
1725
+
1726
+ // --- $() selector variants ------------------------------------------------
1727
+
1728
+ describe('$() selector returns ZQueryCollection', () => {
1729
+ it('CSS selector returns ZQueryCollection', () => {
1730
+ root.innerHTML = '<span class="item">A</span><span class="item">B</span>';
1731
+ const col = query('.item');
1732
+ expect(col).toBeInstanceOf(ZQueryCollection);
1733
+ expect(col.length).toBe(2);
1734
+ });
1735
+
1736
+ it('HTML string creates elements', () => {
1737
+ const col = query('<div class="created"><span>hi</span></div>');
1738
+ expect(col).toBeInstanceOf(ZQueryCollection);
1739
+ expect(col.length).toBe(1);
1740
+ expect(col.first().tagName).toBe('DIV');
1741
+ expect(col.find('span').length).toBe(1);
1742
+ });
1743
+
1744
+ it('wraps a DOM element', () => {
1745
+ const col = query(root);
1746
+ expect(col).toBeInstanceOf(ZQueryCollection);
1747
+ expect(col.first()).toBe(root);
1748
+ });
1749
+
1750
+ it('wraps NodeList', () => {
1751
+ root.innerHTML = '<p>1</p><p>2</p><p>3</p>';
1752
+ const nl = root.querySelectorAll('p');
1753
+ const col = query(nl);
1754
+ expect(col.length).toBe(3);
1755
+ });
1756
+
1757
+ it('wraps Array of elements', () => {
1758
+ const els = [document.createElement('a'), document.createElement('b')];
1759
+ const col = query(els);
1760
+ expect(col.length).toBe(2);
1761
+ });
1762
+
1763
+ it('returns same instance for ZQueryCollection input', () => {
1764
+ const col = new ZQueryCollection([root]);
1765
+ expect(query(col)).toBe(col);
1766
+ });
1767
+
1768
+ it('returns empty collection for null/undefined/false', () => {
1769
+ expect(query(null).length).toBe(0);
1770
+ expect(query(undefined).length).toBe(0);
1771
+ expect(query(false).length).toBe(0);
1772
+ });
1773
+
1774
+ it('context parameter scopes selector', () => {
1775
+ root.innerHTML = '<div id="scope"><span class="x">in</span></div><span class="x">out</span>';
1776
+ const col = query('.x', '#scope');
1777
+ expect(col.length).toBe(1);
1778
+ expect(col.first().textContent).toBe('in');
1779
+ });
1780
+
1781
+ it('context as element scopes selector', () => {
1782
+ root.innerHTML = '<div id="scope2"><span class="y">in</span></div><span class="y">out</span>';
1783
+ const scope = document.getElementById('scope2');
1784
+ const col = query('.y', scope);
1785
+ expect(col.length).toBe(1);
1786
+ });
1787
+ });
1788
+
1789
+ // --- $.all() aliases query -------------------------------------------------
1790
+
1791
+ describe('$.all() collection selector', () => {
1792
+ it('$.all returns ZQueryCollection', () => {
1793
+ root.innerHTML = '<li>1</li><li>2</li>';
1794
+ const col = queryAll('li');
1795
+ expect(col).toBeInstanceOf(ZQueryCollection);
1796
+ expect(col.length).toBe(2);
1797
+ });
1798
+
1799
+ it('$.all passes through existing collection', () => {
1800
+ const existing = new ZQueryCollection([root]);
1801
+ expect(queryAll(existing)).toBe(existing);
1802
+ });
1803
+
1804
+ it('$.all wraps Window', () => {
1805
+ const col = queryAll(window);
1806
+ expect(col.length).toBe(1);
1807
+ expect(col.first()).toBe(window);
1808
+ });
1809
+ });
1810
+
1811
+ // --- $.create() with morph ------------------------------------------------
1812
+
1813
+ describe('$.create() + morph integration', () => {
1814
+ it('creates element with attrs and children', () => {
1815
+ const col = $.create('div', { class: 'box', 'data-id': '42' }, 'Hello');
1816
+ expect(col).toBeInstanceOf(ZQueryCollection);
1817
+ expect(col.first().className).toBe('box');
1818
+ expect(col.first().dataset.id).toBe('42');
1819
+ expect(col.first().textContent).toBe('Hello');
1820
+ });
1821
+
1822
+ it('$.create element can be morphed via collection .morph()', () => {
1823
+ const col = $.create('div', {}, 'initial');
1824
+ root.appendChild(col.first());
1825
+ const el = root.querySelector('div');
1826
+ // morph the created element through collection
1827
+ query(el).morph('<p>morphed</p>');
1828
+ expect(el.innerHTML).toBe('<p>morphed</p>');
1829
+ });
1830
+
1831
+ it('$.create with event handler attrs', () => {
1832
+ let clicked = false;
1833
+ const col = $.create('button', { onClick: () => { clicked = true; } }, 'Click');
1834
+ root.appendChild(col.first());
1835
+ col.first().click();
1836
+ expect(clicked).toBe(true);
1837
+ });
1838
+
1839
+ it('$.create with style object', () => {
1840
+ const col = $.create('span', { style: { color: 'red', fontWeight: 'bold' } }, 'styled');
1841
+ expect(col.first().style.color).toBe('red');
1842
+ expect(col.first().style.fontWeight).toBe('bold');
1843
+ });
1844
+
1845
+ it('$.create with data object', () => {
1846
+ const col = $.create('div', { data: { userId: '7', role: 'admin' } });
1847
+ expect(col.first().dataset.userId).toBe('7');
1848
+ expect(col.first().dataset.role).toBe('admin');
1849
+ });
1850
+
1851
+ it('$.create with Node children', () => {
1852
+ const span = document.createElement('span');
1853
+ span.textContent = 'child';
1854
+ const col = $.create('div', {}, span);
1855
+ expect(col.first().querySelector('span').textContent).toBe('child');
1856
+ });
1857
+ });
1858
+
1859
+ // --- .html() auto-morph + morph engine interaction -------------------------
1860
+
1861
+ describe('collection .html() auto-morph', () => {
1862
+ it('auto-morphs when element has existing children', () => {
1863
+ root.innerHTML = '<p id="keep">old</p>';
1864
+ const pRef = root.querySelector('#keep');
1865
+ query(root).html('<p id="keep">new</p>');
1866
+ // Same DOM node preserved (morph, not replace)
1867
+ expect(root.querySelector('#keep')).toBe(pRef);
1868
+ expect(pRef.textContent).toBe('new');
1869
+ });
1870
+
1871
+ it('uses raw innerHTML when element is empty (fast first-paint)', () => {
1872
+ root.innerHTML = '';
1873
+ query(root).html('<p id="fresh">hello</p>');
1874
+ expect(root.querySelector('#fresh').textContent).toBe('hello');
1875
+ });
1876
+
1877
+ it('empty().html() forces raw innerHTML bypassing morph', () => {
1878
+ root.innerHTML = '<p id="old">old</p>';
1879
+ const oldRef = root.querySelector('#old');
1880
+ query(root).empty().html('<p id="old">replaced</p>');
1881
+ // Not the same reference - was re-created via innerHTML
1882
+ expect(root.querySelector('#old')).not.toBe(oldRef);
1883
+ expect(root.querySelector('#old').textContent).toBe('replaced');
1884
+ });
1885
+
1886
+ it('auto-morph preserves keyed elements with z-key', () => {
1887
+ root.innerHTML = '<div z-key="a">A</div><div z-key="b">B</div>';
1888
+ const aRef = root.querySelector('[z-key="a"]');
1889
+ const bRef = root.querySelector('[z-key="b"]');
1890
+ // Reverse order
1891
+ query(root).html('<div z-key="b">B2</div><div z-key="a">A2</div>');
1892
+ expect(root.querySelector('[z-key="a"]')).toBe(aRef);
1893
+ expect(root.querySelector('[z-key="b"]')).toBe(bRef);
1894
+ expect(aRef.textContent).toBe('A2');
1895
+ expect(bRef.textContent).toBe('B2');
1896
+ });
1897
+
1898
+ it('auto-morph preserves keyed elements with id attribute', () => {
1899
+ root.innerHTML = '<span id="s1">1</span><span id="s2">2</span><span id="s3">3</span>';
1900
+ const refs = { s1: root.querySelector('#s1'), s2: root.querySelector('#s2'), s3: root.querySelector('#s3') };
1901
+ // Rotate: 3,1,2
1902
+ query(root).html('<span id="s3">3</span><span id="s1">1</span><span id="s2">2</span>');
1903
+ expect(root.querySelector('#s1')).toBe(refs.s1);
1904
+ expect(root.querySelector('#s2')).toBe(refs.s2);
1905
+ expect(root.querySelector('#s3')).toBe(refs.s3);
1906
+ });
1907
+
1908
+ it('auto-morph preserves keyed elements with data-id', () => {
1909
+ root.innerHTML = '<div data-id="x">X</div><div data-id="y">Y</div>';
1910
+ const xRef = root.querySelector('[data-id="x"]');
1911
+ // Swap
1912
+ query(root).html('<div data-id="y">Y</div><div data-id="x">X</div>');
1913
+ expect(root.querySelector('[data-id="x"]')).toBe(xRef);
1914
+ });
1915
+
1916
+ it('auto-morph preserves keyed elements with data-key', () => {
1917
+ root.innerHTML = '<li data-key="k1">1</li><li data-key="k2">2</li>';
1918
+ const k1Ref = root.querySelector('[data-key="k1"]');
1919
+ query(root).html('<li data-key="k2">2</li><li data-key="k1">1!</li>');
1920
+ expect(root.querySelector('[data-key="k1"]')).toBe(k1Ref);
1921
+ expect(k1Ref.textContent).toBe('1!');
1922
+ });
1923
+
1924
+ it('auto-morph syncs input value', () => {
1925
+ root.innerHTML = '<input type="text" value="old" />';
1926
+ const input = root.querySelector('input');
1927
+ input.value = 'user-typed';
1928
+ query(root).html('<input type="text" value="synced" />');
1929
+ expect(root.querySelector('input')).toBe(input);
1930
+ expect(input.value).toBe('synced');
1931
+ });
1932
+
1933
+ it('auto-morph syncs checkbox checked state', () => {
1934
+ root.innerHTML = '<input type="checkbox" />';
1935
+ const cb = root.querySelector('input');
1936
+ expect(cb.checked).toBe(false);
1937
+ query(root).html('<input type="checkbox" checked />');
1938
+ expect(root.querySelector('input')).toBe(cb);
1939
+ expect(cb.checked).toBe(true);
1940
+ });
1941
+
1942
+ it('auto-morph syncs textarea value', () => {
1943
+ root.innerHTML = '<textarea>old</textarea>';
1944
+ const ta = root.querySelector('textarea');
1945
+ query(root).html('<textarea>new content</textarea>');
1946
+ expect(root.querySelector('textarea')).toBe(ta);
1947
+ expect(ta.value).toBe('new content');
1948
+ });
1949
+
1950
+ it('auto-morph syncs select value', () => {
1951
+ root.innerHTML = '<select><option value="a">A</option><option value="b">B</option></select>';
1952
+ const sel = root.querySelector('select');
1953
+ sel.value = 'a';
1954
+ query(root).html('<select value="b"><option value="a">A</option><option value="b" selected>B</option></select>');
1955
+ expect(root.querySelector('select')).toBe(sel);
1956
+ });
1957
+
1958
+ it('auto-morph respects z-skip attribute', () => {
1959
+ root.innerHTML = '<div z-skip>DO NOT TOUCH</div><p>morph me</p>';
1960
+ const skipDiv = root.querySelector('[z-skip]');
1961
+ query(root).html('<div z-skip>CHANGED</div><p>morphed</p>');
1962
+ expect(skipDiv.textContent).toBe('DO NOT TOUCH');
1963
+ expect(root.querySelector('p').textContent).toBe('morphed');
1964
+ });
1965
+ });
1966
+
1967
+ // --- .morph() explicit morph -----------------------------------------------
1968
+
1969
+ describe('collection .morph() explicit', () => {
1970
+ it('always uses diff engine even on empty elements', () => {
1971
+ root.innerHTML = '';
1972
+ query(root).morph('<p>explicit</p>');
1973
+ expect(root.querySelector('p').textContent).toBe('explicit');
1974
+ });
1975
+
1976
+ it('preserves node identity for same-tag elements', () => {
1977
+ root.innerHTML = '<div id="m1">old</div>';
1978
+ const ref = root.querySelector('#m1');
1979
+ query(root).morph('<div id="m1">new</div>');
1980
+ expect(root.querySelector('#m1')).toBe(ref);
1981
+ });
1982
+
1983
+ it('is chainable', () => {
1984
+ root.innerHTML = '<span>x</span>';
1985
+ const result = query(root).morph('<span>y</span>').addClass('done');
1986
+ expect(result).toBeInstanceOf(ZQueryCollection);
1987
+ expect(root.classList.contains('done')).toBe(true);
1988
+ });
1989
+
1990
+ it('handles adding new nodes', () => {
1991
+ root.innerHTML = '<p>1</p>';
1992
+ query(root).morph('<p>1</p><p>2</p><p>3</p>');
1993
+ expect(root.querySelectorAll('p').length).toBe(3);
1994
+ });
1995
+
1996
+ it('handles removing nodes', () => {
1997
+ root.innerHTML = '<p>1</p><p>2</p><p>3</p>';
1998
+ query(root).morph('<p>1</p>');
1999
+ expect(root.querySelectorAll('p').length).toBe(1);
2000
+ });
2001
+
2002
+ it('handles complete content replacement', () => {
2003
+ root.innerHTML = '<div>old</div>';
2004
+ query(root).morph('<span>new</span>');
2005
+ expect(root.querySelector('span').textContent).toBe('new');
2006
+ expect(root.querySelector('div')).toBeNull();
2007
+ });
2008
+
2009
+ it('morphs multiple collection elements', () => {
2010
+ root.innerHTML = '<div class="box"><p>old1</p></div><div class="box"><p>old2</p></div>';
2011
+ const boxes = query('.box');
2012
+ boxes.morph('<p>updated</p>');
2013
+ root.querySelectorAll('.box').forEach(box => {
2014
+ expect(box.querySelector('p').textContent).toBe('updated');
2015
+ });
2016
+ });
2017
+ });
2018
+
2019
+ // --- .replaceWith() auto-morph ---------------------------------------------
2020
+
2021
+ describe('collection .replaceWith() + morph', () => {
2022
+ it('auto-morphs when tag name matches (string content)', () => {
2023
+ root.innerHTML = '<p id="rw" class="old">old text</p>';
2024
+ const pRef = root.querySelector('#rw');
2025
+ query(pRef).replaceWith('<p id="rw" class="new">new text</p>');
2026
+ // Same node - morphed, not replaced
2027
+ expect(root.querySelector('#rw')).toBe(pRef);
2028
+ expect(pRef.className).toBe('new');
2029
+ expect(pRef.textContent).toBe('new text');
2030
+ });
2031
+
2032
+ it('replaces when tag differs', () => {
2033
+ root.innerHTML = '<p id="rw2">paragraph</p>';
2034
+ const pRef = root.querySelector('#rw2');
2035
+ query(pRef).replaceWith('<div id="rw2">div now</div>');
2036
+ expect(root.querySelector('#rw2')).not.toBe(pRef);
2037
+ expect(root.querySelector('#rw2').tagName).toBe('DIV');
2038
+ });
2039
+
2040
+ it('replaces with Node content', () => {
2041
+ root.innerHTML = '<span id="rw3">old</span>';
2042
+ const newEl = document.createElement('em');
2043
+ newEl.textContent = 'emphasis';
2044
+ query(root.querySelector('#rw3')).replaceWith(newEl);
2045
+ expect(root.querySelector('em').textContent).toBe('emphasis');
2046
+ expect(root.querySelector('span')).toBeNull();
2047
+ });
2048
+ });
2049
+
2050
+ // --- Keyed morph via $ collection + LIS verification -----------------------
2051
+
2052
+ describe('$ collection + keyed morph (LIS)', () => {
2053
+ it('reverse of keyed list preserves all node identities', () => {
2054
+ root.innerHTML = '<div z-key="1">a</div><div z-key="2">b</div><div z-key="3">c</div><div z-key="4">d</div><div z-key="5">e</div>';
2055
+ const refs = {};
2056
+ for (let i = 1; i <= 5; i++) refs[i] = root.querySelector(`[z-key="${i}"]`);
2057
+ query(root).html('<div z-key="5">e</div><div z-key="4">d</div><div z-key="3">c</div><div z-key="2">b</div><div z-key="1">a</div>');
2058
+ for (let i = 1; i <= 5; i++) {
2059
+ expect(root.querySelector(`[z-key="${i}"]`)).toBe(refs[i]);
2060
+ }
2061
+ });
2062
+
2063
+ it('interleaved insert among keyed nodes', () => {
2064
+ root.innerHTML = '<p z-key="a">A</p><p z-key="c">C</p>';
2065
+ const aRef = root.querySelector('[z-key="a"]');
2066
+ const cRef = root.querySelector('[z-key="c"]');
2067
+ query(root).morph('<p z-key="a">A</p><p z-key="b">B</p><p z-key="c">C</p>');
2068
+ expect(root.querySelector('[z-key="a"]')).toBe(aRef);
2069
+ expect(root.querySelector('[z-key="c"]')).toBe(cRef);
2070
+ expect(root.children.length).toBe(3);
2071
+ expect(root.children[1].textContent).toBe('B');
2072
+ });
2073
+
2074
+ it('keyed removal preserves remaining nodes', () => {
2075
+ root.innerHTML = '<div z-key="x">X</div><div z-key="y">Y</div><div z-key="z">Z</div>';
2076
+ const zRef = root.querySelector('[z-key="z"]');
2077
+ query(root).morph('<div z-key="x">X</div><div z-key="z">Z</div>');
2078
+ expect(root.children.length).toBe(2);
2079
+ expect(root.querySelector('[z-key="z"]')).toBe(zRef);
2080
+ });
2081
+
2082
+ it('mixed keyed + unkeyed nodes', () => {
2083
+ root.innerHTML = '<div z-key="k1">keyed1</div><p>unkeyed</p><div z-key="k2">keyed2</div>';
2084
+ const k1 = root.querySelector('[z-key="k1"]');
2085
+ const k2 = root.querySelector('[z-key="k2"]');
2086
+ query(root).morph('<div z-key="k2">keyed2!</div><p>unkeyed updated</p><div z-key="k1">keyed1!</div>');
2087
+ expect(root.querySelector('[z-key="k1"]')).toBe(k1);
2088
+ expect(root.querySelector('[z-key="k2"]')).toBe(k2);
2089
+ expect(k1.textContent).toBe('keyed1!');
2090
+ expect(k2.textContent).toBe('keyed2!');
2091
+ });
2092
+
2093
+ it('large keyed shuffle preserves all identities', () => {
2094
+ const count = 50;
2095
+ const items = Array.from({ length: count }, (_, i) => `<li z-key="i${i}">${i}</li>`);
2096
+ root.innerHTML = items.join('');
2097
+ const refs = {};
2098
+ for (let i = 0; i < count; i++) refs[i] = root.querySelector(`[z-key="i${i}"]`);
2099
+ // Fisher-Yates shuffle
2100
+ const shuffled = [...items];
2101
+ for (let i = shuffled.length - 1; i > 0; i--) {
2102
+ const j = (i * 7 + 3) % (i + 1); // deterministic pseudo-shuffle
2103
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
2104
+ }
2105
+ query(root).morph(shuffled.join(''));
2106
+ for (let i = 0; i < count; i++) {
2107
+ expect(root.querySelector(`[z-key="i${i}"]`)).toBe(refs[i]);
2108
+ }
2109
+ });
2110
+ });
2111
+
2112
+ // --- $ static helpers with morph -------------------------------------------
2113
+
2114
+ describe('$.morph and $.morphElement static helpers', () => {
2115
+ it('$.morph patches root children', () => {
2116
+ root.innerHTML = '<span>old</span>';
2117
+ const ref = root.querySelector('span');
2118
+ $.morph(root, '<span>new</span>');
2119
+ expect(root.querySelector('span')).toBe(ref);
2120
+ expect(ref.textContent).toBe('new');
2121
+ });
2122
+
2123
+ it('$.morphElement morphs same-tag element in place', () => {
2124
+ root.innerHTML = '<p id="me" class="a">old</p>';
2125
+ const pRef = root.querySelector('#me');
2126
+ const result = $.morphElement(pRef, '<p id="me" class="b">new</p>');
2127
+ expect(result).toBe(pRef);
2128
+ expect(pRef.className).toBe('b');
2129
+ expect(pRef.textContent).toBe('new');
2130
+ });
2131
+
2132
+ it('$.morphElement replaces when tag differs', () => {
2133
+ root.innerHTML = '<p id="me2">para</p>';
2134
+ const pRef = root.querySelector('#me2');
2135
+ const result = $.morphElement(pRef, '<div id="me2">div</div>');
2136
+ expect(result).not.toBe(pRef);
2137
+ expect(result.tagName).toBe('DIV');
2138
+ });
2139
+ });
2140
+
2141
+ // --- $.fn extension + morph -------------------------------------------------
2142
+
2143
+ describe('$.fn prototype extension', () => {
2144
+ it('extending $.fn adds method to all collections', () => {
2145
+ $.fn.testMethod = function() { return 'works'; };
2146
+ const col = query(root);
2147
+ expect(col.testMethod()).toBe('works');
2148
+ delete $.fn.testMethod;
2149
+ });
2150
+
2151
+ it('custom $.fn method can use morph internally', () => {
2152
+ $.fn.updateContent = function(html) {
2153
+ return this.morph(html);
2154
+ };
2155
+ root.innerHTML = '<span>before</span>';
2156
+ const ref = root.querySelector('span');
2157
+ query(root).updateContent('<span>after</span>');
2158
+ expect(root.querySelector('span')).toBe(ref);
2159
+ expect(ref.textContent).toBe('after');
2160
+ delete $.fn.updateContent;
2161
+ });
2162
+ });
2163
+
2164
+ // --- Quick refs with morph -------------------------------------------------
2165
+
2166
+ describe('$ quick refs', () => {
2167
+ it('$.id returns raw element', () => {
2168
+ const el = $.id('zq-root');
2169
+ expect(el).toBe(root);
2170
+ });
2171
+
2172
+ it('$.classes returns ZQueryCollection', () => {
2173
+ root.innerHTML = '<span class="qr">a</span><span class="qr">b</span>';
2174
+ const col = $.classes('qr');
2175
+ expect(col).toBeInstanceOf(ZQueryCollection);
2176
+ expect(col.length).toBe(2);
2177
+ });
2178
+
2179
+ it('$.tag returns ZQueryCollection', () => {
2180
+ root.innerHTML = '<em>1</em><em>2</em>';
2181
+ const col = $.tag('em');
2182
+ expect(col.length).toBeGreaterThanOrEqual(2);
2183
+ });
2184
+
2185
+ it('$.children returns ZQueryCollection of child elements', () => {
2186
+ root.innerHTML = '<p>a</p><p>b</p>';
2187
+ const col = $.children('zq-root');
2188
+ expect(col).toBeInstanceOf(ZQueryCollection);
2189
+ expect(col.length).toBe(2);
2190
+ });
2191
+ });
2192
+
2193
+ // --- Chaining operations after morph ---------------------------------------
2194
+
2195
+ describe('chaining after morph operations', () => {
2196
+ it('morph → find → each', () => {
2197
+ root.innerHTML = '<ul><li>1</li></ul>';
2198
+ query(root).morph('<ul><li class="item">A</li><li class="item">B</li></ul>');
2199
+ const texts = [];
2200
+ query(root).find('.item').each((_, el) => texts.push(el.textContent));
2201
+ expect(texts).toEqual(['A', 'B']);
2202
+ });
2203
+
2204
+ it('morph → addClass → attr', () => {
2205
+ root.innerHTML = '<div>x</div>';
2206
+ const col = query(root).morph('<div>y</div>').addClass('morphed').attr('data-state', 'done');
2207
+ expect(root.classList.contains('morphed')).toBe(true);
2208
+ expect(root.getAttribute('data-state')).toBe('done');
2209
+ expect(col).toBeInstanceOf(ZQueryCollection);
2210
+ });
2211
+
2212
+ it('html → find → text', () => {
2213
+ root.innerHTML = '<span>old</span>';
2214
+ query(root).html('<span id="target">value</span>');
2215
+ expect(query('#target').text()).toBe('value');
2216
+ });
2217
+
2218
+ it('morph → children → filter', () => {
2219
+ root.innerHTML = '<p>x</p>';
2220
+ query(root).morph('<p class="a">1</p><p class="b">2</p><p class="a">3</p>');
2221
+ const filtered = query(root).children('.a');
2222
+ expect(filtered.length).toBe(2);
2223
+ });
2224
+ });
2225
+
2226
+ // --- Edge cases: empty collections -----------------------------------------
2227
+
2228
+ describe('empty collection safety', () => {
2229
+ it('morph on empty collection is no-op', () => {
2230
+ const col = new ZQueryCollection([]);
2231
+ expect(() => col.morph('<p>test</p>')).not.toThrow();
2232
+ });
2233
+
2234
+ it('html(content) on empty collection is no-op', () => {
2235
+ const col = new ZQueryCollection([]);
2236
+ expect(() => col.html('<p>test</p>')).not.toThrow();
2237
+ });
2238
+
2239
+ it('replaceWith on empty collection is no-op', () => {
2240
+ const col = new ZQueryCollection([]);
2241
+ expect(() => col.replaceWith('<p>test</p>')).not.toThrow();
2242
+ });
2243
+
2244
+ it('first() on empty returns null', () => {
2245
+ expect(new ZQueryCollection([]).first()).toBeNull();
2246
+ });
2247
+
2248
+ it('last() on empty returns null', () => {
2249
+ expect(new ZQueryCollection([]).last()).toBeNull();
2250
+ });
2251
+
2252
+ it('eq() on out-of-bounds returns empty collection', () => {
2253
+ const col = new ZQueryCollection([root]);
2254
+ expect(col.eq(99).length).toBe(0);
2255
+ });
2256
+
2257
+ it('html() getter on empty returns undefined', () => {
2258
+ expect(new ZQueryCollection([]).html()).toBeUndefined();
2259
+ });
2260
+
2261
+ it('text() getter on empty returns undefined', () => {
2262
+ expect(new ZQueryCollection([]).text()).toBeUndefined();
2263
+ });
2264
+
2265
+ it('val() getter on empty returns undefined', () => {
2266
+ expect(new ZQueryCollection([]).val()).toBeUndefined();
2267
+ });
2268
+
2269
+ it('attr() getter on empty returns undefined', () => {
2270
+ expect(new ZQueryCollection([]).attr('id')).toBeUndefined();
2271
+ });
2272
+
2273
+ it('hasClass on empty returns false', () => {
2274
+ expect(new ZQueryCollection([]).hasClass('x')).toBe(false);
2275
+ });
2276
+ });
2277
+
2278
+ // --- Attribute morph via collection ----------------------------------------
2279
+
2280
+ describe('attribute morphing through collection', () => {
2281
+ it('morph updates attributes without replacing node', () => {
2282
+ root.innerHTML = '<div id="atm" class="old" data-x="1">text</div>';
2283
+ const ref = root.querySelector('#atm');
2284
+ query(root).morph('<div id="atm" class="new" data-x="2" data-y="3">text</div>');
2285
+ expect(root.querySelector('#atm')).toBe(ref);
2286
+ expect(ref.className).toBe('new');
2287
+ expect(ref.dataset.x).toBe('2');
2288
+ expect(ref.dataset.y).toBe('3');
2289
+ });
2290
+
2291
+ it('morph removes stale attributes', () => {
2292
+ root.innerHTML = '<span id="ra" data-old="yes" title="tip">hi</span>';
2293
+ const ref = root.querySelector('#ra');
2294
+ query(root).morph('<span id="ra">hi</span>');
2295
+ expect(ref.hasAttribute('data-old')).toBe(false);
2296
+ expect(ref.hasAttribute('title')).toBe(false);
2297
+ expect(ref.id).toBe('ra');
2298
+ });
2299
+ });
2300
+
2301
+ // --- Text/comment node morphing via collection -----------------------------
2302
+
2303
+ describe('text and comment node morphing', () => {
2304
+ it('morph updates text nodes', () => {
2305
+ root.innerHTML = 'hello world';
2306
+ query(root).morph('goodbye world');
2307
+ expect(root.textContent).toBe('goodbye world');
2308
+ });
2309
+
2310
+ it('morph handles mixed text and elements', () => {
2311
+ root.innerHTML = '<p>para</p>';
2312
+ query(root).morph('just text');
2313
+ expect(root.textContent).toBe('just text');
2314
+ expect(root.querySelector('p')).toBeNull();
2315
+ });
2316
+
2317
+ it('morph transitions from text to elements', () => {
2318
+ root.textContent = 'plain text';
2319
+ query(root).morph('<p>structured</p>');
2320
+ expect(root.querySelector('p').textContent).toBe('structured');
2321
+ });
2322
+ });
2323
+
2324
+ // --- Event preservation through morph via $ --------------------------------
2325
+
2326
+ describe('event preservation through $ morph', () => {
2327
+ it('event listeners survive morph (same node identity)', () => {
2328
+ root.innerHTML = '<button id="ep">click</button>';
2329
+ const btn = root.querySelector('#ep');
2330
+ let clicked = 0;
2331
+ btn.addEventListener('click', () => { clicked++; });
2332
+ // Morph - button identity preserved
2333
+ query(root).morph('<button id="ep">click!</button>');
2334
+ expect(root.querySelector('#ep')).toBe(btn);
2335
+ btn.click();
2336
+ expect(clicked).toBe(1);
2337
+ });
2338
+
2339
+ it('delegated events via collection survive morph', () => {
2340
+ root.innerHTML = '<div id="del"><span class="target">hit</span></div>';
2341
+ let hits = 0;
2342
+ query('#del').on('click', '.target', () => { hits++; });
2343
+ // Morph inner content
2344
+ query('#del').morph('<span class="target">hit2</span>');
2345
+ root.querySelector('.target').click();
2346
+ expect(hits).toBe(1);
2347
+ });
2348
+ });
2349
+
2350
+ // --- Collection iteration with morphed content -----------------------------
2351
+
2352
+ describe('iteration methods on morphed content', () => {
2353
+ it('forEach works after morph', () => {
2354
+ root.innerHTML = '<span>old</span>';
2355
+ query(root).morph('<span class="it">A</span><span class="it">B</span>');
2356
+ const tags = [];
2357
+ query('.it').forEach(el => tags.push(el.textContent));
2358
+ expect(tags).toEqual(['A', 'B']);
2359
+ });
2360
+
2361
+ it('map works after morph', () => {
2362
+ root.innerHTML = '<p>x</p>';
2363
+ query(root).morph('<p class="mp">1</p><p class="mp">2</p><p class="mp">3</p>');
2364
+ const values = query('.mp').map((_, el) => el.textContent);
2365
+ expect(values).toEqual(['1', '2', '3']);
2366
+ });
2367
+
2368
+ it('Symbol.iterator works on collection', () => {
2369
+ root.innerHTML = '<i>A</i><i>B</i>';
2370
+ const col = query('i');
2371
+ const texts = [];
2372
+ for (const el of col) texts.push(el.textContent);
2373
+ expect(texts).toEqual(['A', 'B']);
2374
+ });
2375
+ });
2376
+ });
2377
+
2378
+ // ===========================================================================
2379
+ // 14. CSP-safe - no eval / new Function anywhere in production source
2380
+ // ===========================================================================
2381
+ describe('CSP-safe (no eval/new Function)', () => {
2382
+ it('safeEval evaluates simple identifiers without eval', () => {
2383
+ const ctx = { foo: 42 };
2384
+ expect(safeEval('foo', [ctx])).toBe(42);
2385
+ });
2386
+
2387
+ it('safeEval evaluates member expressions', () => {
2388
+ const ctx = { user: { name: 'Tony' } };
2389
+ expect(safeEval('user.name', [ctx])).toBe('Tony');
2390
+ });
2391
+
2392
+ it('safeEval evaluates arithmetic expressions', () => {
2393
+ const ctx = { a: 10, b: 3 };
2394
+ expect(safeEval('a + b', [ctx])).toBe(13);
2395
+ expect(safeEval('a * b', [ctx])).toBe(30);
2396
+ expect(safeEval('a - b', [ctx])).toBe(7);
2397
+ });
2398
+
2399
+ it('safeEval evaluates comparison expressions', () => {
2400
+ const ctx = { x: 5 };
2401
+ expect(safeEval('x > 3', [ctx])).toBe(true);
2402
+ expect(safeEval('x === 5', [ctx])).toBe(true);
2403
+ expect(safeEval('x < 2', [ctx])).toBe(false);
2404
+ });
2405
+
2406
+ it('safeEval evaluates ternary expressions', () => {
2407
+ const ctx = { flag: true };
2408
+ expect(safeEval('flag ? "yes" : "no"', [ctx])).toBe('yes');
2409
+ });
2410
+
2411
+ it('safeEval evaluates logical operators', () => {
2412
+ const ctx = { a: true, b: false };
2413
+ expect(safeEval('a && b', [ctx])).toBe(false);
2414
+ expect(safeEval('a || b', [ctx])).toBe(true);
2415
+ expect(safeEval('!b', [ctx])).toBe(true);
2416
+ });
2417
+
2418
+ it('safeEval evaluates array literals', () => {
2419
+ const ctx = { a: 1, b: 2 };
2420
+ expect(safeEval('[a, b]', [ctx])).toEqual([1, 2]);
2421
+ });
2422
+
2423
+ it('safeEval evaluates object literals', () => {
2424
+ const ctx = { x: 10 };
2425
+ expect(safeEval('{ val: x }', [ctx])).toEqual({ val: 10 });
2426
+ });
2427
+
2428
+ it('safeEval evaluates method calls on objects', () => {
2429
+ const ctx = { items: [3, 1, 2] };
2430
+ expect(safeEval('items.includes(2)', [ctx])).toBe(true);
2431
+ expect(safeEval('items.length', [ctx])).toBe(3);
2432
+ });
2433
+
2434
+ it('safeEval evaluates string methods', () => {
2435
+ const ctx = { name: ' hello ' };
2436
+ expect(safeEval('name.trim()', [ctx])).toBe('hello');
2437
+ expect(safeEval('name.trim().toUpperCase()', [ctx])).toBe('HELLO');
2438
+ });
2439
+
2440
+ it('safeEval returns undefined for invalid expressions (graceful fallback)', () => {
2441
+ expect(safeEval('???', [])).toBeUndefined();
2442
+ expect(safeEval('', [])).toBeUndefined();
2443
+ });
2444
+
2445
+ it('safeEval handles template literals', () => {
2446
+ const ctx = { name: 'world' };
2447
+ expect(safeEval('`hello ${name}`', [ctx])).toBe('hello world');
2448
+ });
2449
+
2450
+ it('safeEval evaluates nullish coalescing', () => {
2451
+ const ctx = { val: null, fallback: 42 };
2452
+ expect(safeEval('val ?? fallback', [ctx])).toBe(42);
2453
+ });
2454
+
2455
+ it('safeEval evaluates optional chaining', () => {
2456
+ const ctx = { user: null };
2457
+ expect(safeEval('user?.name', [ctx])).toBeUndefined();
2458
+ const ctx2 = { user: { name: 'Tony' } };
2459
+ expect(safeEval('user?.name', [ctx2])).toBe('Tony');
2460
+ });
2461
+
2462
+ it('safeEval caches parsed ASTs for performance', () => {
2463
+ const ctx = { x: 1 };
2464
+ // Same expression evaluated multiple times should reuse cached AST
2465
+ for (let i = 0; i < 100; i++) {
2466
+ expect(safeEval('x + 1', [ctx])).toBe(2);
2467
+ }
2468
+ });
2469
+
2470
+ it('safeEval handles spread in array literals', () => {
2471
+ const ctx = { arr: [1, 2], extra: 3 };
2472
+ expect(safeEval('[...arr, extra]', [ctx])).toEqual([1, 2, 3]);
2473
+ });
2474
+
2475
+ it('safeEval handles spread combining multiple arrays', () => {
2476
+ const ctx = { a: [1, 2], b: [3, 4] };
2477
+ expect(safeEval('[...a, ...b]', [ctx])).toEqual([1, 2, 3, 4]);
2478
+ });
2479
+
2480
+ it('safeEval handles spread in object literals', () => {
2481
+ const ctx = { base: { x: 1, y: 2 }, extra: 3 };
2482
+ expect(safeEval('{ ...base, z: extra }', [ctx])).toEqual({ x: 1, y: 2, z: 3 });
2483
+ });
2484
+
2485
+ it('safeEval handles spread merging multiple objects', () => {
2486
+ const ctx = { a: { x: 1 }, b: { y: 2, x: 3 } };
2487
+ expect(safeEval('{ ...a, ...b }', [ctx])).toEqual({ x: 3, y: 2 });
2488
+ });
2489
+
2490
+ it('safeEval handles spread in function call arguments', () => {
2491
+ const ctx = { args: [1, 2, 3], Math };
2492
+ expect(safeEval('Math.max(...args)', [ctx])).toBe(3);
2493
+ });
2494
+
2495
+ it('safeEval handles spread with empty arrays', () => {
2496
+ const ctx = { empty: [], items: [1] };
2497
+ expect(safeEval('[...empty, ...items]', [ctx])).toEqual([1]);
2498
+ });
2499
+
2500
+ it('safeEval handles spread of null/undefined gracefully', () => {
2501
+ const ctx = { val: null };
2502
+ // Spreading null should be handled gracefully (skipped)
2503
+ expect(safeEval('[...val]', [ctx])).toEqual([]);
2504
+ });
2505
+
2506
+ it('safeEval evaluates arrow functions in context like filter/map', () => {
2507
+ const ctx = { items: [1, 2, 3, 4] };
2508
+ expect(safeEval('items.filter(x => x > 2)', [ctx])).toEqual([3, 4]);
2509
+ expect(safeEval('items.map(x => x * 2)', [ctx])).toEqual([2, 4, 6, 8]);
2510
+ });
2511
+ });
2512
+
2513
+
2514
+ // ===========================================================================
2515
+ // 15. Event Bus (pub/sub)
2516
+ // ===========================================================================
2517
+ describe('EventBus', () => {
2518
+ let testBus;
2519
+
2520
+ beforeEach(() => {
2521
+ testBus = new EventBus();
2522
+ });
2523
+
2524
+ it('on/emit delivers messages to subscribers', () => {
2525
+ const received = [];
2526
+ testBus.on('msg', (data) => received.push(data));
2527
+ testBus.emit('msg', 'hello');
2528
+ testBus.emit('msg', 'world');
2529
+ expect(received).toEqual(['hello', 'world']);
2530
+ });
2531
+
2532
+ it('on returns an unsubscribe function', () => {
2533
+ const received = [];
2534
+ const unsub = testBus.on('ev', (d) => received.push(d));
2535
+ testBus.emit('ev', 1);
2536
+ unsub();
2537
+ testBus.emit('ev', 2);
2538
+ expect(received).toEqual([1]);
2539
+ });
2540
+
2541
+ it('off removes a specific handler', () => {
2542
+ const received = [];
2543
+ const fn = (d) => received.push(d);
2544
+ testBus.on('ev', fn);
2545
+ testBus.emit('ev', 'a');
2546
+ testBus.off('ev', fn);
2547
+ testBus.emit('ev', 'b');
2548
+ expect(received).toEqual(['a']);
2549
+ });
2550
+
2551
+ it('once fires handler only once then auto-removes', () => {
2552
+ const received = [];
2553
+ testBus.once('ev', (d) => received.push(d));
2554
+ testBus.emit('ev', 'first');
2555
+ testBus.emit('ev', 'second');
2556
+ expect(received).toEqual(['first']);
2557
+ });
2558
+
2559
+ it('once returns an unsubscribe function', () => {
2560
+ const received = [];
2561
+ const unsub = testBus.once('ev', (d) => received.push(d));
2562
+ unsub(); // unsubscribe before any emit
2563
+ testBus.emit('ev', 'data');
2564
+ expect(received).toEqual([]);
2565
+ });
2566
+
2567
+ it('clear removes all handlers for all events', () => {
2568
+ const received = [];
2569
+ testBus.on('a', () => received.push('a'));
2570
+ testBus.on('b', () => received.push('b'));
2571
+ testBus.clear();
2572
+ testBus.emit('a');
2573
+ testBus.emit('b');
2574
+ expect(received).toEqual([]);
2575
+ });
2576
+
2577
+ it('multiple subscribers on same event all receive messages', () => {
2578
+ const r1 = [], r2 = [];
2579
+ testBus.on('ev', (d) => r1.push(d));
2580
+ testBus.on('ev', (d) => r2.push(d));
2581
+ testBus.emit('ev', 'data');
2582
+ expect(r1).toEqual(['data']);
2583
+ expect(r2).toEqual(['data']);
2584
+ });
2585
+
2586
+ it('emit with no subscribers does not throw', () => {
2587
+ expect(() => testBus.emit('nonexistent', 'data')).not.toThrow();
2588
+ });
2589
+
2590
+ it('emit passes multiple arguments', () => {
2591
+ let args;
2592
+ testBus.on('ev', (...a) => { args = a; });
2593
+ testBus.emit('ev', 1, 2, 3);
2594
+ expect(args).toEqual([1, 2, 3]);
2595
+ });
2596
+
2597
+ it('off on nonexistent event does not throw', () => {
2598
+ expect(() => testBus.off('nope', () => {})).not.toThrow();
2599
+ });
2600
+
2601
+ it('same function registered twice is deduplicated (Set)', () => {
2602
+ const received = [];
2603
+ const fn = (d) => received.push(d);
2604
+ testBus.on('ev', fn);
2605
+ testBus.on('ev', fn); // same reference, Set deduplicates
2606
+ testBus.emit('ev', 'x');
2607
+ expect(received).toEqual(['x']); // only once
2608
+ });
2609
+
2610
+ it('on during emit does not affect current emit round (Set.forEach)', () => {
2611
+ // Set.forEach visits elements added during iteration; verify behavior
2612
+ const received = [];
2613
+ testBus.on('ev', () => {
2614
+ received.push('first');
2615
+ testBus.on('ev', () => received.push('dynamic'));
2616
+ });
2617
+ testBus.emit('ev');
2618
+ // Set.forEach WILL call the newly added handler in the same iteration
2619
+ // This is a characteristic of Set behavior, not a bug
2620
+ expect(received).toContain('first');
2621
+ });
2622
+
2623
+ it('handler that throws during emit does not prevent other handlers', () => {
2624
+ // Since Set.forEach is used, an exception in one handler WILL prevent subsequent handlers.
2625
+ // This tests the CURRENT behavior - if the framework wants error isolation,
2626
+ // it would need try/catch inside the forEach.
2627
+ const received = [];
2628
+ testBus.on('ev', () => { throw new Error('boom'); });
2629
+ testBus.on('ev', () => received.push('second'));
2630
+ // Currently, the second handler may not execute due to unprotected forEach.
2631
+ // This documents the behavior - if this test fails after a framework fix, that's good.
2632
+ expect(() => testBus.emit('ev')).toThrow('boom');
2633
+ });
2634
+
2635
+ it('emit with zero arguments works', () => {
2636
+ let called = false;
2637
+ testBus.on('ev', () => { called = true; });
2638
+ testBus.emit('ev');
2639
+ expect(called).toBe(true);
2640
+ });
2641
+
2642
+ it('independent events do not interfere', () => {
2643
+ const aReceived = [], bReceived = [];
2644
+ testBus.on('a', (d) => aReceived.push(d));
2645
+ testBus.on('b', (d) => bReceived.push(d));
2646
+ testBus.emit('a', 1);
2647
+ testBus.emit('b', 2);
2648
+ expect(aReceived).toEqual([1]);
2649
+ expect(bReceived).toEqual([2]);
2650
+ });
2651
+
2652
+ it('global bus is a pre-constructed EventBus instance', () => {
2653
+ expect(bus).toBeDefined();
2654
+ expect(typeof bus.on).toBe('function');
2655
+ expect(typeof bus.off).toBe('function');
2656
+ expect(typeof bus.emit).toBe('function');
2657
+ expect(typeof bus.once).toBe('function');
2658
+ expect(typeof bus.clear).toBe('function');
2659
+ });
2660
+ });
2661
+
2662
+
2663
+ // ===========================================================================
2664
+ // 16. DOM Reconciliation / Morph
2665
+ // ===========================================================================
2666
+ describe('DOM Reconciliation (morph)', () => {
2667
+ let container;
2668
+
2669
+ beforeEach(() => {
2670
+ container = document.createElement('div');
2671
+ document.body.appendChild(container);
2672
+ });
2673
+
2674
+ afterEach(() => {
2675
+ container.remove();
2676
+ });
2677
+
2678
+ it('morph preserves existing DOM nodes when only text changes', () => {
2679
+ container.innerHTML = '<p>old</p>';
2680
+ const pBefore = container.querySelector('p');
2681
+ morph(container, '<p>new</p>');
2682
+ const pAfter = container.querySelector('p');
2683
+ expect(pAfter).toBe(pBefore); // same DOM node preserved
2684
+ expect(pAfter.textContent).toBe('new');
2685
+ });
2686
+
2687
+ it('morph preserves existing DOM nodes when only attributes change', () => {
2688
+ container.innerHTML = '<div class="a"></div>';
2689
+ const divBefore = container.querySelector('div');
2690
+ morph(container, '<div class="b"></div>');
2691
+ expect(container.querySelector('div')).toBe(divBefore);
2692
+ expect(divBefore.className).toBe('b');
2693
+ });
2694
+
2695
+ it('morph adds new elements', () => {
2696
+ container.innerHTML = '<p>1</p>';
2697
+ morph(container, '<p>1</p><p>2</p>');
2698
+ expect(container.children.length).toBe(2);
2699
+ expect(container.children[1].textContent).toBe('2');
2700
+ });
2701
+
2702
+ it('morph removes excess elements', () => {
2703
+ container.innerHTML = '<p>1</p><p>2</p><p>3</p>';
2704
+ morph(container, '<p>1</p>');
2705
+ expect(container.children.length).toBe(1);
2706
+ });
2707
+
2708
+ it('morph handles keyed reordering', () => {
2709
+ container.innerHTML = '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>';
2710
+ const [a, b, c] = [...container.children];
2711
+ morph(container, '<div z-key="c">C</div><div z-key="a">A</div><div z-key="b">B</div>');
2712
+ expect(container.children[0]).toBe(c);
2713
+ expect(container.children[1]).toBe(a);
2714
+ expect(container.children[2]).toBe(b);
2715
+ });
2716
+
2717
+ it('morph preserves input value (focus preservation)', () => {
2718
+ container.innerHTML = '<input type="text" value="hello">';
2719
+ const input = container.querySelector('input');
2720
+ input.value = 'user typed';
2721
+ morph(container, '<input type="text" value="hello">');
2722
+ // morph syncs value from new node, but the input element should be preserved
2723
+ expect(container.querySelector('input')).toBe(input);
2724
+ });
2725
+
2726
+ it('morph handles different node types (replaces entirely)', () => {
2727
+ container.innerHTML = '<p>paragraph</p>';
2728
+ morph(container, '<div>div now</div>');
2729
+ expect(container.children[0].tagName).toBe('DIV');
2730
+ expect(container.children[0].textContent).toBe('div now');
2731
+ });
2732
+
2733
+ it('morph handles empty old and new', () => {
2734
+ container.innerHTML = '';
2735
+ morph(container, '');
2736
+ expect(container.innerHTML).toBe('');
2737
+ });
2738
+
2739
+ it('morph handles old empty, new has content', () => {
2740
+ container.innerHTML = '';
2741
+ morph(container, '<p>added</p>');
2742
+ expect(container.children.length).toBe(1);
2743
+ expect(container.children[0].textContent).toBe('added');
2744
+ });
2745
+
2746
+ it('morph handles old has content, new empty', () => {
2747
+ container.innerHTML = '<p>will remove</p>';
2748
+ morph(container, '');
2749
+ expect(container.children.length).toBe(0);
2750
+ });
2751
+
2752
+ it('morph skips z-skip subtrees', () => {
2753
+ container.innerHTML = '<div z-skip>DO NOT TOUCH</div>';
2754
+ morph(container, '<div z-skip>REPLACED</div>');
2755
+ expect(container.children[0].textContent).toBe('DO NOT TOUCH');
2756
+ });
2757
+
2758
+ it('morph handles text node changes', () => {
2759
+ container.innerHTML = 'plain text';
2760
+ morph(container, 'updated text');
2761
+ expect(container.textContent).toBe('updated text');
2762
+ });
2763
+
2764
+ it('morph handles nested structure changes', () => {
2765
+ container.innerHTML = '<div><span>A</span><span>B</span></div>';
2766
+ morph(container, '<div><span>A</span><span>B</span><span>C</span></div>');
2767
+ expect(container.querySelector('div').children.length).toBe(3);
2768
+ });
2769
+
2770
+ it('morph handles attribute removal', () => {
2771
+ container.innerHTML = '<div class="x" data-val="y"></div>';
2772
+ morph(container, '<div></div>');
2773
+ const d = container.querySelector('div');
2774
+ expect(d.hasAttribute('class')).toBe(false);
2775
+ expect(d.hasAttribute('data-val')).toBe(false);
2776
+ });
2777
+
2778
+ it('morph handles attribute addition', () => {
2779
+ container.innerHTML = '<div></div>';
2780
+ morph(container, '<div class="added" data-info="test"></div>');
2781
+ const d = container.querySelector('div');
2782
+ expect(d.className).toBe('added');
2783
+ expect(d.getAttribute('data-info')).toBe('test');
2784
+ });
2785
+
2786
+ it('isEqualNode fast bail-out: identical subtrees skip processing', () => {
2787
+ const html = '<div><span class="a">text</span><span class="b">more</span></div>';
2788
+ container.innerHTML = html;
2789
+ const spanBefore = container.querySelector('.a');
2790
+ morph(container, html);
2791
+ // Same node preserved (the tree was identical, isEqualNode returned true)
2792
+ expect(container.querySelector('.a')).toBe(spanBefore);
2793
+ });
2794
+
2795
+ it('isEqualNode: changed subtrees are still properly morphed', () => {
2796
+ container.innerHTML = '<div><span class="a">old</span></div>';
2797
+ morph(container, '<div><span class="a">new</span></div>');
2798
+ expect(container.querySelector('.a').textContent).toBe('new');
2799
+ });
2800
+
2801
+ it('morph preserves select element value', () => {
2802
+ container.innerHTML = '<select><option value="a">A</option><option value="b" selected>B</option></select>';
2803
+ const sel = container.querySelector('select');
2804
+ expect(sel.value).toBe('b');
2805
+ morph(container, '<select><option value="a">A</option><option value="b" selected>B</option></select>');
2806
+ expect(container.querySelector('select')).toBe(sel);
2807
+ });
2808
+
2809
+ it('morph handles textarea value sync', () => {
2810
+ container.innerHTML = '<textarea>old content</textarea>';
2811
+ const ta = container.querySelector('textarea');
2812
+ morph(container, '<textarea>new content</textarea>');
2813
+ expect(container.querySelector('textarea')).toBe(ta);
2814
+ expect(ta.value).toBe('new content');
2815
+ });
2816
+
2817
+ it('morphElement works between two standalone elements', () => {
2818
+ const el = document.createElement('div');
2819
+ el.innerHTML = '<span>old</span>';
2820
+ container.appendChild(el);
2821
+ morphElement(el, '<div><span>new</span></div>');
2822
+ expect(el.querySelector('span').textContent).toBe('new');
2823
+ });
2824
+ });
2825
+
2826
+
2827
+ // ===========================================================================
2828
+ // 17. State Management (Store)
2829
+ // ===========================================================================
2830
+ describe('Store', () => {
2831
+ it('createStore creates a store with reactive state', () => {
2832
+ const store = createStore('test-basic', {
2833
+ state: { count: 0, name: 'zQuery' }
2834
+ });
2835
+ expect(store.state.count).toBe(0);
2836
+ expect(store.state.name).toBe('zQuery');
2837
+ });
2838
+
2839
+ it('dispatch mutates state via named actions', () => {
2840
+ const store = createStore('test-dispatch', {
2841
+ state: { count: 0 },
2842
+ actions: {
2843
+ increment(state) { state.count++; },
2844
+ add(state, amount) { state.count += amount; }
2845
+ }
2846
+ });
2847
+ store.dispatch('increment');
2848
+ expect(store.state.count).toBe(1);
2849
+ store.dispatch('add', 5);
2850
+ expect(store.state.count).toBe(6);
2851
+ });
2852
+
2853
+ it('dispatch with unknown action reports error (does not throw)', () => {
2854
+ const store = createStore('test-unknown-action', { state: {} });
2855
+ expect(() => store.dispatch('nonexistent')).not.toThrow();
2856
+ });
2857
+
2858
+ it('subscribe to key-specific changes', () => {
2859
+ const store = createStore('test-sub', {
2860
+ state: { count: 0 },
2861
+ actions: { inc(state) { state.count++; } }
2862
+ });
2863
+ const received = [];
2864
+ store.subscribe('count', (val, old) => received.push({ val, old }));
2865
+ store.dispatch('inc');
2866
+ store.dispatch('inc');
2867
+ expect(received).toEqual([
2868
+ { val: 1, old: 0 },
2869
+ { val: 2, old: 1 }
2870
+ ]);
2871
+ });
2872
+
2873
+ it('subscribe returns unsubscribe function', () => {
2874
+ const store = createStore('test-unsub', {
2875
+ state: { x: 0 },
2876
+ actions: { bump(state) { state.x++; } }
2877
+ });
2878
+ const received = [];
2879
+ const unsub = store.subscribe('x', (val) => received.push(val));
2880
+ store.dispatch('bump');
2881
+ unsub();
2882
+ store.dispatch('bump');
2883
+ expect(received).toEqual([1]);
2884
+ });
2885
+
2886
+ it('wildcard subscribe receives all changes', () => {
2887
+ const store = createStore('test-wildcard', {
2888
+ state: { a: 0, b: 0 },
2889
+ actions: {
2890
+ setA(state, val) { state.a = val; },
2891
+ setB(state, val) { state.b = val; }
2892
+ }
2893
+ });
2894
+ const received = [];
2895
+ store.subscribe((key, val, old) => received.push({ key, val, old }));
2896
+ store.dispatch('setA', 10);
2897
+ store.dispatch('setB', 20);
2898
+ expect(received).toEqual([
2899
+ { key: 'a', val: 10, old: 0 },
2900
+ { key: 'b', val: 20, old: 0 }
2901
+ ]);
2902
+ });
2903
+
2904
+ it('getters compute derived state', () => {
2905
+ const store = createStore('test-getters', {
2906
+ state: { count: 5 },
2907
+ getters: {
2908
+ doubled: (state) => state.count * 2,
2909
+ isPositive: (state) => state.count > 0
2910
+ }
2911
+ });
2912
+ expect(store.getters.doubled).toBe(10);
2913
+ expect(store.getters.isPositive).toBe(true);
2914
+ });
2915
+
2916
+ it('getters recompute when state changes', () => {
2917
+ const store = createStore('test-getters-reactive', {
2918
+ state: { count: 1 },
2919
+ actions: { inc(state) { state.count++; } },
2920
+ getters: { doubled: (state) => state.count * 2 }
2921
+ });
2922
+ expect(store.getters.doubled).toBe(2);
2923
+ store.dispatch('inc');
2924
+ expect(store.getters.doubled).toBe(4);
2925
+ });
2926
+
2927
+ it('middleware can block actions by returning false', () => {
2928
+ const store = createStore('test-mw-block', {
2929
+ state: { count: 0 },
2930
+ actions: { inc(state) { state.count++; } }
2931
+ });
2932
+ store.use((name) => name === 'inc' ? false : undefined);
2933
+ store.dispatch('inc');
2934
+ expect(store.state.count).toBe(0); // blocked
2935
+ });
2936
+
2937
+ it('middleware that throws blocks the action', () => {
2938
+ const store = createStore('test-mw-throw', {
2939
+ state: { count: 0 },
2940
+ actions: { inc(state) { state.count++; } }
2941
+ });
2942
+ store.use(() => { throw new Error('middleware error'); });
2943
+ store.dispatch('inc');
2944
+ expect(store.state.count).toBe(0); // blocked by throw
2945
+ });
2946
+
2947
+ it('middleware receives action name, args, and state', () => {
2948
+ const store = createStore('test-mw-args', {
2949
+ state: { x: 0 },
2950
+ actions: { setX(state, val) { state.x = val; } }
2951
+ });
2952
+ let mwArgs;
2953
+ store.use((name, args, state) => { mwArgs = { name, args, x: state.x }; });
2954
+ store.dispatch('setX', 42);
2955
+ expect(mwArgs.name).toBe('setX');
2956
+ expect(mwArgs.args).toEqual([42]);
2957
+ expect(mwArgs.x).toBe(0); // state before action ran
2958
+ });
2959
+
2960
+ it('use() returns the store for chaining', () => {
2961
+ const store = createStore('test-chain', { state: {} });
2962
+ const result = store.use(() => {});
2963
+ expect(result).toBe(store);
2964
+ });
2965
+
2966
+ it('history tracks dispatched actions', () => {
2967
+ const store = createStore('test-history', {
2968
+ state: { x: 0 },
2969
+ actions: { inc(state) { state.x++; } }
2970
+ });
2971
+ store.dispatch('inc');
2972
+ store.dispatch('inc');
2973
+ const h = store.history;
2974
+ expect(h.length).toBe(2);
2975
+ expect(h[0].action).toBe('inc');
2976
+ expect(h[1].action).toBe('inc');
2977
+ expect(typeof h[0].timestamp).toBe('number');
2978
+ });
2979
+
2980
+ it('history is immutable (returns copy)', () => {
2981
+ const store = createStore('test-history-immut', {
2982
+ state: { x: 0 },
2983
+ actions: { inc(state) { state.x++; } }
2984
+ });
2985
+ store.dispatch('inc');
2986
+ const h1 = store.history;
2987
+ h1.push({ action: 'fake' });
2988
+ expect(store.history.length).toBe(1); // original not mutated
2989
+ });
2990
+
2991
+ it('history caps at maxHistory', () => {
2992
+ const store = createStore('test-maxhist', {
2993
+ state: { x: 0 },
2994
+ maxHistory: 3,
2995
+ actions: { inc(state) { state.x++; } }
2996
+ });
2997
+ for (let i = 0; i < 10; i++) store.dispatch('inc');
2998
+ expect(store.history.length).toBe(3);
2999
+ });
3000
+
3001
+ it('snapshot returns a plain object copy of state', () => {
3002
+ const store = createStore('test-snapshot', {
3003
+ state: { count: 5, nested: { a: 1 } }
3004
+ });
3005
+ const snap = store.snapshot();
3006
+ expect(snap).toEqual({ count: 5, nested: { a: 1 } });
3007
+ // Mutation of snapshot does not affect store
3008
+ snap.count = 999;
3009
+ expect(store.state.count).toBe(5);
3010
+ });
3011
+
3012
+ it('replaceState replaces all state', () => {
3013
+ const store = createStore('test-replace', {
3014
+ state: { a: 1, b: 2 }
3015
+ });
3016
+ store.replaceState({ c: 3, d: 4 });
3017
+ expect(store.state.c).toBe(3);
3018
+ expect(store.state.d).toBe(4);
3019
+ expect(store.state.a).toBeUndefined();
3020
+ expect(store.state.b).toBeUndefined();
3021
+ });
3022
+
3023
+ it('reset restores state and clears history', () => {
3024
+ const store = createStore('test-reset', {
3025
+ state: { count: 0 },
3026
+ actions: { inc(state) { state.count++; } }
3027
+ });
3028
+ store.dispatch('inc');
3029
+ store.dispatch('inc');
3030
+ expect(store.state.count).toBe(2);
3031
+ expect(store.history.length).toBe(2);
3032
+ store.reset({ count: 0 });
3033
+ expect(store.state.count).toBe(0);
3034
+ expect(store.history.length).toBe(0);
3035
+ });
3036
+
3037
+ it('state as a function (factory pattern)', () => {
3038
+ const store = createStore('test-factory', {
3039
+ state: () => ({ count: 0 })
3040
+ });
3041
+ expect(store.state.count).toBe(0);
3042
+ });
3043
+
3044
+ it('getStore retrieves store by name', () => {
3045
+ const store = createStore('findme', { state: { x: 42 } });
3046
+ expect(getStore('findme')).toBe(store);
3047
+ });
3048
+
3049
+ it('getStore returns null for non-existent store', () => {
3050
+ expect(getStore('does-not-exist')).toBeNull();
3051
+ });
3052
+
3053
+ it('createStore with just config (no name) uses default', () => {
3054
+ const store = createStore({ state: { x: 1 } });
3055
+ expect(getStore('default')).toBe(store);
3056
+ });
3057
+
3058
+ it('subscriber that throws does not break other subscribers', () => {
3059
+ const store = createStore('test-sub-throw', {
3060
+ state: { x: 0 },
3061
+ actions: { bump(state) { state.x++; } }
3062
+ });
3063
+ const received = [];
3064
+ store.subscribe('x', () => { throw new Error('sub error'); });
3065
+ store.subscribe('x', (val) => received.push(val));
3066
+ store.dispatch('bump');
3067
+ // The second subscriber still gets called because reportError is used (not re-throw)
3068
+ expect(received).toEqual([1]);
3069
+ });
3070
+
3071
+ it('action return value is returned by dispatch', () => {
3072
+ const store = createStore('test-return', {
3073
+ state: { x: 0 },
3074
+ actions: { compute(state) { return state.x + 100; } }
3075
+ });
3076
+ expect(store.dispatch('compute')).toBe(100);
3077
+ });
3078
+
3079
+ it('multiple middleware run in order', () => {
3080
+ const store = createStore('test-mw-order', {
3081
+ state: { x: 0 },
3082
+ actions: { inc(state) { state.x++; } }
3083
+ });
3084
+ const order = [];
3085
+ store.use(() => { order.push(1); });
3086
+ store.use(() => { order.push(2); });
3087
+ store.dispatch('inc');
3088
+ expect(order).toEqual([1, 2]);
3089
+ });
3090
+
3091
+ it('direct state mutation triggers subscribers', () => {
3092
+ const store = createStore('test-direct', {
3093
+ state: { x: 0 }
3094
+ });
3095
+ const received = [];
3096
+ store.subscribe('x', (val) => received.push(val));
3097
+ store.state.x = 99;
3098
+ expect(received).toEqual([99]);
3099
+ });
3100
+ });
3101
+
3102
+
3103
+ // ===========================================================================
3104
+ // 18. Scoped Styles
3105
+ // ===========================================================================
3106
+ describe('Scoped Styles', () => {
3107
+ afterEach(() => {
3108
+ document.querySelectorAll('style[data-zq-scope]').forEach(s => s.remove());
3109
+ document.querySelectorAll('[class^="scope-test"]').forEach(el => el.remove());
3110
+ });
3111
+
3112
+ it('component with styles creates scoped style element in head', () => {
3113
+ const name = 'scope-test-' + Math.random().toString(36).slice(2, 8);
3114
+ component(name, {
3115
+ styles: '.item { color: red; }',
3116
+ render: () => '<div class="item">test</div>'
3117
+ });
3118
+ const el = document.createElement(name);
3119
+ document.body.appendChild(el);
3120
+ mount(el, name);
3121
+
3122
+ const styleEl = document.querySelector(`style[data-zq-component="${name}"]`);
3123
+ expect(styleEl).toBeTruthy();
3124
+ // Should have scope attribute in selector
3125
+ expect(styleEl.textContent).toContain('[z-s');
3126
+ expect(styleEl.textContent).toContain('.item');
3127
+ el.remove();
3128
+ });
3129
+
3130
+ it('scope attribute is set on the component root element', () => {
3131
+ const name = 'scope-test-attr-' + Math.random().toString(36).slice(2, 8);
3132
+ component(name, {
3133
+ styles: '.inner { color: blue; }',
3134
+ render: () => '<span class="inner">hi</span>'
3135
+ });
3136
+ const el = document.createElement(name);
3137
+ document.body.appendChild(el);
3138
+ mount(el, name);
3139
+
3140
+ const attrs = [...el.attributes].filter(a => a.name.startsWith('z-s'));
3141
+ expect(attrs.length).toBe(1);
3142
+ el.remove();
3143
+ });
3144
+
3145
+ it('scoped styles rewrite selectors with scope attribute', () => {
3146
+ const name = 'scope-test-rewrite-' + Math.random().toString(36).slice(2, 8);
3147
+ component(name, {
3148
+ styles: '.box { padding: 10px; } .box .inner { margin: 5px; }',
3149
+ render: () => '<div class="box"><div class="inner">x</div></div>'
3150
+ });
3151
+ const el = document.createElement(name);
3152
+ document.body.appendChild(el);
3153
+ mount(el, name);
3154
+
3155
+ const styleEl = document.querySelector(`style[data-zq-component="${name}"]`);
3156
+ const text = styleEl.textContent;
3157
+ // Each selector should be prefixed with [z-sN]
3158
+ expect(text).toMatch(/\[z-s\d+\]\s+\.box\s*\{/);
3159
+ expect(text).toMatch(/\[z-s\d+\]\s+\.box \.inner\s*\{/);
3160
+ el.remove();
3161
+ });
3162
+
3163
+ it('scoped styles handle comma-separated selectors', () => {
3164
+ const name = 'scope-test-comma-' + Math.random().toString(36).slice(2, 8);
3165
+ component(name, {
3166
+ styles: '.a, .b { color: red; }',
3167
+ render: () => '<div class="a">A</div><div class="b">B</div>'
3168
+ });
3169
+ const el = document.createElement(name);
3170
+ document.body.appendChild(el);
3171
+ mount(el, name);
3172
+
3173
+ const styleEl = document.querySelector(`style[data-zq-component="${name}"]`);
3174
+ const text = styleEl.textContent;
3175
+ // Both selectors should be independently prefixed
3176
+ expect(text).toMatch(/\[z-s\d+\]\s+\.a/);
3177
+ expect(text).toMatch(/\[z-s\d+\]\s+\.b/);
3178
+ el.remove();
3179
+ });
3180
+
3181
+ it('@keyframes content is NOT scoped', () => {
3182
+ const name = 'scope-test-kf-' + Math.random().toString(36).slice(2, 8);
3183
+ component(name, {
3184
+ styles: '@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .animated { animation: fadeIn 1s; }',
3185
+ render: () => '<div class="animated">x</div>'
3186
+ });
3187
+ const el = document.createElement(name);
3188
+ document.body.appendChild(el);
3189
+ mount(el, name);
3190
+
3191
+ const styleEl = document.querySelector(`style[data-zq-component="${name}"]`);
3192
+ const text = styleEl.textContent;
3193
+ // The @keyframes rule itself should not be scoped
3194
+ expect(text).toContain('@keyframes fadeIn');
3195
+ // "from" and "to" inside @keyframes should NOT have [z-s] prefix
3196
+ expect(text).not.toMatch(/\[z-s\d+\]\s+from/);
3197
+ expect(text).not.toMatch(/\[z-s\d+\]\s+to/);
3198
+ // But .animated SHOULD be scoped
3199
+ expect(text).toMatch(/\[z-s\d+\]\s+\.animated/);
3200
+ el.remove();
3201
+ });
3202
+
3203
+ it('@font-face content is NOT scoped', () => {
3204
+ const name = 'scope-test-ff-' + Math.random().toString(36).slice(2, 8);
3205
+ component(name, {
3206
+ styles: "@font-face { font-family: 'TestFont'; src: url('test.woff2'); } .text { font-family: 'TestFont'; }",
3207
+ render: () => '<div class="text">x</div>'
3208
+ });
3209
+ const el = document.createElement(name);
3210
+ document.body.appendChild(el);
3211
+ mount(el, name);
3212
+
3213
+ const styleEl = document.querySelector(`style[data-zq-component="${name}"]`);
3214
+ const text = styleEl.textContent;
3215
+ expect(text).toContain('@font-face');
3216
+ // .text should be scoped
3217
+ expect(text).toMatch(/\[z-s\d+\]\s+\.text/);
3218
+ el.remove();
3219
+ });
3220
+
3221
+ it('@media rules are not scoped but their contents ARE', () => {
3222
+ const name = 'scope-test-media-' + Math.random().toString(36).slice(2, 8);
3223
+ component(name, {
3224
+ styles: '@media (max-width: 600px) { .mobile { display: block; } }',
3225
+ render: () => '<div class="mobile">x</div>'
3226
+ });
3227
+ const el = document.createElement(name);
3228
+ document.body.appendChild(el);
3229
+ mount(el, name);
3230
+
3231
+ const styleEl = document.querySelector(`style[data-zq-component="${name}"]`);
3232
+ const text = styleEl.textContent;
3233
+ expect(text).toContain('@media');
3234
+ // .mobile inside @media should be scoped
3235
+ expect(text).toMatch(/\[z-s\d+\]\s+\.mobile/);
3236
+ el.remove();
3237
+ });
3238
+ });
3239
+
3240
+
3241
+ // ===========================================================================
3242
+ // 19. Two-Way Binding (z-model)
3243
+ // ===========================================================================
3244
+ describe('Two-Way Binding (z-model)', () => {
3245
+ afterEach(() => {
3246
+ document.querySelectorAll('[class^="bind-test"]').forEach(el => el.remove());
3247
+ });
3248
+
3249
+ it('text input binds to state and reflects state changes', () => {
3250
+ const name = 'bind-test-text-' + Math.random().toString(36).slice(2, 8);
3251
+ component(name, {
3252
+ state: () => ({ name: 'initial' }),
3253
+ render() { return `<input z-model="name" type="text"><span>${this.state.name}</span>`; }
3254
+ });
3255
+ const el = document.createElement(name);
3256
+ document.body.appendChild(el);
3257
+ mount(el, name);
3258
+
3259
+ const input = el.querySelector('input');
3260
+ expect(input.value).toBe('initial');
3261
+ });
3262
+
3263
+ it('checkbox binds to boolean state', () => {
3264
+ const name = 'bind-test-check-' + Math.random().toString(36).slice(2, 8);
3265
+ component(name, {
3266
+ state: () => ({ active: true }),
3267
+ render() { return `<input z-model="active" type="checkbox">`; }
3268
+ });
3269
+ const el = document.createElement(name);
3270
+ document.body.appendChild(el);
3271
+ mount(el, name);
3272
+
3273
+ const input = el.querySelector('input');
3274
+ expect(input.checked).toBe(true);
3275
+ });
3276
+
3277
+ it('radio buttons bind to state value', () => {
3278
+ const name = 'bind-test-radio-' + Math.random().toString(36).slice(2, 8);
3279
+ component(name, {
3280
+ state: () => ({ color: 'blue' }),
3281
+ render() {
3282
+ return `
3283
+ <input z-model="color" type="radio" value="red">
3284
+ <input z-model="color" type="radio" value="blue">
3285
+ <input z-model="color" type="radio" value="green">
3286
+ `;
3287
+ }
3288
+ });
3289
+ const el = document.createElement(name);
3290
+ document.body.appendChild(el);
3291
+ mount(el, name);
3292
+
3293
+ const radios = el.querySelectorAll('input');
3294
+ expect(radios[0].checked).toBe(false); // red
3295
+ expect(radios[1].checked).toBe(true); // blue
3296
+ expect(radios[2].checked).toBe(false); // green
3297
+ });
3298
+
3299
+ it('select element binds to state', () => {
3300
+ const name = 'bind-test-select-' + Math.random().toString(36).slice(2, 8);
3301
+ component(name, {
3302
+ state: () => ({ fruit: 'banana' }),
3303
+ render() {
3304
+ return `
3305
+ <select z-model="fruit">
3306
+ <option value="apple">Apple</option>
3307
+ <option value="banana">Banana</option>
3308
+ <option value="cherry">Cherry</option>
3309
+ </select>
3310
+ `;
3311
+ }
3312
+ });
3313
+ const el = document.createElement(name);
3314
+ document.body.appendChild(el);
3315
+ mount(el, name);
3316
+
3317
+ const select = el.querySelector('select');
3318
+ expect(select.value).toBe('banana');
3319
+ });
3320
+
3321
+ it('select multiple binds to array state', () => {
3322
+ const name = 'bind-test-multi-' + Math.random().toString(36).slice(2, 8);
3323
+ component(name, {
3324
+ state: () => ({ chosen: ['a', 'c'] }),
3325
+ render() {
3326
+ return `
3327
+ <select z-model="chosen" multiple>
3328
+ <option value="a">A</option>
3329
+ <option value="b">B</option>
3330
+ <option value="c">C</option>
3331
+ </select>
3332
+ `;
3333
+ }
3334
+ });
3335
+ const el = document.createElement(name);
3336
+ document.body.appendChild(el);
3337
+ mount(el, name);
3338
+
3339
+ const opts = el.querySelector('select').options;
3340
+ expect(opts[0].selected).toBe(true); // a
3341
+ expect(opts[1].selected).toBe(false); // b
3342
+ expect(opts[2].selected).toBe(true); // c
3343
+ });
3344
+
3345
+ it('contenteditable binds to state', () => {
3346
+ const name = 'bind-test-ce-' + Math.random().toString(36).slice(2, 8);
3347
+ component(name, {
3348
+ state: () => ({ content: 'editable text' }),
3349
+ render() {
3350
+ return `<div z-model="content" contenteditable="true"></div>`;
3351
+ }
3352
+ });
3353
+ const el = document.createElement(name);
3354
+ document.body.appendChild(el);
3355
+ mount(el, name);
3356
+
3357
+ const ce = el.querySelector('[contenteditable]');
3358
+ expect(ce.textContent).toBe('editable text');
3359
+ });
3360
+
3361
+ it('dot-path key binds to nested state', () => {
3362
+ const name = 'bind-test-dot-' + Math.random().toString(36).slice(2, 8);
3363
+ component(name, {
3364
+ state: () => ({ user: { name: 'Tony' } }),
3365
+ render() {
3366
+ return `<input z-model="user.name" type="text">`;
3367
+ }
3368
+ });
3369
+ const el = document.createElement(name);
3370
+ document.body.appendChild(el);
3371
+ mount(el, name);
3372
+
3373
+ const input = el.querySelector('input');
3374
+ expect(input.value).toBe('Tony');
3375
+ });
3376
+
3377
+ it('z-model with undefined state initializes input as empty', () => {
3378
+ const name = 'bind-test-undef-' + Math.random().toString(36).slice(2, 8);
3379
+ component(name, {
3380
+ state: () => ({}),
3381
+ render() {
3382
+ return `<input z-model="missing" type="text">`;
3383
+ }
3384
+ });
3385
+ const el = document.createElement(name);
3386
+ document.body.appendChild(el);
3387
+ mount(el, name);
3388
+
3389
+ const input = el.querySelector('input');
3390
+ expect(input.value).toBe('');
3391
+ });
3392
+ });
3393
+
3394
+
3395
+ // ===========================================================================
3396
+ // 20. Input Modifiers (z-lazy, z-trim, z-number)
3397
+ // ===========================================================================
3398
+ describe('Input Modifiers (z-lazy, z-trim, z-number)', () => {
3399
+ afterEach(() => {
3400
+ document.querySelectorAll('[class^="mod-test"]').forEach(el => el.remove());
3401
+ });
3402
+
3403
+ it('z-lazy uses change event instead of input', () => {
3404
+ const name = 'mod-test-lazy-' + Math.random().toString(36).slice(2, 8);
3405
+ component(name, {
3406
+ state: () => ({ val: '' }),
3407
+ render() {
3408
+ return `<input z-model="val" z-lazy type="text"><span id="out">${this.state.val}</span>`;
3409
+ }
3410
+ });
3411
+ const el = document.createElement(name);
3412
+ document.body.appendChild(el);
3413
+ mount(el, name);
3414
+
3415
+ const input = el.querySelector('input');
3416
+ const inst = getInstance(el);
3417
+
3418
+ // Simulate typing (fires 'input' event - should NOT update with z-lazy)
3419
+ input.value = 'typed';
3420
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3421
+ // State should NOT have changed yet since z-lazy listens to 'change'
3422
+ // We need to check the instance state
3423
+ if (inst) {
3424
+ expect(inst.state.val).toBe('');
3425
+ }
3426
+
3427
+ // Now fire 'change' event - should update
3428
+ input.dispatchEvent(new Event('change', { bubbles: true }));
3429
+ if (inst) {
3430
+ expect(inst.state.val).toBe('typed');
3431
+ }
3432
+ });
3433
+
3434
+ it('z-trim trims whitespace from input value', () => {
3435
+ const name = 'mod-test-trim-' + Math.random().toString(36).slice(2, 8);
3436
+ component(name, {
3437
+ state: () => ({ val: '' }),
3438
+ render() {
3439
+ return `<input z-model="val" z-trim type="text">`;
3440
+ }
3441
+ });
3442
+ const el = document.createElement(name);
3443
+ document.body.appendChild(el);
3444
+ mount(el, name);
3445
+
3446
+ const input = el.querySelector('input');
3447
+ const inst = getInstance(el);
3448
+ input.value = ' hello world ';
3449
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3450
+ if (inst) {
3451
+ expect(inst.state.val).toBe('hello world');
3452
+ }
3453
+ });
3454
+
3455
+ it('z-number converts value to Number', () => {
3456
+ const name = 'mod-test-num-' + Math.random().toString(36).slice(2, 8);
3457
+ component(name, {
3458
+ state: () => ({ val: 0 }),
3459
+ render() {
3460
+ return `<input z-model="val" z-number type="text">`;
3461
+ }
3462
+ });
3463
+ const el = document.createElement(name);
3464
+ document.body.appendChild(el);
3465
+ mount(el, name);
3466
+
3467
+ const input = el.querySelector('input');
3468
+ const inst = getInstance(el);
3469
+ input.value = '42';
3470
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3471
+ if (inst) {
3472
+ expect(inst.state.val).toBe(42);
3473
+ expect(typeof inst.state.val).toBe('number');
3474
+ }
3475
+ });
3476
+
3477
+ it('type="number" auto-applies numeric conversion', () => {
3478
+ const name = 'mod-test-typenum-' + Math.random().toString(36).slice(2, 8);
3479
+ component(name, {
3480
+ state: () => ({ val: 0 }),
3481
+ render() {
3482
+ return `<input z-model="val" type="number">`;
3483
+ }
3484
+ });
3485
+ const el = document.createElement(name);
3486
+ document.body.appendChild(el);
3487
+ mount(el, name);
3488
+
3489
+ const input = el.querySelector('input');
3490
+ const inst = getInstance(el);
3491
+ input.value = '99';
3492
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3493
+ if (inst) {
3494
+ expect(inst.state.val).toBe(99);
3495
+ expect(typeof inst.state.val).toBe('number');
3496
+ }
3497
+ });
3498
+
3499
+ it('type="range" auto-applies numeric conversion', () => {
3500
+ const name = 'mod-test-range-' + Math.random().toString(36).slice(2, 8);
3501
+ component(name, {
3502
+ state: () => ({ val: 50 }),
3503
+ render() {
3504
+ return `<input z-model="val" type="range" min="0" max="100">`;
3505
+ }
3506
+ });
3507
+ const el = document.createElement(name);
3508
+ document.body.appendChild(el);
3509
+ mount(el, name);
3510
+
3511
+ const input = el.querySelector('input');
3512
+ const inst = getInstance(el);
3513
+ input.value = '75';
3514
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3515
+ if (inst) {
3516
+ expect(inst.state.val).toBe(75);
3517
+ }
3518
+ });
3519
+
3520
+ it('z-trim + z-number combined: trims then converts', () => {
3521
+ const name = 'mod-test-combo-' + Math.random().toString(36).slice(2, 8);
3522
+ component(name, {
3523
+ state: () => ({ val: 0 }),
3524
+ render() {
3525
+ return `<input z-model="val" z-trim z-number type="text">`;
3526
+ }
3527
+ });
3528
+ const el = document.createElement(name);
3529
+ document.body.appendChild(el);
3530
+ mount(el, name);
3531
+
3532
+ const input = el.querySelector('input');
3533
+ const inst = getInstance(el);
3534
+ input.value = ' 123 ';
3535
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3536
+ if (inst) {
3537
+ expect(inst.state.val).toBe(123);
3538
+ }
3539
+ });
3540
+
3541
+ it('z-lazy + z-trim combined', () => {
3542
+ const name = 'mod-test-lazy-trim-' + Math.random().toString(36).slice(2, 8);
3543
+ component(name, {
3544
+ state: () => ({ val: '' }),
3545
+ render() {
3546
+ return `<input z-model="val" z-lazy z-trim type="text">`;
3547
+ }
3548
+ });
3549
+ const el = document.createElement(name);
3550
+ document.body.appendChild(el);
3551
+ mount(el, name);
3552
+
3553
+ const input = el.querySelector('input');
3554
+ const inst = getInstance(el);
3555
+ input.value = ' spaced ';
3556
+ // input event should NOT trigger due to z-lazy
3557
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3558
+ if (inst) {
3559
+ expect(inst.state.val).toBe('');
3560
+ }
3561
+ // change event should trigger with trim applied
3562
+ input.dispatchEvent(new Event('change', { bubbles: true }));
3563
+ if (inst) {
3564
+ expect(inst.state.val).toBe('spaced');
3565
+ }
3566
+ });
3567
+
3568
+ it('checkbox binding fires on change event', () => {
3569
+ const name = 'mod-test-cb-change-' + Math.random().toString(36).slice(2, 8);
3570
+ component(name, {
3571
+ state: () => ({ checked: false }),
3572
+ render() {
3573
+ return `<input z-model="checked" type="checkbox">`;
3574
+ }
3575
+ });
3576
+ const el = document.createElement(name);
3577
+ document.body.appendChild(el);
3578
+ mount(el, name);
3579
+
3580
+ const input = el.querySelector('input');
3581
+ const inst = getInstance(el);
3582
+ input.checked = true;
3583
+ input.dispatchEvent(new Event('change', { bubbles: true }));
3584
+ if (inst) {
3585
+ expect(inst.state.checked).toBe(true);
3586
+ }
3587
+ });
3588
+ });
3589
+
3590
+
3591
+ // ===========================================================================
3592
+ // 21. Import/Export Correctness
3593
+ // ===========================================================================
3594
+ describe('Import/Export correctness', () => {
3595
+ it('TrustedHTML class is exported and constructible', () => {
3596
+ expect(TrustedHTML).toBeDefined();
3597
+ expect(typeof TrustedHTML).toBe('function');
3598
+ const t = new TrustedHTML('<b>hi</b>');
3599
+ expect(t.toString()).toBe('<b>hi</b>');
3600
+ });
3601
+
3602
+ it('EventBus class is exported and constructible', () => {
3603
+ expect(EventBus).toBeDefined();
3604
+ expect(typeof EventBus).toBe('function');
3605
+ const b = new EventBus();
3606
+ expect(typeof b.on).toBe('function');
3607
+ expect(typeof b.off).toBe('function');
3608
+ expect(typeof b.emit).toBe('function');
3609
+ expect(typeof b.once).toBe('function');
3610
+ expect(typeof b.clear).toBe('function');
3611
+ });
3612
+
3613
+ it('trust() returns TrustedHTML instances', () => {
3614
+ const t = trust('<em>safe</em>');
3615
+ expect(t).toBeInstanceOf(TrustedHTML);
3616
+ expect(t.toString()).toBe('<em>safe</em>');
3617
+ });
3618
+
3619
+ it('bus is an instance of EventBus', () => {
3620
+ expect(bus).toBeInstanceOf(EventBus);
3621
+ });
3622
+
3623
+ it('$ namespace exposes TrustedHTML and EventBus', async () => {
3624
+ const mod = await import('../index.js');
3625
+ const $ = mod.default;
3626
+ expect($.TrustedHTML).toBe(TrustedHTML);
3627
+ expect($.EventBus).toBe(EventBus);
3628
+ });
3629
+
3630
+ it('named exports include TrustedHTML and EventBus', async () => {
3631
+ const mod = await import('../index.js');
3632
+ expect(mod.TrustedHTML).toBe(TrustedHTML);
3633
+ expect(mod.EventBus).toBe(EventBus);
3634
+ });
3635
+
3636
+ it('all major symbols are exported from index.js', async () => {
3637
+ const mod = await import('../index.js');
3638
+ // Core
3639
+ expect(mod.$).toBeDefined();
3640
+ expect(mod.ZQueryCollection).toBeDefined();
3641
+ // Reactive
3642
+ expect(mod.reactive).toBeDefined();
3643
+ expect(mod.Signal).toBeDefined();
3644
+ expect(mod.signal).toBeDefined();
3645
+ expect(mod.computed).toBeDefined();
3646
+ expect(mod.effect).toBeDefined();
3647
+ // Components
3648
+ expect(mod.component).toBeDefined();
3649
+ expect(mod.mount).toBeDefined();
3650
+ expect(mod.mountAll).toBeDefined();
3651
+ expect(mod.getInstance).toBeDefined();
3652
+ expect(mod.destroy).toBeDefined();
3653
+ expect(mod.style).toBeDefined();
3654
+ // DOM
3655
+ expect(mod.morph).toBeDefined();
3656
+ expect(mod.morphElement).toBeDefined();
3657
+ expect(mod.safeEval).toBeDefined();
3658
+ // Router
3659
+ expect(mod.createRouter).toBeDefined();
3660
+ expect(mod.getRouter).toBeDefined();
3661
+ // Store
3662
+ expect(mod.createStore).toBeDefined();
3663
+ expect(mod.getStore).toBeDefined();
3664
+ // HTTP
3665
+ expect(mod.http).toBeDefined();
3666
+ // Errors
3667
+ expect(mod.ZQueryError).toBeDefined();
3668
+ expect(mod.ErrorCode).toBeDefined();
3669
+ expect(mod.onError).toBeDefined();
3670
+ expect(mod.reportError).toBeDefined();
3671
+ // Utils
3672
+ expect(mod.debounce).toBeDefined();
3673
+ expect(mod.throttle).toBeDefined();
3674
+ expect(mod.pipe).toBeDefined();
3675
+ expect(mod.once).toBeDefined();
3676
+ expect(mod.sleep).toBeDefined();
3677
+ expect(mod.escapeHtml).toBeDefined();
3678
+ expect(mod.html).toBeDefined();
3679
+ expect(mod.trust).toBeDefined();
3680
+ expect(mod.TrustedHTML).toBeDefined();
3681
+ expect(mod.uuid).toBeDefined();
3682
+ expect(mod.camelCase).toBeDefined();
3683
+ expect(mod.kebabCase).toBeDefined();
3684
+ expect(mod.deepClone).toBeDefined();
3685
+ expect(mod.deepMerge).toBeDefined();
3686
+ expect(mod.isEqual).toBeDefined();
3687
+ expect(mod.param).toBeDefined();
3688
+ expect(mod.parseQuery).toBeDefined();
3689
+ expect(mod.storage).toBeDefined();
3690
+ expect(mod.session).toBeDefined();
3691
+ expect(mod.EventBus).toBeDefined();
3692
+ expect(mod.bus).toBeDefined();
3693
+ });
3694
+ });
3695
+
3696
+
3697
+ // ===========================================================================
3698
+ // 22. Reactive system edge cases
3699
+ // ===========================================================================
3700
+ describe('Reactive edge cases', () => {
3701
+ it('reactive proxy cache returns same proxy for same nested object', () => {
3702
+ const data = { nested: { x: 1 } };
3703
+ const changes = [];
3704
+ const r = reactive(data, (k, v, o) => changes.push(k));
3705
+ const p1 = r.nested;
3706
+ const p2 = r.nested;
3707
+ // Should be the same proxy reference (WeakMap cache)
3708
+ expect(p1).toBe(p2);
3709
+ });
3710
+
3711
+ it('reactive.__raw returns the underlying object', () => {
3712
+ const data = { count: 0 };
3713
+ const r = reactive(data, () => {});
3714
+ expect(r.__raw).toBe(data);
3715
+ });
3716
+
3717
+ it('reactive.__isReactive returns true', () => {
3718
+ const r = reactive({}, () => {});
3719
+ expect(r.__isReactive).toBe(true);
3720
+ });
3721
+
3722
+ it('reactive set skips onChange when value is identical', () => {
3723
+ const changes = [];
3724
+ const r = reactive({ x: 5 }, (k, v, o) => changes.push(k));
3725
+ r.x = 5; // same value
3726
+ expect(changes).toEqual([]); // no notification
3727
+ });
3728
+
3729
+ it('reactive delete triggers onChange', () => {
3730
+ const changes = [];
3731
+ const r = reactive({ x: 1 }, (k, v, o) => changes.push({ k, v, o }));
3732
+ delete r.x;
3733
+ expect(changes).toEqual([{ k: 'x', v: undefined, o: 1 }]);
3734
+ });
3735
+
3736
+ it('reactive invalidates proxy cache when old object value is replaced', () => {
3737
+ const data = { nested: { x: 1 } };
3738
+ const r = reactive(data, () => {});
3739
+ const oldProxy = r.nested;
3740
+ r.nested = { x: 2 };
3741
+ const newProxy = r.nested;
3742
+ // New proxy should be different since the underlying object changed
3743
+ expect(newProxy).not.toBe(oldProxy);
3744
+ expect(newProxy.x).toBe(2);
3745
+ });
3746
+
3747
+ it('reactive handles non-object target gracefully', () => {
3748
+ expect(reactive(42, () => {})).toBe(42);
3749
+ expect(reactive(null, () => {})).toBeNull();
3750
+ expect(reactive('str', () => {})).toBe('str');
3751
+ });
3752
+
3753
+ it('reactive onChange error is caught and reported', () => {
3754
+ const r = reactive({ x: 0 }, () => { throw new Error('boom'); });
3755
+ // Should not throw - error is caught and reported internally
3756
+ expect(() => { r.x = 1; }).not.toThrow();
3757
+ });
3758
+
3759
+ it('reactive deeply nested mutation triggers notification', () => {
3760
+ const data = { a: { b: { c: 0 } } };
3761
+ const changes = [];
3762
+ const r = reactive(data, (k, v, o) => changes.push(k));
3763
+ r.a.b.c = 99;
3764
+ expect(changes).toContain('c');
3765
+ });
3766
+
3767
+ it('Signal basic get/set works', () => {
3768
+ const s = new Signal(10);
3769
+ expect(s.value).toBe(10);
3770
+ s.value = 20;
3771
+ expect(s.value).toBe(20);
3772
+ });
3773
+
3774
+ it('Signal skip notification on same value', () => {
3775
+ const s = new Signal(5);
3776
+ const calls = [];
3777
+ s.subscribe(() => calls.push('called'));
3778
+ s.value = 5; // same value
3779
+ expect(calls).toEqual([]);
3780
+ });
3781
+
3782
+ it('Signal.peek() reads without tracking', () => {
3783
+ const s = new Signal(42);
3784
+ expect(s.peek()).toBe(42);
3785
+ });
3786
+
3787
+ it('Signal.toString() returns string of value', () => {
3788
+ const s = new Signal(123);
3789
+ expect(s.toString()).toBe('123');
3790
+ });
3791
+
3792
+ it('Signal subscribe/unsubscribe works', () => {
3793
+ const s = new Signal(0);
3794
+ const calls = [];
3795
+ const unsub = s.subscribe(() => calls.push('notified'));
3796
+ s.value = 1;
3797
+ expect(calls).toEqual(['notified']);
3798
+ unsub();
3799
+ s.value = 2;
3800
+ expect(calls).toEqual(['notified']); // no second call
3801
+ });
3802
+
3803
+ it('signal() shorthand creates a Signal', () => {
3804
+ const s = signal(0);
3805
+ expect(s).toBeInstanceOf(Signal);
3806
+ expect(s.value).toBe(0);
3807
+ });
3808
+
3809
+ it('computed() creates a derived signal', () => {
3810
+ const count = signal(5);
3811
+ const doubled = computed(() => count.value * 2);
3812
+ expect(doubled.value).toBe(10);
3813
+ count.value = 10;
3814
+ expect(doubled.value).toBe(20);
3815
+ });
3816
+
3817
+ it('effect() auto-tracks dependencies', () => {
3818
+ const count = signal(0);
3819
+ const log = [];
3820
+ effect(() => { log.push(count.value); });
3821
+ expect(log).toEqual([0]); // runs immediately
3822
+ count.value = 1;
3823
+ expect(log).toEqual([0, 1]);
3824
+ count.value = 2;
3825
+ expect(log).toEqual([0, 1, 2]);
3826
+ });
3827
+
3828
+ it('effect cleanup: unsubscribe stops tracking', () => {
3829
+ const count = signal(0);
3830
+ const log = [];
3831
+ const unsub = effect(() => { log.push(count.value); });
3832
+ expect(log).toEqual([0]);
3833
+ unsub();
3834
+ count.value = 1;
3835
+ expect(log).toEqual([0]); // no more tracking
3836
+ });
3837
+ });
3838
+
3839
+
3840
+ // ===========================================================================
3841
+ // 23. TrustedHTML / html template tag
3842
+ // ===========================================================================
3843
+ describe('TrustedHTML and html template tag', () => {
3844
+ it('TrustedHTML wraps and unwraps HTML string', () => {
3845
+ const t = new TrustedHTML('<b>bold</b>');
3846
+ expect(t._html).toBe('<b>bold</b>');
3847
+ expect(t.toString()).toBe('<b>bold</b>');
3848
+ expect(`${t}`).toBe('<b>bold</b>');
3849
+ });
3850
+
3851
+ it('trust() returns TrustedHTML instance', () => {
3852
+ const t = trust('<i>italic</i>');
3853
+ expect(t).toBeInstanceOf(TrustedHTML);
3854
+ expect(t.toString()).toBe('<i>italic</i>');
3855
+ });
3856
+
3857
+ it('html template tag escapes interpolated values', () => {
3858
+ const userInput = '<script>alert("xss")</script>';
3859
+ const result = html`<div>${userInput}</div>`;
3860
+ expect(result).toContain('&lt;script&gt;');
3861
+ expect(result).not.toContain('<script>');
3862
+ });
3863
+
3864
+ it('html template tag does not escape TrustedHTML values', () => {
3865
+ const trusted = trust('<b>bold</b>');
3866
+ const result = html`<div>${trusted}</div>`;
3867
+ expect(result).toContain('<b>bold</b>');
3868
+ });
3869
+
3870
+ it('html template tag handles multiple interpolations', () => {
3871
+ const name = 'Tony';
3872
+ const dangerous = '<img onerror="alert(1)">';
3873
+ const safe = trust('<em>safe</em>');
3874
+ const result = html`<p>${name}</p><p>${dangerous}</p><p>${safe}</p>`;
3875
+ expect(result).toContain('Tony');
3876
+ expect(result).toContain('&lt;img');
3877
+ expect(result).toContain('<em>safe</em>');
3878
+ });
3879
+
3880
+ it('html template tag handles null and undefined gracefully', () => {
3881
+ const result = html`<div>${null}${undefined}</div>`;
3882
+ // Should not throw; values should be stringified
3883
+ expect(result).toContain('<div>');
3884
+ });
3885
+
3886
+ it('escapeHtml escapes all dangerous characters', () => {
3887
+ expect(escapeHtml('&')).toBe('&amp;');
3888
+ expect(escapeHtml('<')).toBe('&lt;');
3889
+ expect(escapeHtml('>')).toBe('&gt;');
3890
+ expect(escapeHtml('"')).toBe('&quot;');
3891
+ expect(escapeHtml("'")).toBe('&#39;');
3892
+ expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
3893
+ });
3894
+ });
3895
+
3896
+
3897
+ // ===========================================================================
3898
+ // 24. Bug 9 — `new` constructor globals reachable
3899
+ // ===========================================================================
3900
+ describe('new constructor globals (Bug 9)', () => {
3901
+ const eval_ = (expr, ctx = {}) => safeEval(expr, [ctx]);
3902
+
3903
+ it('new Map() creates a Map', () => {
3904
+ const result = eval_('new Map()');
3905
+ expect(result).toBeInstanceOf(Map);
3906
+ });
3907
+
3908
+ it('new Set([1,2,3]) creates a Set with values', () => {
3909
+ const result = eval_('new Set(items)', { items: [1, 2, 3] });
3910
+ expect(result).toBeInstanceOf(Set);
3911
+ expect(result.size).toBe(3);
3912
+ expect(result.has(2)).toBe(true);
3913
+ });
3914
+
3915
+ it('new RegExp creates a RegExp', () => {
3916
+ const result = eval_('new RegExp(pat, flags)', { pat: '^hello', flags: 'i' });
3917
+ expect(result).toBeInstanceOf(RegExp);
3918
+ expect(result.test('Hello world')).toBe(true);
3919
+ });
3920
+
3921
+ it('new URL creates a URL', () => {
3922
+ const result = eval_('new URL(str)', { str: 'https://example.com/path' });
3923
+ expect(result).toBeInstanceOf(URL);
3924
+ expect(result.pathname).toBe('/path');
3925
+ });
3926
+
3927
+ it('new URLSearchParams creates URLSearchParams', () => {
3928
+ const result = eval_('new URLSearchParams(str)', { str: 'a=1&b=2' });
3929
+ expect(result).toBeInstanceOf(URLSearchParams);
3930
+ expect(result.get('a')).toBe('1');
3931
+ expect(result.get('b')).toBe('2');
3932
+ });
3933
+
3934
+ it('new Error creates an Error', () => {
3935
+ const result = eval_('new Error(msg)', { msg: 'test error' });
3936
+ expect(result).toBeInstanceOf(Error);
3937
+ expect(result.message).toBe('test error');
3938
+ });
3939
+
3940
+ it('Map and Set are accessible as identifiers for instanceof', () => {
3941
+ expect(eval_('val instanceof Map', { val: new Map() })).toBe(true);
3942
+ expect(eval_('val instanceof Set', { val: new Set() })).toBe(true);
3943
+ });
3944
+ });
3945
+
3946
+
3947
+ // ===========================================================================
3948
+ // 25. Bug 10 — optional_call preserves `this` binding
3949
+ // ===========================================================================
3950
+ describe('optional_call this binding (Bug 10)', () => {
3951
+ const eval_ = (expr, ctx = {}) => safeEval(expr, [ctx]);
3952
+
3953
+ it('arr?.filter?.() preserves this on double-optional chain', () => {
3954
+ const result = eval_('arr?.filter?.(x => x > 1)', { arr: [1, 2, 3] });
3955
+ expect(result).toEqual([2, 3]);
3956
+ });
3957
+
3958
+ it('str?.toUpperCase?.() preserves this', () => {
3959
+ const result = eval_('str?.toUpperCase?.()', { str: 'hello' });
3960
+ expect(result).toBe('HELLO');
3961
+ });
3962
+
3963
+ it('obj?.method?.() binds this to obj', () => {
3964
+ const obj = { name: 'Tony', greet() { return this.name; } };
3965
+ const result = eval_('obj?.greet?.()', { obj });
3966
+ expect(result).toBe('Tony');
3967
+ });
3968
+
3969
+ it('null?.method?.() returns undefined (no crash)', () => {
3970
+ const result = eval_('val?.toString?.()', { val: null });
3971
+ expect(result).toBeUndefined();
3972
+ });
3973
+ });
3974
+
3975
+
3976
+ // ===========================================================================
3977
+ // 26. Bug 11 — HTTP abort vs timeout distinction
3978
+ // ===========================================================================
3979
+ describe('HTTP abort vs timeout message (Bug 11)', () => {
3980
+ it('user abort says "aborted" not "timeout"', async () => {
3981
+ const controller = new AbortController();
3982
+ controller.abort(); // abort before fetch
3983
+
3984
+ // Mock fetch to reject with AbortError
3985
+ const originalFetch = globalThis.fetch;
3986
+ globalThis.fetch = vi.fn(() => Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
3987
+
3988
+ const { http } = await import('../src/http.js');
3989
+ try {
3990
+ await http.get('https://example.com/test', null, { signal: controller.signal, timeout: 0 });
3991
+ expect.unreachable('should have thrown');
3992
+ } catch (err) {
3993
+ expect(err.message).toContain('aborted');
3994
+ expect(err.message).not.toContain('timeout');
3995
+ } finally {
3996
+ globalThis.fetch = originalFetch;
3997
+ }
3998
+ });
3999
+ });
4000
+
4001
+
4002
+ // ===========================================================================
4003
+ // 27. Bug 12 — isEqual circular reference protection
4004
+ // ===========================================================================
4005
+ describe('isEqual circular reference protection (Bug 12)', () => {
4006
+ it('does not stack overflow on circular objects', () => {
4007
+ const a = { x: 1 };
4008
+ a.self = a;
4009
+ const b = { x: 1 };
4010
+ b.self = b;
4011
+ // Should not throw — just return true (both are circular in the same shape)
4012
+ expect(() => isEqual(a, b)).not.toThrow();
4013
+ expect(isEqual(a, b)).toBe(true);
4014
+ });
4015
+
4016
+ it('does not stack overflow on circular arrays', () => {
4017
+ const a = [1, 2];
4018
+ a.push(a);
4019
+ const b = [1, 2];
4020
+ b.push(b);
4021
+ expect(() => isEqual(a, b)).not.toThrow();
4022
+ });
4023
+
4024
+ it('still correctly compares non-circular objects', () => {
4025
+ expect(isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true);
4026
+ expect(isEqual({ a: 1 }, { a: 2 })).toBe(false);
4027
+ expect(isEqual([1, 2], [1, 2])).toBe(true);
4028
+ expect(isEqual([1, 2], [1, 3])).toBe(false);
4029
+ });
4030
+ });