zero-query 0.9.0 → 0.9.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/README.md +2 -3
- package/cli/commands/bundle.js +15 -2
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +184 -44
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +6 -2
- package/package.json +1 -1
- package/src/component.js +28 -7
- package/src/core.js +62 -12
- package/src/diff.js +11 -5
- package/src/expression.js +1 -0
- package/src/http.js +17 -1
- package/src/reactive.js +8 -2
- package/src/router.js +37 -8
- package/src/ssr.js +1 -1
- package/src/store.js +5 -0
- package/src/utils.js +12 -6
- package/tests/cli.test.js +456 -0
- package/tests/component.test.js +1387 -0
- package/tests/core.test.js +893 -1
- package/tests/diff.test.js +891 -0
- package/tests/errors.test.js +179 -0
- package/tests/expression.test.js +569 -0
- package/tests/http.test.js +160 -1
- package/tests/reactive.test.js +320 -0
- package/tests/router.test.js +1187 -0
- package/tests/ssr.test.js +261 -0
- package/tests/store.test.js +210 -0
- package/tests/utils.test.js +186 -0
- package/types/store.d.ts +3 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createSSRApp, renderToString } from '../src/ssr.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// createSSRApp
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
describe('createSSRApp', () => {
|
|
10
|
+
it('returns an SSRApp instance', () => {
|
|
11
|
+
const app = createSSRApp();
|
|
12
|
+
expect(app).toBeDefined();
|
|
13
|
+
expect(typeof app.component).toBe('function');
|
|
14
|
+
expect(typeof app.renderToString).toBe('function');
|
|
15
|
+
expect(typeof app.renderPage).toBe('function');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// component registration
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
describe('SSRApp — component', () => {
|
|
25
|
+
it('registers a component and returns self for chaining', () => {
|
|
26
|
+
const app = createSSRApp();
|
|
27
|
+
const result = app.component('my-comp', {
|
|
28
|
+
render() { return '<div>hello</div>'; }
|
|
29
|
+
});
|
|
30
|
+
expect(result).toBe(app);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws when rendering unregistered component', async () => {
|
|
34
|
+
const app = createSSRApp();
|
|
35
|
+
await expect(app.renderToString('nonexistent')).rejects.toThrow('not registered');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// renderToString (app method)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe('SSRApp — renderToString', () => {
|
|
45
|
+
it('renders basic component', async () => {
|
|
46
|
+
const app = createSSRApp();
|
|
47
|
+
app.component('my-page', {
|
|
48
|
+
state: () => ({ title: 'Hello' }),
|
|
49
|
+
render() { return `<h1>${this.state.title}</h1>`; }
|
|
50
|
+
});
|
|
51
|
+
const html = await app.renderToString('my-page');
|
|
52
|
+
expect(html).toContain('<h1>Hello</h1>');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('adds hydration marker by default', async () => {
|
|
56
|
+
const app = createSSRApp();
|
|
57
|
+
app.component('my-comp', { render() { return '<p>test</p>'; } });
|
|
58
|
+
const html = await app.renderToString('my-comp');
|
|
59
|
+
expect(html).toContain('data-zq-ssr');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('omits hydration marker when hydrate=false', async () => {
|
|
63
|
+
const app = createSSRApp();
|
|
64
|
+
app.component('my-comp', { render() { return '<p>test</p>'; } });
|
|
65
|
+
const html = await app.renderToString('my-comp', {}, { hydrate: false });
|
|
66
|
+
expect(html).not.toContain('data-zq-ssr');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('wraps output in component tag', async () => {
|
|
70
|
+
const app = createSSRApp();
|
|
71
|
+
app.component('custom-tag', { render() { return '<span>ok</span>'; } });
|
|
72
|
+
const html = await app.renderToString('custom-tag');
|
|
73
|
+
expect(html).toMatch(/^<custom-tag[^>]*>.*<\/custom-tag>$/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('passes props to component', async () => {
|
|
77
|
+
const app = createSSRApp();
|
|
78
|
+
app.component('greet', {
|
|
79
|
+
render() { return `<span>${this.props.name}</span>`; }
|
|
80
|
+
});
|
|
81
|
+
const html = await app.renderToString('greet', { name: 'World' });
|
|
82
|
+
expect(html).toContain('World');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('strips z-cloak attributes', async () => {
|
|
86
|
+
const app = createSSRApp();
|
|
87
|
+
app.component('my-comp', {
|
|
88
|
+
render() { return '<div z-cloak>content</div>'; }
|
|
89
|
+
});
|
|
90
|
+
const html = await app.renderToString('my-comp');
|
|
91
|
+
expect(html).not.toContain('z-cloak');
|
|
92
|
+
expect(html).toContain('content');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('strips event bindings (@click, z-on:click)', async () => {
|
|
96
|
+
const app = createSSRApp();
|
|
97
|
+
app.component('my-comp', {
|
|
98
|
+
render() { return '<button @click="handle">Click</button>'; }
|
|
99
|
+
});
|
|
100
|
+
const html = await app.renderToString('my-comp');
|
|
101
|
+
expect(html).not.toContain('@click');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// renderToString (standalone function)
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe('renderToString (standalone)', () => {
|
|
111
|
+
it('renders a definition directly', () => {
|
|
112
|
+
const html = renderToString({
|
|
113
|
+
state: () => ({ msg: 'hi' }),
|
|
114
|
+
render() { return `<p>${this.state.msg}</p>`; }
|
|
115
|
+
});
|
|
116
|
+
expect(html).toContain('<p>hi</p>');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns empty string when no render function', () => {
|
|
120
|
+
const html = renderToString({ state: {} });
|
|
121
|
+
expect(html).toBe('');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// State factory
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
describe('SSR — state factory', () => {
|
|
131
|
+
it('calls state function for initial state', async () => {
|
|
132
|
+
const app = createSSRApp();
|
|
133
|
+
let callCount = 0;
|
|
134
|
+
app.component('comp', {
|
|
135
|
+
state: () => { callCount++; return { x: 1 }; },
|
|
136
|
+
render() { return `<div>${this.state.x}</div>`; }
|
|
137
|
+
});
|
|
138
|
+
await app.renderToString('comp');
|
|
139
|
+
expect(callCount).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('supports state as object (copied)', async () => {
|
|
143
|
+
const app = createSSRApp();
|
|
144
|
+
const shared = { x: 1 };
|
|
145
|
+
app.component('comp', {
|
|
146
|
+
state: shared,
|
|
147
|
+
render() { return `<div>${this.state.x}</div>`; }
|
|
148
|
+
});
|
|
149
|
+
const html = await app.renderToString('comp');
|
|
150
|
+
expect(html).toContain('1');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Computed properties
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
describe('SSR — computed', () => {
|
|
160
|
+
it('computes derived values', async () => {
|
|
161
|
+
const app = createSSRApp();
|
|
162
|
+
app.component('comp', {
|
|
163
|
+
state: () => ({ first: 'Jane', last: 'Doe' }),
|
|
164
|
+
computed: {
|
|
165
|
+
full(state) { return `${state.first} ${state.last}`; }
|
|
166
|
+
},
|
|
167
|
+
render() { return `<span>${this.computed.full}</span>`; }
|
|
168
|
+
});
|
|
169
|
+
const html = await app.renderToString('comp');
|
|
170
|
+
expect(html).toContain('Jane Doe');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Init lifecycle
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
describe('SSR — init lifecycle', () => {
|
|
180
|
+
it('calls init() during construction', () => {
|
|
181
|
+
let initCalled = false;
|
|
182
|
+
renderToString({
|
|
183
|
+
init() { initCalled = true; },
|
|
184
|
+
render() { return '<div></div>'; }
|
|
185
|
+
});
|
|
186
|
+
expect(initCalled).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// XSS sanitization via _escapeHtml
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
describe('SSR — XSS sanitization', () => {
|
|
196
|
+
it('escapes HTML in renderPage title', async () => {
|
|
197
|
+
const app = createSSRApp();
|
|
198
|
+
const html = await app.renderPage({ title: '<script>alert("xss")</script>' });
|
|
199
|
+
expect(html).not.toContain('<script>alert("xss")</script>');
|
|
200
|
+
expect(html).toContain('<script>');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('escapes script paths in renderPage', async () => {
|
|
204
|
+
const app = createSSRApp();
|
|
205
|
+
const html = await app.renderPage({ scripts: ['"><script>alert(1)</script>'] });
|
|
206
|
+
expect(html).toContain('"');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('strips onXxx attributes from bodyAttrs', async () => {
|
|
210
|
+
const app = createSSRApp();
|
|
211
|
+
const html = await app.renderPage({ bodyAttrs: 'onclick="alert(1)"' });
|
|
212
|
+
expect(html).not.toContain('onclick');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('strips javascript: from bodyAttrs', async () => {
|
|
216
|
+
const app = createSSRApp();
|
|
217
|
+
const html = await app.renderPage({ bodyAttrs: 'data-x="javascript:void(0)"' });
|
|
218
|
+
expect(html).not.toContain('javascript:');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// renderPage
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
describe('SSRApp — renderPage', () => {
|
|
228
|
+
it('renders a full HTML page', async () => {
|
|
229
|
+
const app = createSSRApp();
|
|
230
|
+
app.component('page', { render() { return '<h1>Home</h1>'; } });
|
|
231
|
+
const html = await app.renderPage({
|
|
232
|
+
component: 'page',
|
|
233
|
+
title: 'My App',
|
|
234
|
+
lang: 'en'
|
|
235
|
+
});
|
|
236
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
237
|
+
expect(html).toContain('<title>My App</title>');
|
|
238
|
+
expect(html).toContain('<h1>Home</h1>');
|
|
239
|
+
expect(html).toContain('lang="en"');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('renders without component', async () => {
|
|
243
|
+
const app = createSSRApp();
|
|
244
|
+
const html = await app.renderPage({ title: 'Empty' });
|
|
245
|
+
expect(html).toContain('<title>Empty</title>');
|
|
246
|
+
expect(html).toContain('<div id="app"></div>');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('includes style links', async () => {
|
|
250
|
+
const app = createSSRApp();
|
|
251
|
+
const html = await app.renderPage({ styles: ['style.css', 'theme.css'] });
|
|
252
|
+
expect(html).toContain('href="style.css"');
|
|
253
|
+
expect(html).toContain('href="theme.css"');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('includes script tags', async () => {
|
|
257
|
+
const app = createSSRApp();
|
|
258
|
+
const html = await app.renderPage({ scripts: ['app.js'] });
|
|
259
|
+
expect(html).toContain('src="app.js"');
|
|
260
|
+
});
|
|
261
|
+
});
|
package/tests/store.test.js
CHANGED
|
@@ -377,3 +377,213 @@ describe('Store — complex getters', () => {
|
|
|
377
377
|
expect(store.getters.doubled).toBe(20);
|
|
378
378
|
});
|
|
379
379
|
});
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// PERF: history trim uses splice (in-place) instead of slice (copy)
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
describe('Store — history trim in-place', () => {
|
|
387
|
+
it('trims history to maxHistory without exceeding', () => {
|
|
388
|
+
const store = createStore('hist-trim', {
|
|
389
|
+
state: { n: 0 },
|
|
390
|
+
actions: { inc(s) { s.n++; } },
|
|
391
|
+
maxHistory: 5,
|
|
392
|
+
});
|
|
393
|
+
for (let i = 0; i < 10; i++) store.dispatch('inc');
|
|
394
|
+
expect(store.history.length).toBe(5);
|
|
395
|
+
// Newest actions should survive
|
|
396
|
+
expect(store.state.n).toBe(10);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('maintains same array identity (splice, not slice)', () => {
|
|
400
|
+
const store = createStore('hist-identity', {
|
|
401
|
+
state: { n: 0 },
|
|
402
|
+
actions: { inc(s) { s.n++; } },
|
|
403
|
+
maxHistory: 3,
|
|
404
|
+
});
|
|
405
|
+
const ref = store._history;
|
|
406
|
+
for (let i = 0; i < 10; i++) store.dispatch('inc');
|
|
407
|
+
// splice modifies in-place so the array reference stays the same
|
|
408
|
+
expect(store._history).toBe(ref);
|
|
409
|
+
expect(store._history.length).toBe(3);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
// ===========================================================================
|
|
415
|
+
// use() — middleware chaining
|
|
416
|
+
// ===========================================================================
|
|
417
|
+
|
|
418
|
+
describe('Store — use() chaining', () => {
|
|
419
|
+
it('returns the store for chaining', () => {
|
|
420
|
+
const store = createStore({
|
|
421
|
+
state: { x: 0 },
|
|
422
|
+
actions: { inc(state) { state.x++; } }
|
|
423
|
+
});
|
|
424
|
+
const result = store.use(() => {}).use(() => {});
|
|
425
|
+
expect(result).toBe(store);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('multiple middleware run in order', () => {
|
|
429
|
+
const order = [];
|
|
430
|
+
const store = createStore({
|
|
431
|
+
state: { x: 0 },
|
|
432
|
+
actions: { inc(state) { state.x++; } }
|
|
433
|
+
});
|
|
434
|
+
store.use(() => { order.push('a'); });
|
|
435
|
+
store.use(() => { order.push('b'); });
|
|
436
|
+
store.dispatch('inc');
|
|
437
|
+
expect(order).toEqual(['a', 'b']);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('middleware returning false blocks action', () => {
|
|
441
|
+
const store = createStore({
|
|
442
|
+
state: { x: 0 },
|
|
443
|
+
actions: { inc(state) { state.x++; } }
|
|
444
|
+
});
|
|
445
|
+
store.use(() => false);
|
|
446
|
+
store.dispatch('inc');
|
|
447
|
+
expect(store.state.x).toBe(0);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
// ===========================================================================
|
|
453
|
+
// debug mode
|
|
454
|
+
// ===========================================================================
|
|
455
|
+
|
|
456
|
+
describe('Store — debug mode', () => {
|
|
457
|
+
it('logs when debug is true', () => {
|
|
458
|
+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
459
|
+
const store = createStore({
|
|
460
|
+
state: { x: 0 },
|
|
461
|
+
actions: { inc(state) { state.x++; } },
|
|
462
|
+
debug: true
|
|
463
|
+
});
|
|
464
|
+
store.dispatch('inc');
|
|
465
|
+
expect(spy).toHaveBeenCalled();
|
|
466
|
+
const logStr = spy.mock.calls[0].join(' ');
|
|
467
|
+
expect(logStr).toContain('inc');
|
|
468
|
+
spy.mockRestore();
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
// ===========================================================================
|
|
474
|
+
// replaceState
|
|
475
|
+
// ===========================================================================
|
|
476
|
+
|
|
477
|
+
describe('Store — replaceState', () => {
|
|
478
|
+
it('replaces all keys', () => {
|
|
479
|
+
const store = createStore({
|
|
480
|
+
state: { a: 1, b: 2 }
|
|
481
|
+
});
|
|
482
|
+
store.replaceState({ c: 3 });
|
|
483
|
+
const snap = store.snapshot();
|
|
484
|
+
expect(snap).not.toHaveProperty('a');
|
|
485
|
+
expect(snap).not.toHaveProperty('b');
|
|
486
|
+
expect(snap.c).toBe(3);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
// ===========================================================================
|
|
492
|
+
// wildcard subscription
|
|
493
|
+
// ===========================================================================
|
|
494
|
+
|
|
495
|
+
describe('Store — wildcard subscription', () => {
|
|
496
|
+
it('fires on any state change', () => {
|
|
497
|
+
const store = createStore({
|
|
498
|
+
state: { a: 1, b: 2 },
|
|
499
|
+
actions: {
|
|
500
|
+
setA(state, v) { state.a = v; },
|
|
501
|
+
setB(state, v) { state.b = v; }
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
const calls = [];
|
|
505
|
+
store.subscribe((key, val, old) => calls.push([key, val, old]));
|
|
506
|
+
store.dispatch('setA', 10);
|
|
507
|
+
store.dispatch('setB', 20);
|
|
508
|
+
expect(calls).toEqual([['a', 10, 1], ['b', 20, 2]]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('unsubscribes wildcard', () => {
|
|
512
|
+
const store = createStore({
|
|
513
|
+
state: { a: 1 },
|
|
514
|
+
actions: { setA(state, v) { state.a = v; } }
|
|
515
|
+
});
|
|
516
|
+
const fn = vi.fn();
|
|
517
|
+
const unsub = store.subscribe(fn);
|
|
518
|
+
store.dispatch('setA', 2);
|
|
519
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
520
|
+
unsub();
|
|
521
|
+
store.dispatch('setA', 3);
|
|
522
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
// ===========================================================================
|
|
528
|
+
// state as factory function
|
|
529
|
+
// ===========================================================================
|
|
530
|
+
|
|
531
|
+
describe('Store — state factory', () => {
|
|
532
|
+
it('calls state function for initial state', () => {
|
|
533
|
+
const store = createStore({
|
|
534
|
+
state: () => ({ count: 0 })
|
|
535
|
+
});
|
|
536
|
+
expect(store.state.count).toBe(0);
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
// ===========================================================================
|
|
542
|
+
// createStore — named stores
|
|
543
|
+
// ===========================================================================
|
|
544
|
+
|
|
545
|
+
describe('createStore — named stores', () => {
|
|
546
|
+
it('creates default store when no name given', () => {
|
|
547
|
+
const store = createStore({ state: { x: 1 } });
|
|
548
|
+
expect(store.state.x).toBe(1);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('dispatch unknown action does not throw', () => {
|
|
552
|
+
const store = createStore({ state: { x: 1 }, actions: {} });
|
|
553
|
+
expect(() => store.dispatch('nope')).not.toThrow();
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
// ===========================================================================
|
|
559
|
+
// reset
|
|
560
|
+
// ===========================================================================
|
|
561
|
+
|
|
562
|
+
describe('Store — reset', () => {
|
|
563
|
+
it('resets state and clears history', () => {
|
|
564
|
+
const store = createStore({
|
|
565
|
+
state: { x: 0 },
|
|
566
|
+
actions: { inc(state) { state.x++; } }
|
|
567
|
+
});
|
|
568
|
+
store.dispatch('inc');
|
|
569
|
+
store.dispatch('inc');
|
|
570
|
+
expect(store.state.x).toBe(2);
|
|
571
|
+
expect(store.history.length).toBe(2);
|
|
572
|
+
store.reset({ x: 0 });
|
|
573
|
+
expect(store.state.x).toBe(0);
|
|
574
|
+
expect(store.history.length).toBe(0);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
// ===========================================================================
|
|
580
|
+
// empty config
|
|
581
|
+
// ===========================================================================
|
|
582
|
+
|
|
583
|
+
describe('Store — empty config', () => {
|
|
584
|
+
it('creates store with no config', () => {
|
|
585
|
+
const store = createStore({});
|
|
586
|
+
expect(store.snapshot()).toEqual({});
|
|
587
|
+
expect(store.history).toEqual([]);
|
|
588
|
+
});
|
|
589
|
+
});
|
package/tests/utils.test.js
CHANGED
|
@@ -265,6 +265,35 @@ describe('isEqual', () => {
|
|
|
265
265
|
expect(isEqual(null, {})).toBe(false);
|
|
266
266
|
expect(isEqual({}, null)).toBe(false);
|
|
267
267
|
});
|
|
268
|
+
|
|
269
|
+
it('distinguishes arrays from plain objects with same indices', () => {
|
|
270
|
+
expect(isEqual([1, 2], { 0: 1, 1: 2 })).toBe(false);
|
|
271
|
+
expect(isEqual({ 0: 'a' }, ['a'])).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// deepMerge — circular reference safety
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
describe('deepMerge — circular reference safety', () => {
|
|
280
|
+
it('does not infinite-loop on circular source', () => {
|
|
281
|
+
const a = { x: 1 };
|
|
282
|
+
const b = { y: 2 };
|
|
283
|
+
b.self = b; // circular
|
|
284
|
+
const result = deepMerge(a, b);
|
|
285
|
+
expect(result.x).toBe(1);
|
|
286
|
+
expect(result.y).toBe(2);
|
|
287
|
+
// circular ref is simply not followed again
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('does not infinite-loop on circular target', () => {
|
|
291
|
+
const a = { x: 1 };
|
|
292
|
+
a.self = a;
|
|
293
|
+
const b = { y: 2 };
|
|
294
|
+
const result = deepMerge(a, b);
|
|
295
|
+
expect(result.y).toBe(2);
|
|
296
|
+
});
|
|
268
297
|
});
|
|
269
298
|
|
|
270
299
|
|
|
@@ -510,3 +539,160 @@ describe('bus (EventBus)', () => {
|
|
|
510
539
|
expect(() => bus.emit('nonexistent')).not.toThrow();
|
|
511
540
|
});
|
|
512
541
|
});
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
// ===========================================================================
|
|
545
|
+
// throttle — window reset
|
|
546
|
+
// ===========================================================================
|
|
547
|
+
|
|
548
|
+
describe('throttle — edge cases', () => {
|
|
549
|
+
it('fires trailing call after wait period', async () => {
|
|
550
|
+
vi.useFakeTimers();
|
|
551
|
+
const fn = vi.fn();
|
|
552
|
+
const throttled = throttle(fn, 100);
|
|
553
|
+
|
|
554
|
+
throttled('a'); // immediate
|
|
555
|
+
throttled('b'); // queued
|
|
556
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
557
|
+
|
|
558
|
+
vi.advanceTimersByTime(100);
|
|
559
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
560
|
+
expect(fn).toHaveBeenLastCalledWith('b');
|
|
561
|
+
vi.useRealTimers();
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
// ===========================================================================
|
|
567
|
+
// deepClone — edge cases
|
|
568
|
+
// ===========================================================================
|
|
569
|
+
|
|
570
|
+
describe('deepClone — edge cases', () => {
|
|
571
|
+
it('clones nested arrays', () => {
|
|
572
|
+
const arr = [[1, 2], [3, 4]];
|
|
573
|
+
const clone = deepClone(arr);
|
|
574
|
+
expect(clone).toEqual(arr);
|
|
575
|
+
clone[0][0] = 99;
|
|
576
|
+
expect(arr[0][0]).toBe(1);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('handles null values', () => {
|
|
580
|
+
expect(deepClone({ a: null })).toEqual({ a: null });
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
// ===========================================================================
|
|
586
|
+
// deepMerge — multiple sources
|
|
587
|
+
// ===========================================================================
|
|
588
|
+
|
|
589
|
+
describe('deepMerge — edge cases', () => {
|
|
590
|
+
it('merges from multiple sources', () => {
|
|
591
|
+
const result = deepMerge({}, { a: 1 }, { b: 2 }, { c: 3 });
|
|
592
|
+
expect(result).toEqual({ a: 1, b: 2, c: 3 });
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('later sources override earlier', () => {
|
|
596
|
+
const result = deepMerge({}, { a: 1 }, { a: 2 });
|
|
597
|
+
expect(result).toEqual({ a: 2 });
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('handles arrays (replaces, not merges)', () => {
|
|
601
|
+
const result = deepMerge({}, { arr: [1, 2] }, { arr: [3] });
|
|
602
|
+
expect(result.arr).toEqual([3]);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
// ===========================================================================
|
|
608
|
+
// isEqual — deeply nested
|
|
609
|
+
// ===========================================================================
|
|
610
|
+
|
|
611
|
+
describe('isEqual — edge cases', () => {
|
|
612
|
+
it('deeply nested equal objects', () => {
|
|
613
|
+
expect(isEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(true);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('arrays of objects', () => {
|
|
617
|
+
expect(isEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
|
|
618
|
+
expect(isEqual([{ a: 1 }], [{ a: 2 }])).toBe(false);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('empty arrays equal', () => {
|
|
622
|
+
expect(isEqual([], [])).toBe(true);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('null vs object', () => {
|
|
626
|
+
expect(isEqual(null, { a: 1 })).toBe(false);
|
|
627
|
+
expect(isEqual({ a: 1 }, null)).toBe(false);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('different types', () => {
|
|
631
|
+
expect(isEqual('1', 1)).toBe(false);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('array vs object', () => {
|
|
635
|
+
expect(isEqual([], {})).toBe(false);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
// ===========================================================================
|
|
641
|
+
// camelCase / kebabCase — edge cases
|
|
642
|
+
// ===========================================================================
|
|
643
|
+
|
|
644
|
+
describe('camelCase / kebabCase — edge cases', () => {
|
|
645
|
+
it('camelCase single word', () => {
|
|
646
|
+
expect(camelCase('hello')).toBe('hello');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('camelCase already camel', () => {
|
|
650
|
+
expect(camelCase('helloWorld')).toBe('helloWorld');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('kebabCase single word lowercase', () => {
|
|
654
|
+
expect(kebabCase('hello')).toBe('hello');
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('kebabCase multiple humps', () => {
|
|
658
|
+
expect(kebabCase('myComponentName')).toBe('my-component-name');
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
// ===========================================================================
|
|
664
|
+
// html tag — escaping
|
|
665
|
+
// ===========================================================================
|
|
666
|
+
|
|
667
|
+
describe('html tag — edge cases', () => {
|
|
668
|
+
it('handles null interp value', () => {
|
|
669
|
+
const result = html`<div>${null}</div>`;
|
|
670
|
+
expect(result).toBe('<div></div>');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('handles undefined interp value', () => {
|
|
674
|
+
const result = html`<div>${undefined}</div>`;
|
|
675
|
+
expect(result).toBe('<div></div>');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('escapes multiple interpolations', () => {
|
|
679
|
+
const a = '<b>';
|
|
680
|
+
const b = '&';
|
|
681
|
+
const result = html`${a} and ${b}`;
|
|
682
|
+
expect(result).toContain('<b>');
|
|
683
|
+
expect(result).toContain('&');
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
// ===========================================================================
|
|
689
|
+
// storage — error handling
|
|
690
|
+
// ===========================================================================
|
|
691
|
+
|
|
692
|
+
describe('storage — parse error fallback', () => {
|
|
693
|
+
it('returns fallback when JSON.parse fails', () => {
|
|
694
|
+
localStorage.setItem('bad', '{invalid json');
|
|
695
|
+
expect(storage.get('bad', 'default')).toBe('default');
|
|
696
|
+
localStorage.removeItem('bad');
|
|
697
|
+
});
|
|
698
|
+
});
|
package/types/store.d.ts
CHANGED