zero-query 1.0.9 → 1.2.0

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.
Files changed (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -0
  5. package/cli/commands/build.js +254 -216
  6. package/cli/commands/bundle.js +1228 -1183
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -167
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +7264 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6252
  81. package/dist/zquery.min.js +8 -601
  82. package/index.d.ts +570 -365
  83. package/index.js +311 -232
  84. package/package.json +76 -69
  85. package/src/component.js +1709 -1454
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -254
  93. package/src/router.js +843 -773
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -272
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1023
  115. package/tests/compare.test.js +497 -0
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -0
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -0
  121. package/tests/electron-features.test.js +864 -0
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -145
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -1,1377 +1,1377 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import {
3
- debounce, throttle, pipe, once, sleep,
4
- escapeHtml, stripHtml, html, trust, uuid, camelCase, kebabCase,
5
- deepClone, deepMerge, isEqual, param, parseQuery,
6
- storage, session, bus,
7
- // New utilities
8
- range, unique, chunk, groupBy,
9
- pick, omit, getPath, setPath, isEmpty,
10
- capitalize, truncate,
11
- clamp,
12
- memoize,
13
- retry, timeout,
14
- } from '../src/utils.js';
15
-
16
-
17
- // ---------------------------------------------------------------------------
18
- // Function utilities
19
- // ---------------------------------------------------------------------------
20
-
21
- describe('debounce', () => {
22
- beforeEach(() => { vi.useFakeTimers(); });
23
-
24
- it('delays execution until after ms of inactivity', () => {
25
- const fn = vi.fn();
26
- const debounced = debounce(fn, 100);
27
- debounced('a');
28
- debounced('b');
29
- expect(fn).not.toHaveBeenCalled();
30
- vi.advanceTimersByTime(100);
31
- expect(fn).toHaveBeenCalledOnce();
32
- expect(fn).toHaveBeenCalledWith('b');
33
- });
34
-
35
- it('uses default 250ms delay', () => {
36
- const fn = vi.fn();
37
- const debounced = debounce(fn);
38
- debounced();
39
- vi.advanceTimersByTime(249);
40
- expect(fn).not.toHaveBeenCalled();
41
- vi.advanceTimersByTime(1);
42
- expect(fn).toHaveBeenCalledOnce();
43
- });
44
-
45
- it('cancel() stops pending execution', () => {
46
- const fn = vi.fn();
47
- const debounced = debounce(fn, 100);
48
- debounced();
49
- debounced.cancel();
50
- vi.advanceTimersByTime(200);
51
- expect(fn).not.toHaveBeenCalled();
52
- });
53
-
54
- it('resets timer on subsequent calls', () => {
55
- const fn = vi.fn();
56
- const debounced = debounce(fn, 100);
57
- debounced();
58
- vi.advanceTimersByTime(80);
59
- debounced();
60
- vi.advanceTimersByTime(80);
61
- expect(fn).not.toHaveBeenCalled();
62
- vi.advanceTimersByTime(20);
63
- expect(fn).toHaveBeenCalledOnce();
64
- });
65
- });
66
-
67
-
68
- describe('throttle', () => {
69
- beforeEach(() => { vi.useFakeTimers(); });
70
-
71
- it('fires immediately on first call', () => {
72
- const fn = vi.fn();
73
- const throttled = throttle(fn, 100);
74
- throttled('a');
75
- expect(fn).toHaveBeenCalledWith('a');
76
- });
77
-
78
- it('delays subsequent calls within the window', () => {
79
- const fn = vi.fn();
80
- const throttled = throttle(fn, 100);
81
- throttled('a');
82
- throttled('b');
83
- expect(fn).toHaveBeenCalledTimes(1);
84
- vi.advanceTimersByTime(100);
85
- expect(fn).toHaveBeenCalledTimes(2);
86
- expect(fn).toHaveBeenLastCalledWith('b');
87
- });
88
- });
89
-
90
-
91
- describe('pipe', () => {
92
- it('composes functions left-to-right', () => {
93
- const add1 = x => x + 1;
94
- const double = x => x * 2;
95
- expect(pipe(add1, double)(3)).toBe(8);
96
- });
97
-
98
- it('handles a single function', () => {
99
- const identity = x => x;
100
- expect(pipe(identity)(42)).toBe(42);
101
- });
102
-
103
- it('handles no functions', () => {
104
- expect(pipe()(10)).toBe(10);
105
- });
106
- });
107
-
108
-
109
- describe('once', () => {
110
- it('only calls function once', () => {
111
- const fn = vi.fn(() => 42);
112
- const onceFn = once(fn);
113
- expect(onceFn()).toBe(42);
114
- expect(onceFn()).toBe(42);
115
- expect(fn).toHaveBeenCalledOnce();
116
- });
117
-
118
- it('passes arguments to the first call', () => {
119
- const fn = vi.fn((a, b) => a + b);
120
- const onceFn = once(fn);
121
- expect(onceFn(1, 2)).toBe(3);
122
- expect(onceFn(10, 20)).toBe(3);
123
- });
124
- });
125
-
126
-
127
- describe('sleep', () => {
128
- it('returns a promise that resolves after ms', async () => {
129
- vi.useFakeTimers();
130
- const p = sleep(100);
131
- vi.advanceTimersByTime(100);
132
- await expect(p).resolves.toBeUndefined();
133
- });
134
- });
135
-
136
-
137
- // ---------------------------------------------------------------------------
138
- // String utilities
139
- // ---------------------------------------------------------------------------
140
-
141
- describe('escapeHtml', () => {
142
- it('escapes &, <, >, ", \'', () => {
143
- expect(escapeHtml('&<>"\'')).toBe('&amp;&lt;&gt;&quot;&#39;');
144
- });
145
-
146
- it('converts non-strings to string', () => {
147
- expect(escapeHtml(42)).toBe('42');
148
- expect(escapeHtml(null)).toBe('null');
149
- });
150
-
151
- it('handles empty string', () => {
152
- expect(escapeHtml('')).toBe('');
153
- });
154
- });
155
-
156
-
157
- describe('stripHtml', () => {
158
- it('removes HTML tags from a string', () => {
159
- expect(stripHtml('<p>Hello <b>World</b></p>')).toBe('Hello World');
160
- });
161
-
162
- it('handles self-closing tags', () => {
163
- expect(stripHtml('Line one<br/>Line two')).toBe('Line oneLine two');
164
- });
165
-
166
- it('handles attributes inside tags', () => {
167
- expect(stripHtml('<a href="https://example.com" class="link">click</a>')).toBe('click');
168
- });
169
-
170
- it('strips nested tags', () => {
171
- expect(stripHtml('<div><p><span>deep</span></p></div>')).toBe('deep');
172
- });
173
-
174
- it('handles string with no tags', () => {
175
- expect(stripHtml('no tags here')).toBe('no tags here');
176
- });
177
-
178
- it('handles empty string', () => {
179
- expect(stripHtml('')).toBe('');
180
- });
181
-
182
- it('converts non-strings to string first', () => {
183
- expect(stripHtml(42)).toBe('42');
184
- expect(stripHtml(null)).toBe('null');
185
- });
186
-
187
- it('preserves text content between multiple tags', () => {
188
- expect(stripHtml('<li>one</li><li>two</li><li>three</li>')).toBe('onetwothree');
189
- });
190
-
191
- it('strips angle-bracket patterns that look like tags', () => {
192
- expect(stripHtml('a < b > c')).toBe('a c');
193
- expect(stripHtml('5 > 3 and 2 < 4')).toBe('5 > 3 and 2 < 4');
194
- });
195
- });
196
-
197
-
198
- describe('trust', () => {
199
- it('returns a TrustedHTML instance', () => {
200
- const t = trust('<b>bold</b>');
201
- expect(t.toString()).toBe('<b>bold</b>');
202
- });
203
- });
204
-
205
-
206
- describe('uuid', () => {
207
- it('returns a string', () => {
208
- expect(typeof uuid()).toBe('string');
209
- });
210
-
211
- it('returns different values on successive calls', () => {
212
- const a = uuid();
213
- const b = uuid();
214
- expect(a).not.toBe(b);
215
- });
216
-
217
- it('has valid UUID v4 format', () => {
218
- expect(uuid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
219
- });
220
- });
221
-
222
-
223
- describe('camelCase', () => {
224
- it('converts kebab-case to camelCase', () => {
225
- expect(camelCase('my-component')).toBe('myComponent');
226
- expect(camelCase('a-b-c')).toBe('aBC');
227
- });
228
-
229
- it('handles no hyphens', () => {
230
- expect(camelCase('hello')).toBe('hello');
231
- });
232
- });
233
-
234
-
235
- describe('kebabCase', () => {
236
- it('converts camelCase to kebab-case', () => {
237
- expect(kebabCase('myComponent')).toBe('my-component');
238
- expect(kebabCase('fooBarBaz')).toBe('foo-bar-baz');
239
- });
240
-
241
- it('handles already kebab-case', () => {
242
- expect(kebabCase('hello')).toBe('hello');
243
- });
244
- });
245
-
246
-
247
- // ---------------------------------------------------------------------------
248
- // Object utilities
249
- // ---------------------------------------------------------------------------
250
-
251
- describe('deepClone', () => {
252
- it('creates an independent copy', () => {
253
- const obj = { a: 1, b: { c: 2 } };
254
- const clone = deepClone(obj);
255
- clone.b.c = 99;
256
- expect(obj.b.c).toBe(2);
257
- });
258
-
259
- it('handles arrays', () => {
260
- const arr = [1, [2, 3]];
261
- const clone = deepClone(arr);
262
- clone[1][0] = 99;
263
- expect(arr[1][0]).toBe(2);
264
- });
265
- });
266
-
267
-
268
- describe('deepMerge', () => {
269
- it('deeply merges objects', () => {
270
- const a = { x: 1, nested: { y: 2 } };
271
- const b = { nested: { z: 3 }, w: 4 };
272
- const result = deepMerge(a, b);
273
- expect(result).toEqual({ x: 1, nested: { y: 2, z: 3 }, w: 4 });
274
- });
275
-
276
- it('overwrites non-object values', () => {
277
- const result = deepMerge({ a: 1 }, { a: 2 });
278
- expect(result.a).toBe(2);
279
- });
280
-
281
- it('handles arrays by replacing them', () => {
282
- const result = deepMerge({ a: [1, 2] }, { a: [3, 4] });
283
- expect(result.a).toEqual([3, 4]);
284
- });
285
- });
286
-
287
-
288
- describe('isEqual', () => {
289
- it('returns true for equal primitives', () => {
290
- expect(isEqual(1, 1)).toBe(true);
291
- expect(isEqual('a', 'a')).toBe(true);
292
- expect(isEqual(null, null)).toBe(true);
293
- });
294
-
295
- it('returns false for different primitives', () => {
296
- expect(isEqual(1, 2)).toBe(false);
297
- expect(isEqual('a', 'b')).toBe(false);
298
- });
299
-
300
- it('returns true for deeply equal objects', () => {
301
- expect(isEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true);
302
- });
303
-
304
- it('returns false for objects with different keys', () => {
305
- expect(isEqual({ a: 1 }, { b: 1 })).toBe(false);
306
- });
307
-
308
- it('returns false for objects with different lengths', () => {
309
- expect(isEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
310
- });
311
-
312
- it('handles null comparisons', () => {
313
- expect(isEqual(null, {})).toBe(false);
314
- expect(isEqual({}, null)).toBe(false);
315
- });
316
-
317
- it('distinguishes arrays from plain objects with same indices', () => {
318
- expect(isEqual([1, 2], { 0: 1, 1: 2 })).toBe(false);
319
- expect(isEqual({ 0: 'a' }, ['a'])).toBe(false);
320
- });
321
- });
322
-
323
- // ---------------------------------------------------------------------------
324
- // deepMerge - circular reference safety
325
- // ---------------------------------------------------------------------------
326
-
327
- describe('deepMerge - circular reference safety', () => {
328
- it('does not infinite-loop on circular source', () => {
329
- const a = { x: 1 };
330
- const b = { y: 2 };
331
- b.self = b; // circular
332
- const result = deepMerge(a, b);
333
- expect(result.x).toBe(1);
334
- expect(result.y).toBe(2);
335
- // circular ref is simply not followed again
336
- });
337
-
338
- it('does not infinite-loop on circular target', () => {
339
- const a = { x: 1 };
340
- a.self = a;
341
- const b = { y: 2 };
342
- const result = deepMerge(a, b);
343
- expect(result.y).toBe(2);
344
- });
345
- });
346
-
347
-
348
- // ---------------------------------------------------------------------------
349
- // URL utilities
350
- // ---------------------------------------------------------------------------
351
-
352
- describe('param', () => {
353
- it('serializes object to query string', () => {
354
- expect(param({ a: '1', b: '2' })).toBe('a=1&b=2');
355
- });
356
-
357
- it('handles empty object', () => {
358
- expect(param({})).toBe('');
359
- });
360
- });
361
-
362
-
363
- describe('parseQuery', () => {
364
- it('parses query string to object', () => {
365
- expect(parseQuery('a=1&b=2')).toEqual({ a: '1', b: '2' });
366
- });
367
-
368
- it('handles leading ?', () => {
369
- expect(parseQuery('?foo=bar')).toEqual({ foo: 'bar' });
370
- });
371
-
372
- it('handles empty string', () => {
373
- expect(parseQuery('')).toEqual({});
374
- });
375
- });
376
-
377
-
378
- // ---------------------------------------------------------------------------
379
- // html template tag
380
- // ---------------------------------------------------------------------------
381
-
382
- describe('html template tag', () => {
383
- it('auto-escapes interpolated values', () => {
384
- const userInput = '<script>alert("xss")</script>';
385
- const result = html`<div>${userInput}</div>`;
386
- expect(result).toContain('&lt;script&gt;');
387
- expect(result).not.toContain('<script>');
388
- });
389
-
390
- it('does not escape trusted HTML', () => {
391
- const safe = trust('<b>bold</b>');
392
- const result = html`<div>${safe}</div>`;
393
- expect(result).toContain('<b>bold</b>');
394
- });
395
-
396
- it('handles null/undefined values', () => {
397
- const result = html`<span>${null}</span>`;
398
- expect(result).toBe('<span></span>');
399
- });
400
- });
401
-
402
-
403
- // ---------------------------------------------------------------------------
404
- // storage helpers
405
- // ---------------------------------------------------------------------------
406
-
407
- describe('storage (localStorage wrapper)', () => {
408
- beforeEach(() => { localStorage.clear(); });
409
-
410
- it('set and get a value', () => {
411
- storage.set('key', { a: 1 });
412
- expect(storage.get('key')).toEqual({ a: 1 });
413
- });
414
-
415
- it('returns fallback for missing key', () => {
416
- expect(storage.get('missing', 'default')).toBe('default');
417
- });
418
-
419
- it('returns null as default fallback', () => {
420
- expect(storage.get('missing')).toBeNull();
421
- });
422
-
423
- it('remove deletes a key', () => {
424
- storage.set('key', 42);
425
- storage.remove('key');
426
- expect(storage.get('key')).toBeNull();
427
- });
428
-
429
- it('clear removes all keys', () => {
430
- storage.set('a', 1);
431
- storage.set('b', 2);
432
- storage.clear();
433
- expect(storage.get('a')).toBeNull();
434
- expect(storage.get('b')).toBeNull();
435
- });
436
-
437
- it('handles non-JSON values gracefully', () => {
438
- localStorage.setItem('bad', '{not json}');
439
- expect(storage.get('bad', 'fallback')).toBe('fallback');
440
- });
441
- });
442
-
443
-
444
- describe('session (sessionStorage wrapper)', () => {
445
- beforeEach(() => { sessionStorage.clear(); });
446
-
447
- it('set and get a value', () => {
448
- session.set('key', [1, 2, 3]);
449
- expect(session.get('key')).toEqual([1, 2, 3]);
450
- });
451
-
452
- it('returns fallback for missing key', () => {
453
- expect(session.get('missing', 'default')).toBe('default');
454
- });
455
-
456
- it('remove deletes a key', () => {
457
- session.set('key', 'val');
458
- session.remove('key');
459
- expect(session.get('key')).toBeNull();
460
- });
461
-
462
- it('clear removes all keys', () => {
463
- session.set('a', 1);
464
- session.clear();
465
- expect(session.get('a')).toBeNull();
466
- });
467
- });
468
-
469
-
470
- // ---------------------------------------------------------------------------
471
- // Event bus
472
- // ---------------------------------------------------------------------------
473
-
474
- describe('bus (event bus)', () => {
475
- beforeEach(() => { bus.clear(); });
476
-
477
- it('on() and emit()', () => {
478
- const fn = vi.fn();
479
- bus.on('test', fn);
480
- bus.emit('test', 'data');
481
- expect(fn).toHaveBeenCalledWith('data');
482
- });
483
-
484
- it('off() removes handler', () => {
485
- const fn = vi.fn();
486
- bus.on('test', fn);
487
- bus.off('test', fn);
488
- bus.emit('test');
489
- expect(fn).not.toHaveBeenCalled();
490
- });
491
-
492
- it('on() returns unsubscribe function', () => {
493
- const fn = vi.fn();
494
- const unsub = bus.on('test', fn);
495
- unsub();
496
- bus.emit('test');
497
- expect(fn).not.toHaveBeenCalled();
498
- });
499
-
500
- it('once() fires handler only once', () => {
501
- const fn = vi.fn();
502
- bus.once('test', fn);
503
- bus.emit('test', 'first');
504
- bus.emit('test', 'second');
505
- expect(fn).toHaveBeenCalledOnce();
506
- expect(fn).toHaveBeenCalledWith('first');
507
- });
508
-
509
- it('emit with multiple args', () => {
510
- const fn = vi.fn();
511
- bus.on('test', fn);
512
- bus.emit('test', 1, 2, 3);
513
- expect(fn).toHaveBeenCalledWith(1, 2, 3);
514
- });
515
-
516
- it('multiple handlers on same event', () => {
517
- const fn1 = vi.fn();
518
- const fn2 = vi.fn();
519
- bus.on('test', fn1);
520
- bus.on('test', fn2);
521
- bus.emit('test');
522
- expect(fn1).toHaveBeenCalledOnce();
523
- expect(fn2).toHaveBeenCalledOnce();
524
- });
525
-
526
- it('clear() removes all handlers', () => {
527
- const fn = vi.fn();
528
- bus.on('a', fn);
529
- bus.on('b', fn);
530
- bus.clear();
531
- bus.emit('a');
532
- bus.emit('b');
533
- expect(fn).not.toHaveBeenCalled();
534
- });
535
- });
536
-
537
- // ---------------------------------------------------------------------------
538
- // Event bus
539
- // ---------------------------------------------------------------------------
540
-
541
- describe('bus (EventBus)', () => {
542
- beforeEach(() => { bus.clear(); });
543
-
544
- it('on/emit - fires handler for matching events', () => {
545
- const fn = vi.fn();
546
- bus.on('test', fn);
547
- bus.emit('test', 42);
548
- expect(fn).toHaveBeenCalledWith(42);
549
- });
550
-
551
- it('off - removes handler', () => {
552
- const fn = vi.fn();
553
- bus.on('test', fn);
554
- bus.off('test', fn);
555
- bus.emit('test');
556
- expect(fn).not.toHaveBeenCalled();
557
- });
558
-
559
- it('on() returns unsubscribe function', () => {
560
- const fn = vi.fn();
561
- const unsub = bus.on('test', fn);
562
- unsub();
563
- bus.emit('test');
564
- expect(fn).not.toHaveBeenCalled();
565
- });
566
-
567
- it('once - fires handler only once', () => {
568
- const fn = vi.fn();
569
- bus.once('test', fn);
570
- bus.emit('test', 'a');
571
- bus.emit('test', 'b');
572
- expect(fn).toHaveBeenCalledOnce();
573
- expect(fn).toHaveBeenCalledWith('a');
574
- });
575
-
576
- it('clear - removes all handlers', () => {
577
- const fn = vi.fn();
578
- bus.on('a', fn);
579
- bus.on('b', fn);
580
- bus.clear();
581
- bus.emit('a');
582
- bus.emit('b');
583
- expect(fn).not.toHaveBeenCalled();
584
- });
585
-
586
- it('emit with no handlers does not throw', () => {
587
- expect(() => bus.emit('nonexistent')).not.toThrow();
588
- });
589
- });
590
-
591
-
592
- // ===========================================================================
593
- // throttle - window reset
594
- // ===========================================================================
595
-
596
- describe('throttle - edge cases', () => {
597
- it('fires trailing call after wait period', async () => {
598
- vi.useFakeTimers();
599
- const fn = vi.fn();
600
- const throttled = throttle(fn, 100);
601
-
602
- throttled('a'); // immediate
603
- throttled('b'); // queued
604
- expect(fn).toHaveBeenCalledTimes(1);
605
-
606
- vi.advanceTimersByTime(100);
607
- expect(fn).toHaveBeenCalledTimes(2);
608
- expect(fn).toHaveBeenLastCalledWith('b');
609
- vi.useRealTimers();
610
- });
611
- });
612
-
613
-
614
- // ===========================================================================
615
- // deepClone - edge cases
616
- // ===========================================================================
617
-
618
- describe('deepClone - edge cases', () => {
619
- it('clones nested arrays', () => {
620
- const arr = [[1, 2], [3, 4]];
621
- const clone = deepClone(arr);
622
- expect(clone).toEqual(arr);
623
- clone[0][0] = 99;
624
- expect(arr[0][0]).toBe(1);
625
- });
626
-
627
- it('handles null values', () => {
628
- expect(deepClone({ a: null })).toEqual({ a: null });
629
- });
630
- });
631
-
632
-
633
- // ===========================================================================
634
- // deepMerge - multiple sources
635
- // ===========================================================================
636
-
637
- describe('deepMerge - edge cases', () => {
638
- it('merges from multiple sources', () => {
639
- const result = deepMerge({}, { a: 1 }, { b: 2 }, { c: 3 });
640
- expect(result).toEqual({ a: 1, b: 2, c: 3 });
641
- });
642
-
643
- it('later sources override earlier', () => {
644
- const result = deepMerge({}, { a: 1 }, { a: 2 });
645
- expect(result).toEqual({ a: 2 });
646
- });
647
-
648
- it('handles arrays (replaces, not merges)', () => {
649
- const result = deepMerge({}, { arr: [1, 2] }, { arr: [3] });
650
- expect(result.arr).toEqual([3]);
651
- });
652
- });
653
-
654
-
655
- // ===========================================================================
656
- // isEqual - deeply nested
657
- // ===========================================================================
658
-
659
- describe('isEqual - edge cases', () => {
660
- it('deeply nested equal objects', () => {
661
- expect(isEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(true);
662
- });
663
-
664
- it('arrays of objects', () => {
665
- expect(isEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
666
- expect(isEqual([{ a: 1 }], [{ a: 2 }])).toBe(false);
667
- });
668
-
669
- it('empty arrays equal', () => {
670
- expect(isEqual([], [])).toBe(true);
671
- });
672
-
673
- it('null vs object', () => {
674
- expect(isEqual(null, { a: 1 })).toBe(false);
675
- expect(isEqual({ a: 1 }, null)).toBe(false);
676
- });
677
-
678
- it('different types', () => {
679
- expect(isEqual('1', 1)).toBe(false);
680
- });
681
-
682
- it('array vs object', () => {
683
- expect(isEqual([], {})).toBe(false);
684
- });
685
- });
686
-
687
-
688
- // ===========================================================================
689
- // camelCase / kebabCase - edge cases
690
- // ===========================================================================
691
-
692
- describe('camelCase / kebabCase - edge cases', () => {
693
- it('camelCase single word', () => {
694
- expect(camelCase('hello')).toBe('hello');
695
- });
696
-
697
- it('camelCase already camel', () => {
698
- expect(camelCase('helloWorld')).toBe('helloWorld');
699
- });
700
-
701
- it('kebabCase single word lowercase', () => {
702
- expect(kebabCase('hello')).toBe('hello');
703
- });
704
-
705
- it('kebabCase multiple humps', () => {
706
- expect(kebabCase('myComponentName')).toBe('my-component-name');
707
- });
708
- });
709
-
710
-
711
- // ===========================================================================
712
- // html tag - escaping
713
- // ===========================================================================
714
-
715
- describe('html tag - edge cases', () => {
716
- it('handles null interp value', () => {
717
- const result = html`<div>${null}</div>`;
718
- expect(result).toBe('<div></div>');
719
- });
720
-
721
- it('handles undefined interp value', () => {
722
- const result = html`<div>${undefined}</div>`;
723
- expect(result).toBe('<div></div>');
724
- });
725
-
726
- it('escapes multiple interpolations', () => {
727
- const a = '<b>';
728
- const b = '&';
729
- const result = html`${a} and ${b}`;
730
- expect(result).toContain('&lt;b&gt;');
731
- expect(result).toContain('&amp;');
732
- });
733
- });
734
-
735
-
736
- // ===========================================================================
737
- // storage - error handling
738
- // ===========================================================================
739
-
740
- describe('storage - parse error fallback', () => {
741
- it('returns fallback when JSON.parse fails', () => {
742
- localStorage.setItem('bad', '{invalid json');
743
- expect(storage.get('bad', 'default')).toBe('default');
744
- localStorage.removeItem('bad');
745
- });
746
- });
747
-
748
-
749
- // ===========================================================================
750
- // NEW UTILITIES - Array
751
- // ===========================================================================
752
-
753
- describe('range', () => {
754
- it('generates range from 0 to n-1 with single arg', () => {
755
- expect(range(5)).toEqual([0, 1, 2, 3, 4]);
756
- });
757
-
758
- it('generates range from start to end-1', () => {
759
- expect(range(2, 6)).toEqual([2, 3, 4, 5]);
760
- });
761
-
762
- it('generates range with custom step', () => {
763
- expect(range(0, 10, 3)).toEqual([0, 3, 6, 9]);
764
- });
765
-
766
- it('handles negative step (descending)', () => {
767
- expect(range(5, 0, -1)).toEqual([5, 4, 3, 2, 1]);
768
- });
769
-
770
- it('returns empty array for zero or negative count', () => {
771
- expect(range(0)).toEqual([]);
772
- expect(range(-3)).toEqual([]);
773
- });
774
-
775
- it('returns empty array when step goes wrong direction', () => {
776
- expect(range(0, 5, -1)).toEqual([]);
777
- expect(range(5, 0, 1)).toEqual([]);
778
- });
779
-
780
- it('handles step of 1 as default', () => {
781
- expect(range(1, 4)).toEqual([1, 2, 3]);
782
- });
783
-
784
- it('handles float steps', () => {
785
- const r = range(0, 1, 0.25);
786
- expect(r.length).toBe(4);
787
- expect(r[0]).toBeCloseTo(0);
788
- expect(r[3]).toBeCloseTo(0.75);
789
- });
790
- });
791
-
792
-
793
- describe('unique', () => {
794
- it('deduplicates primitive arrays', () => {
795
- expect(unique([1, 2, 2, 3, 1, 3])).toEqual([1, 2, 3]);
796
- });
797
-
798
- it('preserves order (first occurrence)', () => {
799
- expect(unique([3, 1, 2, 1, 3])).toEqual([3, 1, 2]);
800
- });
801
-
802
- it('handles strings', () => {
803
- expect(unique(['a', 'b', 'a', 'c'])).toEqual(['a', 'b', 'c']);
804
- });
805
-
806
- it('deduplicates by key function', () => {
807
- const items = [{ id: 1, n: 'a' }, { id: 2, n: 'b' }, { id: 1, n: 'c' }];
808
- const result = unique(items, item => item.id);
809
- expect(result).toEqual([{ id: 1, n: 'a' }, { id: 2, n: 'b' }]);
810
- });
811
-
812
- it('handles empty array', () => {
813
- expect(unique([])).toEqual([]);
814
- });
815
- });
816
-
817
-
818
- describe('chunk', () => {
819
- it('splits array into chunks of given size', () => {
820
- expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
821
- });
822
-
823
- it('returns single chunk when size >= length', () => {
824
- expect(chunk([1, 2, 3], 5)).toEqual([[1, 2, 3]]);
825
- });
826
-
827
- it('handles exact division', () => {
828
- expect(chunk([1, 2, 3, 4], 2)).toEqual([[1, 2], [3, 4]]);
829
- });
830
-
831
- it('handles chunk size of 1', () => {
832
- expect(chunk([1, 2, 3], 1)).toEqual([[1], [2], [3]]);
833
- });
834
-
835
- it('returns empty array for empty input', () => {
836
- expect(chunk([], 3)).toEqual([]);
837
- });
838
- });
839
-
840
-
841
- describe('groupBy', () => {
842
- it('groups by string key function', () => {
843
- const items = [
844
- { type: 'fruit', name: 'apple' },
845
- { type: 'veg', name: 'carrot' },
846
- { type: 'fruit', name: 'banana' },
847
- ];
848
- const result = groupBy(items, item => item.type);
849
- expect(result).toEqual({
850
- fruit: [{ type: 'fruit', name: 'apple' }, { type: 'fruit', name: 'banana' }],
851
- veg: [{ type: 'veg', name: 'carrot' }],
852
- });
853
- });
854
-
855
- it('groups by computed value', () => {
856
- const nums = [1, 2, 3, 4, 5, 6];
857
- const result = groupBy(nums, n => n % 2 === 0 ? 'even' : 'odd');
858
- expect(result.even).toEqual([2, 4, 6]);
859
- expect(result.odd).toEqual([1, 3, 5]);
860
- });
861
-
862
- it('handles empty array', () => {
863
- expect(groupBy([], () => 'key')).toEqual({});
864
- });
865
- });
866
-
867
-
868
- // ===========================================================================
869
- // NEW UTILITIES - Object
870
- // ===========================================================================
871
-
872
- describe('pick', () => {
873
- it('picks specified keys from object', () => {
874
- const obj = { a: 1, b: 2, c: 3, d: 4 };
875
- expect(pick(obj, ['a', 'c'])).toEqual({ a: 1, c: 3 });
876
- });
877
-
878
- it('ignores keys that do not exist', () => {
879
- expect(pick({ a: 1 }, ['a', 'z'])).toEqual({ a: 1 });
880
- });
881
-
882
- it('returns empty object for empty keys', () => {
883
- expect(pick({ a: 1 }, [])).toEqual({});
884
- });
885
-
886
- it('handles undefined/null values in picked keys', () => {
887
- expect(pick({ a: null, b: undefined, c: 0 }, ['a', 'b', 'c'])).toEqual({ a: null, b: undefined, c: 0 });
888
- });
889
- });
890
-
891
-
892
- describe('omit', () => {
893
- it('omits specified keys from object', () => {
894
- const obj = { a: 1, b: 2, c: 3, d: 4 };
895
- expect(omit(obj, ['b', 'd'])).toEqual({ a: 1, c: 3 });
896
- });
897
-
898
- it('returns full object when no keys match', () => {
899
- expect(omit({ a: 1, b: 2 }, ['z'])).toEqual({ a: 1, b: 2 });
900
- });
901
-
902
- it('returns empty object when all keys omitted', () => {
903
- expect(omit({ a: 1, b: 2 }, ['a', 'b'])).toEqual({});
904
- });
905
- });
906
-
907
-
908
- describe('getPath', () => {
909
- it('gets nested value by dot path', () => {
910
- const obj = { a: { b: { c: 42 } } };
911
- expect(getPath(obj, 'a.b.c')).toBe(42);
912
- });
913
-
914
- it('gets top-level value', () => {
915
- expect(getPath({ x: 10 }, 'x')).toBe(10);
916
- });
917
-
918
- it('returns fallback for missing path', () => {
919
- expect(getPath({ a: 1 }, 'b.c', 'default')).toBe('default');
920
- });
921
-
922
- it('returns undefined by default for missing path', () => {
923
- expect(getPath({}, 'a.b')).toBeUndefined();
924
- });
925
-
926
- it('handles null intermediate values', () => {
927
- expect(getPath({ a: null }, 'a.b', 'nope')).toBe('nope');
928
- });
929
-
930
- it('works with array indices', () => {
931
- const obj = { items: ['zero', 'one', 'two'] };
932
- expect(getPath(obj, 'items.1')).toBe('one');
933
- });
934
- });
935
-
936
-
937
- describe('setPath', () => {
938
- it('sets nested value by dot path', () => {
939
- const obj = { a: { b: { c: 1 } } };
940
- setPath(obj, 'a.b.c', 99);
941
- expect(obj.a.b.c).toBe(99);
942
- });
943
-
944
- it('creates intermediate objects when missing', () => {
945
- const obj = {};
946
- setPath(obj, 'a.b.c', 42);
947
- expect(obj.a.b.c).toBe(42);
948
- });
949
-
950
- it('sets top-level value', () => {
951
- const obj = {};
952
- setPath(obj, 'x', 10);
953
- expect(obj.x).toBe(10);
954
- });
955
-
956
- it('overwrites existing intermediate non-object', () => {
957
- const obj = { a: 5 };
958
- setPath(obj, 'a.b', 10);
959
- expect(obj.a.b).toBe(10);
960
- });
961
- });
962
-
963
-
964
- describe('isEmpty', () => {
965
- it('returns true for null and undefined', () => {
966
- expect(isEmpty(null)).toBe(true);
967
- expect(isEmpty(undefined)).toBe(true);
968
- });
969
-
970
- it('returns true for empty string', () => {
971
- expect(isEmpty('')).toBe(true);
972
- });
973
-
974
- it('returns true for empty array', () => {
975
- expect(isEmpty([])).toBe(true);
976
- });
977
-
978
- it('returns true for empty object', () => {
979
- expect(isEmpty({})).toBe(true);
980
- });
981
-
982
- it('returns false for non-empty string', () => {
983
- expect(isEmpty('hello')).toBe(false);
984
- });
985
-
986
- it('returns false for non-empty array', () => {
987
- expect(isEmpty([1])).toBe(false);
988
- });
989
-
990
- it('returns false for non-empty object', () => {
991
- expect(isEmpty({ a: 1 })).toBe(false);
992
- });
993
-
994
- it('returns false for number zero', () => {
995
- expect(isEmpty(0)).toBe(false);
996
- });
997
-
998
- it('returns false for boolean false', () => {
999
- expect(isEmpty(false)).toBe(false);
1000
- });
1001
-
1002
- it('returns true for empty Map and Set', () => {
1003
- expect(isEmpty(new Map())).toBe(true);
1004
- expect(isEmpty(new Set())).toBe(true);
1005
- });
1006
-
1007
- it('returns false for non-empty Map and Set', () => {
1008
- expect(isEmpty(new Map([['a', 1]]))).toBe(false);
1009
- expect(isEmpty(new Set([1]))).toBe(false);
1010
- });
1011
- });
1012
-
1013
-
1014
- // ===========================================================================
1015
- // NEW UTILITIES - String
1016
- // ===========================================================================
1017
-
1018
- describe('capitalize', () => {
1019
- it('capitalizes first letter', () => {
1020
- expect(capitalize('hello')).toBe('Hello');
1021
- });
1022
-
1023
- it('handles single character', () => {
1024
- expect(capitalize('a')).toBe('A');
1025
- });
1026
-
1027
- it('handles empty string', () => {
1028
- expect(capitalize('')).toBe('');
1029
- });
1030
-
1031
- it('lowercases the rest', () => {
1032
- expect(capitalize('hELLO')).toBe('Hello');
1033
- });
1034
-
1035
- it('handles already capitalized', () => {
1036
- expect(capitalize('Hello')).toBe('Hello');
1037
- });
1038
- });
1039
-
1040
-
1041
- describe('truncate', () => {
1042
- it('truncates long strings with ellipsis', () => {
1043
- expect(truncate('Hello, World!', 8)).toBe('Hello, \u2026');
1044
- });
1045
-
1046
- it('does not truncate short strings', () => {
1047
- expect(truncate('Hi', 10)).toBe('Hi');
1048
- });
1049
-
1050
- it('uses custom suffix', () => {
1051
- expect(truncate('Hello, World!', 8, '---')).toBe('Hello---');
1052
- });
1053
-
1054
- it('handles exact length (no truncation needed)', () => {
1055
- expect(truncate('Hello', 5)).toBe('Hello');
1056
- });
1057
-
1058
- it('handles empty string', () => {
1059
- expect(truncate('', 5)).toBe('');
1060
- });
1061
-
1062
- it('handles suffix longer than maxLen gracefully', () => {
1063
- expect(truncate('Hello, World!', 2)).toBe('H\u2026');
1064
- });
1065
- });
1066
-
1067
-
1068
- // ===========================================================================
1069
- // NEW UTILITIES - Number
1070
- // ===========================================================================
1071
-
1072
- describe('clamp', () => {
1073
- it('clamps value below min to min', () => {
1074
- expect(clamp(-5, 0, 100)).toBe(0);
1075
- });
1076
-
1077
- it('clamps value above max to max', () => {
1078
- expect(clamp(150, 0, 100)).toBe(100);
1079
- });
1080
-
1081
- it('returns value when within range', () => {
1082
- expect(clamp(50, 0, 100)).toBe(50);
1083
- });
1084
-
1085
- it('handles min === max', () => {
1086
- expect(clamp(50, 10, 10)).toBe(10);
1087
- });
1088
-
1089
- it('handles negative ranges', () => {
1090
- expect(clamp(-50, -100, -10)).toBe(-50);
1091
- expect(clamp(-200, -100, -10)).toBe(-100);
1092
- });
1093
-
1094
- it('clamps at boundaries', () => {
1095
- expect(clamp(0, 0, 100)).toBe(0);
1096
- expect(clamp(100, 0, 100)).toBe(100);
1097
- });
1098
- });
1099
-
1100
-
1101
- // ===========================================================================
1102
- // NEW UTILITIES - Function
1103
- // ===========================================================================
1104
-
1105
- describe('memoize', () => {
1106
- it('caches results for same arguments', () => {
1107
- const fn = vi.fn(x => x * 2);
1108
- const memoized = memoize(fn);
1109
- expect(memoized(5)).toBe(10);
1110
- expect(memoized(5)).toBe(10);
1111
- expect(fn).toHaveBeenCalledTimes(1);
1112
- });
1113
-
1114
- it('recomputes for different arguments', () => {
1115
- const fn = vi.fn(x => x * 2);
1116
- const memoized = memoize(fn);
1117
- expect(memoized(5)).toBe(10);
1118
- expect(memoized(3)).toBe(6);
1119
- expect(fn).toHaveBeenCalledTimes(2);
1120
- });
1121
-
1122
- it('uses custom key function', () => {
1123
- const fn = vi.fn((a, b) => a + b);
1124
- const memoized = memoize(fn, (a, b) => `${a}:${b}`);
1125
- expect(memoized(1, 2)).toBe(3);
1126
- expect(memoized(1, 2)).toBe(3);
1127
- expect(fn).toHaveBeenCalledTimes(1);
1128
- expect(memoized(2, 1)).toBe(3);
1129
- expect(fn).toHaveBeenCalledTimes(2);
1130
- });
1131
-
1132
- it('has .clear() to reset cache', () => {
1133
- const fn = vi.fn(x => x * 2);
1134
- const memoized = memoize(fn);
1135
- memoized(5);
1136
- memoized.clear();
1137
- memoized(5);
1138
- expect(fn).toHaveBeenCalledTimes(2);
1139
- });
1140
-
1141
- it('respects maxSize option', () => {
1142
- const fn = vi.fn(x => x * 2);
1143
- const memoized = memoize(fn, { maxSize: 2 });
1144
- memoized(1);
1145
- memoized(2);
1146
- memoized(3); // evicts 1
1147
- expect(fn).toHaveBeenCalledTimes(3);
1148
- memoized(2); // still cached
1149
- expect(fn).toHaveBeenCalledTimes(3);
1150
- memoized(1); // evicted, recomputes
1151
- expect(fn).toHaveBeenCalledTimes(4);
1152
- });
1153
- });
1154
-
1155
-
1156
- // ===========================================================================
1157
- // NEW UTILITIES - Async
1158
- // ===========================================================================
1159
-
1160
- describe('retry', () => {
1161
- it('resolves on first success', async () => {
1162
- const fn = vi.fn(async () => 42);
1163
- const result = await retry(fn);
1164
- expect(result).toBe(42);
1165
- expect(fn).toHaveBeenCalledTimes(1);
1166
- });
1167
-
1168
- it('retries on failure and succeeds', async () => {
1169
- let calls = 0;
1170
- const fn = async () => {
1171
- calls++;
1172
- if (calls < 3) throw new Error('fail');
1173
- return 'ok';
1174
- };
1175
- const result = await retry(fn, { attempts: 3, delay: 0 });
1176
- expect(result).toBe('ok');
1177
- expect(calls).toBe(3);
1178
- });
1179
-
1180
- it('throws after exhausting all attempts', async () => {
1181
- const fn = async () => { throw new Error('always fails'); };
1182
- await expect(retry(fn, { attempts: 3, delay: 0 })).rejects.toThrow('always fails');
1183
- });
1184
-
1185
- it('passes attempt number to function', async () => {
1186
- const fn = vi.fn(async (attempt) => attempt);
1187
- await retry(fn, { attempts: 1, delay: 0 });
1188
- expect(fn).toHaveBeenCalledWith(1);
1189
- });
1190
-
1191
- it('uses exponential backoff when configured', async () => {
1192
- vi.useFakeTimers();
1193
- let calls = 0;
1194
- const fn = async () => { calls++; if (calls < 3) throw new Error('fail'); return 'done'; };
1195
- const p = retry(fn, { attempts: 3, delay: 100, backoff: 2 });
1196
- // First call happens immediately, fails
1197
- await vi.advanceTimersByTimeAsync(0);
1198
- // Second call after 100ms delay, fails
1199
- await vi.advanceTimersByTimeAsync(100);
1200
- // Third call after 200ms delay (100 * 2), succeeds
1201
- await vi.advanceTimersByTimeAsync(200);
1202
- const result = await p;
1203
- expect(result).toBe('done');
1204
- vi.useRealTimers();
1205
- });
1206
- });
1207
-
1208
-
1209
- describe('timeout', () => {
1210
- it('resolves if promise completes before timeout', async () => {
1211
- const p = Promise.resolve(42);
1212
- const result = await timeout(p, 1000);
1213
- expect(result).toBe(42);
1214
- });
1215
-
1216
- it('rejects if promise exceeds timeout', async () => {
1217
- vi.useFakeTimers();
1218
- const p = new Promise(() => {}); // never resolves
1219
- const tp = timeout(p, 100);
1220
- vi.advanceTimersByTime(100);
1221
- await expect(tp).rejects.toThrow('Timed out');
1222
- vi.useRealTimers();
1223
- });
1224
-
1225
- it('uses custom error message', async () => {
1226
- vi.useFakeTimers();
1227
- const p = new Promise(() => {});
1228
- const tp = timeout(p, 100, 'Custom timeout');
1229
- vi.advanceTimersByTime(100);
1230
- await expect(tp).rejects.toThrow('Custom timeout');
1231
- vi.useRealTimers();
1232
- });
1233
-
1234
- it('clears timer on successful resolution', async () => {
1235
- const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
1236
- await timeout(Promise.resolve('ok'), 5000);
1237
- expect(clearSpy).toHaveBeenCalled();
1238
- clearSpy.mockRestore();
1239
- });
1240
- });
1241
-
1242
-
1243
- // ===========================================================================
1244
- // memoize - LRU behaviour
1245
- // ===========================================================================
1246
-
1247
- describe('memoize - LRU eviction', () => {
1248
- it('promotes recently-read entries so they survive eviction', () => {
1249
- const fn = vi.fn(x => x * 2);
1250
- const mem = memoize(fn, { maxSize: 3 });
1251
-
1252
- mem(1); // cache: [1]
1253
- mem(2); // cache: [1, 2]
1254
- mem(3); // cache: [1, 2, 3]
1255
-
1256
- // Access 1 to promote it (LRU moves it to newest)
1257
- mem(1); // cache: [2, 3, 1]
1258
- expect(fn).toHaveBeenCalledTimes(3); // still cached, no recompute
1259
-
1260
- // Insert 4 -> should evict 2 (least recently used), NOT 1
1261
- mem(4); // cache: [3, 1, 4]
1262
- expect(fn).toHaveBeenCalledTimes(4);
1263
-
1264
- // 1 should still be cached (was promoted)
1265
- mem(1);
1266
- expect(fn).toHaveBeenCalledTimes(4); // no recompute
1267
-
1268
- // 2 should be evicted
1269
- mem(2);
1270
- expect(fn).toHaveBeenCalledTimes(5); // recomputation
1271
- });
1272
-
1273
- it('evicts in LRU order, not insertion order', () => {
1274
- const fn = vi.fn(x => x);
1275
- const mem = memoize(fn, { maxSize: 2 });
1276
-
1277
- mem('a'); // [a]
1278
- mem('b'); // [a, b]
1279
-
1280
- // Read 'a' - makes 'b' the LRU
1281
- mem('a'); // [b, a]
1282
-
1283
- // Insert 'c' - should evict 'b', not 'a'
1284
- mem('c'); // [a, c]
1285
-
1286
- // 'a' still cached
1287
- mem('a');
1288
- expect(fn).toHaveBeenCalledTimes(3); // a, b, c
1289
-
1290
- // 'b' was evicted
1291
- mem('b');
1292
- expect(fn).toHaveBeenCalledTimes(4);
1293
- });
1294
- });
1295
-
1296
-
1297
- // ===========================================================================
1298
- // deepClone - enhanced types
1299
- // ===========================================================================
1300
-
1301
- describe('deepClone - enhanced types', () => {
1302
- it('clones Date objects', () => {
1303
- const date = new Date('2024-01-15T12:00:00Z');
1304
- const clone = deepClone(date);
1305
- expect(clone).toEqual(date);
1306
- expect(clone).not.toBe(date);
1307
- expect(clone instanceof Date).toBe(true);
1308
- expect(clone.getTime()).toBe(date.getTime());
1309
- });
1310
-
1311
- it('clones nested Dates', () => {
1312
- const obj = { created: new Date('2024-01-15T12:00:00Z'), meta: { updated: new Date('2024-06-15T12:00:00Z') } };
1313
- const clone = deepClone(obj);
1314
- clone.created.setFullYear(2000);
1315
- expect(obj.created.getFullYear()).toBe(2024);
1316
- });
1317
-
1318
- it('clones RegExp', () => {
1319
- const re = /hello/gi;
1320
- const clone = deepClone(re);
1321
- expect(clone).toEqual(re);
1322
- expect(clone).not.toBe(re);
1323
- expect(clone.source).toBe('hello');
1324
- expect(clone.flags).toBe('gi');
1325
- });
1326
-
1327
- it('clones Map', () => {
1328
- const map = new Map([['a', 1], ['b', { deep: true }]]);
1329
- const clone = deepClone(map);
1330
- expect(clone).not.toBe(map);
1331
- expect(clone.get('a')).toBe(1);
1332
- clone.get('b').deep = false;
1333
- expect(map.get('b').deep).toBe(true);
1334
- });
1335
-
1336
- it('clones Set', () => {
1337
- const set = new Set([1, 2, { x: 3 }]);
1338
- const clone = deepClone(set);
1339
- expect(clone).not.toBe(set);
1340
- expect(clone.size).toBe(3);
1341
- expect(clone.has(1)).toBe(true);
1342
- expect(clone.has(2)).toBe(true);
1343
- });
1344
-
1345
- it('handles undefined values in objects', () => {
1346
- const obj = { a: 1, b: undefined, c: 'hello' };
1347
- const clone = deepClone(obj);
1348
- expect(clone.b).toBeUndefined();
1349
- expect('b' in clone).toBe(true);
1350
- });
1351
-
1352
- it('handles null values', () => {
1353
- const obj = { a: null, b: { c: null } };
1354
- const clone = deepClone(obj);
1355
- expect(clone.a).toBeNull();
1356
- expect(clone.b.c).toBeNull();
1357
- });
1358
-
1359
- it('handles circular references', () => {
1360
- const obj = { a: 1 };
1361
- obj.self = obj;
1362
- const clone = deepClone(obj);
1363
- expect(clone.a).toBe(1);
1364
- expect(clone.self).toBe(clone);
1365
- expect(clone.self).not.toBe(obj);
1366
- });
1367
-
1368
- it('handles nested circular references', () => {
1369
- const a = { name: 'a' };
1370
- const b = { name: 'b', ref: a };
1371
- a.ref = b;
1372
- const clone = deepClone(a);
1373
- expect(clone.name).toBe('a');
1374
- expect(clone.ref.name).toBe('b');
1375
- expect(clone.ref.ref).toBe(clone);
1376
- });
1377
- });
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ debounce, throttle, pipe, once, sleep,
4
+ escapeHtml, stripHtml, html, trust, uuid, camelCase, kebabCase,
5
+ deepClone, deepMerge, isEqual, param, parseQuery,
6
+ storage, session, bus,
7
+ // New utilities
8
+ range, unique, chunk, groupBy,
9
+ pick, omit, getPath, setPath, isEmpty,
10
+ capitalize, truncate,
11
+ clamp,
12
+ memoize,
13
+ retry, timeout,
14
+ } from '../src/utils.js';
15
+
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Function utilities
19
+ // ---------------------------------------------------------------------------
20
+
21
+ describe('debounce', () => {
22
+ beforeEach(() => { vi.useFakeTimers(); });
23
+
24
+ it('delays execution until after ms of inactivity', () => {
25
+ const fn = vi.fn();
26
+ const debounced = debounce(fn, 100);
27
+ debounced('a');
28
+ debounced('b');
29
+ expect(fn).not.toHaveBeenCalled();
30
+ vi.advanceTimersByTime(100);
31
+ expect(fn).toHaveBeenCalledOnce();
32
+ expect(fn).toHaveBeenCalledWith('b');
33
+ });
34
+
35
+ it('uses default 250ms delay', () => {
36
+ const fn = vi.fn();
37
+ const debounced = debounce(fn);
38
+ debounced();
39
+ vi.advanceTimersByTime(249);
40
+ expect(fn).not.toHaveBeenCalled();
41
+ vi.advanceTimersByTime(1);
42
+ expect(fn).toHaveBeenCalledOnce();
43
+ });
44
+
45
+ it('cancel() stops pending execution', () => {
46
+ const fn = vi.fn();
47
+ const debounced = debounce(fn, 100);
48
+ debounced();
49
+ debounced.cancel();
50
+ vi.advanceTimersByTime(200);
51
+ expect(fn).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('resets timer on subsequent calls', () => {
55
+ const fn = vi.fn();
56
+ const debounced = debounce(fn, 100);
57
+ debounced();
58
+ vi.advanceTimersByTime(80);
59
+ debounced();
60
+ vi.advanceTimersByTime(80);
61
+ expect(fn).not.toHaveBeenCalled();
62
+ vi.advanceTimersByTime(20);
63
+ expect(fn).toHaveBeenCalledOnce();
64
+ });
65
+ });
66
+
67
+
68
+ describe('throttle', () => {
69
+ beforeEach(() => { vi.useFakeTimers(); });
70
+
71
+ it('fires immediately on first call', () => {
72
+ const fn = vi.fn();
73
+ const throttled = throttle(fn, 100);
74
+ throttled('a');
75
+ expect(fn).toHaveBeenCalledWith('a');
76
+ });
77
+
78
+ it('delays subsequent calls within the window', () => {
79
+ const fn = vi.fn();
80
+ const throttled = throttle(fn, 100);
81
+ throttled('a');
82
+ throttled('b');
83
+ expect(fn).toHaveBeenCalledTimes(1);
84
+ vi.advanceTimersByTime(100);
85
+ expect(fn).toHaveBeenCalledTimes(2);
86
+ expect(fn).toHaveBeenLastCalledWith('b');
87
+ });
88
+ });
89
+
90
+
91
+ describe('pipe', () => {
92
+ it('composes functions left-to-right', () => {
93
+ const add1 = x => x + 1;
94
+ const double = x => x * 2;
95
+ expect(pipe(add1, double)(3)).toBe(8);
96
+ });
97
+
98
+ it('handles a single function', () => {
99
+ const identity = x => x;
100
+ expect(pipe(identity)(42)).toBe(42);
101
+ });
102
+
103
+ it('handles no functions', () => {
104
+ expect(pipe()(10)).toBe(10);
105
+ });
106
+ });
107
+
108
+
109
+ describe('once', () => {
110
+ it('only calls function once', () => {
111
+ const fn = vi.fn(() => 42);
112
+ const onceFn = once(fn);
113
+ expect(onceFn()).toBe(42);
114
+ expect(onceFn()).toBe(42);
115
+ expect(fn).toHaveBeenCalledOnce();
116
+ });
117
+
118
+ it('passes arguments to the first call', () => {
119
+ const fn = vi.fn((a, b) => a + b);
120
+ const onceFn = once(fn);
121
+ expect(onceFn(1, 2)).toBe(3);
122
+ expect(onceFn(10, 20)).toBe(3);
123
+ });
124
+ });
125
+
126
+
127
+ describe('sleep', () => {
128
+ it('returns a promise that resolves after ms', async () => {
129
+ vi.useFakeTimers();
130
+ const p = sleep(100);
131
+ vi.advanceTimersByTime(100);
132
+ await expect(p).resolves.toBeUndefined();
133
+ });
134
+ });
135
+
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // String utilities
139
+ // ---------------------------------------------------------------------------
140
+
141
+ describe('escapeHtml', () => {
142
+ it('escapes &, <, >, ", \'', () => {
143
+ expect(escapeHtml('&<>"\'')).toBe('&amp;&lt;&gt;&quot;&#39;');
144
+ });
145
+
146
+ it('converts non-strings to string', () => {
147
+ expect(escapeHtml(42)).toBe('42');
148
+ expect(escapeHtml(null)).toBe('null');
149
+ });
150
+
151
+ it('handles empty string', () => {
152
+ expect(escapeHtml('')).toBe('');
153
+ });
154
+ });
155
+
156
+
157
+ describe('stripHtml', () => {
158
+ it('removes HTML tags from a string', () => {
159
+ expect(stripHtml('<p>Hello <b>World</b></p>')).toBe('Hello World');
160
+ });
161
+
162
+ it('handles self-closing tags', () => {
163
+ expect(stripHtml('Line one<br/>Line two')).toBe('Line oneLine two');
164
+ });
165
+
166
+ it('handles attributes inside tags', () => {
167
+ expect(stripHtml('<a href="https://example.com" class="link">click</a>')).toBe('click');
168
+ });
169
+
170
+ it('strips nested tags', () => {
171
+ expect(stripHtml('<div><p><span>deep</span></p></div>')).toBe('deep');
172
+ });
173
+
174
+ it('handles string with no tags', () => {
175
+ expect(stripHtml('no tags here')).toBe('no tags here');
176
+ });
177
+
178
+ it('handles empty string', () => {
179
+ expect(stripHtml('')).toBe('');
180
+ });
181
+
182
+ it('converts non-strings to string first', () => {
183
+ expect(stripHtml(42)).toBe('42');
184
+ expect(stripHtml(null)).toBe('null');
185
+ });
186
+
187
+ it('preserves text content between multiple tags', () => {
188
+ expect(stripHtml('<li>one</li><li>two</li><li>three</li>')).toBe('onetwothree');
189
+ });
190
+
191
+ it('strips angle-bracket patterns that look like tags', () => {
192
+ expect(stripHtml('a < b > c')).toBe('a c');
193
+ expect(stripHtml('5 > 3 and 2 < 4')).toBe('5 > 3 and 2 < 4');
194
+ });
195
+ });
196
+
197
+
198
+ describe('trust', () => {
199
+ it('returns a TrustedHTML instance', () => {
200
+ const t = trust('<b>bold</b>');
201
+ expect(t.toString()).toBe('<b>bold</b>');
202
+ });
203
+ });
204
+
205
+
206
+ describe('uuid', () => {
207
+ it('returns a string', () => {
208
+ expect(typeof uuid()).toBe('string');
209
+ });
210
+
211
+ it('returns different values on successive calls', () => {
212
+ const a = uuid();
213
+ const b = uuid();
214
+ expect(a).not.toBe(b);
215
+ });
216
+
217
+ it('has valid UUID v4 format', () => {
218
+ expect(uuid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
219
+ });
220
+ });
221
+
222
+
223
+ describe('camelCase', () => {
224
+ it('converts kebab-case to camelCase', () => {
225
+ expect(camelCase('my-component')).toBe('myComponent');
226
+ expect(camelCase('a-b-c')).toBe('aBC');
227
+ });
228
+
229
+ it('handles no hyphens', () => {
230
+ expect(camelCase('hello')).toBe('hello');
231
+ });
232
+ });
233
+
234
+
235
+ describe('kebabCase', () => {
236
+ it('converts camelCase to kebab-case', () => {
237
+ expect(kebabCase('myComponent')).toBe('my-component');
238
+ expect(kebabCase('fooBarBaz')).toBe('foo-bar-baz');
239
+ });
240
+
241
+ it('handles already kebab-case', () => {
242
+ expect(kebabCase('hello')).toBe('hello');
243
+ });
244
+ });
245
+
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Object utilities
249
+ // ---------------------------------------------------------------------------
250
+
251
+ describe('deepClone', () => {
252
+ it('creates an independent copy', () => {
253
+ const obj = { a: 1, b: { c: 2 } };
254
+ const clone = deepClone(obj);
255
+ clone.b.c = 99;
256
+ expect(obj.b.c).toBe(2);
257
+ });
258
+
259
+ it('handles arrays', () => {
260
+ const arr = [1, [2, 3]];
261
+ const clone = deepClone(arr);
262
+ clone[1][0] = 99;
263
+ expect(arr[1][0]).toBe(2);
264
+ });
265
+ });
266
+
267
+
268
+ describe('deepMerge', () => {
269
+ it('deeply merges objects', () => {
270
+ const a = { x: 1, nested: { y: 2 } };
271
+ const b = { nested: { z: 3 }, w: 4 };
272
+ const result = deepMerge(a, b);
273
+ expect(result).toEqual({ x: 1, nested: { y: 2, z: 3 }, w: 4 });
274
+ });
275
+
276
+ it('overwrites non-object values', () => {
277
+ const result = deepMerge({ a: 1 }, { a: 2 });
278
+ expect(result.a).toBe(2);
279
+ });
280
+
281
+ it('handles arrays by replacing them', () => {
282
+ const result = deepMerge({ a: [1, 2] }, { a: [3, 4] });
283
+ expect(result.a).toEqual([3, 4]);
284
+ });
285
+ });
286
+
287
+
288
+ describe('isEqual', () => {
289
+ it('returns true for equal primitives', () => {
290
+ expect(isEqual(1, 1)).toBe(true);
291
+ expect(isEqual('a', 'a')).toBe(true);
292
+ expect(isEqual(null, null)).toBe(true);
293
+ });
294
+
295
+ it('returns false for different primitives', () => {
296
+ expect(isEqual(1, 2)).toBe(false);
297
+ expect(isEqual('a', 'b')).toBe(false);
298
+ });
299
+
300
+ it('returns true for deeply equal objects', () => {
301
+ expect(isEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true);
302
+ });
303
+
304
+ it('returns false for objects with different keys', () => {
305
+ expect(isEqual({ a: 1 }, { b: 1 })).toBe(false);
306
+ });
307
+
308
+ it('returns false for objects with different lengths', () => {
309
+ expect(isEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
310
+ });
311
+
312
+ it('handles null comparisons', () => {
313
+ expect(isEqual(null, {})).toBe(false);
314
+ expect(isEqual({}, null)).toBe(false);
315
+ });
316
+
317
+ it('distinguishes arrays from plain objects with same indices', () => {
318
+ expect(isEqual([1, 2], { 0: 1, 1: 2 })).toBe(false);
319
+ expect(isEqual({ 0: 'a' }, ['a'])).toBe(false);
320
+ });
321
+ });
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // deepMerge - circular reference safety
325
+ // ---------------------------------------------------------------------------
326
+
327
+ describe('deepMerge - circular reference safety', () => {
328
+ it('does not infinite-loop on circular source', () => {
329
+ const a = { x: 1 };
330
+ const b = { y: 2 };
331
+ b.self = b; // circular
332
+ const result = deepMerge(a, b);
333
+ expect(result.x).toBe(1);
334
+ expect(result.y).toBe(2);
335
+ // circular ref is simply not followed again
336
+ });
337
+
338
+ it('does not infinite-loop on circular target', () => {
339
+ const a = { x: 1 };
340
+ a.self = a;
341
+ const b = { y: 2 };
342
+ const result = deepMerge(a, b);
343
+ expect(result.y).toBe(2);
344
+ });
345
+ });
346
+
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // URL utilities
350
+ // ---------------------------------------------------------------------------
351
+
352
+ describe('param', () => {
353
+ it('serializes object to query string', () => {
354
+ expect(param({ a: '1', b: '2' })).toBe('a=1&b=2');
355
+ });
356
+
357
+ it('handles empty object', () => {
358
+ expect(param({})).toBe('');
359
+ });
360
+ });
361
+
362
+
363
+ describe('parseQuery', () => {
364
+ it('parses query string to object', () => {
365
+ expect(parseQuery('a=1&b=2')).toEqual({ a: '1', b: '2' });
366
+ });
367
+
368
+ it('handles leading ?', () => {
369
+ expect(parseQuery('?foo=bar')).toEqual({ foo: 'bar' });
370
+ });
371
+
372
+ it('handles empty string', () => {
373
+ expect(parseQuery('')).toEqual({});
374
+ });
375
+ });
376
+
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // html template tag
380
+ // ---------------------------------------------------------------------------
381
+
382
+ describe('html template tag', () => {
383
+ it('auto-escapes interpolated values', () => {
384
+ const userInput = '<script>alert("xss")</script>';
385
+ const result = html`<div>${userInput}</div>`;
386
+ expect(result).toContain('&lt;script&gt;');
387
+ expect(result).not.toContain('<script>');
388
+ });
389
+
390
+ it('does not escape trusted HTML', () => {
391
+ const safe = trust('<b>bold</b>');
392
+ const result = html`<div>${safe}</div>`;
393
+ expect(result).toContain('<b>bold</b>');
394
+ });
395
+
396
+ it('handles null/undefined values', () => {
397
+ const result = html`<span>${null}</span>`;
398
+ expect(result).toBe('<span></span>');
399
+ });
400
+ });
401
+
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // storage helpers
405
+ // ---------------------------------------------------------------------------
406
+
407
+ describe('storage (localStorage wrapper)', () => {
408
+ beforeEach(() => { localStorage.clear(); });
409
+
410
+ it('set and get a value', () => {
411
+ storage.set('key', { a: 1 });
412
+ expect(storage.get('key')).toEqual({ a: 1 });
413
+ });
414
+
415
+ it('returns fallback for missing key', () => {
416
+ expect(storage.get('missing', 'default')).toBe('default');
417
+ });
418
+
419
+ it('returns null as default fallback', () => {
420
+ expect(storage.get('missing')).toBeNull();
421
+ });
422
+
423
+ it('remove deletes a key', () => {
424
+ storage.set('key', 42);
425
+ storage.remove('key');
426
+ expect(storage.get('key')).toBeNull();
427
+ });
428
+
429
+ it('clear removes all keys', () => {
430
+ storage.set('a', 1);
431
+ storage.set('b', 2);
432
+ storage.clear();
433
+ expect(storage.get('a')).toBeNull();
434
+ expect(storage.get('b')).toBeNull();
435
+ });
436
+
437
+ it('handles non-JSON values gracefully', () => {
438
+ localStorage.setItem('bad', '{not json}');
439
+ expect(storage.get('bad', 'fallback')).toBe('fallback');
440
+ });
441
+ });
442
+
443
+
444
+ describe('session (sessionStorage wrapper)', () => {
445
+ beforeEach(() => { sessionStorage.clear(); });
446
+
447
+ it('set and get a value', () => {
448
+ session.set('key', [1, 2, 3]);
449
+ expect(session.get('key')).toEqual([1, 2, 3]);
450
+ });
451
+
452
+ it('returns fallback for missing key', () => {
453
+ expect(session.get('missing', 'default')).toBe('default');
454
+ });
455
+
456
+ it('remove deletes a key', () => {
457
+ session.set('key', 'val');
458
+ session.remove('key');
459
+ expect(session.get('key')).toBeNull();
460
+ });
461
+
462
+ it('clear removes all keys', () => {
463
+ session.set('a', 1);
464
+ session.clear();
465
+ expect(session.get('a')).toBeNull();
466
+ });
467
+ });
468
+
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Event bus
472
+ // ---------------------------------------------------------------------------
473
+
474
+ describe('bus (event bus)', () => {
475
+ beforeEach(() => { bus.clear(); });
476
+
477
+ it('on() and emit()', () => {
478
+ const fn = vi.fn();
479
+ bus.on('test', fn);
480
+ bus.emit('test', 'data');
481
+ expect(fn).toHaveBeenCalledWith('data');
482
+ });
483
+
484
+ it('off() removes handler', () => {
485
+ const fn = vi.fn();
486
+ bus.on('test', fn);
487
+ bus.off('test', fn);
488
+ bus.emit('test');
489
+ expect(fn).not.toHaveBeenCalled();
490
+ });
491
+
492
+ it('on() returns unsubscribe function', () => {
493
+ const fn = vi.fn();
494
+ const unsub = bus.on('test', fn);
495
+ unsub();
496
+ bus.emit('test');
497
+ expect(fn).not.toHaveBeenCalled();
498
+ });
499
+
500
+ it('once() fires handler only once', () => {
501
+ const fn = vi.fn();
502
+ bus.once('test', fn);
503
+ bus.emit('test', 'first');
504
+ bus.emit('test', 'second');
505
+ expect(fn).toHaveBeenCalledOnce();
506
+ expect(fn).toHaveBeenCalledWith('first');
507
+ });
508
+
509
+ it('emit with multiple args', () => {
510
+ const fn = vi.fn();
511
+ bus.on('test', fn);
512
+ bus.emit('test', 1, 2, 3);
513
+ expect(fn).toHaveBeenCalledWith(1, 2, 3);
514
+ });
515
+
516
+ it('multiple handlers on same event', () => {
517
+ const fn1 = vi.fn();
518
+ const fn2 = vi.fn();
519
+ bus.on('test', fn1);
520
+ bus.on('test', fn2);
521
+ bus.emit('test');
522
+ expect(fn1).toHaveBeenCalledOnce();
523
+ expect(fn2).toHaveBeenCalledOnce();
524
+ });
525
+
526
+ it('clear() removes all handlers', () => {
527
+ const fn = vi.fn();
528
+ bus.on('a', fn);
529
+ bus.on('b', fn);
530
+ bus.clear();
531
+ bus.emit('a');
532
+ bus.emit('b');
533
+ expect(fn).not.toHaveBeenCalled();
534
+ });
535
+ });
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Event bus
539
+ // ---------------------------------------------------------------------------
540
+
541
+ describe('bus (EventBus)', () => {
542
+ beforeEach(() => { bus.clear(); });
543
+
544
+ it('on/emit - fires handler for matching events', () => {
545
+ const fn = vi.fn();
546
+ bus.on('test', fn);
547
+ bus.emit('test', 42);
548
+ expect(fn).toHaveBeenCalledWith(42);
549
+ });
550
+
551
+ it('off - removes handler', () => {
552
+ const fn = vi.fn();
553
+ bus.on('test', fn);
554
+ bus.off('test', fn);
555
+ bus.emit('test');
556
+ expect(fn).not.toHaveBeenCalled();
557
+ });
558
+
559
+ it('on() returns unsubscribe function', () => {
560
+ const fn = vi.fn();
561
+ const unsub = bus.on('test', fn);
562
+ unsub();
563
+ bus.emit('test');
564
+ expect(fn).not.toHaveBeenCalled();
565
+ });
566
+
567
+ it('once - fires handler only once', () => {
568
+ const fn = vi.fn();
569
+ bus.once('test', fn);
570
+ bus.emit('test', 'a');
571
+ bus.emit('test', 'b');
572
+ expect(fn).toHaveBeenCalledOnce();
573
+ expect(fn).toHaveBeenCalledWith('a');
574
+ });
575
+
576
+ it('clear - removes all handlers', () => {
577
+ const fn = vi.fn();
578
+ bus.on('a', fn);
579
+ bus.on('b', fn);
580
+ bus.clear();
581
+ bus.emit('a');
582
+ bus.emit('b');
583
+ expect(fn).not.toHaveBeenCalled();
584
+ });
585
+
586
+ it('emit with no handlers does not throw', () => {
587
+ expect(() => bus.emit('nonexistent')).not.toThrow();
588
+ });
589
+ });
590
+
591
+
592
+ // ===========================================================================
593
+ // throttle - window reset
594
+ // ===========================================================================
595
+
596
+ describe('throttle - edge cases', () => {
597
+ it('fires trailing call after wait period', async () => {
598
+ vi.useFakeTimers();
599
+ const fn = vi.fn();
600
+ const throttled = throttle(fn, 100);
601
+
602
+ throttled('a'); // immediate
603
+ throttled('b'); // queued
604
+ expect(fn).toHaveBeenCalledTimes(1);
605
+
606
+ vi.advanceTimersByTime(100);
607
+ expect(fn).toHaveBeenCalledTimes(2);
608
+ expect(fn).toHaveBeenLastCalledWith('b');
609
+ vi.useRealTimers();
610
+ });
611
+ });
612
+
613
+
614
+ // ===========================================================================
615
+ // deepClone - edge cases
616
+ // ===========================================================================
617
+
618
+ describe('deepClone - edge cases', () => {
619
+ it('clones nested arrays', () => {
620
+ const arr = [[1, 2], [3, 4]];
621
+ const clone = deepClone(arr);
622
+ expect(clone).toEqual(arr);
623
+ clone[0][0] = 99;
624
+ expect(arr[0][0]).toBe(1);
625
+ });
626
+
627
+ it('handles null values', () => {
628
+ expect(deepClone({ a: null })).toEqual({ a: null });
629
+ });
630
+ });
631
+
632
+
633
+ // ===========================================================================
634
+ // deepMerge - multiple sources
635
+ // ===========================================================================
636
+
637
+ describe('deepMerge - edge cases', () => {
638
+ it('merges from multiple sources', () => {
639
+ const result = deepMerge({}, { a: 1 }, { b: 2 }, { c: 3 });
640
+ expect(result).toEqual({ a: 1, b: 2, c: 3 });
641
+ });
642
+
643
+ it('later sources override earlier', () => {
644
+ const result = deepMerge({}, { a: 1 }, { a: 2 });
645
+ expect(result).toEqual({ a: 2 });
646
+ });
647
+
648
+ it('handles arrays (replaces, not merges)', () => {
649
+ const result = deepMerge({}, { arr: [1, 2] }, { arr: [3] });
650
+ expect(result.arr).toEqual([3]);
651
+ });
652
+ });
653
+
654
+
655
+ // ===========================================================================
656
+ // isEqual - deeply nested
657
+ // ===========================================================================
658
+
659
+ describe('isEqual - edge cases', () => {
660
+ it('deeply nested equal objects', () => {
661
+ expect(isEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(true);
662
+ });
663
+
664
+ it('arrays of objects', () => {
665
+ expect(isEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
666
+ expect(isEqual([{ a: 1 }], [{ a: 2 }])).toBe(false);
667
+ });
668
+
669
+ it('empty arrays equal', () => {
670
+ expect(isEqual([], [])).toBe(true);
671
+ });
672
+
673
+ it('null vs object', () => {
674
+ expect(isEqual(null, { a: 1 })).toBe(false);
675
+ expect(isEqual({ a: 1 }, null)).toBe(false);
676
+ });
677
+
678
+ it('different types', () => {
679
+ expect(isEqual('1', 1)).toBe(false);
680
+ });
681
+
682
+ it('array vs object', () => {
683
+ expect(isEqual([], {})).toBe(false);
684
+ });
685
+ });
686
+
687
+
688
+ // ===========================================================================
689
+ // camelCase / kebabCase - edge cases
690
+ // ===========================================================================
691
+
692
+ describe('camelCase / kebabCase - edge cases', () => {
693
+ it('camelCase single word', () => {
694
+ expect(camelCase('hello')).toBe('hello');
695
+ });
696
+
697
+ it('camelCase already camel', () => {
698
+ expect(camelCase('helloWorld')).toBe('helloWorld');
699
+ });
700
+
701
+ it('kebabCase single word lowercase', () => {
702
+ expect(kebabCase('hello')).toBe('hello');
703
+ });
704
+
705
+ it('kebabCase multiple humps', () => {
706
+ expect(kebabCase('myComponentName')).toBe('my-component-name');
707
+ });
708
+ });
709
+
710
+
711
+ // ===========================================================================
712
+ // html tag - escaping
713
+ // ===========================================================================
714
+
715
+ describe('html tag - edge cases', () => {
716
+ it('handles null interp value', () => {
717
+ const result = html`<div>${null}</div>`;
718
+ expect(result).toBe('<div></div>');
719
+ });
720
+
721
+ it('handles undefined interp value', () => {
722
+ const result = html`<div>${undefined}</div>`;
723
+ expect(result).toBe('<div></div>');
724
+ });
725
+
726
+ it('escapes multiple interpolations', () => {
727
+ const a = '<b>';
728
+ const b = '&';
729
+ const result = html`${a} and ${b}`;
730
+ expect(result).toContain('&lt;b&gt;');
731
+ expect(result).toContain('&amp;');
732
+ });
733
+ });
734
+
735
+
736
+ // ===========================================================================
737
+ // storage - error handling
738
+ // ===========================================================================
739
+
740
+ describe('storage - parse error fallback', () => {
741
+ it('returns fallback when JSON.parse fails', () => {
742
+ localStorage.setItem('bad', '{invalid json');
743
+ expect(storage.get('bad', 'default')).toBe('default');
744
+ localStorage.removeItem('bad');
745
+ });
746
+ });
747
+
748
+
749
+ // ===========================================================================
750
+ // NEW UTILITIES - Array
751
+ // ===========================================================================
752
+
753
+ describe('range', () => {
754
+ it('generates range from 0 to n-1 with single arg', () => {
755
+ expect(range(5)).toEqual([0, 1, 2, 3, 4]);
756
+ });
757
+
758
+ it('generates range from start to end-1', () => {
759
+ expect(range(2, 6)).toEqual([2, 3, 4, 5]);
760
+ });
761
+
762
+ it('generates range with custom step', () => {
763
+ expect(range(0, 10, 3)).toEqual([0, 3, 6, 9]);
764
+ });
765
+
766
+ it('handles negative step (descending)', () => {
767
+ expect(range(5, 0, -1)).toEqual([5, 4, 3, 2, 1]);
768
+ });
769
+
770
+ it('returns empty array for zero or negative count', () => {
771
+ expect(range(0)).toEqual([]);
772
+ expect(range(-3)).toEqual([]);
773
+ });
774
+
775
+ it('returns empty array when step goes wrong direction', () => {
776
+ expect(range(0, 5, -1)).toEqual([]);
777
+ expect(range(5, 0, 1)).toEqual([]);
778
+ });
779
+
780
+ it('handles step of 1 as default', () => {
781
+ expect(range(1, 4)).toEqual([1, 2, 3]);
782
+ });
783
+
784
+ it('handles float steps', () => {
785
+ const r = range(0, 1, 0.25);
786
+ expect(r.length).toBe(4);
787
+ expect(r[0]).toBeCloseTo(0);
788
+ expect(r[3]).toBeCloseTo(0.75);
789
+ });
790
+ });
791
+
792
+
793
+ describe('unique', () => {
794
+ it('deduplicates primitive arrays', () => {
795
+ expect(unique([1, 2, 2, 3, 1, 3])).toEqual([1, 2, 3]);
796
+ });
797
+
798
+ it('preserves order (first occurrence)', () => {
799
+ expect(unique([3, 1, 2, 1, 3])).toEqual([3, 1, 2]);
800
+ });
801
+
802
+ it('handles strings', () => {
803
+ expect(unique(['a', 'b', 'a', 'c'])).toEqual(['a', 'b', 'c']);
804
+ });
805
+
806
+ it('deduplicates by key function', () => {
807
+ const items = [{ id: 1, n: 'a' }, { id: 2, n: 'b' }, { id: 1, n: 'c' }];
808
+ const result = unique(items, item => item.id);
809
+ expect(result).toEqual([{ id: 1, n: 'a' }, { id: 2, n: 'b' }]);
810
+ });
811
+
812
+ it('handles empty array', () => {
813
+ expect(unique([])).toEqual([]);
814
+ });
815
+ });
816
+
817
+
818
+ describe('chunk', () => {
819
+ it('splits array into chunks of given size', () => {
820
+ expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
821
+ });
822
+
823
+ it('returns single chunk when size >= length', () => {
824
+ expect(chunk([1, 2, 3], 5)).toEqual([[1, 2, 3]]);
825
+ });
826
+
827
+ it('handles exact division', () => {
828
+ expect(chunk([1, 2, 3, 4], 2)).toEqual([[1, 2], [3, 4]]);
829
+ });
830
+
831
+ it('handles chunk size of 1', () => {
832
+ expect(chunk([1, 2, 3], 1)).toEqual([[1], [2], [3]]);
833
+ });
834
+
835
+ it('returns empty array for empty input', () => {
836
+ expect(chunk([], 3)).toEqual([]);
837
+ });
838
+ });
839
+
840
+
841
+ describe('groupBy', () => {
842
+ it('groups by string key function', () => {
843
+ const items = [
844
+ { type: 'fruit', name: 'apple' },
845
+ { type: 'veg', name: 'carrot' },
846
+ { type: 'fruit', name: 'banana' },
847
+ ];
848
+ const result = groupBy(items, item => item.type);
849
+ expect(result).toEqual({
850
+ fruit: [{ type: 'fruit', name: 'apple' }, { type: 'fruit', name: 'banana' }],
851
+ veg: [{ type: 'veg', name: 'carrot' }],
852
+ });
853
+ });
854
+
855
+ it('groups by computed value', () => {
856
+ const nums = [1, 2, 3, 4, 5, 6];
857
+ const result = groupBy(nums, n => n % 2 === 0 ? 'even' : 'odd');
858
+ expect(result.even).toEqual([2, 4, 6]);
859
+ expect(result.odd).toEqual([1, 3, 5]);
860
+ });
861
+
862
+ it('handles empty array', () => {
863
+ expect(groupBy([], () => 'key')).toEqual({});
864
+ });
865
+ });
866
+
867
+
868
+ // ===========================================================================
869
+ // NEW UTILITIES - Object
870
+ // ===========================================================================
871
+
872
+ describe('pick', () => {
873
+ it('picks specified keys from object', () => {
874
+ const obj = { a: 1, b: 2, c: 3, d: 4 };
875
+ expect(pick(obj, ['a', 'c'])).toEqual({ a: 1, c: 3 });
876
+ });
877
+
878
+ it('ignores keys that do not exist', () => {
879
+ expect(pick({ a: 1 }, ['a', 'z'])).toEqual({ a: 1 });
880
+ });
881
+
882
+ it('returns empty object for empty keys', () => {
883
+ expect(pick({ a: 1 }, [])).toEqual({});
884
+ });
885
+
886
+ it('handles undefined/null values in picked keys', () => {
887
+ expect(pick({ a: null, b: undefined, c: 0 }, ['a', 'b', 'c'])).toEqual({ a: null, b: undefined, c: 0 });
888
+ });
889
+ });
890
+
891
+
892
+ describe('omit', () => {
893
+ it('omits specified keys from object', () => {
894
+ const obj = { a: 1, b: 2, c: 3, d: 4 };
895
+ expect(omit(obj, ['b', 'd'])).toEqual({ a: 1, c: 3 });
896
+ });
897
+
898
+ it('returns full object when no keys match', () => {
899
+ expect(omit({ a: 1, b: 2 }, ['z'])).toEqual({ a: 1, b: 2 });
900
+ });
901
+
902
+ it('returns empty object when all keys omitted', () => {
903
+ expect(omit({ a: 1, b: 2 }, ['a', 'b'])).toEqual({});
904
+ });
905
+ });
906
+
907
+
908
+ describe('getPath', () => {
909
+ it('gets nested value by dot path', () => {
910
+ const obj = { a: { b: { c: 42 } } };
911
+ expect(getPath(obj, 'a.b.c')).toBe(42);
912
+ });
913
+
914
+ it('gets top-level value', () => {
915
+ expect(getPath({ x: 10 }, 'x')).toBe(10);
916
+ });
917
+
918
+ it('returns fallback for missing path', () => {
919
+ expect(getPath({ a: 1 }, 'b.c', 'default')).toBe('default');
920
+ });
921
+
922
+ it('returns undefined by default for missing path', () => {
923
+ expect(getPath({}, 'a.b')).toBeUndefined();
924
+ });
925
+
926
+ it('handles null intermediate values', () => {
927
+ expect(getPath({ a: null }, 'a.b', 'nope')).toBe('nope');
928
+ });
929
+
930
+ it('works with array indices', () => {
931
+ const obj = { items: ['zero', 'one', 'two'] };
932
+ expect(getPath(obj, 'items.1')).toBe('one');
933
+ });
934
+ });
935
+
936
+
937
+ describe('setPath', () => {
938
+ it('sets nested value by dot path', () => {
939
+ const obj = { a: { b: { c: 1 } } };
940
+ setPath(obj, 'a.b.c', 99);
941
+ expect(obj.a.b.c).toBe(99);
942
+ });
943
+
944
+ it('creates intermediate objects when missing', () => {
945
+ const obj = {};
946
+ setPath(obj, 'a.b.c', 42);
947
+ expect(obj.a.b.c).toBe(42);
948
+ });
949
+
950
+ it('sets top-level value', () => {
951
+ const obj = {};
952
+ setPath(obj, 'x', 10);
953
+ expect(obj.x).toBe(10);
954
+ });
955
+
956
+ it('overwrites existing intermediate non-object', () => {
957
+ const obj = { a: 5 };
958
+ setPath(obj, 'a.b', 10);
959
+ expect(obj.a.b).toBe(10);
960
+ });
961
+ });
962
+
963
+
964
+ describe('isEmpty', () => {
965
+ it('returns true for null and undefined', () => {
966
+ expect(isEmpty(null)).toBe(true);
967
+ expect(isEmpty(undefined)).toBe(true);
968
+ });
969
+
970
+ it('returns true for empty string', () => {
971
+ expect(isEmpty('')).toBe(true);
972
+ });
973
+
974
+ it('returns true for empty array', () => {
975
+ expect(isEmpty([])).toBe(true);
976
+ });
977
+
978
+ it('returns true for empty object', () => {
979
+ expect(isEmpty({})).toBe(true);
980
+ });
981
+
982
+ it('returns false for non-empty string', () => {
983
+ expect(isEmpty('hello')).toBe(false);
984
+ });
985
+
986
+ it('returns false for non-empty array', () => {
987
+ expect(isEmpty([1])).toBe(false);
988
+ });
989
+
990
+ it('returns false for non-empty object', () => {
991
+ expect(isEmpty({ a: 1 })).toBe(false);
992
+ });
993
+
994
+ it('returns false for number zero', () => {
995
+ expect(isEmpty(0)).toBe(false);
996
+ });
997
+
998
+ it('returns false for boolean false', () => {
999
+ expect(isEmpty(false)).toBe(false);
1000
+ });
1001
+
1002
+ it('returns true for empty Map and Set', () => {
1003
+ expect(isEmpty(new Map())).toBe(true);
1004
+ expect(isEmpty(new Set())).toBe(true);
1005
+ });
1006
+
1007
+ it('returns false for non-empty Map and Set', () => {
1008
+ expect(isEmpty(new Map([['a', 1]]))).toBe(false);
1009
+ expect(isEmpty(new Set([1]))).toBe(false);
1010
+ });
1011
+ });
1012
+
1013
+
1014
+ // ===========================================================================
1015
+ // NEW UTILITIES - String
1016
+ // ===========================================================================
1017
+
1018
+ describe('capitalize', () => {
1019
+ it('capitalizes first letter', () => {
1020
+ expect(capitalize('hello')).toBe('Hello');
1021
+ });
1022
+
1023
+ it('handles single character', () => {
1024
+ expect(capitalize('a')).toBe('A');
1025
+ });
1026
+
1027
+ it('handles empty string', () => {
1028
+ expect(capitalize('')).toBe('');
1029
+ });
1030
+
1031
+ it('lowercases the rest', () => {
1032
+ expect(capitalize('hELLO')).toBe('Hello');
1033
+ });
1034
+
1035
+ it('handles already capitalized', () => {
1036
+ expect(capitalize('Hello')).toBe('Hello');
1037
+ });
1038
+ });
1039
+
1040
+
1041
+ describe('truncate', () => {
1042
+ it('truncates long strings with ellipsis', () => {
1043
+ expect(truncate('Hello, World!', 8)).toBe('Hello, \u2026');
1044
+ });
1045
+
1046
+ it('does not truncate short strings', () => {
1047
+ expect(truncate('Hi', 10)).toBe('Hi');
1048
+ });
1049
+
1050
+ it('uses custom suffix', () => {
1051
+ expect(truncate('Hello, World!', 8, '---')).toBe('Hello---');
1052
+ });
1053
+
1054
+ it('handles exact length (no truncation needed)', () => {
1055
+ expect(truncate('Hello', 5)).toBe('Hello');
1056
+ });
1057
+
1058
+ it('handles empty string', () => {
1059
+ expect(truncate('', 5)).toBe('');
1060
+ });
1061
+
1062
+ it('handles suffix longer than maxLen gracefully', () => {
1063
+ expect(truncate('Hello, World!', 2)).toBe('H\u2026');
1064
+ });
1065
+ });
1066
+
1067
+
1068
+ // ===========================================================================
1069
+ // NEW UTILITIES - Number
1070
+ // ===========================================================================
1071
+
1072
+ describe('clamp', () => {
1073
+ it('clamps value below min to min', () => {
1074
+ expect(clamp(-5, 0, 100)).toBe(0);
1075
+ });
1076
+
1077
+ it('clamps value above max to max', () => {
1078
+ expect(clamp(150, 0, 100)).toBe(100);
1079
+ });
1080
+
1081
+ it('returns value when within range', () => {
1082
+ expect(clamp(50, 0, 100)).toBe(50);
1083
+ });
1084
+
1085
+ it('handles min === max', () => {
1086
+ expect(clamp(50, 10, 10)).toBe(10);
1087
+ });
1088
+
1089
+ it('handles negative ranges', () => {
1090
+ expect(clamp(-50, -100, -10)).toBe(-50);
1091
+ expect(clamp(-200, -100, -10)).toBe(-100);
1092
+ });
1093
+
1094
+ it('clamps at boundaries', () => {
1095
+ expect(clamp(0, 0, 100)).toBe(0);
1096
+ expect(clamp(100, 0, 100)).toBe(100);
1097
+ });
1098
+ });
1099
+
1100
+
1101
+ // ===========================================================================
1102
+ // NEW UTILITIES - Function
1103
+ // ===========================================================================
1104
+
1105
+ describe('memoize', () => {
1106
+ it('caches results for same arguments', () => {
1107
+ const fn = vi.fn(x => x * 2);
1108
+ const memoized = memoize(fn);
1109
+ expect(memoized(5)).toBe(10);
1110
+ expect(memoized(5)).toBe(10);
1111
+ expect(fn).toHaveBeenCalledTimes(1);
1112
+ });
1113
+
1114
+ it('recomputes for different arguments', () => {
1115
+ const fn = vi.fn(x => x * 2);
1116
+ const memoized = memoize(fn);
1117
+ expect(memoized(5)).toBe(10);
1118
+ expect(memoized(3)).toBe(6);
1119
+ expect(fn).toHaveBeenCalledTimes(2);
1120
+ });
1121
+
1122
+ it('uses custom key function', () => {
1123
+ const fn = vi.fn((a, b) => a + b);
1124
+ const memoized = memoize(fn, (a, b) => `${a}:${b}`);
1125
+ expect(memoized(1, 2)).toBe(3);
1126
+ expect(memoized(1, 2)).toBe(3);
1127
+ expect(fn).toHaveBeenCalledTimes(1);
1128
+ expect(memoized(2, 1)).toBe(3);
1129
+ expect(fn).toHaveBeenCalledTimes(2);
1130
+ });
1131
+
1132
+ it('has .clear() to reset cache', () => {
1133
+ const fn = vi.fn(x => x * 2);
1134
+ const memoized = memoize(fn);
1135
+ memoized(5);
1136
+ memoized.clear();
1137
+ memoized(5);
1138
+ expect(fn).toHaveBeenCalledTimes(2);
1139
+ });
1140
+
1141
+ it('respects maxSize option', () => {
1142
+ const fn = vi.fn(x => x * 2);
1143
+ const memoized = memoize(fn, { maxSize: 2 });
1144
+ memoized(1);
1145
+ memoized(2);
1146
+ memoized(3); // evicts 1
1147
+ expect(fn).toHaveBeenCalledTimes(3);
1148
+ memoized(2); // still cached
1149
+ expect(fn).toHaveBeenCalledTimes(3);
1150
+ memoized(1); // evicted, recomputes
1151
+ expect(fn).toHaveBeenCalledTimes(4);
1152
+ });
1153
+ });
1154
+
1155
+
1156
+ // ===========================================================================
1157
+ // NEW UTILITIES - Async
1158
+ // ===========================================================================
1159
+
1160
+ describe('retry', () => {
1161
+ it('resolves on first success', async () => {
1162
+ const fn = vi.fn(async () => 42);
1163
+ const result = await retry(fn);
1164
+ expect(result).toBe(42);
1165
+ expect(fn).toHaveBeenCalledTimes(1);
1166
+ });
1167
+
1168
+ it('retries on failure and succeeds', async () => {
1169
+ let calls = 0;
1170
+ const fn = async () => {
1171
+ calls++;
1172
+ if (calls < 3) throw new Error('fail');
1173
+ return 'ok';
1174
+ };
1175
+ const result = await retry(fn, { attempts: 3, delay: 0 });
1176
+ expect(result).toBe('ok');
1177
+ expect(calls).toBe(3);
1178
+ });
1179
+
1180
+ it('throws after exhausting all attempts', async () => {
1181
+ const fn = async () => { throw new Error('always fails'); };
1182
+ await expect(retry(fn, { attempts: 3, delay: 0 })).rejects.toThrow('always fails');
1183
+ });
1184
+
1185
+ it('passes attempt number to function', async () => {
1186
+ const fn = vi.fn(async (attempt) => attempt);
1187
+ await retry(fn, { attempts: 1, delay: 0 });
1188
+ expect(fn).toHaveBeenCalledWith(1);
1189
+ });
1190
+
1191
+ it('uses exponential backoff when configured', async () => {
1192
+ vi.useFakeTimers();
1193
+ let calls = 0;
1194
+ const fn = async () => { calls++; if (calls < 3) throw new Error('fail'); return 'done'; };
1195
+ const p = retry(fn, { attempts: 3, delay: 100, backoff: 2 });
1196
+ // First call happens immediately, fails
1197
+ await vi.advanceTimersByTimeAsync(0);
1198
+ // Second call after 100ms delay, fails
1199
+ await vi.advanceTimersByTimeAsync(100);
1200
+ // Third call after 200ms delay (100 * 2), succeeds
1201
+ await vi.advanceTimersByTimeAsync(200);
1202
+ const result = await p;
1203
+ expect(result).toBe('done');
1204
+ vi.useRealTimers();
1205
+ });
1206
+ });
1207
+
1208
+
1209
+ describe('timeout', () => {
1210
+ it('resolves if promise completes before timeout', async () => {
1211
+ const p = Promise.resolve(42);
1212
+ const result = await timeout(p, 1000);
1213
+ expect(result).toBe(42);
1214
+ });
1215
+
1216
+ it('rejects if promise exceeds timeout', async () => {
1217
+ vi.useFakeTimers();
1218
+ const p = new Promise(() => {}); // never resolves
1219
+ const tp = timeout(p, 100);
1220
+ vi.advanceTimersByTime(100);
1221
+ await expect(tp).rejects.toThrow('Timed out');
1222
+ vi.useRealTimers();
1223
+ });
1224
+
1225
+ it('uses custom error message', async () => {
1226
+ vi.useFakeTimers();
1227
+ const p = new Promise(() => {});
1228
+ const tp = timeout(p, 100, 'Custom timeout');
1229
+ vi.advanceTimersByTime(100);
1230
+ await expect(tp).rejects.toThrow('Custom timeout');
1231
+ vi.useRealTimers();
1232
+ });
1233
+
1234
+ it('clears timer on successful resolution', async () => {
1235
+ const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
1236
+ await timeout(Promise.resolve('ok'), 5000);
1237
+ expect(clearSpy).toHaveBeenCalled();
1238
+ clearSpy.mockRestore();
1239
+ });
1240
+ });
1241
+
1242
+
1243
+ // ===========================================================================
1244
+ // memoize - LRU behaviour
1245
+ // ===========================================================================
1246
+
1247
+ describe('memoize - LRU eviction', () => {
1248
+ it('promotes recently-read entries so they survive eviction', () => {
1249
+ const fn = vi.fn(x => x * 2);
1250
+ const mem = memoize(fn, { maxSize: 3 });
1251
+
1252
+ mem(1); // cache: [1]
1253
+ mem(2); // cache: [1, 2]
1254
+ mem(3); // cache: [1, 2, 3]
1255
+
1256
+ // Access 1 to promote it (LRU moves it to newest)
1257
+ mem(1); // cache: [2, 3, 1]
1258
+ expect(fn).toHaveBeenCalledTimes(3); // still cached, no recompute
1259
+
1260
+ // Insert 4 -> should evict 2 (least recently used), NOT 1
1261
+ mem(4); // cache: [3, 1, 4]
1262
+ expect(fn).toHaveBeenCalledTimes(4);
1263
+
1264
+ // 1 should still be cached (was promoted)
1265
+ mem(1);
1266
+ expect(fn).toHaveBeenCalledTimes(4); // no recompute
1267
+
1268
+ // 2 should be evicted
1269
+ mem(2);
1270
+ expect(fn).toHaveBeenCalledTimes(5); // recomputation
1271
+ });
1272
+
1273
+ it('evicts in LRU order, not insertion order', () => {
1274
+ const fn = vi.fn(x => x);
1275
+ const mem = memoize(fn, { maxSize: 2 });
1276
+
1277
+ mem('a'); // [a]
1278
+ mem('b'); // [a, b]
1279
+
1280
+ // Read 'a' - makes 'b' the LRU
1281
+ mem('a'); // [b, a]
1282
+
1283
+ // Insert 'c' - should evict 'b', not 'a'
1284
+ mem('c'); // [a, c]
1285
+
1286
+ // 'a' still cached
1287
+ mem('a');
1288
+ expect(fn).toHaveBeenCalledTimes(3); // a, b, c
1289
+
1290
+ // 'b' was evicted
1291
+ mem('b');
1292
+ expect(fn).toHaveBeenCalledTimes(4);
1293
+ });
1294
+ });
1295
+
1296
+
1297
+ // ===========================================================================
1298
+ // deepClone - enhanced types
1299
+ // ===========================================================================
1300
+
1301
+ describe('deepClone - enhanced types', () => {
1302
+ it('clones Date objects', () => {
1303
+ const date = new Date('2024-01-15T12:00:00Z');
1304
+ const clone = deepClone(date);
1305
+ expect(clone).toEqual(date);
1306
+ expect(clone).not.toBe(date);
1307
+ expect(clone instanceof Date).toBe(true);
1308
+ expect(clone.getTime()).toBe(date.getTime());
1309
+ });
1310
+
1311
+ it('clones nested Dates', () => {
1312
+ const obj = { created: new Date('2024-01-15T12:00:00Z'), meta: { updated: new Date('2024-06-15T12:00:00Z') } };
1313
+ const clone = deepClone(obj);
1314
+ clone.created.setFullYear(2000);
1315
+ expect(obj.created.getFullYear()).toBe(2024);
1316
+ });
1317
+
1318
+ it('clones RegExp', () => {
1319
+ const re = /hello/gi;
1320
+ const clone = deepClone(re);
1321
+ expect(clone).toEqual(re);
1322
+ expect(clone).not.toBe(re);
1323
+ expect(clone.source).toBe('hello');
1324
+ expect(clone.flags).toBe('gi');
1325
+ });
1326
+
1327
+ it('clones Map', () => {
1328
+ const map = new Map([['a', 1], ['b', { deep: true }]]);
1329
+ const clone = deepClone(map);
1330
+ expect(clone).not.toBe(map);
1331
+ expect(clone.get('a')).toBe(1);
1332
+ clone.get('b').deep = false;
1333
+ expect(map.get('b').deep).toBe(true);
1334
+ });
1335
+
1336
+ it('clones Set', () => {
1337
+ const set = new Set([1, 2, { x: 3 }]);
1338
+ const clone = deepClone(set);
1339
+ expect(clone).not.toBe(set);
1340
+ expect(clone.size).toBe(3);
1341
+ expect(clone.has(1)).toBe(true);
1342
+ expect(clone.has(2)).toBe(true);
1343
+ });
1344
+
1345
+ it('handles undefined values in objects', () => {
1346
+ const obj = { a: 1, b: undefined, c: 'hello' };
1347
+ const clone = deepClone(obj);
1348
+ expect(clone.b).toBeUndefined();
1349
+ expect('b' in clone).toBe(true);
1350
+ });
1351
+
1352
+ it('handles null values', () => {
1353
+ const obj = { a: null, b: { c: null } };
1354
+ const clone = deepClone(obj);
1355
+ expect(clone.a).toBeNull();
1356
+ expect(clone.b.c).toBeNull();
1357
+ });
1358
+
1359
+ it('handles circular references', () => {
1360
+ const obj = { a: 1 };
1361
+ obj.self = obj;
1362
+ const clone = deepClone(obj);
1363
+ expect(clone.a).toBe(1);
1364
+ expect(clone.self).toBe(clone);
1365
+ expect(clone.self).not.toBe(obj);
1366
+ });
1367
+
1368
+ it('handles nested circular references', () => {
1369
+ const a = { name: 'a' };
1370
+ const b = { name: 'b', ref: a };
1371
+ a.ref = b;
1372
+ const clone = deepClone(a);
1373
+ expect(clone.name).toBe('a');
1374
+ expect(clone.ref.name).toBe('b');
1375
+ expect(clone.ref.ref).toBe(clone);
1376
+ });
1377
+ });