zero-query 0.9.0 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/cli/commands/bundle.js +15 -2
- package/cli/commands/dev/devtools/js/elements.js +5 -0
- package/cli/scaffold/app/app.js +1 -1
- package/cli/scaffold/global.css +1 -2
- package/cli/scaffold/index.html +2 -1
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +544 -71
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +51 -3
- package/index.js +32 -4
- package/package.json +1 -1
- package/src/component.js +60 -10
- package/src/core.js +65 -13
- package/src/diff.js +11 -5
- package/src/expression.js +104 -16
- package/src/http.js +23 -3
- package/src/reactive.js +8 -2
- package/src/router.js +43 -9
- package/src/ssr.js +1 -1
- package/src/store.js +5 -0
- package/src/utils.js +203 -11
- package/tests/audit.test.js +4030 -0
- package/tests/cli.test.js +456 -0
- package/tests/component.test.js +1387 -0
- package/tests/core.test.js +934 -1
- package/tests/diff.test.js +891 -0
- package/tests/errors.test.js +179 -0
- package/tests/expression.test.js +569 -0
- package/tests/http.test.js +160 -1
- package/tests/reactive.test.js +320 -0
- package/tests/router.test.js +1187 -0
- package/tests/ssr.test.js +261 -0
- package/tests/store.test.js +210 -0
- package/tests/utils.test.js +729 -1
- package/types/store.d.ts +3 -0
- package/types/utils.d.ts +103 -0
package/tests/utils.test.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
debounce, throttle, pipe, once, sleep,
|
|
4
|
-
escapeHtml, html, trust, uuid, camelCase, kebabCase,
|
|
4
|
+
escapeHtml, stripHtml, html, trust, uuid, camelCase, kebabCase,
|
|
5
5
|
deepClone, deepMerge, isEqual, param, parseQuery,
|
|
6
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,
|
|
7
14
|
} from '../src/utils.js';
|
|
8
15
|
|
|
9
16
|
|
|
@@ -147,6 +154,47 @@ describe('escapeHtml', () => {
|
|
|
147
154
|
});
|
|
148
155
|
|
|
149
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
|
+
|
|
150
198
|
describe('trust', () => {
|
|
151
199
|
it('returns a TrustedHTML instance', () => {
|
|
152
200
|
const t = trust('<b>bold</b>');
|
|
@@ -265,6 +313,35 @@ describe('isEqual', () => {
|
|
|
265
313
|
expect(isEqual(null, {})).toBe(false);
|
|
266
314
|
expect(isEqual({}, null)).toBe(false);
|
|
267
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
|
+
});
|
|
268
345
|
});
|
|
269
346
|
|
|
270
347
|
|
|
@@ -510,3 +587,654 @@ describe('bus (EventBus)', () => {
|
|
|
510
587
|
expect(() => bus.emit('nonexistent')).not.toThrow();
|
|
511
588
|
});
|
|
512
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('<b>');
|
|
731
|
+
expect(result).toContain('&');
|
|
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
|
+
});
|