zero-query 0.7.5 → 0.8.7
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 -30
- package/cli/commands/build.js +110 -1
- package/cli/commands/bundle.js +127 -50
- 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 +28 -3
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +377 -0
- package/cli/commands/dev/server.js +8 -0
- package/cli/commands/dev/watcher.js +26 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +1 -1
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- 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} +3 -2
- package/cli/scaffold/index.html +11 -11
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +740 -226
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +11 -11
- package/index.js +15 -10
- package/package.json +3 -2
- package/src/component.js +154 -139
- package/src/core.js +57 -11
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +196 -7
- package/src/ssr.js +1 -1
- package/tests/component.test.js +582 -0
- package/tests/core.test.js +251 -0
- package/tests/diff.test.js +333 -2
- package/tests/expression.test.js +148 -0
- package/tests/http.test.js +108 -0
- package/tests/reactive.test.js +148 -0
- package/tests/router.test.js +317 -0
- package/tests/store.test.js +126 -0
- package/tests/utils.test.js +161 -2
- package/types/collection.d.ts +17 -2
- package/types/component.d.ts +10 -34
- package/types/misc.d.ts +13 -0
- package/types/router.d.ts +30 -1
- 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.css +0 -0
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
- /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
package/tests/router.test.js
CHANGED
|
@@ -330,3 +330,320 @@ describe('Router — destroy', () => {
|
|
|
330
330
|
expect(router._listeners.size).toBe(0);
|
|
331
331
|
});
|
|
332
332
|
});
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Wildcard / catch-all routes
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe('Router — wildcard routes', () => {
|
|
340
|
+
it('compiles wildcard route', () => {
|
|
341
|
+
const router = createRouter({
|
|
342
|
+
mode: 'hash',
|
|
343
|
+
routes: [
|
|
344
|
+
{ path: '/docs/:section', component: 'docs-page' },
|
|
345
|
+
{ path: '*', component: 'home-page' },
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
// Wildcard is compiled as last route
|
|
349
|
+
expect(router._routes.length).toBeGreaterThanOrEqual(2);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Query string handling
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
describe('Router — query parsing', () => {
|
|
359
|
+
it('_normalizePath strips query string for route matching', () => {
|
|
360
|
+
const router = createRouter({
|
|
361
|
+
mode: 'hash',
|
|
362
|
+
routes: [],
|
|
363
|
+
});
|
|
364
|
+
// _normalizePath does not strip query params — it only normalizes slashes and base prefix
|
|
365
|
+
const path = router._normalizePath('/docs?section=intro');
|
|
366
|
+
expect(path).toBe('/docs?section=intro');
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Multiple guards
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
describe('Router — multiple guards', () => {
|
|
376
|
+
it('registers multiple beforeEach guards', () => {
|
|
377
|
+
const router = createRouter({
|
|
378
|
+
mode: 'hash',
|
|
379
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
380
|
+
});
|
|
381
|
+
const g1 = vi.fn();
|
|
382
|
+
const g2 = vi.fn();
|
|
383
|
+
router.beforeEach(g1);
|
|
384
|
+
router.beforeEach(g2);
|
|
385
|
+
expect(router._guards.before.length).toBe(2);
|
|
386
|
+
expect(router._guards.before).toContain(g1);
|
|
387
|
+
expect(router._guards.before).toContain(g2);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('registers multiple afterEach guards', () => {
|
|
391
|
+
const router = createRouter({
|
|
392
|
+
mode: 'hash',
|
|
393
|
+
routes: [{ path: '/', component: 'home-page' }],
|
|
394
|
+
});
|
|
395
|
+
const g1 = vi.fn();
|
|
396
|
+
const g2 = vi.fn();
|
|
397
|
+
router.afterEach(g1);
|
|
398
|
+
router.afterEach(g2);
|
|
399
|
+
expect(router._guards.after.length).toBe(2);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Route with multiple params
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
describe('Router — multi-param routes', () => {
|
|
409
|
+
it('compiles route with multiple params', () => {
|
|
410
|
+
const router = createRouter({
|
|
411
|
+
mode: 'hash',
|
|
412
|
+
routes: [
|
|
413
|
+
{ path: '/post/:pid/comment/:cid', component: 'user-page' },
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
const route = router._routes[0];
|
|
417
|
+
expect(route._regex.test('/post/1/comment/5')).toBe(true);
|
|
418
|
+
expect(route._keys).toEqual(['pid', 'cid']);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// Same-path deduplication
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
describe('Router — same-path deduplication', () => {
|
|
428
|
+
let router;
|
|
429
|
+
|
|
430
|
+
beforeEach(() => {
|
|
431
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
432
|
+
window.location.hash = '#/';
|
|
433
|
+
router = createRouter({
|
|
434
|
+
el: '#app',
|
|
435
|
+
mode: 'hash',
|
|
436
|
+
routes: [
|
|
437
|
+
{ path: '/', component: 'home-page' },
|
|
438
|
+
{ path: '/about', component: 'about-page' },
|
|
439
|
+
],
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('skips duplicate hash navigation to the same path', () => {
|
|
444
|
+
router.navigate('/about');
|
|
445
|
+
expect(window.location.hash).toBe('#/about');
|
|
446
|
+
// Navigate to the same path again — should be a no-op
|
|
447
|
+
const result = router.navigate('/about');
|
|
448
|
+
expect(window.location.hash).toBe('#/about');
|
|
449
|
+
expect(result).toBe(router); // still returns the router chain
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('allows forced duplicate navigation with options.force', () => {
|
|
453
|
+
router.navigate('/about');
|
|
454
|
+
// Force navigation to same path
|
|
455
|
+
router.navigate('/about', { force: true });
|
|
456
|
+
expect(window.location.hash).toBe('#/about');
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// History mode — same-path / hash-only navigation
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
describe('Router — history mode deduplication', () => {
|
|
466
|
+
let router;
|
|
467
|
+
let pushSpy, replaceSpy;
|
|
468
|
+
|
|
469
|
+
beforeEach(() => {
|
|
470
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
471
|
+
pushSpy = vi.spyOn(window.history, 'pushState');
|
|
472
|
+
replaceSpy = vi.spyOn(window.history, 'replaceState');
|
|
473
|
+
router = createRouter({
|
|
474
|
+
el: '#app',
|
|
475
|
+
mode: 'history',
|
|
476
|
+
routes: [
|
|
477
|
+
{ path: '/', component: 'home-page' },
|
|
478
|
+
{ path: '/about', component: 'about-page' },
|
|
479
|
+
{ path: '/docs', component: 'docs-page' },
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
// Reset spy call counts after initial resolve
|
|
483
|
+
pushSpy.mockClear();
|
|
484
|
+
replaceSpy.mockClear();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
afterEach(() => {
|
|
488
|
+
pushSpy.mockRestore();
|
|
489
|
+
replaceSpy.mockRestore();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('uses pushState for different routes', () => {
|
|
493
|
+
router.navigate('/about');
|
|
494
|
+
expect(pushSpy).toHaveBeenCalledTimes(1);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('uses replaceState for same-route hash-only change', () => {
|
|
498
|
+
// Navigate to /docs first
|
|
499
|
+
router.navigate('/docs');
|
|
500
|
+
pushSpy.mockClear();
|
|
501
|
+
replaceSpy.mockClear();
|
|
502
|
+
// Navigate to /docs#section — same route, different hash
|
|
503
|
+
router.navigate('/docs#section');
|
|
504
|
+
expect(pushSpy).not.toHaveBeenCalled();
|
|
505
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Sub-route history substates
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
describe('Router — substates', () => {
|
|
515
|
+
let router;
|
|
516
|
+
let pushSpy;
|
|
517
|
+
|
|
518
|
+
beforeEach(() => {
|
|
519
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
520
|
+
pushSpy = vi.spyOn(window.history, 'pushState');
|
|
521
|
+
router = createRouter({
|
|
522
|
+
el: '#app',
|
|
523
|
+
mode: 'history',
|
|
524
|
+
routes: [
|
|
525
|
+
{ path: '/', component: 'home-page' },
|
|
526
|
+
],
|
|
527
|
+
});
|
|
528
|
+
pushSpy.mockClear();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
afterEach(() => {
|
|
532
|
+
pushSpy.mockRestore();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('pushSubstate pushes a history entry with substate marker', () => {
|
|
536
|
+
router.pushSubstate('modal', { id: 'confirm' });
|
|
537
|
+
expect(pushSpy).toHaveBeenCalledTimes(1);
|
|
538
|
+
const state = pushSpy.mock.calls[0][0];
|
|
539
|
+
expect(state.__zq).toBe('substate');
|
|
540
|
+
expect(state.key).toBe('modal');
|
|
541
|
+
expect(state.data).toEqual({ id: 'confirm' });
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('onSubstate registers and unregisters listeners', () => {
|
|
545
|
+
const fn = vi.fn();
|
|
546
|
+
const unsub = router.onSubstate(fn);
|
|
547
|
+
expect(router._substateListeners).toContain(fn);
|
|
548
|
+
unsub();
|
|
549
|
+
expect(router._substateListeners).not.toContain(fn);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('_fireSubstate calls listeners and returns true if any handles it', () => {
|
|
553
|
+
const fn1 = vi.fn(() => false);
|
|
554
|
+
const fn2 = vi.fn(() => true);
|
|
555
|
+
router.onSubstate(fn1);
|
|
556
|
+
router.onSubstate(fn2);
|
|
557
|
+
const handled = router._fireSubstate('modal', { id: 'x' }, 'pop');
|
|
558
|
+
expect(fn1).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
|
|
559
|
+
expect(fn2).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
|
|
560
|
+
expect(handled).toBe(true);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('_fireSubstate returns false if no listener handles it', () => {
|
|
564
|
+
const fn = vi.fn(() => undefined);
|
|
565
|
+
router.onSubstate(fn);
|
|
566
|
+
const handled = router._fireSubstate('tab', { index: 0 }, 'pop');
|
|
567
|
+
expect(handled).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('_fireSubstate catches errors in listeners', () => {
|
|
571
|
+
const fn = vi.fn(() => { throw new Error('oops'); });
|
|
572
|
+
router.onSubstate(fn);
|
|
573
|
+
// Should not throw
|
|
574
|
+
expect(() => router._fireSubstate('modal', {}, 'pop')).not.toThrow();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('destroy clears substate listeners', () => {
|
|
578
|
+
router.onSubstate(() => {});
|
|
579
|
+
router.onSubstate(() => {});
|
|
580
|
+
router.destroy();
|
|
581
|
+
expect(router._substateListeners.length).toBe(0);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('pushSubstate sets _inSubstate flag', () => {
|
|
585
|
+
expect(router._inSubstate).toBe(false);
|
|
586
|
+
router.pushSubstate('tab', { id: 'a' });
|
|
587
|
+
expect(router._inSubstate).toBe(true);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('popstate past all substates fires reset action', () => {
|
|
591
|
+
const fn = vi.fn(() => true);
|
|
592
|
+
router.onSubstate(fn);
|
|
593
|
+
router.pushSubstate('tab', { id: 'a' });
|
|
594
|
+
expect(router._inSubstate).toBe(true);
|
|
595
|
+
|
|
596
|
+
// Simulate popstate landing on a non-substate entry
|
|
597
|
+
const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
|
|
598
|
+
window.dispatchEvent(evt);
|
|
599
|
+
|
|
600
|
+
// Should have fired with reset action
|
|
601
|
+
expect(fn).toHaveBeenCalledWith(null, null, 'reset');
|
|
602
|
+
expect(router._inSubstate).toBe(false);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('popstate on a substate entry keeps _inSubstate true', () => {
|
|
606
|
+
const fn = vi.fn(() => true);
|
|
607
|
+
router.onSubstate(fn);
|
|
608
|
+
router.pushSubstate('tab', { id: 'a' });
|
|
609
|
+
router.pushSubstate('tab', { id: 'b' });
|
|
610
|
+
|
|
611
|
+
// Simulate popstate landing on a substate entry
|
|
612
|
+
const evt = new PopStateEvent('popstate', {
|
|
613
|
+
state: { __zq: 'substate', key: 'tab', data: { id: 'a' } }
|
|
614
|
+
});
|
|
615
|
+
window.dispatchEvent(evt);
|
|
616
|
+
|
|
617
|
+
expect(fn).toHaveBeenCalledWith('tab', { id: 'a' }, 'pop');
|
|
618
|
+
expect(router._inSubstate).toBe(true);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('reset action is not fired when no substates were active', () => {
|
|
622
|
+
const fn = vi.fn();
|
|
623
|
+
router.onSubstate(fn);
|
|
624
|
+
// _inSubstate is false, so popstate on a route entry should not fire reset
|
|
625
|
+
const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
|
|
626
|
+
window.dispatchEvent(evt);
|
|
627
|
+
// fn should NOT have been called with 'reset'
|
|
628
|
+
const resetCalls = fn.mock.calls.filter(c => c[2] === 'reset');
|
|
629
|
+
expect(resetCalls.length).toBe(0);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// Edge cases
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
describe('Router — edge cases', () => {
|
|
639
|
+
it('handles empty routes array', () => {
|
|
640
|
+
const router = createRouter({ mode: 'hash', routes: [] });
|
|
641
|
+
expect(router._routes.length).toBe(0);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('getRouter returns null before creation', () => {
|
|
645
|
+
// After the tests create routers, getRouter should return the latest
|
|
646
|
+
const r = getRouter();
|
|
647
|
+
expect(r).toBeDefined();
|
|
648
|
+
});
|
|
649
|
+
});
|
package/tests/store.test.js
CHANGED
|
@@ -251,3 +251,129 @@ describe('Store — snapshot & replaceState', () => {
|
|
|
251
251
|
expect(store.history.length).toBe(0);
|
|
252
252
|
});
|
|
253
253
|
});
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Multiple middleware
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
describe('Store — multiple middleware', () => {
|
|
261
|
+
it('runs middleware in order', () => {
|
|
262
|
+
const order = [];
|
|
263
|
+
const store = createStore('mw-multi', {
|
|
264
|
+
state: { x: 0 },
|
|
265
|
+
actions: { inc(state) { state.x++; } },
|
|
266
|
+
});
|
|
267
|
+
store.use(() => { order.push('a'); });
|
|
268
|
+
store.use(() => { order.push('b'); });
|
|
269
|
+
store.dispatch('inc');
|
|
270
|
+
expect(order).toEqual(['a', 'b']);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('second middleware can block even if first passes', () => {
|
|
274
|
+
const store = createStore('mw-multi-block', {
|
|
275
|
+
state: { x: 0 },
|
|
276
|
+
actions: { inc(state) { state.x++; } },
|
|
277
|
+
});
|
|
278
|
+
store.use(() => true);
|
|
279
|
+
store.use(() => false);
|
|
280
|
+
store.dispatch('inc');
|
|
281
|
+
expect(store.state.x).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Async actions
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
describe('Store — async actions', () => {
|
|
291
|
+
it('supports async action returning promise', async () => {
|
|
292
|
+
const store = createStore('async-1', {
|
|
293
|
+
state: { data: null },
|
|
294
|
+
actions: {
|
|
295
|
+
async fetchData(state) {
|
|
296
|
+
state.data = await Promise.resolve('loaded');
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
await store.dispatch('fetchData');
|
|
301
|
+
expect(store.state.data).toBe('loaded');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Subscriber deduplication
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
describe('Store — subscriber edge cases', () => {
|
|
311
|
+
it('same function subscribed twice fires twice', () => {
|
|
312
|
+
const store = createStore('sub-dedup', {
|
|
313
|
+
state: { x: 0 },
|
|
314
|
+
actions: { inc(state) { state.x++; } },
|
|
315
|
+
});
|
|
316
|
+
const fn = vi.fn();
|
|
317
|
+
store.subscribe('x', fn);
|
|
318
|
+
store.subscribe('x', fn); // Set deduplicates
|
|
319
|
+
store.dispatch('inc');
|
|
320
|
+
expect(fn).toHaveBeenCalledOnce(); // Set prevents duplicates
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('wildcard and key subscriber both fire', () => {
|
|
324
|
+
const store = createStore('sub-both', {
|
|
325
|
+
state: { x: 0 },
|
|
326
|
+
actions: { inc(state) { state.x++; } },
|
|
327
|
+
});
|
|
328
|
+
const keyFn = vi.fn();
|
|
329
|
+
const wildFn = vi.fn();
|
|
330
|
+
store.subscribe('x', keyFn);
|
|
331
|
+
store.subscribe(wildFn);
|
|
332
|
+
store.dispatch('inc');
|
|
333
|
+
expect(keyFn).toHaveBeenCalledOnce();
|
|
334
|
+
expect(wildFn).toHaveBeenCalledOnce();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Action return value
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
describe('Store — action return value', () => {
|
|
344
|
+
it('dispatch returns action result', () => {
|
|
345
|
+
const store = createStore('ret-1', {
|
|
346
|
+
state: { x: 0 },
|
|
347
|
+
actions: { compute(state) { return state.x + 10; } },
|
|
348
|
+
});
|
|
349
|
+
expect(store.dispatch('compute')).toBe(10);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Getters with multiple state keys
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
describe('Store — complex getters', () => {
|
|
359
|
+
it('getter uses multiple state keys', () => {
|
|
360
|
+
const store = createStore('getter-multi', {
|
|
361
|
+
state: { firstName: 'Tony', lastName: 'W' },
|
|
362
|
+
getters: {
|
|
363
|
+
fullName: (state) => `${state.firstName} ${state.lastName}`,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
expect(store.getters.fullName).toBe('Tony W');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('getter recalculates after state change', () => {
|
|
370
|
+
const store = createStore('getter-recalc', {
|
|
371
|
+
state: { count: 2 },
|
|
372
|
+
actions: { set(state, v) { state.count = v; } },
|
|
373
|
+
getters: { doubled: (s) => s.count * 2 },
|
|
374
|
+
});
|
|
375
|
+
expect(store.getters.doubled).toBe(4);
|
|
376
|
+
store.dispatch('set', 10);
|
|
377
|
+
expect(store.getters.doubled).toBe(20);
|
|
378
|
+
});
|
|
379
|
+
});
|
package/tests/utils.test.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
debounce, throttle, pipe, once, sleep,
|
|
4
|
-
escapeHtml, trust, uuid, camelCase, kebabCase,
|
|
4
|
+
escapeHtml, html, trust, uuid, camelCase, kebabCase,
|
|
5
5
|
deepClone, deepMerge, isEqual, param, parseQuery,
|
|
6
|
-
bus,
|
|
6
|
+
storage, session, bus,
|
|
7
7
|
} from '../src/utils.js';
|
|
8
8
|
|
|
9
9
|
|
|
@@ -298,6 +298,165 @@ describe('parseQuery', () => {
|
|
|
298
298
|
});
|
|
299
299
|
|
|
300
300
|
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// html template tag
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
describe('html template tag', () => {
|
|
306
|
+
it('auto-escapes interpolated values', () => {
|
|
307
|
+
const userInput = '<script>alert("xss")</script>';
|
|
308
|
+
const result = html`<div>${userInput}</div>`;
|
|
309
|
+
expect(result).toContain('<script>');
|
|
310
|
+
expect(result).not.toContain('<script>');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('does not escape trusted HTML', () => {
|
|
314
|
+
const safe = trust('<b>bold</b>');
|
|
315
|
+
const result = html`<div>${safe}</div>`;
|
|
316
|
+
expect(result).toContain('<b>bold</b>');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('handles null/undefined values', () => {
|
|
320
|
+
const result = html`<span>${null}</span>`;
|
|
321
|
+
expect(result).toBe('<span></span>');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// storage helpers
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
describe('storage (localStorage wrapper)', () => {
|
|
331
|
+
beforeEach(() => { localStorage.clear(); });
|
|
332
|
+
|
|
333
|
+
it('set and get a value', () => {
|
|
334
|
+
storage.set('key', { a: 1 });
|
|
335
|
+
expect(storage.get('key')).toEqual({ a: 1 });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('returns fallback for missing key', () => {
|
|
339
|
+
expect(storage.get('missing', 'default')).toBe('default');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('returns null as default fallback', () => {
|
|
343
|
+
expect(storage.get('missing')).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('remove deletes a key', () => {
|
|
347
|
+
storage.set('key', 42);
|
|
348
|
+
storage.remove('key');
|
|
349
|
+
expect(storage.get('key')).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('clear removes all keys', () => {
|
|
353
|
+
storage.set('a', 1);
|
|
354
|
+
storage.set('b', 2);
|
|
355
|
+
storage.clear();
|
|
356
|
+
expect(storage.get('a')).toBeNull();
|
|
357
|
+
expect(storage.get('b')).toBeNull();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('handles non-JSON values gracefully', () => {
|
|
361
|
+
localStorage.setItem('bad', '{not json}');
|
|
362
|
+
expect(storage.get('bad', 'fallback')).toBe('fallback');
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
describe('session (sessionStorage wrapper)', () => {
|
|
368
|
+
beforeEach(() => { sessionStorage.clear(); });
|
|
369
|
+
|
|
370
|
+
it('set and get a value', () => {
|
|
371
|
+
session.set('key', [1, 2, 3]);
|
|
372
|
+
expect(session.get('key')).toEqual([1, 2, 3]);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('returns fallback for missing key', () => {
|
|
376
|
+
expect(session.get('missing', 'default')).toBe('default');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('remove deletes a key', () => {
|
|
380
|
+
session.set('key', 'val');
|
|
381
|
+
session.remove('key');
|
|
382
|
+
expect(session.get('key')).toBeNull();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('clear removes all keys', () => {
|
|
386
|
+
session.set('a', 1);
|
|
387
|
+
session.clear();
|
|
388
|
+
expect(session.get('a')).toBeNull();
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Event bus
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
describe('bus (event bus)', () => {
|
|
398
|
+
beforeEach(() => { bus.clear(); });
|
|
399
|
+
|
|
400
|
+
it('on() and emit()', () => {
|
|
401
|
+
const fn = vi.fn();
|
|
402
|
+
bus.on('test', fn);
|
|
403
|
+
bus.emit('test', 'data');
|
|
404
|
+
expect(fn).toHaveBeenCalledWith('data');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('off() removes handler', () => {
|
|
408
|
+
const fn = vi.fn();
|
|
409
|
+
bus.on('test', fn);
|
|
410
|
+
bus.off('test', fn);
|
|
411
|
+
bus.emit('test');
|
|
412
|
+
expect(fn).not.toHaveBeenCalled();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('on() returns unsubscribe function', () => {
|
|
416
|
+
const fn = vi.fn();
|
|
417
|
+
const unsub = bus.on('test', fn);
|
|
418
|
+
unsub();
|
|
419
|
+
bus.emit('test');
|
|
420
|
+
expect(fn).not.toHaveBeenCalled();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('once() fires handler only once', () => {
|
|
424
|
+
const fn = vi.fn();
|
|
425
|
+
bus.once('test', fn);
|
|
426
|
+
bus.emit('test', 'first');
|
|
427
|
+
bus.emit('test', 'second');
|
|
428
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
429
|
+
expect(fn).toHaveBeenCalledWith('first');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('emit with multiple args', () => {
|
|
433
|
+
const fn = vi.fn();
|
|
434
|
+
bus.on('test', fn);
|
|
435
|
+
bus.emit('test', 1, 2, 3);
|
|
436
|
+
expect(fn).toHaveBeenCalledWith(1, 2, 3);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('multiple handlers on same event', () => {
|
|
440
|
+
const fn1 = vi.fn();
|
|
441
|
+
const fn2 = vi.fn();
|
|
442
|
+
bus.on('test', fn1);
|
|
443
|
+
bus.on('test', fn2);
|
|
444
|
+
bus.emit('test');
|
|
445
|
+
expect(fn1).toHaveBeenCalledOnce();
|
|
446
|
+
expect(fn2).toHaveBeenCalledOnce();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('clear() removes all handlers', () => {
|
|
450
|
+
const fn = vi.fn();
|
|
451
|
+
bus.on('a', fn);
|
|
452
|
+
bus.on('b', fn);
|
|
453
|
+
bus.clear();
|
|
454
|
+
bus.emit('a');
|
|
455
|
+
bus.emit('b');
|
|
456
|
+
expect(fn).not.toHaveBeenCalled();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
301
460
|
// ---------------------------------------------------------------------------
|
|
302
461
|
// Event bus
|
|
303
462
|
// ---------------------------------------------------------------------------
|
package/types/collection.d.ts
CHANGED
|
@@ -209,9 +209,20 @@ export class ZQueryCollection {
|
|
|
209
209
|
|
|
210
210
|
/** Get `innerHTML` of the first element, or `undefined` if empty. */
|
|
211
211
|
html(): string | undefined;
|
|
212
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* Set content on all elements. Auto-morphs when the element already has
|
|
214
|
+
* children (preserves focus, scroll, form state, keyed reorder via LIS).
|
|
215
|
+
* Empty elements receive raw `innerHTML` for fast first-paint.
|
|
216
|
+
* Use `empty().html(content)` to force raw innerHTML.
|
|
217
|
+
*/
|
|
213
218
|
html(content: string): this;
|
|
214
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Morph all elements' children to match new HTML using the diff engine.
|
|
222
|
+
* Always morphs regardless of whether the element already has children.
|
|
223
|
+
*/
|
|
224
|
+
morph(content: string): this;
|
|
225
|
+
|
|
215
226
|
/** Get `textContent` of the first element, or `undefined` if empty. */
|
|
216
227
|
text(): string | undefined;
|
|
217
228
|
/** Set `textContent` on all elements. */
|
|
@@ -248,7 +259,11 @@ export class ZQueryCollection {
|
|
|
248
259
|
/** Clone elements (default: deep clone). */
|
|
249
260
|
clone(deep?: boolean): ZQueryCollection;
|
|
250
261
|
|
|
251
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* Replace elements with new content. When given an HTML string with the
|
|
264
|
+
* same tag name, morphs the element in place (preserving identity and state).
|
|
265
|
+
* Falls back to full replacement when the tag name differs or content is a Node.
|
|
266
|
+
*/
|
|
252
267
|
replaceWith(content: string | Node): this;
|
|
253
268
|
|
|
254
269
|
/** Insert every element in the collection at the end of the target. */
|