zero-query 1.0.5 → 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.
@@ -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
+ });