@nativerent/js-utils 1.1.0 → 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.
- package/jest.config.mjs +4 -0
- package/jest.setup.ts +4 -0
- package/package.json +1 -1
- package/src/index.ts +187 -198
- package/src/types.d.ts +0 -2
- package/tests/tests.ts +1728 -0
package/tests/tests.ts
CHANGED
|
@@ -1,18 +1,55 @@
|
|
|
1
1
|
import {
|
|
2
2
|
areDiff,
|
|
3
3
|
arrayDiff,
|
|
4
|
+
arrayIntersect,
|
|
5
|
+
autoSizeText,
|
|
4
6
|
capitalize,
|
|
7
|
+
countObjectInnerLength,
|
|
8
|
+
createHtmlElement,
|
|
9
|
+
createLinkElement,
|
|
10
|
+
createPixelElement,
|
|
11
|
+
createScriptElement,
|
|
12
|
+
createStyleElement,
|
|
13
|
+
createSvgElement,
|
|
5
14
|
debounce,
|
|
15
|
+
decodeSafeURL,
|
|
16
|
+
deepCloneObject,
|
|
17
|
+
encodeQueryString,
|
|
18
|
+
extractNumbers,
|
|
6
19
|
filterObjectKeysByValues,
|
|
20
|
+
fireBlurEvent,
|
|
21
|
+
fireEvent,
|
|
22
|
+
fireInputEvent,
|
|
23
|
+
flatHtmlAttributes,
|
|
24
|
+
flattenObject,
|
|
25
|
+
flattenObjectAsArray,
|
|
7
26
|
formatNumber,
|
|
8
27
|
formatNumberWithSign,
|
|
28
|
+
formatPercent,
|
|
9
29
|
formatWithSign,
|
|
10
30
|
getFromObject,
|
|
31
|
+
getHtmlElement,
|
|
32
|
+
getNumericStyleProp,
|
|
33
|
+
getObjectKeys,
|
|
34
|
+
getRandomInt,
|
|
11
35
|
getRandomItem,
|
|
12
36
|
getRandomStr,
|
|
37
|
+
getSafeURL,
|
|
38
|
+
getScreenSize,
|
|
39
|
+
hashCode,
|
|
40
|
+
hasObjectChanged,
|
|
41
|
+
injectScript,
|
|
42
|
+
injectStyleLink,
|
|
43
|
+
insertCss,
|
|
44
|
+
insertHtmlElements,
|
|
45
|
+
insertJs,
|
|
13
46
|
isBool,
|
|
14
47
|
isDefined,
|
|
48
|
+
isElementVisible,
|
|
15
49
|
isFn,
|
|
50
|
+
isHTMLElement,
|
|
51
|
+
isNotEmptyString,
|
|
52
|
+
isNullOrUndef,
|
|
16
53
|
isNum,
|
|
17
54
|
isObject,
|
|
18
55
|
isStr,
|
|
@@ -20,13 +57,18 @@ import {
|
|
|
20
57
|
isUndef,
|
|
21
58
|
objectHasProp,
|
|
22
59
|
objectToQueryString,
|
|
60
|
+
onDOMReady,
|
|
23
61
|
parseObjectPathStr,
|
|
24
62
|
parseURL,
|
|
63
|
+
removeSpaces,
|
|
25
64
|
roundBigNum,
|
|
26
65
|
roundDown,
|
|
27
66
|
roundUp,
|
|
28
67
|
sortByAlphabet,
|
|
68
|
+
stringToHtmlElements,
|
|
29
69
|
sumArray,
|
|
70
|
+
throttle,
|
|
71
|
+
toBinaryStr,
|
|
30
72
|
toCamelCase,
|
|
31
73
|
toPercent,
|
|
32
74
|
toSnakeCase,
|
|
@@ -201,6 +243,16 @@ test.each([
|
|
|
201
243
|
expect(formatNumber(num, digits)).toBe(expected);
|
|
202
244
|
});
|
|
203
245
|
|
|
246
|
+
test.each([
|
|
247
|
+
[34, "34.00%"],
|
|
248
|
+
["4546.77", "4 546.77%"],
|
|
249
|
+
["4546.774", "4 546.77%"],
|
|
250
|
+
["1000.009", "1 000.01%"],
|
|
251
|
+
["1000000.001", "1 000 000.00%"],
|
|
252
|
+
])("formatPercent", (num, expected) => {
|
|
253
|
+
expect(formatPercent(num)).toBe(expected);
|
|
254
|
+
});
|
|
255
|
+
|
|
204
256
|
test.each([
|
|
205
257
|
[34, 5, "+34.00000"],
|
|
206
258
|
[-4545, 2, "-4 545.00"],
|
|
@@ -252,6 +304,40 @@ test.each([
|
|
|
252
304
|
expect(arrayDiff(arr1, arr2)).toEqual(expected);
|
|
253
305
|
});
|
|
254
306
|
|
|
307
|
+
test.each([
|
|
308
|
+
[[1, 2, 3], [4, 5, 6], []],
|
|
309
|
+
[
|
|
310
|
+
["q", "w", "e", "r", "t", "y"],
|
|
311
|
+
["w", "q", "r"],
|
|
312
|
+
["q", "w", "r"],
|
|
313
|
+
],
|
|
314
|
+
[
|
|
315
|
+
["multi dimensional array", "t", "e", "y"],
|
|
316
|
+
["e", "t", "multi dimensional array", "y"],
|
|
317
|
+
["multi dimensional array", "t", "e", "y"],
|
|
318
|
+
],
|
|
319
|
+
[
|
|
320
|
+
// keeps duplicates
|
|
321
|
+
[true, false, false, true, 1, 0],
|
|
322
|
+
[true, true, false, 0, false, 0, false, false],
|
|
323
|
+
[true, false, false, true, 0],
|
|
324
|
+
],
|
|
325
|
+
[
|
|
326
|
+
[null, false, "657", "#000"],
|
|
327
|
+
["black", undefined, 0, false, 657, null],
|
|
328
|
+
[null, false],
|
|
329
|
+
],
|
|
330
|
+
[
|
|
331
|
+
[null, undefined, 1],
|
|
332
|
+
[undefined, null],
|
|
333
|
+
[null, undefined],
|
|
334
|
+
],
|
|
335
|
+
[[], [undefined, null, 1, "d"], []],
|
|
336
|
+
[[NaN, 1], [NaN], [NaN]],
|
|
337
|
+
])("arrayIntersect", (arr1, arr2, expected) => {
|
|
338
|
+
expect(arrayIntersect(arr1, arr2)).toEqual(expected);
|
|
339
|
+
});
|
|
340
|
+
|
|
255
341
|
test.each([
|
|
256
342
|
[{ d: "d" }, true],
|
|
257
343
|
[["d"], false],
|
|
@@ -328,6 +414,52 @@ test.each([
|
|
|
328
414
|
expect(isString(val)).toBe(expected);
|
|
329
415
|
});
|
|
330
416
|
|
|
417
|
+
test.each([
|
|
418
|
+
[{ d: "d" }, false],
|
|
419
|
+
[["d"], false],
|
|
420
|
+
[null, false],
|
|
421
|
+
[undefined, false],
|
|
422
|
+
[0, false],
|
|
423
|
+
[() => "g", false],
|
|
424
|
+
[NaN, false],
|
|
425
|
+
["", false],
|
|
426
|
+
["str", true],
|
|
427
|
+
[120, false],
|
|
428
|
+
[Symbol("s"), false],
|
|
429
|
+
[function () {}, false],
|
|
430
|
+
[false, false],
|
|
431
|
+
[true, false],
|
|
432
|
+
["false", true],
|
|
433
|
+
["true", true],
|
|
434
|
+
["1", true],
|
|
435
|
+
["empty", true],
|
|
436
|
+
])("isNotEmptyString", (val, expected) => {
|
|
437
|
+
expect(isNotEmptyString(val)).toBe(expected);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test.each([
|
|
441
|
+
[{ d: "d" }, false],
|
|
442
|
+
[["d"], false],
|
|
443
|
+
[null, false],
|
|
444
|
+
[undefined, false],
|
|
445
|
+
[0, false],
|
|
446
|
+
[() => "g", false],
|
|
447
|
+
[NaN, false],
|
|
448
|
+
["", false],
|
|
449
|
+
["str", true],
|
|
450
|
+
[120, false],
|
|
451
|
+
[Symbol("s"), false],
|
|
452
|
+
[function () {}, false],
|
|
453
|
+
[false, false],
|
|
454
|
+
[true, false],
|
|
455
|
+
["false", true],
|
|
456
|
+
["true", true],
|
|
457
|
+
["1", true],
|
|
458
|
+
["empty", true],
|
|
459
|
+
])("isNotEmptyString", (val, expected) => {
|
|
460
|
+
expect(isNotEmptyString(val)).toBe(expected);
|
|
461
|
+
});
|
|
462
|
+
|
|
331
463
|
test.each([
|
|
332
464
|
[{ d: "d" }, false],
|
|
333
465
|
[["d"], false],
|
|
@@ -404,10 +536,29 @@ test.each([
|
|
|
404
536
|
expect(isDefined(val)).toBe(expected);
|
|
405
537
|
});
|
|
406
538
|
|
|
539
|
+
test.each([
|
|
540
|
+
[null, true],
|
|
541
|
+
[undefined, true],
|
|
542
|
+
[0, false],
|
|
543
|
+
["", false],
|
|
544
|
+
[false, false],
|
|
545
|
+
[{}, false],
|
|
546
|
+
[[], false],
|
|
547
|
+
[() => {}, false],
|
|
548
|
+
[NaN, false],
|
|
549
|
+
[Symbol("s"), false],
|
|
550
|
+
])("isNullOrUndef", (val, expected) => {
|
|
551
|
+
expect(isNullOrUndef(val)).toBe(expected);
|
|
552
|
+
});
|
|
553
|
+
|
|
407
554
|
test.each([[isDefined], [parseURL]])("debounce", (fn) => {
|
|
408
555
|
expect(typeof debounce(fn, 100)).toBe("function");
|
|
409
556
|
});
|
|
410
557
|
|
|
558
|
+
test.each([[isDefined], [parseURL]])("throttle", (fn) => {
|
|
559
|
+
expect(typeof throttle(fn, 100)).toBe("function");
|
|
560
|
+
});
|
|
561
|
+
|
|
411
562
|
test.each([
|
|
412
563
|
[
|
|
413
564
|
{ start: 134356456456, end: null, status: [1, 2, 4, 7] },
|
|
@@ -519,3 +670,1580 @@ test.each([
|
|
|
519
670
|
])("roundDown", (num, precision, expected) => {
|
|
520
671
|
expect(roundDown(num, precision)).toBe(expected);
|
|
521
672
|
});
|
|
673
|
+
|
|
674
|
+
describe("isHTMLElement", () => {
|
|
675
|
+
test("true for HTMLElement", () => {
|
|
676
|
+
expect(isHTMLElement(document.createElement("div"))).toBe(true);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("true for SVGElement", () => {
|
|
680
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
681
|
+
expect(isHTMLElement(svg)).toBe(true);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("false for non-elements", () => {
|
|
685
|
+
expect(isHTMLElement(null)).toBe(false);
|
|
686
|
+
expect(isHTMLElement({})).toBe(false);
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
describe("deepCloneObject", () => {
|
|
691
|
+
it("clones a plain object deeply", () => {
|
|
692
|
+
const input = {
|
|
693
|
+
a: 1,
|
|
694
|
+
b: { c: 2 },
|
|
695
|
+
d: [1, { e: 3 }],
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const clone = deepCloneObject(input);
|
|
699
|
+
|
|
700
|
+
// same structure
|
|
701
|
+
expect(clone).toEqual(input);
|
|
702
|
+
|
|
703
|
+
// not the same references
|
|
704
|
+
expect(clone).not.toBe(input);
|
|
705
|
+
expect(clone.b).not.toBe(input.b);
|
|
706
|
+
expect(clone.d).not.toBe(input.d);
|
|
707
|
+
expect(clone.d[1]).not.toBe(input.d[1]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("changes to clone do not affect original", () => {
|
|
711
|
+
const input = { nested: { x: 1 } };
|
|
712
|
+
const clone = deepCloneObject(input);
|
|
713
|
+
|
|
714
|
+
clone.nested.x = 999;
|
|
715
|
+
|
|
716
|
+
expect(input.nested.x).toBe(1);
|
|
717
|
+
expect(clone.nested.x).toBe(999);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("drops undefined values in objects and turns them into null in arrays", () => {
|
|
721
|
+
const input = {
|
|
722
|
+
a: undefined as unknown as number | undefined,
|
|
723
|
+
arr: [1, undefined, 3],
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const clone = deepCloneObject(input);
|
|
727
|
+
|
|
728
|
+
// JSON.stringify removes undefined object props
|
|
729
|
+
expect("a" in (clone as any)).toBe(false);
|
|
730
|
+
|
|
731
|
+
// JSON.stringify converts undefined array entries to null
|
|
732
|
+
expect((clone as any).arr).toEqual([1, null, 3]);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("serializes Date to ISO string (lossy)", () => {
|
|
736
|
+
const input = { when: new Date("2020-01-01T00:00:00.000Z") };
|
|
737
|
+
const clone = deepCloneObject(input);
|
|
738
|
+
|
|
739
|
+
// Date becomes string
|
|
740
|
+
expect(typeof (clone as any).when).toBe("string");
|
|
741
|
+
expect((clone as any).when).toBe("2020-01-01T00:00:00.000Z");
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("throws on circular references", () => {
|
|
745
|
+
const input: any = { a: 1 };
|
|
746
|
+
input.self = input;
|
|
747
|
+
|
|
748
|
+
expect(() => deepCloneObject(input)).toThrow();
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
describe("getNumericStyleProp", () => {
|
|
753
|
+
let el: HTMLElement;
|
|
754
|
+
|
|
755
|
+
beforeEach(() => {
|
|
756
|
+
el = document.createElement("div");
|
|
757
|
+
document.body.appendChild(el);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("returns numeric value for px styles", () => {
|
|
761
|
+
el.style.marginTop = "12px";
|
|
762
|
+
|
|
763
|
+
expect(getNumericStyleProp("marginTop", el)).toBe(12);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("returns 0 if style property is not set", () => {
|
|
767
|
+
expect(getNumericStyleProp("paddingLeft", el)).toBe(0);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("does not truncate fractional px values", () => {
|
|
771
|
+
el.style.width = "10.5px";
|
|
772
|
+
|
|
773
|
+
expect(getNumericStyleProp("width", el)).toBe(10.5);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("returns NaN for non-px values", () => {
|
|
777
|
+
el.style.width = "auto";
|
|
778
|
+
|
|
779
|
+
expect(getNumericStyleProp("width", el)).toBe(NaN);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("returns 0 when computed style value is empty string", () => {
|
|
783
|
+
// jsdom often returns "" for unset values
|
|
784
|
+
expect(getNumericStyleProp("height", el)).toBe(0);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
describe("getObjectKeys", () => {
|
|
789
|
+
it("returns own enumerable keys", () => {
|
|
790
|
+
const obj = { a: 1, b: 2 };
|
|
791
|
+
|
|
792
|
+
expect(getObjectKeys(obj)).toEqual(["a", "b"]);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("does not include inherited properties", () => {
|
|
796
|
+
const proto = { inherited: true };
|
|
797
|
+
const obj = Object.create(proto);
|
|
798
|
+
obj.own = true;
|
|
799
|
+
|
|
800
|
+
expect(getObjectKeys(obj)).toEqual(["own"]);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("ignores non-enumerable properties", () => {
|
|
804
|
+
const obj = {};
|
|
805
|
+
Object.defineProperty(obj, "hidden", {
|
|
806
|
+
value: 123,
|
|
807
|
+
enumerable: false,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
expect(getObjectKeys(obj)).toEqual([]);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it("returns empty array for empty object", () => {
|
|
814
|
+
expect(getObjectKeys({})).toEqual([]);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("does not include symbol keys", () => {
|
|
818
|
+
const sym = Symbol("x");
|
|
819
|
+
const obj = { a: 1, [sym]: 2 };
|
|
820
|
+
|
|
821
|
+
expect(getObjectKeys(obj)).toEqual(["a"]);
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
describe("countObjectInnerLength", () => {
|
|
826
|
+
it("returns 0 for empty object", () => {
|
|
827
|
+
expect(countObjectInnerLength({})).toBe(0);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it("returns length for single key", () => {
|
|
831
|
+
expect(countObjectInnerLength({ a: [1, 2, 3] })).toBe(3);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("sums lengths for multiple keys", () => {
|
|
835
|
+
expect(countObjectInnerLength({ a: [1, 2], b: [], c: ["x"] })).toBe(3);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("treats null/undefined arrays as 0 (defensive version)", () => {
|
|
839
|
+
expect(
|
|
840
|
+
countObjectInnerLength({ a: [1], b: null as any, c: undefined as any }),
|
|
841
|
+
).toBe(1);
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
describe("isElementVisible", () => {
|
|
846
|
+
let el: HTMLElement;
|
|
847
|
+
|
|
848
|
+
beforeEach(() => {
|
|
849
|
+
el = document.createElement("div");
|
|
850
|
+
Object.defineProperty(window, "innerHeight", {
|
|
851
|
+
value: 800,
|
|
852
|
+
configurable: true,
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("returns false when height is 0", () => {
|
|
857
|
+
mockRect(el, { top: 0, bottom: 0, height: 0 });
|
|
858
|
+
expect(isElementVisible(el)).toBe(false);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("returns true when fully visible", () => {
|
|
862
|
+
mockRect(el, { top: 100, bottom: 200, height: 100 });
|
|
863
|
+
expect(isElementVisible(el, 50)).toBe(true);
|
|
864
|
+
expect(isElementVisible(el, 100)).toBe(true);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it("returns true when partially visible above threshold", () => {
|
|
868
|
+
// element spans -50..50 => 50px visible of 100px => 50%
|
|
869
|
+
mockRect(el, { top: -50, bottom: 50, height: 100 });
|
|
870
|
+
expect(isElementVisible(el, 50)).toBe(true);
|
|
871
|
+
expect(isElementVisible(el, 51)).toBe(false);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it("returns false when completely above viewport", () => {
|
|
875
|
+
mockRect(el, { top: -200, bottom: -100, height: 100 });
|
|
876
|
+
expect(isElementVisible(el)).toBe(false);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("returns false when completely below viewport", () => {
|
|
880
|
+
mockRect(el, { top: 900, bottom: 1000, height: 100 });
|
|
881
|
+
expect(isElementVisible(el)).toBe(false);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it("handles element taller than viewport", () => {
|
|
885
|
+
// element spans -200..1000 (height 1200), visible is 800 => 66.6%
|
|
886
|
+
mockRect(el, { top: -200, bottom: 1000, height: 1200 });
|
|
887
|
+
expect(isElementVisible(el, 66)).toBe(true);
|
|
888
|
+
expect(isElementVisible(el, 67)).toBe(false);
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
describe("decodeSafeURL", () => {
|
|
893
|
+
it("decodes a valid encoded URL", () => {
|
|
894
|
+
const encoded = "https://example.com/path%20with%20spaces";
|
|
895
|
+
expect(decodeSafeURL(encoded)).toBe("https://example.com/path with spaces");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("returns original string for already decoded URLs", () => {
|
|
899
|
+
const url = "https://example.com/path with spaces";
|
|
900
|
+
expect(decodeSafeURL(url)).toBe(url);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("returns original string for malformed encoding", () => {
|
|
904
|
+
const malformed = "https://example.com/%E0%A4%A";
|
|
905
|
+
expect(decodeSafeURL(malformed)).toBe(malformed);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("does not decode query string components", () => {
|
|
909
|
+
const encoded = "https://example.com/?a=1%26b=2";
|
|
910
|
+
// decodeURI keeps %26 intact
|
|
911
|
+
expect(decodeSafeURL(encoded)).toBe("https://example.com/?a=1%26b=2");
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("handles empty string", () => {
|
|
915
|
+
expect(decodeSafeURL("")).toBe("");
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
describe("getSafeURL", () => {
|
|
920
|
+
beforeEach(() => {
|
|
921
|
+
history.pushState({}, "", "/current%20path");
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("returns encoded URL for valid input", () => {
|
|
925
|
+
const url = "https://example.com/path with spaces";
|
|
926
|
+
expect(getSafeURL(url)).toBe("https://example.com/path%20with%20spaces");
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("handles malformed encoded URLs safely", () => {
|
|
930
|
+
const malformed = "https://example.com/%E0%A4%A";
|
|
931
|
+
expect(getSafeURL(malformed)).toBe(encodeURI(malformed));
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("uses location.href when url is undefined", () => {
|
|
935
|
+
expect(getSafeURL()).toBe("https://example.com/current%20path");
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("uses location.href when url is empty string (current behavior)", () => {
|
|
939
|
+
expect(getSafeURL("")).toBe("https://example.com/current%20path");
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("does not double-encode an already encoded URL", () => {
|
|
943
|
+
const encoded = "https://example.com/a%20b";
|
|
944
|
+
expect(getSafeURL(encoded)).toBe("https://example.com/a%20b");
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
describe("encodeQueryString", () => {
|
|
949
|
+
it("returns input when url is empty", () => {
|
|
950
|
+
expect(encodeQueryString("")).toBe("");
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it("returns input when url has no query string", () => {
|
|
954
|
+
const url = "https://example.com/path";
|
|
955
|
+
expect(encodeQueryString(url)).toBe(url);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("encodes query string values", () => {
|
|
959
|
+
const url = "https://example.com/path?a=1 b&c=hello world";
|
|
960
|
+
expect(encodeQueryString(url)).toBe(
|
|
961
|
+
"https://example.com/path?a=1%20b&c=hello%20world",
|
|
962
|
+
);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("preserves base URL and replaces query string", () => {
|
|
966
|
+
const url = "https://example.com/path/to?a=old value";
|
|
967
|
+
expect(encodeQueryString(url)).toBe(
|
|
968
|
+
"https://example.com/path/to?a=old%20value",
|
|
969
|
+
);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("drops hash fragment with current implementation", () => {
|
|
973
|
+
const url = "https://example.com/path?a=1#section";
|
|
974
|
+
expect(encodeQueryString(url)).toBe("https://example.com/path?a=1");
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it("does not double-encode an already encoded query", () => {
|
|
978
|
+
const url = "https://example.com/path?a=hello%20world";
|
|
979
|
+
expect(encodeQueryString(url)).toBe(
|
|
980
|
+
"https://example.com/path?a=hello%20world",
|
|
981
|
+
);
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
describe("injectScript", () => {
|
|
986
|
+
beforeEach(() => {
|
|
987
|
+
document.head.innerHTML = "";
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it("appends a script element to document.head", () => {
|
|
991
|
+
injectScript("test.js");
|
|
992
|
+
|
|
993
|
+
const scripts = document.head.querySelectorAll("script");
|
|
994
|
+
expect(scripts.length).toBe(1);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("sets the script src correctly", () => {
|
|
998
|
+
injectScript("https://example.com/test.js");
|
|
999
|
+
|
|
1000
|
+
const script = document.head.querySelector("script") as HTMLScriptElement;
|
|
1001
|
+
expect(script.src).toBe("https://example.com/test.js");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("sets async to true", () => {
|
|
1005
|
+
injectScript("test.js");
|
|
1006
|
+
|
|
1007
|
+
const script = document.head.querySelector("script") as HTMLScriptElement;
|
|
1008
|
+
expect(script.async).toBe(true);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("does not remove existing scripts", () => {
|
|
1012
|
+
injectScript("a.js");
|
|
1013
|
+
injectScript("b.js");
|
|
1014
|
+
|
|
1015
|
+
const scripts = document.head.querySelectorAll("script");
|
|
1016
|
+
expect(scripts.length).toBe(2);
|
|
1017
|
+
expect(scripts[0].src).toContain("a.js");
|
|
1018
|
+
expect(scripts[1].src).toContain("b.js");
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
describe("injectStyleLink", () => {
|
|
1023
|
+
beforeEach(() => {
|
|
1024
|
+
document.head.innerHTML = "";
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("appends a link element to document.head", () => {
|
|
1028
|
+
injectStyleLink("styles.css");
|
|
1029
|
+
|
|
1030
|
+
const links = document.head.querySelectorAll("link");
|
|
1031
|
+
expect(links.length).toBe(1);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('sets rel="stylesheet"', () => {
|
|
1035
|
+
injectStyleLink("styles.css");
|
|
1036
|
+
|
|
1037
|
+
const link = document.head.querySelector("link") as HTMLLinkElement;
|
|
1038
|
+
expect(link.rel).toBe("stylesheet");
|
|
1039
|
+
// or: expect(link.getAttribute('rel')).toBe('stylesheet');
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("sets href correctly", () => {
|
|
1043
|
+
injectStyleLink("https://example.com/styles.css");
|
|
1044
|
+
|
|
1045
|
+
const link = document.head.querySelector("link") as HTMLLinkElement;
|
|
1046
|
+
expect(link.href).toBe("https://example.com/styles.css");
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("does not remove existing links", () => {
|
|
1050
|
+
injectStyleLink("a.css");
|
|
1051
|
+
injectStyleLink("b.css");
|
|
1052
|
+
|
|
1053
|
+
const links = document.head.querySelectorAll('link[rel="stylesheet"]');
|
|
1054
|
+
expect(links.length).toBe(2);
|
|
1055
|
+
expect((links[0] as HTMLLinkElement).href).toContain("a.css");
|
|
1056
|
+
expect((links[1] as HTMLLinkElement).href).toContain("b.css");
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
describe("toBinaryStr", () => {
|
|
1061
|
+
it("returns identical output for ASCII strings", () => {
|
|
1062
|
+
const input = "hello";
|
|
1063
|
+
const binary = toBinaryStr(input);
|
|
1064
|
+
|
|
1065
|
+
expect(binary).toBe("hello");
|
|
1066
|
+
expect(binary.length).toBe(5);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it("encodes non-ASCII characters as UTF-8 bytes", () => {
|
|
1070
|
+
// '€' = 0xE2 0x82 0xAC in UTF-8
|
|
1071
|
+
const binary = toBinaryStr("€");
|
|
1072
|
+
|
|
1073
|
+
expect(binary.length).toBe(3);
|
|
1074
|
+
expect(binary.charCodeAt(0)).toBe(0xe2);
|
|
1075
|
+
expect(binary.charCodeAt(1)).toBe(0x82);
|
|
1076
|
+
expect(binary.charCodeAt(2)).toBe(0xac);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it("handles emoji correctly (4-byte UTF-8)", () => {
|
|
1080
|
+
// 😀 = F0 9F 98 80
|
|
1081
|
+
const binary = toBinaryStr("😀");
|
|
1082
|
+
|
|
1083
|
+
expect([
|
|
1084
|
+
binary.charCodeAt(0),
|
|
1085
|
+
binary.charCodeAt(1),
|
|
1086
|
+
binary.charCodeAt(2),
|
|
1087
|
+
binary.charCodeAt(3),
|
|
1088
|
+
]).toEqual([0xf0, 0x9f, 0x98, 0x80]);
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("can be round-tripped back to the original string", () => {
|
|
1092
|
+
const input = "Hello € 😀";
|
|
1093
|
+
|
|
1094
|
+
const binary = toBinaryStr(input);
|
|
1095
|
+
const decoded = new TextDecoder().decode(
|
|
1096
|
+
Uint8Array.from(binary, (c) => c.charCodeAt(0)),
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
expect(decoded).toBe(input);
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
describe("getHtmlElement", () => {
|
|
1104
|
+
beforeEach(() => {
|
|
1105
|
+
document.body.innerHTML = "";
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("returns the element when it exists", () => {
|
|
1109
|
+
document.body.innerHTML = '<div id="test"></div>';
|
|
1110
|
+
|
|
1111
|
+
const el = getHtmlElement("test");
|
|
1112
|
+
|
|
1113
|
+
expect(el).not.toBeNull();
|
|
1114
|
+
expect(el?.id).toBe("test");
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it("returns null when the element does not exist", () => {
|
|
1118
|
+
const el = getHtmlElement("missing");
|
|
1119
|
+
|
|
1120
|
+
expect(el).toBeNull();
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
describe("stringToHtmlElements", () => {
|
|
1125
|
+
it("returns empty NodeList for empty string", () => {
|
|
1126
|
+
const nodes = stringToHtmlElements("");
|
|
1127
|
+
expect(nodes.length).toBe(0);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it("parses a single element", () => {
|
|
1131
|
+
const nodes = stringToHtmlElements("<span>Hi</span>");
|
|
1132
|
+
|
|
1133
|
+
expect(nodes.length).toBe(1);
|
|
1134
|
+
expect(nodes[0].nodeType).toBe(Node.ELEMENT_NODE);
|
|
1135
|
+
expect((nodes[0] as HTMLElement).tagName).toBe("SPAN");
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it("parses multiple sibling elements", () => {
|
|
1139
|
+
const nodes = stringToHtmlElements("<div></div><p></p>");
|
|
1140
|
+
|
|
1141
|
+
expect(nodes.length).toBe(2);
|
|
1142
|
+
expect((nodes[0] as HTMLElement).tagName).toBe("DIV");
|
|
1143
|
+
expect((nodes[1] as HTMLElement).tagName).toBe("P");
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("includes text nodes", () => {
|
|
1147
|
+
const nodes = stringToHtmlElements("Hello <b>world</b>");
|
|
1148
|
+
|
|
1149
|
+
expect(nodes.length).toBe(2);
|
|
1150
|
+
expect(nodes[0].nodeType).toBe(Node.TEXT_NODE);
|
|
1151
|
+
expect(nodes[0].textContent).toBe("Hello ");
|
|
1152
|
+
expect((nodes[1] as HTMLElement).tagName).toBe("B");
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it("does not strip newlines before parsing", () => {
|
|
1156
|
+
const nodes = stringToHtmlElements(`
|
|
1157
|
+
<div>
|
|
1158
|
+
test
|
|
1159
|
+
</div>
|
|
1160
|
+
`);
|
|
1161
|
+
|
|
1162
|
+
expect(nodes.length).toBe(1);
|
|
1163
|
+
expect((nodes[0] as HTMLElement).textContent).toBe(" test ");
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
it("ignores leading and trailing whitespace", () => {
|
|
1167
|
+
const nodes = stringToHtmlElements(" <span></span> ");
|
|
1168
|
+
expect(nodes.length).toBe(1);
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
describe("createHtmlElement", () => {
|
|
1173
|
+
it("creates an element of the given type", () => {
|
|
1174
|
+
const el = createHtmlElement("div");
|
|
1175
|
+
|
|
1176
|
+
expect(el).toBeInstanceOf(HTMLElement);
|
|
1177
|
+
expect(el.tagName).toBe("DIV");
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
it("sets provided attributes", () => {
|
|
1181
|
+
const el = createHtmlElement("input", {
|
|
1182
|
+
type: "text",
|
|
1183
|
+
name: "username",
|
|
1184
|
+
value: "john",
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
expect(el.getAttribute("type")).toBe("text");
|
|
1188
|
+
expect(el.getAttribute("name")).toBe("username");
|
|
1189
|
+
expect(el.getAttribute("value")).toBe("john");
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("returns an element without attributes when none are provided", () => {
|
|
1193
|
+
const el = createHtmlElement("span");
|
|
1194
|
+
|
|
1195
|
+
expect(el.attributes.length).toBe(0);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
it("ignores inherited attributes", () => {
|
|
1199
|
+
const proto = { inherited: "nope" };
|
|
1200
|
+
const attrs = Object.create(proto);
|
|
1201
|
+
attrs.id = "test";
|
|
1202
|
+
|
|
1203
|
+
const el = createHtmlElement("div", attrs);
|
|
1204
|
+
|
|
1205
|
+
expect(el.getAttribute("id")).toBe("test");
|
|
1206
|
+
expect(el.getAttribute("inherited")).toBeNull();
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
it("overwrites duplicate attributes", () => {
|
|
1210
|
+
const el = createHtmlElement("div", { id: "a", id2: "b" });
|
|
1211
|
+
|
|
1212
|
+
// Setting the same attribute twice isn’t possible in object literal,
|
|
1213
|
+
// but this documents that attributes are applied in key order.
|
|
1214
|
+
expect(el.getAttribute("id")).toBe("a");
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
describe("flatHtmlAttributes", () => {
|
|
1219
|
+
it("returns empty object when attributes is undefined", () => {
|
|
1220
|
+
expect(flatHtmlAttributes(undefined)).toEqual({});
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it("returns empty object when attributes is null", () => {
|
|
1224
|
+
expect(flatHtmlAttributes(null as any)).toEqual({});
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it("flattens element attributes into an object", () => {
|
|
1228
|
+
const el = document.createElement("div");
|
|
1229
|
+
el.setAttribute("id", "test");
|
|
1230
|
+
el.setAttribute("data-x", "123");
|
|
1231
|
+
|
|
1232
|
+
expect(flatHtmlAttributes(el.attributes)).toEqual({
|
|
1233
|
+
id: "test",
|
|
1234
|
+
"data-x": "123",
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
it("returns empty object for element with no attributes", () => {
|
|
1239
|
+
const el = document.createElement("span");
|
|
1240
|
+
|
|
1241
|
+
expect(flatHtmlAttributes(el.attributes)).toEqual({});
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
it("overwrites duplicate attribute names with the last value", () => {
|
|
1245
|
+
const el = document.createElement("div");
|
|
1246
|
+
el.setAttribute("data-x", "1");
|
|
1247
|
+
el.setAttribute("data-x", "2");
|
|
1248
|
+
|
|
1249
|
+
expect(flatHtmlAttributes(el.attributes)).toEqual({
|
|
1250
|
+
"data-x": "2",
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
describe("createScriptElement", () => {
|
|
1256
|
+
it("creates a script element", () => {
|
|
1257
|
+
const el = createScriptElement("https://example.com/a.js");
|
|
1258
|
+
expect(el.tagName).toBe("SCRIPT");
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("sets src and async when inline is false (default)", () => {
|
|
1262
|
+
const el = createScriptElement("https://example.com/a.js");
|
|
1263
|
+
expect(el.src).toBe("https://example.com/a.js");
|
|
1264
|
+
expect(el.async).toBe(true);
|
|
1265
|
+
expect(el.textContent).toBe("");
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
it("creates an inline script when inline is true", () => {
|
|
1269
|
+
const el = createScriptElement('console.log("hi")', true);
|
|
1270
|
+
expect(el.src).toBe(""); // no external src
|
|
1271
|
+
expect(el.async).toBe(undefined); // default; you never set it in inline mode
|
|
1272
|
+
expect(el.textContent).toBe('console.log("hi")');
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("sets provided attributes", () => {
|
|
1276
|
+
const el = createScriptElement("https://example.com/a.js", false, {
|
|
1277
|
+
defer: "true",
|
|
1278
|
+
"data-test": "1",
|
|
1279
|
+
nonce: "abc123",
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
expect(el.getAttribute("defer")).toBe("true");
|
|
1283
|
+
expect(el.getAttribute("data-test")).toBe("1");
|
|
1284
|
+
expect(el.getAttribute("nonce")).toBe("abc123");
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it("sets provided attributes also in inline mode", () => {
|
|
1288
|
+
const el = createScriptElement("alert(1)", true, { nonce: "n" });
|
|
1289
|
+
expect(el.getAttribute("nonce")).toBe("n");
|
|
1290
|
+
expect(el.textContent).toBe("alert(1)");
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
describe("createStyleElement", () => {
|
|
1295
|
+
it("creates a style element", () => {
|
|
1296
|
+
const el = createStyleElement(null);
|
|
1297
|
+
|
|
1298
|
+
expect(el.tagName).toBe("STYLE");
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it("adds a text node when css is provided", () => {
|
|
1302
|
+
const css = "body { color: red; }";
|
|
1303
|
+
const el = createStyleElement(css);
|
|
1304
|
+
|
|
1305
|
+
expect(el.textContent).toBe(css);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
it("returns an empty style element when css is null", () => {
|
|
1309
|
+
const el = createStyleElement(null);
|
|
1310
|
+
|
|
1311
|
+
expect(el.textContent).toBe("");
|
|
1312
|
+
expect(el.childNodes.length).toBe(0);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
it("does not add a text node for empty string (current behavior)", () => {
|
|
1316
|
+
const el = createStyleElement("");
|
|
1317
|
+
|
|
1318
|
+
expect(el.textContent).toBe("");
|
|
1319
|
+
expect(el.childNodes.length).toBe(0);
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
describe("createLinkElement", () => {
|
|
1324
|
+
it("creates a link element", () => {
|
|
1325
|
+
const el = createLinkElement("style.css");
|
|
1326
|
+
|
|
1327
|
+
expect(el.tagName).toBe("LINK");
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it("sets rel to stylesheet", () => {
|
|
1331
|
+
const el = createLinkElement("style.css");
|
|
1332
|
+
|
|
1333
|
+
expect(el.rel).toBe("stylesheet");
|
|
1334
|
+
expect(el.getAttribute("rel")).toBe("stylesheet");
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
it("sets href correctly", () => {
|
|
1338
|
+
const el = createLinkElement("https://example.com/style.css");
|
|
1339
|
+
|
|
1340
|
+
// absolute URLs are normalized by the browser/jsdom
|
|
1341
|
+
expect(el.href).toBe("https://example.com/style.css");
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it("keeps href attribute for relative paths", () => {
|
|
1345
|
+
const el = createLinkElement("style.css");
|
|
1346
|
+
|
|
1347
|
+
expect(el.getAttribute("href")).toBe("style.css");
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
describe("createSvgElement", () => {
|
|
1352
|
+
it("creates an SVG element in the SVG namespace", () => {
|
|
1353
|
+
const el = createSvgElement("", {});
|
|
1354
|
+
|
|
1355
|
+
expect(el.tagName.toLowerCase()).toBe("svg");
|
|
1356
|
+
expect(el.namespaceURI).toBe("http://www.w3.org/2000/svg");
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it("sets provided attributes", () => {
|
|
1360
|
+
const el = createSvgElement("", {
|
|
1361
|
+
width: "24",
|
|
1362
|
+
height: "24",
|
|
1363
|
+
viewBox: "0 0 24 24",
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
expect(el.getAttribute("width")).toBe("24");
|
|
1367
|
+
expect(el.getAttribute("height")).toBe("24");
|
|
1368
|
+
expect(el.getAttribute("viewBox")).toBe("0 0 24 24");
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it("sets inner HTML content", () => {
|
|
1372
|
+
const el = createSvgElement('<circle cx="5" cy="5" r="5"></circle>', {});
|
|
1373
|
+
|
|
1374
|
+
expect(el.querySelector("circle")).not.toBeNull();
|
|
1375
|
+
const circle = el.querySelector("circle")!;
|
|
1376
|
+
expect(circle.getAttribute("r")).toBe("5");
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
it("supports nested SVG elements", () => {
|
|
1380
|
+
const el = createSvgElement('<g><path d="M0 0h10v10H0z"></path></g>', {});
|
|
1381
|
+
|
|
1382
|
+
expect(el.querySelector("g")).not.toBeNull();
|
|
1383
|
+
expect(el.querySelector("path")?.getAttribute("d")).toBe("M0 0h10v10H0z");
|
|
1384
|
+
});
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
describe("createPixelElement", () => {
|
|
1388
|
+
it("creates an image element", () => {
|
|
1389
|
+
const el = createPixelElement("https://example.com/pixel");
|
|
1390
|
+
|
|
1391
|
+
expect(el.tagName).toBe("IMG");
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it("sets src correctly", () => {
|
|
1395
|
+
const el = createPixelElement("https://example.com/pixel");
|
|
1396
|
+
|
|
1397
|
+
// absolute URLs are normalized by jsdom
|
|
1398
|
+
expect(el.src).toBe("https://example.com/pixel");
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("sets width and height to 1", () => {
|
|
1402
|
+
const el = createPixelElement("x");
|
|
1403
|
+
|
|
1404
|
+
expect(el.width).toBe(1);
|
|
1405
|
+
expect(el.height).toBe(1);
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
it("positions the image off-screen", () => {
|
|
1409
|
+
const el = createPixelElement("x");
|
|
1410
|
+
|
|
1411
|
+
expect(el.style.position).toBe("absolute");
|
|
1412
|
+
expect(el.style.left).toBe("-99999px");
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it("does not append the element to the document", () => {
|
|
1416
|
+
createPixelElement("x");
|
|
1417
|
+
|
|
1418
|
+
expect(document.querySelectorAll("img").length).toBe(0);
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
describe("insertJs", () => {
|
|
1423
|
+
beforeEach(() => {
|
|
1424
|
+
document.head.innerHTML = "";
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
it("appends a script element to document.head", () => {
|
|
1428
|
+
insertJs("https://example.com/a.js");
|
|
1429
|
+
|
|
1430
|
+
const scripts = document.head.querySelectorAll("script");
|
|
1431
|
+
expect(scripts.length).toBe(1);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it("creates an external script when inline is false", () => {
|
|
1435
|
+
insertJs("https://example.com/a.js");
|
|
1436
|
+
|
|
1437
|
+
const script = document.head.querySelector("script")!;
|
|
1438
|
+
expect(script.getAttribute("src")).toBe("https://example.com/a.js");
|
|
1439
|
+
expect(script.textContent).toBe("");
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
it("creates an inline script when inline is true", () => {
|
|
1443
|
+
insertJs('console.log("hi")', true);
|
|
1444
|
+
|
|
1445
|
+
const script = document.head.querySelector("script")!;
|
|
1446
|
+
expect(script.getAttribute("src")).toBeNull();
|
|
1447
|
+
expect(script.textContent).toBe('console.log("hi")');
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
it("does not append multiple scripts unintentionally", () => {
|
|
1451
|
+
insertJs("a.js");
|
|
1452
|
+
insertJs("b.js");
|
|
1453
|
+
|
|
1454
|
+
const scripts = document.head.querySelectorAll("script");
|
|
1455
|
+
expect(scripts.length).toBe(2);
|
|
1456
|
+
});
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
describe("insertCss", () => {
|
|
1460
|
+
beforeEach(() => {
|
|
1461
|
+
document.head.innerHTML = "";
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it("inserts a style element when external is false", () => {
|
|
1465
|
+
insertCss("body { color: red; }");
|
|
1466
|
+
|
|
1467
|
+
const style = document.head.querySelector("style");
|
|
1468
|
+
expect(style).not.toBeNull();
|
|
1469
|
+
expect(style!.textContent).toBe("body { color: red; }");
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
it("inserts a link element when external is true", () => {
|
|
1473
|
+
insertCss("https://example.com/style.css", undefined, true);
|
|
1474
|
+
|
|
1475
|
+
const link = document.head.querySelector("link");
|
|
1476
|
+
expect(link).not.toBeNull();
|
|
1477
|
+
expect(link!.rel).toBe("stylesheet");
|
|
1478
|
+
expect(link!.getAttribute("href")).toBe("https://example.com/style.css");
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
it("sets className on the inserted element", () => {
|
|
1482
|
+
insertCss("body { color: red; }", "test-class");
|
|
1483
|
+
|
|
1484
|
+
const style = document.head.querySelector("style")!;
|
|
1485
|
+
expect(style.className).toBe("test-class");
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
it("sets className on external link element", () => {
|
|
1489
|
+
insertCss("style.css", "external-style", true);
|
|
1490
|
+
|
|
1491
|
+
const link = document.head.querySelector("link")!;
|
|
1492
|
+
expect(link.className).toBe("external-style");
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it("appends multiple css elements when called multiple times", () => {
|
|
1496
|
+
insertCss("a {}");
|
|
1497
|
+
insertCss("b {}");
|
|
1498
|
+
|
|
1499
|
+
expect(document.head.querySelectorAll("style").length).toBe(2);
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
describe("onDOMReady", () => {
|
|
1504
|
+
afterEach(() => {
|
|
1505
|
+
jest.restoreAllMocks();
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
it("calls callback immediately when document is interactive", () => {
|
|
1509
|
+
Object.defineProperty(document, "readyState", {
|
|
1510
|
+
value: "interactive",
|
|
1511
|
+
configurable: true,
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
const cb = jest.fn();
|
|
1515
|
+
onDOMReady(cb, 1, "a");
|
|
1516
|
+
|
|
1517
|
+
expect(cb).toHaveBeenCalledWith(1, "a");
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it("calls callback immediately when document is complete", () => {
|
|
1521
|
+
Object.defineProperty(document, "readyState", {
|
|
1522
|
+
value: "complete",
|
|
1523
|
+
configurable: true,
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
const cb = jest.fn();
|
|
1527
|
+
onDOMReady(cb);
|
|
1528
|
+
|
|
1529
|
+
expect(cb).toHaveBeenCalled();
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
it("waits for DOMContentLoaded when document is loading", () => {
|
|
1533
|
+
Object.defineProperty(document, "readyState", {
|
|
1534
|
+
value: "loading",
|
|
1535
|
+
configurable: true,
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
const cb = jest.fn();
|
|
1539
|
+
onDOMReady(cb, "x");
|
|
1540
|
+
|
|
1541
|
+
expect(cb).not.toHaveBeenCalled();
|
|
1542
|
+
|
|
1543
|
+
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
1544
|
+
|
|
1545
|
+
expect(cb).toHaveBeenCalledWith("x");
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
describe("fireEvent", () => {
|
|
1550
|
+
beforeEach(() => {
|
|
1551
|
+
jest.useFakeTimers();
|
|
1552
|
+
document.body.innerHTML = "";
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
afterEach(() => {
|
|
1556
|
+
jest.useRealTimers();
|
|
1557
|
+
jest.restoreAllMocks();
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it("dispatches an event on a provided element", () => {
|
|
1561
|
+
const el = document.createElement("button");
|
|
1562
|
+
document.body.appendChild(el);
|
|
1563
|
+
|
|
1564
|
+
const handler = jest.fn();
|
|
1565
|
+
el.addEventListener("custom", handler);
|
|
1566
|
+
|
|
1567
|
+
fireEvent("custom", el);
|
|
1568
|
+
|
|
1569
|
+
jest.advanceTimersByTime(0);
|
|
1570
|
+
|
|
1571
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
it("dispatches an event when given a selector string", () => {
|
|
1575
|
+
document.body.innerHTML = '<div id="x"></div>';
|
|
1576
|
+
const el = document.querySelector("#x")!;
|
|
1577
|
+
|
|
1578
|
+
const handler = jest.fn();
|
|
1579
|
+
el.addEventListener("ping", handler);
|
|
1580
|
+
|
|
1581
|
+
fireEvent("ping", "#x");
|
|
1582
|
+
|
|
1583
|
+
jest.advanceTimersByTime(0);
|
|
1584
|
+
|
|
1585
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it("does nothing when selector does not match", () => {
|
|
1589
|
+
const handler = jest.fn();
|
|
1590
|
+
document.body.addEventListener("ping", handler);
|
|
1591
|
+
|
|
1592
|
+
fireEvent("ping", "#missing");
|
|
1593
|
+
jest.advanceTimersByTime(0);
|
|
1594
|
+
|
|
1595
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
it("supports delay", () => {
|
|
1599
|
+
const el = document.createElement("div");
|
|
1600
|
+
document.body.appendChild(el);
|
|
1601
|
+
|
|
1602
|
+
const handler = jest.fn();
|
|
1603
|
+
el.addEventListener("later", handler);
|
|
1604
|
+
|
|
1605
|
+
fireEvent("later", el, 100);
|
|
1606
|
+
|
|
1607
|
+
jest.advanceTimersByTime(99);
|
|
1608
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1609
|
+
|
|
1610
|
+
jest.advanceTimersByTime(1);
|
|
1611
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it("event bubbles", () => {
|
|
1615
|
+
const parent = document.createElement("div");
|
|
1616
|
+
const child = document.createElement("span");
|
|
1617
|
+
parent.appendChild(child);
|
|
1618
|
+
document.body.appendChild(parent);
|
|
1619
|
+
|
|
1620
|
+
const parentHandler = jest.fn();
|
|
1621
|
+
parent.addEventListener("bub", parentHandler);
|
|
1622
|
+
|
|
1623
|
+
fireEvent("bub", child);
|
|
1624
|
+
jest.advanceTimersByTime(0);
|
|
1625
|
+
|
|
1626
|
+
expect(parentHandler).toHaveBeenCalledTimes(1);
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
describe("fireInputEvent", () => {
|
|
1631
|
+
beforeEach(() => {
|
|
1632
|
+
jest.useFakeTimers();
|
|
1633
|
+
document.body.innerHTML = "";
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
afterEach(() => {
|
|
1637
|
+
jest.useRealTimers();
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
it("dispatches an input event on an element", () => {
|
|
1641
|
+
const input = document.createElement("input");
|
|
1642
|
+
document.body.appendChild(input);
|
|
1643
|
+
|
|
1644
|
+
const handler = jest.fn();
|
|
1645
|
+
input.addEventListener("input", handler);
|
|
1646
|
+
|
|
1647
|
+
fireInputEvent(input);
|
|
1648
|
+
jest.advanceTimersByTime(0);
|
|
1649
|
+
|
|
1650
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
it("dispatches an input event when given a selector", () => {
|
|
1654
|
+
document.body.innerHTML = '<input id="i" />';
|
|
1655
|
+
const input = document.querySelector("#i")!;
|
|
1656
|
+
|
|
1657
|
+
const handler = jest.fn();
|
|
1658
|
+
input.addEventListener("input", handler);
|
|
1659
|
+
|
|
1660
|
+
fireInputEvent("#i");
|
|
1661
|
+
jest.advanceTimersByTime(0);
|
|
1662
|
+
|
|
1663
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
it("does nothing when element is null", () => {
|
|
1667
|
+
const handler = jest.fn();
|
|
1668
|
+
document.body.addEventListener("input", handler);
|
|
1669
|
+
|
|
1670
|
+
fireInputEvent(null);
|
|
1671
|
+
jest.advanceTimersByTime(0);
|
|
1672
|
+
|
|
1673
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
it("respects delay", () => {
|
|
1677
|
+
const input = document.createElement("input");
|
|
1678
|
+
document.body.appendChild(input);
|
|
1679
|
+
|
|
1680
|
+
const handler = jest.fn();
|
|
1681
|
+
input.addEventListener("input", handler);
|
|
1682
|
+
|
|
1683
|
+
fireInputEvent(input, 50);
|
|
1684
|
+
|
|
1685
|
+
jest.advanceTimersByTime(49);
|
|
1686
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1687
|
+
|
|
1688
|
+
jest.advanceTimersByTime(1);
|
|
1689
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1690
|
+
});
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
describe("fireBlurEvent", () => {
|
|
1694
|
+
beforeEach(() => {
|
|
1695
|
+
jest.useFakeTimers();
|
|
1696
|
+
document.body.innerHTML = "";
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
afterEach(() => {
|
|
1700
|
+
jest.useRealTimers();
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
it("dispatches a blur event on an element", () => {
|
|
1704
|
+
const input = document.createElement("input");
|
|
1705
|
+
document.body.appendChild(input);
|
|
1706
|
+
|
|
1707
|
+
const handler = jest.fn();
|
|
1708
|
+
input.addEventListener("blur", handler);
|
|
1709
|
+
|
|
1710
|
+
fireBlurEvent(input);
|
|
1711
|
+
jest.advanceTimersByTime(0);
|
|
1712
|
+
|
|
1713
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
it("dispatches a blur event when given a selector", () => {
|
|
1717
|
+
document.body.innerHTML = '<input id="i" />';
|
|
1718
|
+
const input = document.querySelector("#i")!;
|
|
1719
|
+
|
|
1720
|
+
const handler = jest.fn();
|
|
1721
|
+
input.addEventListener("blur", handler);
|
|
1722
|
+
|
|
1723
|
+
fireBlurEvent("#i");
|
|
1724
|
+
jest.advanceTimersByTime(0);
|
|
1725
|
+
|
|
1726
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
it("does nothing when element is null", () => {
|
|
1730
|
+
const handler = jest.fn();
|
|
1731
|
+
document.body.addEventListener("blur", handler);
|
|
1732
|
+
|
|
1733
|
+
fireBlurEvent(null);
|
|
1734
|
+
jest.advanceTimersByTime(0);
|
|
1735
|
+
|
|
1736
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it("respects delay", () => {
|
|
1740
|
+
const input = document.createElement("input");
|
|
1741
|
+
document.body.appendChild(input);
|
|
1742
|
+
|
|
1743
|
+
const handler = jest.fn();
|
|
1744
|
+
input.addEventListener("blur", handler);
|
|
1745
|
+
|
|
1746
|
+
fireBlurEvent(input, 25);
|
|
1747
|
+
|
|
1748
|
+
jest.advanceTimersByTime(24);
|
|
1749
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1750
|
+
|
|
1751
|
+
jest.advanceTimersByTime(1);
|
|
1752
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
it("bubbles (non-native behavior, but current contract)", () => {
|
|
1756
|
+
const parent = document.createElement("div");
|
|
1757
|
+
const input = document.createElement("input");
|
|
1758
|
+
parent.appendChild(input);
|
|
1759
|
+
document.body.appendChild(parent);
|
|
1760
|
+
|
|
1761
|
+
const parentHandler = jest.fn();
|
|
1762
|
+
parent.addEventListener("blur", parentHandler);
|
|
1763
|
+
|
|
1764
|
+
fireBlurEvent(input);
|
|
1765
|
+
jest.advanceTimersByTime(0);
|
|
1766
|
+
|
|
1767
|
+
expect(parentHandler).toHaveBeenCalledTimes(1);
|
|
1768
|
+
});
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
describe("insertHtmlElements", () => {
|
|
1772
|
+
beforeEach(() => {
|
|
1773
|
+
document.body.innerHTML = "";
|
|
1774
|
+
document.head.innerHTML = "";
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
it("defaults appendTo to document.body", () => {
|
|
1778
|
+
const nodes = stringToHtmlElements('<div id="x"></div>') as any;
|
|
1779
|
+
insertHtmlElements(nodes);
|
|
1780
|
+
|
|
1781
|
+
expect(document.body.querySelector("#x")).not.toBeNull();
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
it("inserts elements and preserves attributes", () => {
|
|
1785
|
+
const nodes = stringToHtmlElements('<div id="a" data-x="1"></div>') as any;
|
|
1786
|
+
insertHtmlElements(nodes);
|
|
1787
|
+
|
|
1788
|
+
const el = document.body.querySelector("#a") as HTMLElement;
|
|
1789
|
+
expect(el).not.toBeNull();
|
|
1790
|
+
expect(el.getAttribute("data-x")).toBe("1");
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
it("inserts text nodes and ignores comments", () => {
|
|
1794
|
+
const nodes = stringToHtmlElements("Hello<!--c--><b>world</b>") as any;
|
|
1795
|
+
insertHtmlElements(nodes);
|
|
1796
|
+
|
|
1797
|
+
expect(document.body.textContent).toContain("Hello");
|
|
1798
|
+
expect(document.body.querySelector("b")?.textContent).toBe("world");
|
|
1799
|
+
// comment should not appear as a node we inserted
|
|
1800
|
+
expect(document.body.innerHTML).not.toContain("<!--");
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
it("recreates external scripts with attributes", () => {
|
|
1804
|
+
const nodes = stringToHtmlElements(
|
|
1805
|
+
'<script src="a.js" data-test="1"></script>',
|
|
1806
|
+
) as any;
|
|
1807
|
+
|
|
1808
|
+
insertHtmlElements(nodes);
|
|
1809
|
+
|
|
1810
|
+
const script = document.body.querySelector("script") as HTMLScriptElement;
|
|
1811
|
+
expect(script).not.toBeNull();
|
|
1812
|
+
expect(script.getAttribute("src")).toBe("a.js");
|
|
1813
|
+
expect(script.getAttribute("data-test")).toBe("1");
|
|
1814
|
+
expect(script.async).toBe(true);
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
it("recreates inline scripts", () => {
|
|
1818
|
+
const nodes = stringToHtmlElements(
|
|
1819
|
+
'<script>console.log("x")</script>',
|
|
1820
|
+
) as any;
|
|
1821
|
+
insertHtmlElements(nodes);
|
|
1822
|
+
|
|
1823
|
+
const script = document.body.querySelector("script") as HTMLScriptElement;
|
|
1824
|
+
expect(script).not.toBeNull();
|
|
1825
|
+
expect(script.getAttribute("src")).toBeNull();
|
|
1826
|
+
expect(script.textContent).toBe('console.log("x")');
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
it("recreates style elements", () => {
|
|
1830
|
+
const nodes = stringToHtmlElements("<style>body{margin:0}</style>") as any;
|
|
1831
|
+
insertHtmlElements(nodes);
|
|
1832
|
+
|
|
1833
|
+
const style = document.body.querySelector("style") as HTMLStyleElement;
|
|
1834
|
+
expect(style).not.toBeNull();
|
|
1835
|
+
expect(style.textContent).toContain("body{margin:0}");
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
it("recreates svg elements and attributes", () => {
|
|
1839
|
+
const nodes = stringToHtmlElements(
|
|
1840
|
+
'<svg width="10"><circle cx="5" cy="5" r="5"></circle></svg>',
|
|
1841
|
+
) as any;
|
|
1842
|
+
|
|
1843
|
+
insertHtmlElements(nodes);
|
|
1844
|
+
|
|
1845
|
+
const svg = document.body.querySelector("svg") as SVGSVGElement;
|
|
1846
|
+
expect(svg).not.toBeNull();
|
|
1847
|
+
expect(svg.getAttribute("width")).toBe("10");
|
|
1848
|
+
expect(svg.querySelector("circle")).not.toBeNull();
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
it("recursively inserts nested children", () => {
|
|
1852
|
+
const nodes = stringToHtmlElements("<div><span>Hi</span></div>") as any;
|
|
1853
|
+
insertHtmlElements(nodes);
|
|
1854
|
+
|
|
1855
|
+
expect(document.body.querySelector("div span")?.textContent).toBe("Hi");
|
|
1856
|
+
});
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
describe("getScreenSize", () => {
|
|
1860
|
+
afterEach(() => {
|
|
1861
|
+
jest.restoreAllMocks();
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it("returns screen width and height when available", () => {
|
|
1865
|
+
Object.defineProperty(window, "screen", {
|
|
1866
|
+
value: { width: 1920, height: 1080 },
|
|
1867
|
+
configurable: true,
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
expect(getScreenSize()).toEqual({ width: 1920, height: 1080 });
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
it("falls back to window inner size when screen is undefined", () => {
|
|
1874
|
+
Object.defineProperty(window, "screen", {
|
|
1875
|
+
value: undefined,
|
|
1876
|
+
configurable: true,
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
Object.defineProperty(window, "innerWidth", {
|
|
1880
|
+
value: 800,
|
|
1881
|
+
configurable: true,
|
|
1882
|
+
});
|
|
1883
|
+
Object.defineProperty(window, "innerHeight", {
|
|
1884
|
+
value: 600,
|
|
1885
|
+
configurable: true,
|
|
1886
|
+
});
|
|
1887
|
+
|
|
1888
|
+
expect(getScreenSize()).toEqual({ width: 800, height: 600 });
|
|
1889
|
+
});
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
describe("getRandomInt", () => {
|
|
1893
|
+
it("returns values within [min, max)", () => {
|
|
1894
|
+
for (let i = 0; i < 1000; i++) {
|
|
1895
|
+
const n = getRandomInt(5, 10);
|
|
1896
|
+
expect(n).toBeGreaterThanOrEqual(5);
|
|
1897
|
+
expect(n).toBeLessThan(10);
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
it("returns integers only", () => {
|
|
1902
|
+
const n = getRandomInt(0, 10);
|
|
1903
|
+
expect(Number.isInteger(n)).toBe(true);
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
it("handles non-integer inputs by rounding", () => {
|
|
1907
|
+
for (let i = 0; i < 100; i++) {
|
|
1908
|
+
const n = getRandomInt(1.2, 4.8);
|
|
1909
|
+
expect(n).toBeGreaterThanOrEqual(2);
|
|
1910
|
+
expect(n).toBeLessThan(4);
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
describe("hashCode", () => {
|
|
1916
|
+
it("returns a number", () => {
|
|
1917
|
+
expect(typeof hashCode("test")).toBe("number");
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
it("returns the same hash for the same input", () => {
|
|
1921
|
+
expect(hashCode("hello")).toBe(hashCode("hello"));
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
it("returns different hashes for different strings", () => {
|
|
1925
|
+
expect(hashCode("hello")).not.toBe(hashCode("world"));
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
it("is case-sensitive", () => {
|
|
1929
|
+
expect(hashCode("Hello")).not.toBe(hashCode("hello"));
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it("returns 0 for empty string", () => {
|
|
1933
|
+
expect(hashCode("")).toBe(0);
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
it("returns an unsigned 32-bit integer", () => {
|
|
1937
|
+
const hash = hashCode("some string");
|
|
1938
|
+
expect(hash).toBeGreaterThanOrEqual(0);
|
|
1939
|
+
expect(hash).toBeLessThanOrEqual(0xffffffff);
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
it("handles unicode characters", () => {
|
|
1943
|
+
const hash1 = hashCode("€");
|
|
1944
|
+
const hash2 = hashCode("😀");
|
|
1945
|
+
|
|
1946
|
+
expect(hash1).toBeGreaterThanOrEqual(0);
|
|
1947
|
+
expect(hash2).toBeGreaterThanOrEqual(0);
|
|
1948
|
+
expect(hash1).not.toBe(hash2);
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
it("produces stable output across calls", () => {
|
|
1952
|
+
const value = "consistent input";
|
|
1953
|
+
const expected = hashCode(value);
|
|
1954
|
+
|
|
1955
|
+
for (let i = 0; i < 10; i++) {
|
|
1956
|
+
expect(hashCode(value)).toBe(expected);
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
describe("hasObjectChanged", () => {
|
|
1962
|
+
it("returns false when objects are equal", () => {
|
|
1963
|
+
const oldObj = { a: 1, b: "x" };
|
|
1964
|
+
const newObj = { a: 1, b: "x" };
|
|
1965
|
+
|
|
1966
|
+
expect(hasObjectChanged(oldObj, Object.entries(newObj))).toBe(false);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
it("returns true when a primitive value changes", () => {
|
|
1970
|
+
const oldObj = { a: 1 };
|
|
1971
|
+
const newObj = { a: 2 };
|
|
1972
|
+
|
|
1973
|
+
expect(hasObjectChanged(oldObj, Object.entries(newObj))).toBe(true);
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
it("returns true when a key is missing in new (newVal undefined)", () => {
|
|
1977
|
+
const oldObj = { a: 1 };
|
|
1978
|
+
const newObj = {};
|
|
1979
|
+
|
|
1980
|
+
expect(hasObjectChanged(oldObj, Object.entries(newObj))).toBe(true);
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
it("handles nested objects", () => {
|
|
1984
|
+
const oldObj = { a: { x: 1 } };
|
|
1985
|
+
const newObj = { a: { x: 2 } };
|
|
1986
|
+
|
|
1987
|
+
expect(hasObjectChanged(oldObj, Object.entries(newObj))).toBe(true);
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
it("returns true when old is object but new is not", () => {
|
|
1991
|
+
const oldObj = { a: { x: 1 } };
|
|
1992
|
+
const newObj = { a: 123 };
|
|
1993
|
+
|
|
1994
|
+
expect(hasObjectChanged(oldObj, Object.entries(newObj))).toBe(true);
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
it("compares arrays (whatever arrayDiff semantics are)", () => {
|
|
1998
|
+
const oldObj = { a: [1, 2] };
|
|
1999
|
+
const newObj = { a: [1, 2, 3] };
|
|
2000
|
+
|
|
2001
|
+
expect(hasObjectChanged(oldObj, Object.entries(newObj))).toBe(true);
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
it("does not detect new keys by default (current semantics)", () => {
|
|
2005
|
+
const oldObj = { a: 1 };
|
|
2006
|
+
const newObj = { a: 1, b: 2 };
|
|
2007
|
+
|
|
2008
|
+
expect(hasObjectChanged(oldObj, Object.entries(newObj))).toBe(false);
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
it("detects new keys when enabled", () => {
|
|
2012
|
+
const oldObj = { a: 1 };
|
|
2013
|
+
const newObj = { a: 1, b: 2 };
|
|
2014
|
+
|
|
2015
|
+
expect(hasObjectChanged(oldObj, Object.entries(newObj), true)).toBe(true);
|
|
2016
|
+
});
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
describe("extractNumbers", () => {
|
|
2020
|
+
it("extracts digits from a string", () => {
|
|
2021
|
+
expect(extractNumbers("abc123def")).toBe("123");
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
it("extracts digits from a number", () => {
|
|
2025
|
+
expect(extractNumbers(12345)).toBe("12345");
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
it("removes non-digit characters", () => {
|
|
2029
|
+
expect(extractNumbers("a1-b2.c3")).toBe("123");
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
it("removes minus sign and decimal point", () => {
|
|
2033
|
+
expect(extractNumbers(-12.34)).toBe("1234");
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
it("returns empty string when there are no digits", () => {
|
|
2037
|
+
expect(extractNumbers("abc")).toBe("");
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
it("returns empty string for non-string, non-number values", () => {
|
|
2041
|
+
expect(extractNumbers(null)).toBe("");
|
|
2042
|
+
expect(extractNumbers(undefined)).toBe("");
|
|
2043
|
+
expect(extractNumbers({})).toBe("");
|
|
2044
|
+
expect(extractNumbers([])).toBe("");
|
|
2045
|
+
expect(extractNumbers(() => 123)).toBe("");
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
it("handles numeric strings", () => {
|
|
2049
|
+
expect(extractNumbers("00123")).toBe("00123");
|
|
2050
|
+
});
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
describe("removeSpaces", () => {
|
|
2054
|
+
it("removes spaces from a string", () => {
|
|
2055
|
+
expect(removeSpaces("a b c")).toBe("abc");
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
it("removes all whitespace characters", () => {
|
|
2059
|
+
expect(removeSpaces("a\tb\nc")).toBe("abc");
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
it("removes whitespace from numbers converted to string", () => {
|
|
2063
|
+
expect(removeSpaces(1_000_000)).toBe("1000000");
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
it("returns empty string when input has only whitespace", () => {
|
|
2067
|
+
expect(removeSpaces(" \n\t")).toBe("");
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
it("returns empty string for non-string, non-number values", () => {
|
|
2071
|
+
expect(removeSpaces(null)).toBe("");
|
|
2072
|
+
expect(removeSpaces(undefined)).toBe("");
|
|
2073
|
+
expect(removeSpaces({})).toBe("");
|
|
2074
|
+
expect(removeSpaces([])).toBe("");
|
|
2075
|
+
expect(removeSpaces(() => "x")).toBe("");
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
it("does not modify strings without whitespace", () => {
|
|
2079
|
+
expect(removeSpaces("abc")).toBe("abc");
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
it("handles numeric strings", () => {
|
|
2083
|
+
expect(removeSpaces(" 001 23 ")).toBe("00123");
|
|
2084
|
+
});
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
describe("flattenObject", () => {
|
|
2088
|
+
it("flattens nested objects with dot notation", () => {
|
|
2089
|
+
expect(flattenObject({ a: { b: { c: 1 } } })).toEqual({ "a.b.c": 1 });
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
it("keeps top-level primitive keys", () => {
|
|
2093
|
+
expect(flattenObject({ a: 1, b: "x" })).toEqual({ a: 1, b: "x" });
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
it("flattens arrays using numeric indices", () => {
|
|
2097
|
+
expect(flattenObject({ arr: [10, 20] })).toEqual({
|
|
2098
|
+
"arr.0": 10,
|
|
2099
|
+
"arr.1": 20,
|
|
2100
|
+
});
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
it("flattens nested arrays/objects", () => {
|
|
2104
|
+
expect(flattenObject({ a: [{ b: 1 }] })).toEqual({ "a.0.b": 1 });
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
it("treats null as a leaf value", () => {
|
|
2108
|
+
expect(flattenObject({ a: null })).toEqual({ a: null });
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
it("omits empty objects/arrays (current behavior)", () => {
|
|
2112
|
+
expect(flattenObject({ a: {}, b: [] })).toEqual({});
|
|
2113
|
+
});
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
describe("flattenObjectAsArray", () => {
|
|
2117
|
+
it("converts dot notation to bracket notation", () => {
|
|
2118
|
+
expect(flattenObjectAsArray({ a: { b: { c: 1 } } })).toEqual({
|
|
2119
|
+
"a[b][c]": 1,
|
|
2120
|
+
});
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
it("handles arrays by converting numeric segments to brackets", () => {
|
|
2124
|
+
expect(flattenObjectAsArray({ arr: [10, 20] })).toEqual({
|
|
2125
|
+
"arr[0]": 10,
|
|
2126
|
+
"arr[1]": 20,
|
|
2127
|
+
});
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
it("handles mixed nested arrays and objects", () => {
|
|
2131
|
+
expect(flattenObjectAsArray({ a: [{ b: 1 }] })).toEqual({ "a[0][b]": 1 });
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
it("returns empty object for empty input", () => {
|
|
2135
|
+
expect(flattenObjectAsArray({} as any)).toEqual({});
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
it("keeps null leaf values", () => {
|
|
2139
|
+
expect(flattenObjectAsArray({ a: null } as any)).toEqual({ a: null });
|
|
2140
|
+
});
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
describe("autoSizeText (using real getNumericStyleProp)", () => {
|
|
2144
|
+
let el: HTMLElement;
|
|
2145
|
+
|
|
2146
|
+
// Model: textHeight() inside your code is:
|
|
2147
|
+
// floor(scrollHeight - paddingTop - paddingBottom)
|
|
2148
|
+
// We'll set scrollHeight = fontSize*2 + paddingTop + paddingBottom
|
|
2149
|
+
// => textHeight = fontSize*2 (nice and deterministic / monotonic)
|
|
2150
|
+
const paddingTopPx = 2;
|
|
2151
|
+
const paddingBottomPx = 2;
|
|
2152
|
+
|
|
2153
|
+
beforeEach(() => {
|
|
2154
|
+
el = document.createElement("div");
|
|
2155
|
+
|
|
2156
|
+
// Ensure a starting font size unless the test sets it explicitly
|
|
2157
|
+
el.style.fontSize = "10px";
|
|
2158
|
+
|
|
2159
|
+
// Mock computed style to reflect element.style + fixed paddings
|
|
2160
|
+
jest
|
|
2161
|
+
.spyOn(window, "getComputedStyle")
|
|
2162
|
+
.mockImplementation((elem: Element) => {
|
|
2163
|
+
const htmlEl = elem as HTMLElement;
|
|
2164
|
+
|
|
2165
|
+
return {
|
|
2166
|
+
fontSize: htmlEl.style.fontSize || "10px",
|
|
2167
|
+
paddingTop: `${paddingTopPx}px`,
|
|
2168
|
+
paddingBottom: `${paddingBottomPx}px`,
|
|
2169
|
+
} as any;
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
// scrollHeight depends on current font size (in px)
|
|
2173
|
+
Object.defineProperty(el, "scrollHeight", {
|
|
2174
|
+
get() {
|
|
2175
|
+
const fontSize = parseFloat(el.style.fontSize || "0"); // may be NaN
|
|
2176
|
+
// If NaN, make scrollHeight NaN-ish; but DOM expects number, so fallback
|
|
2177
|
+
const fs = Number.isFinite(fontSize) ? fontSize : 0;
|
|
2178
|
+
return fs * 2 + paddingTopPx + paddingBottomPx;
|
|
2179
|
+
},
|
|
2180
|
+
configurable: true,
|
|
2181
|
+
});
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
afterEach(() => {
|
|
2185
|
+
jest.restoreAllMocks();
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
it("reduces font size until text fits the height", () => {
|
|
2189
|
+
// textHeight = fontSize * 2
|
|
2190
|
+
// need <= 30 => fontSize <= 15
|
|
2191
|
+
el.style.fontSize = "20px";
|
|
2192
|
+
|
|
2193
|
+
autoSizeText(el, /*height*/ 30, /*minFontSize*/ 5, /*maxFontSize*/ 50);
|
|
2194
|
+
|
|
2195
|
+
expect(el.style.fontSize).toBe("15px");
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
it("enlarges font size until it reaches the height (or passes it)", () => {
|
|
2199
|
+
// start at 10 => textHeight=20, height=30 => should grow to 15 => textHeight=30
|
|
2200
|
+
el.style.fontSize = "10px";
|
|
2201
|
+
|
|
2202
|
+
autoSizeText(el, /*height*/ 30, /*minFontSize*/ 5, /*maxFontSize*/ 50);
|
|
2203
|
+
|
|
2204
|
+
expect(el.style.fontSize).toBe("15px");
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it("does not reduce below minFontSize (even if still too tall)", () => {
|
|
2208
|
+
// height=5 means we'd like fontSize<=2.5, but min is 10
|
|
2209
|
+
el.style.fontSize = "20px";
|
|
2210
|
+
|
|
2211
|
+
autoSizeText(el, /*height*/ 5, /*minFontSize*/ 10, /*maxFontSize*/ 50);
|
|
2212
|
+
|
|
2213
|
+
expect(el.style.fontSize).toBe("10px");
|
|
2214
|
+
});
|
|
2215
|
+
|
|
2216
|
+
it("does not enlarge above maxFontSize", () => {
|
|
2217
|
+
// height is huge so it tries to enlarge, but max is 12
|
|
2218
|
+
el.style.fontSize = "10px";
|
|
2219
|
+
|
|
2220
|
+
autoSizeText(el, /*height*/ 200, /*minFontSize*/ 5, /*maxFontSize*/ 12);
|
|
2221
|
+
|
|
2222
|
+
expect(el.style.fontSize).toBe("12px");
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
it("does nothing when computed fontSize is non-numeric (NaN path)", () => {
|
|
2226
|
+
el.style.fontSize = "medium";
|
|
2227
|
+
|
|
2228
|
+
autoSizeText(el, /*height*/ 30, /*minFontSize*/ 5, /*maxFontSize*/ 50);
|
|
2229
|
+
|
|
2230
|
+
// Current implementation can't adjust because fontSize becomes NaN.
|
|
2231
|
+
// It should not throw, and fontSize stays as originally set.
|
|
2232
|
+
expect(el.style.fontSize).toBe("medium");
|
|
2233
|
+
});
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
function mockRect(el: HTMLElement, rect: Partial<DOMRect>): void {
|
|
2237
|
+
jest.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
2238
|
+
x: 0,
|
|
2239
|
+
y: 0,
|
|
2240
|
+
left: 0,
|
|
2241
|
+
right: 0,
|
|
2242
|
+
top: 0,
|
|
2243
|
+
bottom: 0,
|
|
2244
|
+
width: 0,
|
|
2245
|
+
height: 0,
|
|
2246
|
+
toJSON: () => ({}),
|
|
2247
|
+
...rect,
|
|
2248
|
+
} as DOMRect);
|
|
2249
|
+
}
|