zero-query 1.0.9 → 1.1.1
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/cli/commands/build-api.js +442 -0
- package/cli/commands/build.js +33 -2
- package/cli/commands/bundle.js +41 -0
- package/cli/commands/dev/server.js +56 -3
- package/cli/scaffold/default/app/components/contacts/contacts.css +9 -9
- package/cli/scaffold/default/app/components/playground/playground.css +1 -1
- package/cli/scaffold/default/app/components/playground/playground.html +5 -5
- package/cli/scaffold/default/app/components/playground/playground.js +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +3 -3
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +4 -4
- package/cli/utils.js +6 -6
- package/dist/API.md +6603 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +387 -25
- package/dist/zquery.min.js +47 -17
- package/index.d.ts +9 -3
- package/index.js +10 -2
- package/package.json +2 -1
- package/src/component.js +243 -6
- package/src/reactive.js +4 -3
- package/src/router.js +79 -9
- package/src/store.js +49 -3
- package/tests/cli.test.js +80 -0
- package/tests/compare.test.js +486 -0
- package/tests/dev-server.test.js +489 -0
- package/tests/docs.test.js +1650 -0
- package/tests/electron-features.test.js +864 -0
- package/types/misc.d.ts +7 -7
- package/types/reactive.d.ts +1 -1
- package/types/store.d.ts +2 -1
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createStore, getStore, connectStore } from '../src/store.js';
|
|
3
|
+
import { component, mount, mountAll, getInstance, destroy, getRegistry } from '../src/component.js';
|
|
4
|
+
import { createRouter, getRouter } from '../src/router.js';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// ===========================================================================
|
|
8
|
+
// Feature 1: Multi-Key Store Subscriptions
|
|
9
|
+
// ===========================================================================
|
|
10
|
+
|
|
11
|
+
describe('Store - multi-key subscriptions', () => {
|
|
12
|
+
it('subscribes to multiple keys with array syntax', () => {
|
|
13
|
+
const store = createStore('multi-key-1', {
|
|
14
|
+
state: { files: [], isProcessing: false, operation: '' },
|
|
15
|
+
actions: {
|
|
16
|
+
setFiles(state, v) { state.files = v; },
|
|
17
|
+
setProcessing(state, v) { state.isProcessing = v; },
|
|
18
|
+
setOperation(state, v) { state.operation = v; },
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const calls = [];
|
|
22
|
+
store.subscribe(['files', 'isProcessing'], (key, value, old) => {
|
|
23
|
+
calls.push({ key, newVal: value, oldVal: old });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
store.dispatch('setFiles', ['a.mp3']);
|
|
27
|
+
store.dispatch('setProcessing', true);
|
|
28
|
+
store.dispatch('setOperation', 'encode');
|
|
29
|
+
|
|
30
|
+
// Should only fire for 'files' and 'isProcessing', NOT 'operation'
|
|
31
|
+
expect(calls.length).toBe(2);
|
|
32
|
+
expect(calls[0].key).toBe('files');
|
|
33
|
+
expect(calls[1].key).toBe('isProcessing');
|
|
34
|
+
expect(calls[1].newVal).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('unsubscribes from multi-key subscription', () => {
|
|
38
|
+
const store = createStore('multi-key-2', {
|
|
39
|
+
state: { a: 0, b: 0 },
|
|
40
|
+
actions: {
|
|
41
|
+
setA(state, v) { state.a = v; },
|
|
42
|
+
setB(state, v) { state.b = v; },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const fn = vi.fn();
|
|
46
|
+
const unsub = store.subscribe(['a', 'b'], fn);
|
|
47
|
+
|
|
48
|
+
store.dispatch('setA', 1);
|
|
49
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
50
|
+
|
|
51
|
+
unsub();
|
|
52
|
+
store.dispatch('setB', 2);
|
|
53
|
+
expect(fn).toHaveBeenCalledTimes(1); // still 1, unsubscribed
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('multi-key subscription does not fire for unlisted keys', () => {
|
|
57
|
+
const store = createStore('multi-key-3', {
|
|
58
|
+
state: { x: 0, y: 0, z: 0 },
|
|
59
|
+
actions: {
|
|
60
|
+
setX(state, v) { state.x = v; },
|
|
61
|
+
setY(state, v) { state.y = v; },
|
|
62
|
+
setZ(state, v) { state.z = v; },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const fn = vi.fn();
|
|
66
|
+
store.subscribe(['x'], fn);
|
|
67
|
+
|
|
68
|
+
store.dispatch('setY', 1);
|
|
69
|
+
store.dispatch('setZ', 2);
|
|
70
|
+
expect(fn).not.toHaveBeenCalled();
|
|
71
|
+
|
|
72
|
+
store.dispatch('setX', 5);
|
|
73
|
+
expect(fn).toHaveBeenCalledWith('x', 5, 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('mixed: single-key, multi-key, and wildcard subscribers all fire correctly', () => {
|
|
77
|
+
const store = createStore('multi-key-4', {
|
|
78
|
+
state: { a: 0, b: 0, c: 0 },
|
|
79
|
+
actions: {
|
|
80
|
+
setA(state, v) { state.a = v; },
|
|
81
|
+
setB(state, v) { state.b = v; },
|
|
82
|
+
setC(state, v) { state.c = v; },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const singleFn = vi.fn();
|
|
86
|
+
const multiFn = vi.fn();
|
|
87
|
+
const wildcardFn = vi.fn();
|
|
88
|
+
|
|
89
|
+
store.subscribe('a', singleFn);
|
|
90
|
+
store.subscribe(['a', 'b'], multiFn);
|
|
91
|
+
store.subscribe(wildcardFn);
|
|
92
|
+
|
|
93
|
+
store.dispatch('setA', 10);
|
|
94
|
+
expect(singleFn).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(multiFn).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(wildcardFn).toHaveBeenCalledTimes(1);
|
|
97
|
+
|
|
98
|
+
store.dispatch('setC', 30);
|
|
99
|
+
expect(singleFn).toHaveBeenCalledTimes(1);
|
|
100
|
+
expect(multiFn).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(wildcardFn).toHaveBeenCalledTimes(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('multi-key subscription works with batch', () => {
|
|
105
|
+
const store = createStore('multi-key-batch', {
|
|
106
|
+
state: { a: 0, b: 0 },
|
|
107
|
+
});
|
|
108
|
+
const fn = vi.fn();
|
|
109
|
+
store.subscribe(['a', 'b'], fn);
|
|
110
|
+
|
|
111
|
+
store.batch(state => {
|
|
112
|
+
state.a = 1;
|
|
113
|
+
state.b = 2;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Batch deduplicates per key, so each key fires once
|
|
117
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('empty array subscription never fires', () => {
|
|
121
|
+
const store = createStore('multi-key-empty', {
|
|
122
|
+
state: { x: 0 },
|
|
123
|
+
actions: { setX(state, v) { state.x = v; } },
|
|
124
|
+
});
|
|
125
|
+
const fn = vi.fn();
|
|
126
|
+
store.subscribe([], fn);
|
|
127
|
+
store.dispatch('setX', 1);
|
|
128
|
+
expect(fn).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
// ===========================================================================
|
|
134
|
+
// Feature 2: Reactive Component Props
|
|
135
|
+
// ===========================================================================
|
|
136
|
+
|
|
137
|
+
describe('Component - reactive props', () => {
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
document.body.innerHTML = '';
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('reads props from element attributes with type coercion', () => {
|
|
143
|
+
component('prop-card', {
|
|
144
|
+
props: {
|
|
145
|
+
label: { type: String, default: '' },
|
|
146
|
+
value: { type: Number, default: 0 },
|
|
147
|
+
active: { type: Boolean, default: false },
|
|
148
|
+
},
|
|
149
|
+
render() {
|
|
150
|
+
return `<div>${this.props.label}: ${this.props.value} (${this.props.active})</div>`;
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
document.body.innerHTML = '<prop-card id="pc" label="Count" value="42" active="true"></prop-card>';
|
|
154
|
+
const inst = mount('#pc', 'prop-card');
|
|
155
|
+
expect(inst.props.label).toBe('Count');
|
|
156
|
+
expect(inst.props.value).toBe(42);
|
|
157
|
+
expect(inst.props.active).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('uses default values when attributes are missing', () => {
|
|
161
|
+
component('prop-defaults', {
|
|
162
|
+
props: {
|
|
163
|
+
label: { type: String, default: 'untitled' },
|
|
164
|
+
count: { type: Number, default: 5 },
|
|
165
|
+
},
|
|
166
|
+
render() {
|
|
167
|
+
return `<div>${this.props.label}: ${this.props.count}</div>`;
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
document.body.innerHTML = '<prop-defaults id="pd"></prop-defaults>';
|
|
171
|
+
const inst = mount('#pd', 'prop-defaults');
|
|
172
|
+
expect(inst.props.label).toBe('untitled');
|
|
173
|
+
expect(inst.props.count).toBe(5);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('passed props override attributes', () => {
|
|
177
|
+
component('prop-override', {
|
|
178
|
+
props: {
|
|
179
|
+
title: { type: String, default: '' },
|
|
180
|
+
},
|
|
181
|
+
render() {
|
|
182
|
+
return `<h1>${this.props.title}</h1>`;
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
document.body.innerHTML = '<prop-override id="po" title="from-attr"></prop-override>';
|
|
186
|
+
const inst = mount('#po', 'prop-override', { title: 'from-mount' });
|
|
187
|
+
expect(inst.props.title).toBe('from-mount');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('coerces Boolean props correctly', () => {
|
|
191
|
+
component('prop-bool', {
|
|
192
|
+
props: {
|
|
193
|
+
enabled: { type: Boolean, default: false },
|
|
194
|
+
disabled: { type: Boolean, default: true },
|
|
195
|
+
},
|
|
196
|
+
render() { return '<div></div>'; },
|
|
197
|
+
});
|
|
198
|
+
document.body.innerHTML = '<prop-bool id="pb" enabled="true" disabled="false"></prop-bool>';
|
|
199
|
+
const inst = mount('#pb', 'prop-bool');
|
|
200
|
+
expect(inst.props.enabled).toBe(true);
|
|
201
|
+
expect(inst.props.disabled).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('coerces Object/JSON props', () => {
|
|
205
|
+
component('prop-json', {
|
|
206
|
+
props: {
|
|
207
|
+
config: { type: Object, default: () => ({}) },
|
|
208
|
+
},
|
|
209
|
+
render() { return '<div></div>'; },
|
|
210
|
+
});
|
|
211
|
+
document.body.innerHTML = `<prop-json id="pj" config='{"theme":"dark"}'></prop-json>`;
|
|
212
|
+
const inst = mount('#pj', 'prop-json');
|
|
213
|
+
expect(inst.props.config).toEqual({ theme: 'dark' });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('props are frozen (read-only)', () => {
|
|
217
|
+
component('prop-frozen', {
|
|
218
|
+
props: {
|
|
219
|
+
name: { type: String, default: 'test' },
|
|
220
|
+
},
|
|
221
|
+
render() { return '<div></div>'; },
|
|
222
|
+
});
|
|
223
|
+
document.body.innerHTML = '<prop-frozen id="pf" name="hello"></prop-frozen>';
|
|
224
|
+
const inst = mount('#pf', 'prop-frozen');
|
|
225
|
+
expect(() => { inst.props.name = 'changed'; }).toThrow();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('re-reads props when attributes change (MutationObserver)', async () => {
|
|
229
|
+
component('prop-observe', {
|
|
230
|
+
props: {
|
|
231
|
+
label: { type: String, default: '' },
|
|
232
|
+
},
|
|
233
|
+
render() {
|
|
234
|
+
return `<span>${this.props.label}</span>`;
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
document.body.innerHTML = '<prop-observe id="pob" label="initial"></prop-observe>';
|
|
238
|
+
const inst = mount('#pob', 'prop-observe');
|
|
239
|
+
expect(inst.props.label).toBe('initial');
|
|
240
|
+
|
|
241
|
+
// Change the attribute
|
|
242
|
+
document.querySelector('#pob').setAttribute('label', 'updated');
|
|
243
|
+
// MutationObserver fires asynchronously
|
|
244
|
+
await new Promise(r => setTimeout(r, 50));
|
|
245
|
+
expect(inst.props.label).toBe('updated');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('supports shorthand type-only syntax', () => {
|
|
249
|
+
component('prop-short', {
|
|
250
|
+
props: {
|
|
251
|
+
name: String,
|
|
252
|
+
count: Number,
|
|
253
|
+
},
|
|
254
|
+
render() { return '<div></div>'; },
|
|
255
|
+
});
|
|
256
|
+
document.body.innerHTML = '<prop-short id="ps" name="test" count="7"></prop-short>';
|
|
257
|
+
const inst = mount('#ps', 'prop-short');
|
|
258
|
+
expect(inst.props.name).toBe('test');
|
|
259
|
+
expect(inst.props.count).toBe(7);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('legacy frozen props work when no props definition', () => {
|
|
263
|
+
component('prop-legacy', {
|
|
264
|
+
render() { return `<div>${this.props.title}</div>`; },
|
|
265
|
+
});
|
|
266
|
+
document.body.innerHTML = '<prop-legacy id="pl"></prop-legacy>';
|
|
267
|
+
const inst = mount('#pl', 'prop-legacy', { title: 'hello' });
|
|
268
|
+
expect(inst.props.title).toBe('hello');
|
|
269
|
+
expect(Object.isFrozen(inst.props)).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('cleans up MutationObserver on destroy', () => {
|
|
273
|
+
component('prop-cleanup', {
|
|
274
|
+
props: {
|
|
275
|
+
val: { type: String, default: '' },
|
|
276
|
+
},
|
|
277
|
+
render() { return '<div></div>'; },
|
|
278
|
+
});
|
|
279
|
+
document.body.innerHTML = '<prop-cleanup id="pcl" val="x"></prop-cleanup>';
|
|
280
|
+
const inst = mount('#pcl', 'prop-cleanup');
|
|
281
|
+
expect(inst._propObserver).toBeDefined();
|
|
282
|
+
inst.destroy();
|
|
283
|
+
expect(inst._propObserver).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
// ===========================================================================
|
|
289
|
+
// Feature 3: Store-Component Connector
|
|
290
|
+
// ===========================================================================
|
|
291
|
+
|
|
292
|
+
describe('connectStore - store-component connector', () => {
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
document.body.innerHTML = '';
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('creates a connector descriptor', () => {
|
|
298
|
+
const store = createStore('conn-test-1', { state: { x: 1 } });
|
|
299
|
+
const desc = connectStore(store, ['x']);
|
|
300
|
+
expect(desc._zqConnector).toBe(true);
|
|
301
|
+
expect(desc.store).toBe(store);
|
|
302
|
+
expect(desc.keys).toEqual(['x']);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('auto-syncs store state to component.stores on mount', () => {
|
|
306
|
+
const myStore = createStore('conn-test-2', {
|
|
307
|
+
state: { files: ['a.mp3', 'b.mp3'], mode: 'normal' },
|
|
308
|
+
actions: { setMode(state, v) { state.mode = v; } },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
component('store-comp', {
|
|
312
|
+
stores: {
|
|
313
|
+
app: connectStore(myStore, ['files', 'mode']),
|
|
314
|
+
},
|
|
315
|
+
render() {
|
|
316
|
+
return `<div>${this.stores.app.files.length} files, ${this.stores.app.mode}</div>`;
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
document.body.innerHTML = '<store-comp id="sc"></store-comp>';
|
|
321
|
+
const inst = mount('#sc', 'store-comp');
|
|
322
|
+
|
|
323
|
+
expect(inst.stores.app.files).toEqual(['a.mp3', 'b.mp3']);
|
|
324
|
+
expect(inst.stores.app.mode).toBe('normal');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('updates component when store state changes', async () => {
|
|
328
|
+
const myStore = createStore('conn-test-3', {
|
|
329
|
+
state: { count: 0 },
|
|
330
|
+
actions: { inc(state) { state.count++; } },
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
component('store-reactive', {
|
|
334
|
+
stores: {
|
|
335
|
+
data: connectStore(myStore, ['count']),
|
|
336
|
+
},
|
|
337
|
+
render() {
|
|
338
|
+
return `<span class="count">${this.stores.data.count}</span>`;
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
document.body.innerHTML = '<store-reactive id="sr"></store-reactive>';
|
|
343
|
+
const inst = mount('#sr', 'store-reactive');
|
|
344
|
+
expect(inst.stores.data.count).toBe(0);
|
|
345
|
+
|
|
346
|
+
myStore.dispatch('inc');
|
|
347
|
+
expect(inst.stores.data.count).toBe(1);
|
|
348
|
+
|
|
349
|
+
// Wait for microtask re-render
|
|
350
|
+
await new Promise(r => queueMicrotask(r));
|
|
351
|
+
expect(document.querySelector('.count').textContent).toBe('1');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('cleans up subscriptions on destroy', () => {
|
|
355
|
+
const myStore = createStore('conn-test-4', {
|
|
356
|
+
state: { x: 0 },
|
|
357
|
+
actions: { setX(state, v) { state.x = v; } },
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
component('store-cleanup', {
|
|
361
|
+
stores: {
|
|
362
|
+
s: connectStore(myStore, ['x']),
|
|
363
|
+
},
|
|
364
|
+
render() { return '<div></div>'; },
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
document.body.innerHTML = '<store-cleanup id="scl"></store-cleanup>';
|
|
368
|
+
const inst = mount('#scl', 'store-cleanup');
|
|
369
|
+
|
|
370
|
+
const wildcardsBefore = myStore._wildcards.size;
|
|
371
|
+
inst.destroy();
|
|
372
|
+
const wildcardsAfter = myStore._wildcards.size;
|
|
373
|
+
expect(wildcardsAfter).toBe(wildcardsBefore - 1);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('supports multiple store connections', () => {
|
|
377
|
+
const storeA = createStore('conn-a', {
|
|
378
|
+
state: { mode: 'edit' },
|
|
379
|
+
});
|
|
380
|
+
const storeB = createStore('conn-b', {
|
|
381
|
+
state: { theme: 'dark' },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
component('multi-store', {
|
|
385
|
+
stores: {
|
|
386
|
+
app: connectStore(storeA, ['mode']),
|
|
387
|
+
ui: connectStore(storeB, ['theme']),
|
|
388
|
+
},
|
|
389
|
+
render() {
|
|
390
|
+
return `<div>${this.stores.app.mode} - ${this.stores.ui.theme}</div>`;
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
document.body.innerHTML = '<multi-store id="ms"></multi-store>';
|
|
395
|
+
const inst = mount('#ms', 'multi-store');
|
|
396
|
+
expect(inst.stores.app.mode).toBe('edit');
|
|
397
|
+
expect(inst.stores.ui.theme).toBe('dark');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('only subscribes to listed keys, not all', () => {
|
|
401
|
+
const myStore = createStore('conn-selective', {
|
|
402
|
+
state: { a: 0, b: 0, c: 0 },
|
|
403
|
+
actions: { setC(state, v) { state.c = v; } },
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const updateSpy = vi.fn();
|
|
407
|
+
component('store-selective', {
|
|
408
|
+
stores: {
|
|
409
|
+
s: connectStore(myStore, ['a', 'b']),
|
|
410
|
+
},
|
|
411
|
+
updated: updateSpy,
|
|
412
|
+
render() { return '<div></div>'; },
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
document.body.innerHTML = '<store-selective id="ss"></store-selective>';
|
|
416
|
+
mount('#ss', 'store-selective');
|
|
417
|
+
|
|
418
|
+
// Change a key NOT in the subscription list
|
|
419
|
+
myStore.dispatch('setC', 99);
|
|
420
|
+
|
|
421
|
+
// updated() should NOT be called for key 'c'
|
|
422
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
// ===========================================================================
|
|
428
|
+
// Feature 4: Router keepAlive
|
|
429
|
+
// ===========================================================================
|
|
430
|
+
|
|
431
|
+
describe('Router - keepAlive', () => {
|
|
432
|
+
beforeEach(() => {
|
|
433
|
+
document.body.innerHTML = '<div id="outlet"></div>';
|
|
434
|
+
window.location.hash = '#/';
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
afterEach(() => {
|
|
438
|
+
const router = getRouter();
|
|
439
|
+
if (router) router.destroy();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('caches keep-alive route components', async () => {
|
|
443
|
+
component('player-page', {
|
|
444
|
+
state: () => ({ playCount: 0 }),
|
|
445
|
+
render() { return `<div class="player">${this.state.playCount}</div>`; },
|
|
446
|
+
});
|
|
447
|
+
component('dash-page', {
|
|
448
|
+
render() { return '<div class="dash">Dashboard</div>'; },
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const router = createRouter({
|
|
452
|
+
el: '#outlet',
|
|
453
|
+
mode: 'hash',
|
|
454
|
+
routes: [
|
|
455
|
+
{ path: '/player', component: 'player-page', keepAlive: true },
|
|
456
|
+
{ path: '/', component: 'dash-page' },
|
|
457
|
+
],
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Navigate to player
|
|
461
|
+
router.navigate('/player');
|
|
462
|
+
await new Promise(r => setTimeout(r, 50));
|
|
463
|
+
expect(document.querySelector('.player')).not.toBeNull();
|
|
464
|
+
|
|
465
|
+
// Mutate player state
|
|
466
|
+
const playerInst = router._instance;
|
|
467
|
+
playerInst.state.playCount = 5;
|
|
468
|
+
|
|
469
|
+
// Navigate away
|
|
470
|
+
router.navigate('/');
|
|
471
|
+
await new Promise(r => setTimeout(r, 50));
|
|
472
|
+
|
|
473
|
+
// Navigate back to player - should reuse cached instance
|
|
474
|
+
router.navigate('/player');
|
|
475
|
+
await new Promise(r => setTimeout(r, 50));
|
|
476
|
+
expect(router._instance).toBe(playerInst);
|
|
477
|
+
expect(router._instance.state.playCount).toBe(5); // state preserved
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('calls activated/deactivated lifecycle hooks', async () => {
|
|
481
|
+
const activated = vi.fn();
|
|
482
|
+
const deactivated = vi.fn();
|
|
483
|
+
|
|
484
|
+
component('ka-comp', {
|
|
485
|
+
activated,
|
|
486
|
+
deactivated,
|
|
487
|
+
render() { return '<div>keepAlive comp</div>'; },
|
|
488
|
+
});
|
|
489
|
+
component('other-comp', {
|
|
490
|
+
render() { return '<div>other</div>'; },
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const router = createRouter({
|
|
494
|
+
el: '#outlet',
|
|
495
|
+
mode: 'hash',
|
|
496
|
+
routes: [
|
|
497
|
+
{ path: '/ka', component: 'ka-comp', keepAlive: true },
|
|
498
|
+
{ path: '/', component: 'other-comp' },
|
|
499
|
+
],
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// First visit - activated on mount
|
|
503
|
+
router.navigate('/ka');
|
|
504
|
+
await new Promise(r => setTimeout(r, 50));
|
|
505
|
+
expect(activated).toHaveBeenCalledTimes(1);
|
|
506
|
+
|
|
507
|
+
// Navigate away - deactivated
|
|
508
|
+
router.navigate('/');
|
|
509
|
+
await new Promise(r => setTimeout(r, 50));
|
|
510
|
+
expect(deactivated).toHaveBeenCalledTimes(1);
|
|
511
|
+
|
|
512
|
+
// Navigate back - activated again
|
|
513
|
+
router.navigate('/ka');
|
|
514
|
+
await new Promise(r => setTimeout(r, 50));
|
|
515
|
+
expect(activated).toHaveBeenCalledTimes(2);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('non-keepAlive routes destroy normally', async () => {
|
|
519
|
+
const destroyFn = vi.fn();
|
|
520
|
+
component('normal-page', {
|
|
521
|
+
destroyed: destroyFn,
|
|
522
|
+
render() { return '<div>normal</div>'; },
|
|
523
|
+
});
|
|
524
|
+
component('ka-page', {
|
|
525
|
+
render() { return '<div>ka</div>'; },
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const router = createRouter({
|
|
529
|
+
el: '#outlet',
|
|
530
|
+
mode: 'hash',
|
|
531
|
+
routes: [
|
|
532
|
+
{ path: '/a', component: 'normal-page' },
|
|
533
|
+
{ path: '/b', component: 'ka-page', keepAlive: true },
|
|
534
|
+
],
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
router.navigate('/a');
|
|
538
|
+
await new Promise(r => setTimeout(r, 50));
|
|
539
|
+
|
|
540
|
+
router.navigate('/b');
|
|
541
|
+
await new Promise(r => setTimeout(r, 50));
|
|
542
|
+
expect(destroyFn).toHaveBeenCalledTimes(1);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('hides keep-alive container with display:none on deactivation', async () => {
|
|
546
|
+
component('ka-hide', {
|
|
547
|
+
render() { return '<div>hidden-test</div>'; },
|
|
548
|
+
});
|
|
549
|
+
component('other-hide', {
|
|
550
|
+
render() { return '<div>other</div>'; },
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const router = createRouter({
|
|
554
|
+
el: '#outlet',
|
|
555
|
+
mode: 'hash',
|
|
556
|
+
routes: [
|
|
557
|
+
{ path: '/ka', component: 'ka-hide', keepAlive: true },
|
|
558
|
+
{ path: '/', component: 'other-hide' },
|
|
559
|
+
],
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
router.navigate('/ka');
|
|
563
|
+
await new Promise(r => setTimeout(r, 50));
|
|
564
|
+
const kaContainer = document.querySelector('ka-hide');
|
|
565
|
+
expect(kaContainer).not.toBeNull();
|
|
566
|
+
|
|
567
|
+
router.navigate('/');
|
|
568
|
+
await new Promise(r => setTimeout(r, 50));
|
|
569
|
+
// The ka container should still be in the DOM but hidden
|
|
570
|
+
const hiddenKa = document.querySelector('ka-hide');
|
|
571
|
+
expect(hiddenKa).not.toBeNull();
|
|
572
|
+
expect(hiddenKa.style.display).toBe('none');
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
// ===========================================================================
|
|
578
|
+
// Feature 5: Transition Directives
|
|
579
|
+
// ===========================================================================
|
|
580
|
+
|
|
581
|
+
describe('Component - transitions', () => {
|
|
582
|
+
beforeEach(() => {
|
|
583
|
+
document.body.innerHTML = '';
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('adds enter transition classes on z-if=true with z-transition', () => {
|
|
587
|
+
component('trans-if', {
|
|
588
|
+
state: () => ({ show: true }),
|
|
589
|
+
render() {
|
|
590
|
+
return `<div z-if="show" z-transition="fade">Content</div>`;
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
document.body.innerHTML = '<trans-if id="ti"></trans-if>';
|
|
594
|
+
const inst = mount('#ti', 'trans-if');
|
|
595
|
+
const el = document.querySelector('#ti div');
|
|
596
|
+
// On enter, should have enter-active and enter-from/enter-to classes
|
|
597
|
+
expect(el.classList.contains('fade-enter-active') || el.classList.contains('fade-enter-from')).toBe(true);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('component-level transition config applies enter class', () => {
|
|
601
|
+
component('trans-cfg', {
|
|
602
|
+
state: () => ({ show: true }),
|
|
603
|
+
transition: {
|
|
604
|
+
enter: 'animate-fade-in',
|
|
605
|
+
leave: 'animate-fade-out',
|
|
606
|
+
duration: 10,
|
|
607
|
+
},
|
|
608
|
+
render() {
|
|
609
|
+
return `<div z-if="show" z-transition="custom">Content</div>`;
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
document.body.innerHTML = '<trans-cfg id="tc"></trans-cfg>';
|
|
613
|
+
mount('#tc', 'trans-cfg');
|
|
614
|
+
// Component-level transition should apply enter class
|
|
615
|
+
const el = document.querySelector('#tc div');
|
|
616
|
+
expect(el).not.toBeNull();
|
|
617
|
+
expect(el.classList.contains('animate-fade-in')).toBe(true);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('z-show with z-transition sets enter classes on visible elements', () => {
|
|
621
|
+
component('trans-show', {
|
|
622
|
+
state: () => ({ visible: true }),
|
|
623
|
+
render() {
|
|
624
|
+
return `<div z-show="visible" z-transition="slide" data-zq-hidden="">Content</div>`;
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
document.body.innerHTML = '<trans-show id="ts"></trans-show>';
|
|
628
|
+
mount('#ts', 'trans-show');
|
|
629
|
+
const el = document.querySelector('#ts div');
|
|
630
|
+
expect(el).not.toBeNull();
|
|
631
|
+
// Should be visible (not hidden)
|
|
632
|
+
expect(el.style.display).not.toBe('none');
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
// ===========================================================================
|
|
638
|
+
// Feature 6: Component Scoped Events (emit/on)
|
|
639
|
+
// ===========================================================================
|
|
640
|
+
|
|
641
|
+
describe('Component - scoped events (emit/on)', () => {
|
|
642
|
+
beforeEach(() => {
|
|
643
|
+
document.body.innerHTML = '';
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('emit() dispatches CustomEvent that bubbles', () => {
|
|
647
|
+
component('emit-child', {
|
|
648
|
+
render() { return '<button>click</button>'; },
|
|
649
|
+
doEmit() {
|
|
650
|
+
this.emit('item-selected', { id: 42 });
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
document.body.innerHTML = '<emit-child id="ec"></emit-child>';
|
|
655
|
+
const inst = mount('#ec', 'emit-child');
|
|
656
|
+
|
|
657
|
+
const handler = vi.fn();
|
|
658
|
+
document.body.addEventListener('item-selected', handler);
|
|
659
|
+
|
|
660
|
+
inst.doEmit();
|
|
661
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
662
|
+
expect(handler.mock.calls[0][0].detail).toEqual({ id: 42 });
|
|
663
|
+
|
|
664
|
+
document.body.removeEventListener('item-selected', handler);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('emit() event is cancelable', () => {
|
|
668
|
+
component('emit-cancel', {
|
|
669
|
+
render() { return '<div></div>'; },
|
|
670
|
+
fire() { return this.emit('my-event', { x: 1 }); },
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
document.body.innerHTML = '<emit-cancel id="ecn"></emit-cancel>';
|
|
674
|
+
const inst = mount('#ecn', 'emit-cancel');
|
|
675
|
+
|
|
676
|
+
let prevented = false;
|
|
677
|
+
document.querySelector('#ecn').addEventListener('my-event', (e) => {
|
|
678
|
+
e.preventDefault();
|
|
679
|
+
prevented = true;
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
inst.fire();
|
|
683
|
+
expect(prevented).toBe(true);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('parent @event binding catches child emit', async () => {
|
|
687
|
+
component('child-emitter', {
|
|
688
|
+
render() { return '<button @click="sendEvent">Go</button>'; },
|
|
689
|
+
sendEvent() {
|
|
690
|
+
this.emit('done', { result: 'ok' });
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
component('parent-listener', {
|
|
695
|
+
state: () => ({ received: '' }),
|
|
696
|
+
render() {
|
|
697
|
+
return `<child-emitter @done="onDone"></child-emitter><span>${this.state.received}</span>`;
|
|
698
|
+
},
|
|
699
|
+
onDone(e) {
|
|
700
|
+
this.state.received = e.detail.result;
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
document.body.innerHTML = '<parent-listener id="pl"></parent-listener>';
|
|
705
|
+
const parentInst = mount('#pl', 'parent-listener');
|
|
706
|
+
|
|
707
|
+
// Get the child instance and trigger emit
|
|
708
|
+
const childEl = document.querySelector('child-emitter');
|
|
709
|
+
const childInst = getInstance(childEl);
|
|
710
|
+
childInst.sendEvent();
|
|
711
|
+
|
|
712
|
+
// Wait for re-render
|
|
713
|
+
await new Promise(r => queueMicrotask(r));
|
|
714
|
+
expect(parentInst.state.received).toBe('ok');
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
// ===========================================================================
|
|
720
|
+
// Feature 7: Electron Environment Detection
|
|
721
|
+
// ===========================================================================
|
|
722
|
+
|
|
723
|
+
describe('Electron environment detection', () => {
|
|
724
|
+
it('$.isElectron is false in jsdom/browser', async () => {
|
|
725
|
+
// Dynamic import to test the evaluated values
|
|
726
|
+
const mod = await import('../index.js');
|
|
727
|
+
const $ = mod.default;
|
|
728
|
+
expect($.isElectron).toBe(false);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('$.platform is "browser" in jsdom', async () => {
|
|
732
|
+
const mod = await import('../index.js');
|
|
733
|
+
const $ = mod.default;
|
|
734
|
+
expect($.platform).toBe('browser');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('$.isElectron detects Electron via navigator.userAgent', () => {
|
|
738
|
+
// Simulate Electron user agent
|
|
739
|
+
const original = navigator.userAgent;
|
|
740
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
741
|
+
value: 'Mozilla/5.0 Electron/25.0.0',
|
|
742
|
+
configurable: true,
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Re-evaluate the detection logic
|
|
746
|
+
const isElectron = /Electron/i.test(navigator.userAgent);
|
|
747
|
+
expect(isElectron).toBe(true);
|
|
748
|
+
|
|
749
|
+
// Restore
|
|
750
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
751
|
+
value: original,
|
|
752
|
+
configurable: true,
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('$.isElectron detects Electron via process.versions.electron', () => {
|
|
757
|
+
// Simulate Electron process object
|
|
758
|
+
const origProcess = globalThis.process;
|
|
759
|
+
globalThis.process = { versions: { electron: '25.0.0', node: '18.0.0' } };
|
|
760
|
+
|
|
761
|
+
const isElectron = typeof process !== 'undefined' && process.versions != null && !!process.versions.electron;
|
|
762
|
+
expect(isElectron).toBe(true);
|
|
763
|
+
|
|
764
|
+
globalThis.process = origProcess;
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('platform resolves correctly for different environments', () => {
|
|
768
|
+
// browser: window exists, not electron
|
|
769
|
+
const platformBrowser = (false) ? 'electron' : (typeof window !== 'undefined') ? 'browser' : 'node';
|
|
770
|
+
expect(platformBrowser).toBe('browser');
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
// ===========================================================================
|
|
776
|
+
// Feature: connectStore is exported
|
|
777
|
+
// ===========================================================================
|
|
778
|
+
|
|
779
|
+
describe('connectStore export', () => {
|
|
780
|
+
it('is importable from store module', () => {
|
|
781
|
+
expect(typeof connectStore).toBe('function');
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('is available on $ namespace', async () => {
|
|
785
|
+
const mod = await import('../index.js');
|
|
786
|
+
const $ = mod.default;
|
|
787
|
+
expect(typeof $.connectStore).toBe('function');
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
// ===========================================================================
|
|
793
|
+
// Integration: Full component with props + stores + emit
|
|
794
|
+
// ===========================================================================
|
|
795
|
+
|
|
796
|
+
describe('Integration - props + stores + emit', () => {
|
|
797
|
+
beforeEach(() => {
|
|
798
|
+
document.body.innerHTML = '';
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('component with reactive props and store connector renders correctly', async () => {
|
|
802
|
+
const appStore = createStore('integ-store', {
|
|
803
|
+
state: { theme: 'dark', count: 0 },
|
|
804
|
+
actions: {
|
|
805
|
+
setTheme(state, v) { state.theme = v; },
|
|
806
|
+
inc(state) { state.count++; },
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
component('integ-card', {
|
|
811
|
+
props: {
|
|
812
|
+
title: { type: String, default: 'Card' },
|
|
813
|
+
size: { type: Number, default: 1 },
|
|
814
|
+
},
|
|
815
|
+
stores: {
|
|
816
|
+
app: connectStore(appStore, ['theme', 'count']),
|
|
817
|
+
},
|
|
818
|
+
render() {
|
|
819
|
+
return `<div class="card ${this.stores.app.theme}">
|
|
820
|
+
<h2>${this.props.title} (${this.props.size})</h2>
|
|
821
|
+
<span class="count">${this.stores.app.count}</span>
|
|
822
|
+
</div>`;
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
document.body.innerHTML = '<integ-card id="ic" title="Stats" size="3"></integ-card>';
|
|
827
|
+
const inst = mount('#ic', 'integ-card');
|
|
828
|
+
|
|
829
|
+
expect(inst.props.title).toBe('Stats');
|
|
830
|
+
expect(inst.props.size).toBe(3);
|
|
831
|
+
expect(inst.stores.app.theme).toBe('dark');
|
|
832
|
+
expect(inst.stores.app.count).toBe(0);
|
|
833
|
+
|
|
834
|
+
// Dispatch store action
|
|
835
|
+
appStore.dispatch('inc');
|
|
836
|
+
expect(inst.stores.app.count).toBe(1);
|
|
837
|
+
|
|
838
|
+
// Wait for re-render
|
|
839
|
+
await new Promise(r => queueMicrotask(r));
|
|
840
|
+
expect(document.querySelector('.count').textContent).toBe('1');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('emitting from connected component works', () => {
|
|
844
|
+
const store = createStore('integ-emit', { state: { x: 0 } });
|
|
845
|
+
|
|
846
|
+
component('emit-connected', {
|
|
847
|
+
stores: {
|
|
848
|
+
s: connectStore(store, ['x']),
|
|
849
|
+
},
|
|
850
|
+
render() { return '<div></div>'; },
|
|
851
|
+
notify() { this.emit('status-change', { x: this.stores.s.x }); },
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
document.body.innerHTML = '<emit-connected id="emc"></emit-connected>';
|
|
855
|
+
const inst = mount('#emc', 'emit-connected');
|
|
856
|
+
|
|
857
|
+
const handler = vi.fn();
|
|
858
|
+
document.querySelector('#emc').addEventListener('status-change', handler);
|
|
859
|
+
|
|
860
|
+
inst.notify();
|
|
861
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
862
|
+
expect(handler.mock.calls[0][0].detail).toEqual({ x: 0 });
|
|
863
|
+
});
|
|
864
|
+
});
|