zero-query 0.6.3 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -29
- package/cli/commands/build.js +113 -4
- package/cli/commands/bundle.js +392 -29
- package/cli/commands/create.js +1 -1
- package/cli/commands/dev/devtools/index.js +56 -0
- package/cli/commands/dev/devtools/js/components.js +49 -0
- package/cli/commands/dev/devtools/js/core.js +409 -0
- package/cli/commands/dev/devtools/js/elements.js +413 -0
- package/cli/commands/dev/devtools/js/network.js +166 -0
- package/cli/commands/dev/devtools/js/performance.js +73 -0
- package/cli/commands/dev/devtools/js/router.js +105 -0
- package/cli/commands/dev/devtools/js/source.js +132 -0
- package/cli/commands/dev/devtools/js/stats.js +35 -0
- package/cli/commands/dev/devtools/js/tabs.js +79 -0
- package/cli/commands/dev/devtools/panel.html +95 -0
- package/cli/commands/dev/devtools/styles.css +244 -0
- package/cli/commands/dev/index.js +29 -4
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +428 -2
- package/cli/commands/dev/server.js +42 -5
- package/cli/commands/dev/watcher.js +59 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +16 -23
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
- package/cli/scaffold/app/components/home.js +137 -0
- package/cli/scaffold/{scripts → app}/routes.js +1 -1
- package/cli/scaffold/{scripts → app}/store.js +6 -6
- package/cli/scaffold/assets/.gitkeep +0 -0
- package/cli/scaffold/{styles/styles.css → global.css} +4 -2
- package/cli/scaffold/index.html +12 -11
- package/cli/utils.js +111 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +1122 -158
- package/dist/zquery.min.js +3 -16
- package/index.d.ts +129 -1290
- package/index.js +15 -10
- package/package.json +7 -6
- package/src/component.js +172 -49
- package/src/core.js +359 -18
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +243 -7
- package/tests/component.test.js +886 -0
- package/tests/core.test.js +977 -0
- package/tests/diff.test.js +525 -0
- package/tests/errors.test.js +162 -0
- package/tests/expression.test.js +482 -0
- package/tests/http.test.js +289 -0
- package/tests/reactive.test.js +339 -0
- package/tests/router.test.js +649 -0
- package/tests/store.test.js +379 -0
- package/tests/utils.test.js +512 -0
- package/types/collection.d.ts +383 -0
- package/types/component.d.ts +217 -0
- package/types/errors.d.ts +103 -0
- package/types/http.d.ts +81 -0
- package/types/misc.d.ts +179 -0
- package/types/reactive.d.ts +76 -0
- package/types/router.d.ts +161 -0
- package/types/ssr.d.ts +49 -0
- package/types/store.d.ts +107 -0
- package/types/utils.d.ts +142 -0
- package/cli/commands/dev.old.js +0 -520
- package/cli/scaffold/scripts/components/home.js +0 -137
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { morph, morphElement } from '../src/diff.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
function el(html) {
|
|
9
|
+
const div = document.createElement('div');
|
|
10
|
+
div.innerHTML = html;
|
|
11
|
+
return div;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function morphAndGet(oldHTML, newHTML) {
|
|
15
|
+
const root = el(oldHTML);
|
|
16
|
+
morph(root, newHTML);
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Basic morphing
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
describe('morph — basic', () => {
|
|
26
|
+
it('updates text content', () => {
|
|
27
|
+
const root = morphAndGet('<p>old</p>', '<p>new</p>');
|
|
28
|
+
expect(root.innerHTML).toBe('<p>new</p>');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('appends new elements', () => {
|
|
32
|
+
const root = morphAndGet('<p>a</p>', '<p>a</p><p>b</p>');
|
|
33
|
+
expect(root.children.length).toBe(2);
|
|
34
|
+
expect(root.children[1].textContent).toBe('b');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('removes extra elements', () => {
|
|
38
|
+
const root = morphAndGet('<p>a</p><p>b</p>', '<p>a</p>');
|
|
39
|
+
expect(root.children.length).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('replaces elements with different tag', () => {
|
|
43
|
+
const root = morphAndGet('<p>text</p>', '<div>text</div>');
|
|
44
|
+
expect(root.children[0].tagName).toBe('DIV');
|
|
45
|
+
expect(root.children[0].textContent).toBe('text');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('handles empty to content', () => {
|
|
49
|
+
const root = morphAndGet('', '<p>hello</p>');
|
|
50
|
+
expect(root.innerHTML).toBe('<p>hello</p>');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles content to empty', () => {
|
|
54
|
+
const root = morphAndGet('<p>hello</p>', '');
|
|
55
|
+
expect(root.children.length).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('handles multiple appends at once', () => {
|
|
59
|
+
const root = morphAndGet('<p>a</p>', '<p>a</p><p>b</p><p>c</p><p>d</p>');
|
|
60
|
+
expect(root.children.length).toBe(4);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles complete replacement of all children', () => {
|
|
64
|
+
const root = morphAndGet('<p>a</p><p>b</p>', '<span>x</span><span>y</span>');
|
|
65
|
+
expect(root.children[0].tagName).toBe('SPAN');
|
|
66
|
+
expect(root.children[1].tagName).toBe('SPAN');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles single text node to multiple elements', () => {
|
|
70
|
+
const root = document.createElement('div');
|
|
71
|
+
root.textContent = 'hello';
|
|
72
|
+
morph(root, '<p>a</p><p>b</p>');
|
|
73
|
+
expect(root.children.length).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles morphing identical content (no-op)', () => {
|
|
77
|
+
const root = el('<p>same</p>');
|
|
78
|
+
const child = root.children[0];
|
|
79
|
+
morph(root, '<p>same</p>');
|
|
80
|
+
expect(root.children[0]).toBe(child); // identity preserved
|
|
81
|
+
expect(root.innerHTML).toBe('<p>same</p>');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Attribute morphing
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('morph — attributes', () => {
|
|
91
|
+
it('adds new attributes', () => {
|
|
92
|
+
const root = morphAndGet('<div></div>', '<div class="active"></div>');
|
|
93
|
+
expect(root.children[0].className).toBe('active');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('updates existing attributes', () => {
|
|
97
|
+
const root = morphAndGet('<div class="old"></div>', '<div class="new"></div>');
|
|
98
|
+
expect(root.children[0].className).toBe('new');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('removes stale attributes', () => {
|
|
102
|
+
const root = morphAndGet('<div class="x" id="y"></div>', '<div class="x"></div>');
|
|
103
|
+
expect(root.children[0].hasAttribute('id')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('handles multiple attribute changes simultaneously', () => {
|
|
107
|
+
const root = morphAndGet(
|
|
108
|
+
'<div class="a" id="old" data-x="1"></div>',
|
|
109
|
+
'<div class="b" title="new" data-y="2"></div>'
|
|
110
|
+
);
|
|
111
|
+
const d = root.children[0];
|
|
112
|
+
expect(d.className).toBe('b');
|
|
113
|
+
expect(d.hasAttribute('id')).toBe(false);
|
|
114
|
+
expect(d.hasAttribute('data-x')).toBe(false);
|
|
115
|
+
expect(d.getAttribute('title')).toBe('new');
|
|
116
|
+
expect(d.getAttribute('data-y')).toBe('2');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('preserves attributes that have not changed', () => {
|
|
120
|
+
const root = morphAndGet('<div class="keep" id="a"></div>', '<div class="keep" id="b"></div>');
|
|
121
|
+
expect(root.children[0].className).toBe('keep');
|
|
122
|
+
expect(root.children[0].id).toBe('b');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('handles attribute with empty value', () => {
|
|
126
|
+
const root = morphAndGet('<div></div>', '<div hidden></div>');
|
|
127
|
+
expect(root.children[0].hasAttribute('hidden')).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Keyed reconciliation
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe('morph — keyed', () => {
|
|
137
|
+
it('matches elements by z-key', () => {
|
|
138
|
+
const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
|
|
139
|
+
morph(root, '<div z-key="b">B-updated</div><div z-key="a">A-updated</div>');
|
|
140
|
+
const kids = [...root.children];
|
|
141
|
+
expect(kids[0].getAttribute('z-key')).toBe('b');
|
|
142
|
+
expect(kids[0].textContent).toBe('B-updated');
|
|
143
|
+
expect(kids[1].getAttribute('z-key')).toBe('a');
|
|
144
|
+
expect(kids[1].textContent).toBe('A-updated');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('removes keyed elements not in new tree', () => {
|
|
148
|
+
const root = el('<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>');
|
|
149
|
+
morph(root, '<div z-key="a">A</div><div z-key="c">C</div>');
|
|
150
|
+
expect(root.children.length).toBe(2);
|
|
151
|
+
expect(root.children[0].getAttribute('z-key')).toBe('a');
|
|
152
|
+
expect(root.children[1].getAttribute('z-key')).toBe('c');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('inserts new keyed elements', () => {
|
|
156
|
+
const root = el('<div z-key="a">A</div>');
|
|
157
|
+
morph(root, '<div z-key="a">A</div><div z-key="b">B</div>');
|
|
158
|
+
expect(root.children.length).toBe(2);
|
|
159
|
+
expect(root.children[1].getAttribute('z-key')).toBe('b');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('preserves keyed node identity on reorder', () => {
|
|
163
|
+
const root = el('<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>');
|
|
164
|
+
const nodeA = root.children[0];
|
|
165
|
+
const nodeB = root.children[1];
|
|
166
|
+
const nodeC = root.children[2];
|
|
167
|
+
morph(root, '<div z-key="c">C</div><div z-key="a">A</div><div z-key="b">B</div>');
|
|
168
|
+
// Same DOM nodes, just reordered
|
|
169
|
+
expect(root.children[0]).toBe(nodeC);
|
|
170
|
+
expect(root.children[1]).toBe(nodeA);
|
|
171
|
+
expect(root.children[2]).toBe(nodeB);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('handles mixed keyed and unkeyed nodes', () => {
|
|
175
|
+
const root = el('<div z-key="a">A</div><p>unkeyed</p><div z-key="b">B</div>');
|
|
176
|
+
morph(root, '<div z-key="b">B2</div><span>new-unkeyed</span><div z-key="a">A2</div>');
|
|
177
|
+
expect(root.children[0].getAttribute('z-key')).toBe('b');
|
|
178
|
+
expect(root.children[0].textContent).toBe('B2');
|
|
179
|
+
expect(root.children[2].getAttribute('z-key')).toBe('a');
|
|
180
|
+
expect(root.children[2].textContent).toBe('A2');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('handles complete key set swap', () => {
|
|
184
|
+
const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
|
|
185
|
+
morph(root, '<div z-key="c">C</div><div z-key="d">D</div>');
|
|
186
|
+
expect(root.children.length).toBe(2);
|
|
187
|
+
expect(root.children[0].getAttribute('z-key')).toBe('c');
|
|
188
|
+
expect(root.children[1].getAttribute('z-key')).toBe('d');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('handles reverse order (worst case for naive diff)', () => {
|
|
192
|
+
const root = el(
|
|
193
|
+
'<div z-key="1">1</div><div z-key="2">2</div><div z-key="3">3</div>' +
|
|
194
|
+
'<div z-key="4">4</div><div z-key="5">5</div>'
|
|
195
|
+
);
|
|
196
|
+
morph(root,
|
|
197
|
+
'<div z-key="5">5</div><div z-key="4">4</div><div z-key="3">3</div>' +
|
|
198
|
+
'<div z-key="2">2</div><div z-key="1">1</div>'
|
|
199
|
+
);
|
|
200
|
+
expect([...root.children].map(c => c.getAttribute('z-key'))).toEqual(['5','4','3','2','1']);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Input / form element handling
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
describe('morph — form elements', () => {
|
|
210
|
+
it('syncs input value', () => {
|
|
211
|
+
const root = el('<input value="old">');
|
|
212
|
+
morph(root, '<input value="new">');
|
|
213
|
+
expect(root.querySelector('input').value).toBe('new');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('syncs checkbox checked state', () => {
|
|
217
|
+
const root = el('<input type="checkbox">');
|
|
218
|
+
root.querySelector('input').checked = false;
|
|
219
|
+
morph(root, '<input type="checkbox" checked>');
|
|
220
|
+
expect(root.querySelector('input').checked).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('syncs textarea content', () => {
|
|
224
|
+
const root = el('<textarea>old</textarea>');
|
|
225
|
+
morph(root, '<textarea>new</textarea>');
|
|
226
|
+
expect(root.querySelector('textarea').value).toBe('new');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('syncs input disabled state', () => {
|
|
230
|
+
const root = el('<input>');
|
|
231
|
+
morph(root, '<input disabled>');
|
|
232
|
+
expect(root.querySelector('input').disabled).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('syncs radio button', () => {
|
|
236
|
+
const root = el('<input type="radio" value="a">');
|
|
237
|
+
root.querySelector('input').checked = false;
|
|
238
|
+
morph(root, '<input type="radio" value="a" checked>');
|
|
239
|
+
expect(root.querySelector('input').checked).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('syncs select element value', () => {
|
|
243
|
+
const root = el('<select><option value="a">A</option><option value="b">B</option></select>');
|
|
244
|
+
root.querySelector('select').value = 'a';
|
|
245
|
+
morph(root, '<select><option value="a">A</option><option value="b" selected>B</option></select>');
|
|
246
|
+
// Note: jsdom may handle selected differently, but the morph should attempt to sync
|
|
247
|
+
expect(root.querySelector('select')).toBeTruthy();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Text and comment nodes
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
describe('morph — text nodes', () => {
|
|
257
|
+
it('updates text nodes', () => {
|
|
258
|
+
const root = document.createElement('div');
|
|
259
|
+
root.textContent = 'old';
|
|
260
|
+
morph(root, 'new');
|
|
261
|
+
expect(root.textContent).toBe('new');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('handles multiple text nodes', () => {
|
|
265
|
+
const root = document.createElement('div');
|
|
266
|
+
root.appendChild(document.createTextNode('a'));
|
|
267
|
+
root.appendChild(document.createTextNode('b'));
|
|
268
|
+
morph(root, 'xy');
|
|
269
|
+
expect(root.textContent).toBe('xy');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('handles comment nodes', () => {
|
|
273
|
+
const root = document.createElement('div');
|
|
274
|
+
root.appendChild(document.createComment('old comment'));
|
|
275
|
+
morph(root, '<!-- new comment -->');
|
|
276
|
+
expect(root.childNodes[0].nodeType).toBe(8); // Comment node
|
|
277
|
+
expect(root.childNodes[0].nodeValue).toBe(' new comment ');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Nested children
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
describe('morph — nested', () => {
|
|
287
|
+
it('recursively morphs nested elements', () => {
|
|
288
|
+
const root = morphAndGet(
|
|
289
|
+
'<ul><li>a</li><li>b</li></ul>',
|
|
290
|
+
'<ul><li>a</li><li>b-updated</li><li>c</li></ul>'
|
|
291
|
+
);
|
|
292
|
+
const items = root.querySelectorAll('li');
|
|
293
|
+
expect(items.length).toBe(3);
|
|
294
|
+
expect(items[1].textContent).toBe('b-updated');
|
|
295
|
+
expect(items[2].textContent).toBe('c');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('handles structure change in nested elements', () => {
|
|
299
|
+
const root = morphAndGet(
|
|
300
|
+
'<div><span>text</span></div>',
|
|
301
|
+
'<div><p>new text</p></div>'
|
|
302
|
+
);
|
|
303
|
+
expect(root.querySelector('p').textContent).toBe('new text');
|
|
304
|
+
expect(root.querySelector('span')).toBeNull();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('handles deeply nested changes (3 levels)', () => {
|
|
308
|
+
const root = morphAndGet(
|
|
309
|
+
'<div><div><div><span>deep</span></div></div></div>',
|
|
310
|
+
'<div><div><div><span>updated</span></div></div></div>'
|
|
311
|
+
);
|
|
312
|
+
expect(root.querySelector('span').textContent).toBe('updated');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('handles nested list reorder with keys', () => {
|
|
316
|
+
const root = morphAndGet(
|
|
317
|
+
'<ul><li z-key="a">A</li><li z-key="b">B</li><li z-key="c">C</li></ul>',
|
|
318
|
+
'<ul><li z-key="c">C</li><li z-key="a">A</li><li z-key="b">B</li></ul>'
|
|
319
|
+
);
|
|
320
|
+
const items = root.querySelectorAll('li');
|
|
321
|
+
expect(items[0].getAttribute('z-key')).toBe('c');
|
|
322
|
+
expect(items[1].getAttribute('z-key')).toBe('a');
|
|
323
|
+
expect(items[2].getAttribute('z-key')).toBe('b');
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Preserves unchanged nodes (identity)
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
describe('morph — preservation', () => {
|
|
333
|
+
it('does not replace elements that have not changed', () => {
|
|
334
|
+
const root = el('<p>same</p><p>will-change</p>');
|
|
335
|
+
const firstP = root.children[0];
|
|
336
|
+
morph(root, '<p>same</p><p>changed</p>');
|
|
337
|
+
expect(root.children[0]).toBe(firstP); // same DOM node
|
|
338
|
+
expect(root.children[1].textContent).toBe('changed');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('preserves identity of deeply nested unchanged nodes', () => {
|
|
342
|
+
const root = el('<div><ul><li>item</li></ul></div>');
|
|
343
|
+
const li = root.querySelector('li');
|
|
344
|
+
morph(root, '<div><ul><li>item</li></ul></div>');
|
|
345
|
+
expect(root.querySelector('li')).toBe(li);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// z-skip directive
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
describe('morph — z-skip', () => {
|
|
355
|
+
it('skips subtrees with z-skip attribute', () => {
|
|
356
|
+
const root = el('<div z-skip><p>original</p></div>');
|
|
357
|
+
const p = root.querySelector('p');
|
|
358
|
+
morph(root, '<div z-skip><p>updated</p></div>');
|
|
359
|
+
// The inner content should NOT change because z-skip is set
|
|
360
|
+
expect(root.querySelector('p')).toBe(p);
|
|
361
|
+
expect(root.querySelector('p').textContent).toBe('original');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('morphs sibling of z-skip normally', () => {
|
|
365
|
+
const root = el('<div z-skip><p>skip me</p></div><div><p>morph me</p></div>');
|
|
366
|
+
morph(root, '<div z-skip><p>different</p></div><div><p>morphed</p></div>');
|
|
367
|
+
expect(root.children[0].querySelector('p').textContent).toBe('skip me');
|
|
368
|
+
expect(root.children[1].querySelector('p').textContent).toBe('morphed');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// isEqualNode fast bail-out
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
describe('morph — fast bail-out', () => {
|
|
378
|
+
it('uses isEqualNode to skip identical elements', () => {
|
|
379
|
+
const root = el('<div><p class="a" id="b">text</p></div>');
|
|
380
|
+
const p = root.querySelector('p');
|
|
381
|
+
morph(root, '<div><p class="a" id="b">text</p></div>');
|
|
382
|
+
// isEqualNode returns true, so the node should be kept
|
|
383
|
+
expect(root.querySelector('p')).toBe(p);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Edge cases & stress
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
describe('morph — edge cases', () => {
|
|
393
|
+
it('handles whitespace-only text nodes', () => {
|
|
394
|
+
const root = morphAndGet('<div> </div>', '<div>text</div>');
|
|
395
|
+
expect(root.children[0].textContent).toBe('text');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('handles node type change (text → element)', () => {
|
|
399
|
+
const root = document.createElement('div');
|
|
400
|
+
root.appendChild(document.createTextNode('text'));
|
|
401
|
+
morph(root, '<p>element</p>');
|
|
402
|
+
expect(root.children[0].tagName).toBe('P');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('handles large number of children', () => {
|
|
406
|
+
const oldItems = Array.from({ length: 100 }, (_, i) => `<li>${i}</li>`).join('');
|
|
407
|
+
const newItems = Array.from({ length: 100 }, (_, i) => `<li>${i * 2}</li>`).join('');
|
|
408
|
+
const root = morphAndGet(`<ul>${oldItems}</ul>`, `<ul>${newItems}</ul>`);
|
|
409
|
+
expect(root.querySelectorAll('li').length).toBe(100);
|
|
410
|
+
expect(root.querySelectorAll('li')[50].textContent).toBe('100');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('handles large keyed list shuffle', () => {
|
|
414
|
+
const ids = Array.from({ length: 50 }, (_, i) => i);
|
|
415
|
+
const shuffled = [...ids].sort(() => Math.random() - 0.5);
|
|
416
|
+
const oldHTML = ids.map(i => `<div z-key="${i}">${i}</div>`).join('');
|
|
417
|
+
const newHTML = shuffled.map(i => `<div z-key="${i}">${i}</div>`).join('');
|
|
418
|
+
const root = el(oldHTML);
|
|
419
|
+
morph(root, newHTML);
|
|
420
|
+
const result = [...root.children].map(c => c.getAttribute('z-key'));
|
|
421
|
+
expect(result).toEqual(shuffled.map(String));
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('handles empty string to empty string', () => {
|
|
425
|
+
const root = morphAndGet('', '');
|
|
426
|
+
expect(root.childNodes.length).toBe(0);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// Auto-keyed reconciliation (id / data-id / data-key)
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
describe('morph — auto-keyed via id attribute', () => {
|
|
436
|
+
it('reorders elements by id without z-key', () => {
|
|
437
|
+
const root = el(
|
|
438
|
+
'<div id="a">A</div><div id="b">B</div><div id="c">C</div>'
|
|
439
|
+
);
|
|
440
|
+
const refA = root.children[0];
|
|
441
|
+
const refB = root.children[1];
|
|
442
|
+
morph(root, '<div id="c">C</div><div id="a">A</div><div id="b">B</div>');
|
|
443
|
+
// Node identity preserved — same DOM nodes, different order
|
|
444
|
+
expect(root.children[1]).toBe(refA);
|
|
445
|
+
expect(root.children[2]).toBe(refB);
|
|
446
|
+
expect([...root.children].map(c => c.id)).toEqual(['c', 'a', 'b']);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('reorders elements by data-id without z-key', () => {
|
|
450
|
+
const root = el(
|
|
451
|
+
'<li data-id="1">One</li><li data-id="2">Two</li><li data-id="3">Three</li>'
|
|
452
|
+
);
|
|
453
|
+
const ref1 = root.children[0];
|
|
454
|
+
morph(root, '<li data-id="3">Three</li><li data-id="1">One</li><li data-id="2">Two</li>');
|
|
455
|
+
expect(root.children[1]).toBe(ref1);
|
|
456
|
+
expect([...root.children].map(c => c.dataset.id)).toEqual(['3', '1', '2']);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('reorders elements by data-key without z-key', () => {
|
|
460
|
+
const root = el(
|
|
461
|
+
'<span data-key="x">X</span><span data-key="y">Y</span>'
|
|
462
|
+
);
|
|
463
|
+
const refX = root.children[0];
|
|
464
|
+
morph(root, '<span data-key="y">Y</span><span data-key="x">X</span>');
|
|
465
|
+
expect(root.children[1]).toBe(refX);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('removes auto-keyed elements not in new tree', () => {
|
|
469
|
+
const root = morphAndGet(
|
|
470
|
+
'<p id="a">A</p><p id="b">B</p><p id="c">C</p>',
|
|
471
|
+
'<p id="a">A</p><p id="c">C</p>'
|
|
472
|
+
);
|
|
473
|
+
expect(root.children.length).toBe(2);
|
|
474
|
+
expect([...root.children].map(c => c.id)).toEqual(['a', 'c']);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('inserts new auto-keyed elements', () => {
|
|
478
|
+
const root = morphAndGet(
|
|
479
|
+
'<p id="a">A</p><p id="c">C</p>',
|
|
480
|
+
'<p id="a">A</p><p id="b">B</p><p id="c">C</p>'
|
|
481
|
+
);
|
|
482
|
+
expect(root.children.length).toBe(3);
|
|
483
|
+
expect([...root.children].map(c => c.id)).toEqual(['a', 'b', 'c']);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// morphElement — single-element morph
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
describe('morphElement', () => {
|
|
493
|
+
it('morphs attributes and children, preserving identity', () => {
|
|
494
|
+
const root = el('<div class="old"><span>old</span></div>');
|
|
495
|
+
const target = root.children[0];
|
|
496
|
+
const result = morphElement(target, '<div class="new"><span>new</span></div>');
|
|
497
|
+
expect(result).toBe(target); // same node
|
|
498
|
+
expect(target.className).toBe('new');
|
|
499
|
+
expect(target.children[0].textContent).toBe('new');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('replaces element when tag name differs', () => {
|
|
503
|
+
const root = el('<div>old</div>');
|
|
504
|
+
const target = root.children[0];
|
|
505
|
+
const result = morphElement(target, '<section>new</section>');
|
|
506
|
+
expect(result).not.toBe(target);
|
|
507
|
+
expect(result.tagName).toBe('SECTION');
|
|
508
|
+
expect(result.textContent).toBe('new');
|
|
509
|
+
expect(root.children[0]).toBe(result);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('returns original element when nothing changes', () => {
|
|
513
|
+
const root = el('<p class="x">same</p>');
|
|
514
|
+
const target = root.children[0];
|
|
515
|
+
const result = morphElement(target, '<p class="x">same</p>');
|
|
516
|
+
expect(result).toBe(target);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('handles empty new HTML gracefully', () => {
|
|
520
|
+
const root = el('<div>content</div>');
|
|
521
|
+
const target = root.children[0];
|
|
522
|
+
const result = morphElement(target, '');
|
|
523
|
+
expect(result).toBe(target); // no-op on empty
|
|
524
|
+
});
|
|
525
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ZQueryError, ErrorCode, onError, reportError, guardCallback, validate } from '../src/errors.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// ZQueryError
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
describe('ZQueryError', () => {
|
|
10
|
+
it('extends Error', () => {
|
|
11
|
+
const err = new ZQueryError(ErrorCode.INVALID_ARGUMENT, 'test msg');
|
|
12
|
+
expect(err).toBeInstanceOf(Error);
|
|
13
|
+
expect(err).toBeInstanceOf(ZQueryError);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sets code, message, and context', () => {
|
|
17
|
+
const err = new ZQueryError(ErrorCode.COMP_RENDER, 'render failed', { component: 'my-app' });
|
|
18
|
+
expect(err.code).toBe('ZQ_COMP_RENDER');
|
|
19
|
+
expect(err.message).toBe('render failed');
|
|
20
|
+
expect(err.context.component).toBe('my-app');
|
|
21
|
+
expect(err.name).toBe('ZQueryError');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('stores cause', () => {
|
|
25
|
+
const cause = new Error('original');
|
|
26
|
+
const err = new ZQueryError(ErrorCode.HTTP_REQUEST, 'http error', {}, cause);
|
|
27
|
+
expect(err.cause).toBe(cause);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// ErrorCode
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('ErrorCode', () => {
|
|
37
|
+
it('is frozen', () => {
|
|
38
|
+
expect(Object.isFrozen(ErrorCode)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('contains expected codes', () => {
|
|
42
|
+
expect(ErrorCode.REACTIVE_CALLBACK).toBe('ZQ_REACTIVE_CALLBACK');
|
|
43
|
+
expect(ErrorCode.EXPR_PARSE).toBe('ZQ_EXPR_PARSE');
|
|
44
|
+
expect(ErrorCode.COMP_NOT_FOUND).toBe('ZQ_COMP_NOT_FOUND');
|
|
45
|
+
expect(ErrorCode.STORE_ACTION).toBe('ZQ_STORE_ACTION');
|
|
46
|
+
expect(ErrorCode.HTTP_REQUEST).toBe('ZQ_HTTP_REQUEST');
|
|
47
|
+
expect(ErrorCode.ROUTER_LOAD).toBe('ZQ_ROUTER_LOAD');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// onError / reportError
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
describe('reportError', () => {
|
|
57
|
+
let errorSpy;
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
onError(null); // reset handler
|
|
60
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
errorSpy.mockRestore();
|
|
64
|
+
onError(null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('logs to console.error', () => {
|
|
68
|
+
reportError(ErrorCode.STORE_ACTION, 'test error', { action: 'foo' });
|
|
69
|
+
expect(errorSpy).toHaveBeenCalledOnce();
|
|
70
|
+
expect(errorSpy.mock.calls[0][0]).toContain('ZQ_STORE_ACTION');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('calls global error handler', () => {
|
|
74
|
+
const handler = vi.fn();
|
|
75
|
+
onError(handler);
|
|
76
|
+
reportError(ErrorCode.COMP_RENDER, 'render failed');
|
|
77
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
78
|
+
const err = handler.mock.calls[0][0];
|
|
79
|
+
expect(err).toBeInstanceOf(ZQueryError);
|
|
80
|
+
expect(err.code).toBe('ZQ_COMP_RENDER');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not crash if handler throws', () => {
|
|
84
|
+
onError(() => { throw new Error('handler error'); });
|
|
85
|
+
expect(() => reportError(ErrorCode.COMP_RENDER, 'test')).not.toThrow();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('passes cause through', () => {
|
|
89
|
+
const handler = vi.fn();
|
|
90
|
+
onError(handler);
|
|
91
|
+
const cause = new Error('root cause');
|
|
92
|
+
reportError(ErrorCode.HTTP_REQUEST, 'http failed', {}, cause);
|
|
93
|
+
expect(handler.mock.calls[0][0].cause).toBe(cause);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// guardCallback
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('guardCallback', () => {
|
|
103
|
+
let errorSpy;
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
onError(null);
|
|
106
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
107
|
+
});
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
errorSpy.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns the same value as original function', () => {
|
|
113
|
+
const guarded = guardCallback((x) => x * 2, ErrorCode.COMP_RENDER);
|
|
114
|
+
expect(guarded(5)).toBe(10);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('catches errors and reports them', () => {
|
|
118
|
+
const handler = vi.fn();
|
|
119
|
+
onError(handler);
|
|
120
|
+
const guarded = guardCallback(() => { throw new Error('boom'); }, ErrorCode.COMP_RENDER, { component: 'test' });
|
|
121
|
+
expect(() => guarded()).not.toThrow();
|
|
122
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('passes arguments through', () => {
|
|
126
|
+
const guarded = guardCallback((a, b) => a + b, ErrorCode.COMP_RENDER);
|
|
127
|
+
expect(guarded(1, 2)).toBe(3);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// validate
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe('validate', () => {
|
|
137
|
+
it('passes for valid values', () => {
|
|
138
|
+
expect(() => validate('hello', 'name', 'string')).not.toThrow();
|
|
139
|
+
expect(() => validate(42, 'count', 'number')).not.toThrow();
|
|
140
|
+
expect(() => validate(() => {}, 'fn', 'function')).not.toThrow();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('throws ZQueryError for null', () => {
|
|
144
|
+
expect(() => validate(null, 'name', 'string')).toThrow(ZQueryError);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('throws ZQueryError for undefined', () => {
|
|
148
|
+
expect(() => validate(undefined, 'count')).toThrow(ZQueryError);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('throws ZQueryError for wrong type', () => {
|
|
152
|
+
expect(() => validate(42, 'name', 'string')).toThrow(ZQueryError);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('error has INVALID_ARGUMENT code', () => {
|
|
156
|
+
try {
|
|
157
|
+
validate(null, 'param');
|
|
158
|
+
} catch (err) {
|
|
159
|
+
expect(err.code).toBe(ErrorCode.INVALID_ARGUMENT);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|