diff --git a/.size-limit.json b/.size-limit.json index 22df70bf..5fa6fb0e 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,42 +2,42 @@ { "name": "es5-full", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "39 kb", + "limit": "40.5 kb", "brotli": false, "running": false }, { "name": "es6-full", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "37.5 kb", + "limit": "39 kb", "brotli": false, "running": false }, { "name": "es5-full-brotli", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "13.25 kb", + "limit": "13.75 kb", "brotli": true, "running": false }, { "name": "es6-full-brotli", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "13.25 kb", + "limit": "13.75 kb", "brotli": true, "running": false }, { "name": "es5-zip", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "14.5 Kb", + "limit": "15.25 Kb", "gzip": true, "running": false }, { "name": "es6-zip", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "14.5 Kb", + "limit": "15.25 Kb", "gzip": true, "running": false }, diff --git a/CHANGELOG.md b/CHANGELOG.md index ed887e73..c883df8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,20 @@ - `ObjDefinePropDescriptorMap` is now `Record` (unchanged on TypeScript v4.4 and later) - `isAsyncGenerator` now narrows to the new `IAsyncGeneratorFunction` (assignable to `AsyncGeneratorFunction`), and `RequestIdleCallback` / `scheduleIdleCallback` use the new `IdleCallbackFn`, `IIdleDeadline` and `IIdleRequestOptions` (the same shapes as the DOM types) as these are not provided by older TypeScript versions - `isAsyncIterable` now narrows to the new `IAsyncIterable` (and `IAsyncIterator`) so the exported types no longer require the ES2018 lib. With TypeScript v4.3 and later it is interchangeable with the native `AsyncIterable`, older compilers using the ES2018 lib see `any` (so a value declared as a union with `AsyncIterable` is no longer narrowed on those versions) +- `arrUnique` and `arrUnion` no longer discard distinct values. Objects, arrays, functions and dates were compared by their string form, so `arrUnique([{ a: 1 }, { b: 2 }])` returned `[{ a: 1 }]` and a `Symbol` threw a `TypeError`; these are now compared by identity, as documented +- `encodeAsBase64`, `decodeBase64` and the URL-safe variants now use the native `btoa` / `atob` in browsers. The native functions were called with the wrong `this` so every call failed and used the polyfill, which gave different results to Node, eg. `decodeBase64("QQ")` returned `"A\0\0"` (now `"A"`) and values containing a line break were corrupted +- `encodeAsHex`, `encodeAsBase64` and `encodeAsBase64Url` no longer corrupt text containing characters above `U+00FF`, and it is restored by the matching decode function + - **Output change:** only for text which was previously corrupted, this is now encoded as the UTF-8 byte order mark followed by the UTF-8 bytes, eg. `encodeAsHex("€")` -> `"efbbbfe282ac"` (was `"ac"`). The encoding of all other text is unchanged, except for text that itself starts with the characters of that marker + - **Decoding change:** `decodeHex`, `decodeBase64` and `decodeBase64Url` decode a value that starts with the marker and contains valid UTF-8 as text, all other values are decoded as before + - An unpaired surrogate is not valid text, so like `TextEncoder` it is encoded as `U+FFFD` +- `objCopyProps` and `objExtend` now refuse to change a built-in prototype such as `Object.prototype` (the prototype pollution guard was checking the source rather than the target), and their documentation now states that only own enumerable properties are copied and that `__proto__`, `constructor` and `prototype` are skipped +- `arrForEach` no longer throws when passed a string, which also fixes `arrChunk`, `arrCompact`, `arrUnique`, `arrGroupBy`, `arrPartition`, `arrDropWhile`, `arrTakeWhile`, `arrIntersection`, `arrDifference` and `arrFlatten`, and `arrShuffle` and the `polyArrWith` polyfill also now support a string. `arrDropWhile`, `arrDrop`, `arrTake` and `arrRotate` now return an array when passed a string (as documented), previously they could return a string +- `polyArrFindLastIndex` and `polyArrFindLast` (used by `arrFindLastIndex` / `arrFindLast` when there is no native function) now visit every index like the native functions and as documented, previously the empty slots of a sparse array were skipped and a string threw a `TypeError` +- `iterTake` now closes the source iterator on the `next()` following the last value (like the native `Iterator.prototype.take`), previously it was closed before the consumer received the last value +- `arrIntersection` and `arrUnion` no longer throw for a `null` or `undefined` array after the first, `arrIntersection` treats it as empty and `arrUnion` ignores it +- `scheduleMicrotask` and `scheduleNextTick` no longer report `enabled` for a task that was rejected by, or run synchronously by, a custom `scheduleFn` +- `polyArrAt`, `polyArrWith` and `polyArrFill` now convert their indexes to integers like the native functions, so fractional and `NaN` indexes no longer return the wrong result or add fractional properties +- `objDeepCopy` handlers now receive the correct `path` for an empty string key and for a key of `0` passed to `details.copy()` ### Performance Improvements @@ -36,6 +50,10 @@ - `fnBind()` now forwards the arguments object to the intrinsic `Function.prototype.bind` (`FuncCall.apply(FuncBind, arguments)`, which is `fn.bind(thisArg, ...args)`) rather than slicing the arguments into a new array and calling `fn.bind.apply()`, ~60-69% faster per call in V8 micro-benchmarks. It remains a function so the existing overloads are unchanged, and it no longer depends on the function's own (overridable) `bind` / `call` / `apply` properties - `arrSlice()` now uses the same native function wrapper as `arrIndexOf()` / `arrMap()` (`_unwrapFunction("slice", ArrProto)`) rather than slicing its own arguments into a new array before calling `slice`, ~16% faster in a V8 micro-benchmark (and smaller). It still uses the value's own `slice` function when present, otherwise `Array.prototype.slice`, and as it's now a `const` its `name` and `length` have changed - `createFnDeferredProxy()`, `objExtend()`, `deepExtend()`, `createCustomError()` (when calling the base class) and the `scheduleTimeout()` / `createTimeout()` callback wrapper now pass their `arguments` object directly rather than slicing it into a new array on every call (`objExtend()` ~29% faster in a V8 micro-benchmark). The arguments are still copied where they are kept or modified after the call returns (the timer `refresh()` arguments) +- `objCopyProps`, `objExtend`, `deepExtend` and `objDeepCopy` no longer allocate a deep copy context for primitive values when there is no handler, ~80% faster for objects of primitive values +- `objDeepCopy` and `objCopyProps` now track visited objects with a `Map` when available (a linear search otherwise), so they no longer slow down quadratically as the number of objects grows, eg. copying 16,000 objects went from ~585ms to ~48ms +- `arrUnique` and `arrUnion` now use a `Map` when available (a linear search otherwise) for all values rather than building a string key for primitives, ~5x faster for numbers and strings +- `encodeAsBase64` and `decodeBase64` are ~10x faster in browsers now the native functions are used, as they no longer throw and fall back to the polyfill on every call # v0.16.0 July 18th, 2026 diff --git a/lib/src/array/at.ts b/lib/src/array/at.ts index 297e448e..8c26baa0 100644 --- a/lib/src/array/at.ts +++ b/lib/src/array/at.ts @@ -54,16 +54,13 @@ export function polyArrAt(theArray: ArrayLike, index: number): T | undefin if (isArrayLike(theArray)) { const len = theArray.length; - let idx = index; - - // Convert negative index to positive - if (idx < 0) { - idx = len + idx; - } - - // Check bounds and get value - if (idx >= 0 && idx < len) { - result = theArray[idx]; + // Convert the index (NaN becomes 0), any other value outside of the range is rejected by the check + let idx = +index || 0; + + // Check the bounds before truncating, which uses % as a bitwise operator would wrap the indexes of an array longer than 2^31 + if (len && idx > -len - 1 && idx < len) { + idx -= idx % 1; + result = theArray[idx < 0 ? len + idx : idx]; } } diff --git a/lib/src/array/drop.ts b/lib/src/array/drop.ts index ecd7f1c3..0a01f486 100644 --- a/lib/src/array/drop.ts +++ b/lib/src/array/drop.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import { arrSlice } from "./slice"; +import { _arrSliceToArr } from "./slice"; import { mathMax } from "../math/min_max"; /** @@ -33,5 +33,5 @@ import { mathMax } from "../math/min_max"; */ /*#__NO_SIDE_EFFECTS__*/ export function arrDrop(theArray: ArrayLike | null | undefined, count: number): T[] { - return arrSlice(theArray, mathMax(0, count)); + return _arrSliceToArr(theArray, mathMax(0, count)); } diff --git a/lib/src/array/drop_while.ts b/lib/src/array/drop_while.ts index 67b6c554..aef5ff40 100644 --- a/lib/src/array/drop_while.ts +++ b/lib/src/array/drop_while.ts @@ -10,7 +10,7 @@ import { fnCall } from "../funcs/funcs"; import { isArrayLike } from "../helpers/base"; import { ArrPredicateCallbackFn, ArrPredicateCallbackFn2 } from "./callbacks"; import { arrForEach } from "./forEach"; -import { arrSlice } from "./slice"; +import { _arrSliceToArr } from "./slice"; /** * The arrDropWhile() method returns a new array with elements from the beginning removed @@ -47,7 +47,7 @@ export function arrDropWhile( if (isArrayLike(theArray)) { arrForEach(theArray, (item, index) => { if (!fnCall(callbackFn, thisArg, item, index, theArray as any)) { - result = arrSlice(theArray, index); + result = _arrSliceToArr(theArray, index); return -1; // Break out of forEach } }); diff --git a/lib/src/array/fill.ts b/lib/src/array/fill.ts index cfeeb466..774ab5db 100644 --- a/lib/src/array/fill.ts +++ b/lib/src/array/fill.ts @@ -11,7 +11,32 @@ import { _unwrapFunctionWithPoly } from "../internal/unwrapFunction"; import { isArrayLike } from "../helpers/base"; import { getLength } from "../helpers/length"; import { WritableArrayLike } from "../helpers/arrayLike"; -import { mathMax, mathMin } from "../math/min_max"; + +/** + * @internal + * @ignore + * Converts a start / end value into the position within the array that it identifies, which is always in range. + * @param value - The value to convert (NaN becomes 0) + * @param len - The length of the array + * @returns The position within the array + */ +/*#__NO_SIDE_EFFECTS__*/ +function _getFillIdx(value: number, len: number): number { + let idx = +value || 0; + + // Check the bounds before truncating, which uses % as a bitwise operator would wrap the indexes of an array longer than 2^31 + if (idx <= -len) { + return 0; + } + + if (idx >= len) { + return len; + } + + idx -= idx % 1; + + return idx < 0 ? len + idx : idx; +} /** * The arrFill() method changes all elements in an array to a static value, from a start index @@ -55,21 +80,8 @@ export const arrFill = (/*#__PURE__*/_unwrapFunctionWithPoly("fill", ArrProto, p export function polyArrFill(theArray: WritableArrayLike, value: T, start?: number, end?: number): WritableArrayLike { if (isArrayLike(theArray)) { const len = getLength(theArray); - let startIdx = start === UNDEF_VALUE ? 0 : start; - let endIdx = end === UNDEF_VALUE ? len : end; - - // Handle negative indices - if (startIdx < 0) { - startIdx = mathMax(len + startIdx, 0); - } else { - startIdx = mathMin(startIdx, len); - } - - if (endIdx < 0) { - endIdx = mathMax(len + endIdx, 0); - } else { - endIdx = mathMin(endIdx, len); - } + const startIdx = _getFillIdx(start, len); + const endIdx = end === UNDEF_VALUE ? len : _getFillIdx(end, len); // Fill the array for (let i = startIdx; i < endIdx; i++) { diff --git a/lib/src/array/forEach.ts b/lib/src/array/forEach.ts index c3a74b96..c967c702 100644 --- a/lib/src/array/forEach.ts +++ b/lib/src/array/forEach.ts @@ -6,8 +6,8 @@ * Licensed under the MIT license. */ -import { isStrictNullOrUndefined } from "../helpers/base"; -import { CALL, LENGTH } from "../internal/constants"; +import { isStrictNullOrUndefined, isString } from "../helpers/base"; +import { CALL, LENGTH, ObjClass } from "../internal/constants"; /** * Calls the provided `callbackFn` function once for each element in an array in ascending index order. It is not invoked for index properties @@ -56,8 +56,10 @@ export function arrForEach(theArray: ArrayLike, callbackfn: (value: export function arrForEach = ArrayLike>(theArray: A, callbackfn: (value: T, index: number, array: A) => void | number, thisArg?: any): void { if (theArray) { const len = theArray[LENGTH] >>> 0; + // The "in" operator throws for a string primitive (which is ArrayLike), so use its object form + const theObj: any = isString(theArray) ? ObjClass(theArray) : theArray; for (let idx = 0; idx < len; idx++) { - if (idx in theArray) { + if (idx in theObj) { if (callbackfn[CALL](isStrictNullOrUndefined(thisArg) ? theArray as any : thisArg, theArray[idx], idx, theArray) === -1) { break; } diff --git a/lib/src/array/intersection.ts b/lib/src/array/intersection.ts index 4ea42a87..0b0bcd3c 100644 --- a/lib/src/array/intersection.ts +++ b/lib/src/array/intersection.ts @@ -44,7 +44,7 @@ export function arrIntersection(): T[] { arrForEach(firstArray, (item) => { let inAll = true; arrForEach(theArrays, (arr, index) => { - if (index > 0 && !arrIncludes(arr, item)) { + if (index > 0 && !(isArrayLike(arr) && arrIncludes(arr, item))) { inAll = false; return -1; // Break out of forEach } diff --git a/lib/src/array/rotate.ts b/lib/src/array/rotate.ts index 03064442..422791d1 100644 --- a/lib/src/array/rotate.ts +++ b/lib/src/array/rotate.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import { arrSlice } from "./slice"; +import { _arrSliceToArr, arrSlice } from "./slice"; import { isArrayLike } from "../helpers/base"; import { arrAppend } from "./append"; @@ -60,7 +60,7 @@ export function arrRotate(theArray: ArrayLike | null | undefined, count: n } if (!result) { - result = arrSlice(theArray); + result = _arrSliceToArr(theArray); } } diff --git a/lib/src/array/shuffle.ts b/lib/src/array/shuffle.ts index e6901ad5..6918c166 100644 --- a/lib/src/array/shuffle.ts +++ b/lib/src/array/shuffle.ts @@ -9,7 +9,7 @@ import { mathFloor } from "../math/floor"; import { mathRandom } from "../math/random"; import { getLength } from "../helpers/length"; -import { arrSlice } from "./slice"; +import { _arrSliceToArr } from "./slice"; /** * The arrShuffle() method returns a new array with elements randomly shuffled. @@ -33,7 +33,7 @@ import { arrSlice } from "./slice"; */ /*#__NO_SIDE_EFFECTS__*/ export function arrShuffle(theArray: ArrayLike | null | undefined): T[] { - const result = arrSlice(theArray); + const result: T[] = _arrSliceToArr(theArray); const len = getLength(result); // Fisher-Yates shuffle diff --git a/lib/src/array/slice.ts b/lib/src/array/slice.ts index 3a70a26a..5dd8e295 100644 --- a/lib/src/array/slice.ts +++ b/lib/src/array/slice.ts @@ -6,7 +6,8 @@ * Licensed under the MIT license. */ -import { ArrProto } from "../internal/constants"; +import { isString } from "../helpers/base"; +import { ArrProto, ArrSlice, CALL } from "../internal/constants"; import { _unwrapFunction } from "../internal/unwrapFunction"; /** @@ -59,3 +60,18 @@ import { _unwrapFunction } from "../internal/unwrapFunction"; * ``` */ export const arrSlice: (theArray: ArrayLike, start?: number, end?: number) => T[] = (/*#__PURE__*/_unwrapFunction("slice", ArrProto)); + +/** + * @internal + * @ignore + * Slices the value into an array. A string has its own slice function which returns a string, so it uses the Array + * slice function, any other value uses {@link arrSlice} (so it still uses the value's own slice function when present). + * @param theArray - The value to slice + * @param start - The zero-based index at which to start extraction + * @param end - The zero-based index at which to end extraction + * @returns The new array + */ +/*#__NO_SIDE_EFFECTS__*/ +export function _arrSliceToArr(theArray: ArrayLike | null | undefined, start?: number, end?: number): T[] { + return isString(theArray) ? ArrSlice[CALL](theArray, start, end) : arrSlice(theArray, start, end); +} diff --git a/lib/src/array/take.ts b/lib/src/array/take.ts index 1c7cfe76..d91759b3 100644 --- a/lib/src/array/take.ts +++ b/lib/src/array/take.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import { arrSlice } from "./slice"; +import { _arrSliceToArr } from "./slice"; import { mathMax } from "../math/min_max"; /** @@ -33,5 +33,5 @@ import { mathMax } from "../math/min_max"; */ /*#__NO_SIDE_EFFECTS__*/ export function arrTake(theArray: ArrayLike | null | undefined, count: number): T[] { - return arrSlice(theArray, 0, mathMax(0, count)); + return _arrSliceToArr(theArray, 0, mathMax(0, count)); } diff --git a/lib/src/array/union.ts b/lib/src/array/union.ts index 42b85fd1..a60e6c46 100644 --- a/lib/src/array/union.ts +++ b/lib/src/array/union.ts @@ -6,6 +6,7 @@ * Licensed under the MIT license. */ +import { isArrayLike } from "../helpers/base"; import { arrUnique } from "./unique"; import { arrAppend } from "./append"; import { arrSlice } from "./slice"; @@ -39,7 +40,9 @@ export function arrUnion(): T[] { let theArgs = arguments; arrForEach(theArgs, (arr) => { - combined = arrAppend(combined, arrSlice(arr)); + if (isArrayLike(arr)) { + combined = arrAppend(combined, arrSlice(arr)); + } }); return arrUnique(combined); diff --git a/lib/src/array/unique.ts b/lib/src/array/unique.ts index d925c994..4385d3d5 100644 --- a/lib/src/array/unique.ts +++ b/lib/src/array/unique.ts @@ -7,8 +7,7 @@ */ import { isArrayLike } from "../helpers/base"; -import { objCreate } from "../object/create"; -import { objHasOwn } from "../object/has_own"; +import { _createIdentityMap } from "../internal/identity_map"; import { arrForEach } from "./forEach"; /** @@ -38,15 +37,10 @@ export function arrUnique(theArray: ArrayLike | null | undefined): T[] { const result: T[] = []; if (isArrayLike(theArray)) { - const seen: any = objCreate(null); + const seen = _createIdentityMap(); arrForEach(theArray, (item) => { - const key = ((typeof item) + "_" + item); - - if (!objHasOwn(seen, key)) { - seen[key] = 1; - result.push(item); - } + !seen(item, 1) && result.push(item); }); } diff --git a/lib/src/array/with.ts b/lib/src/array/with.ts index 2765af64..04dfa43c 100644 --- a/lib/src/array/with.ts +++ b/lib/src/array/with.ts @@ -10,7 +10,7 @@ import { ArrProto } from "../internal/constants"; import { _unwrapFunctionWithPoly } from "../internal/unwrapFunction"; import { isArrayLike } from "../helpers/base"; import { getLength } from "../helpers/length"; -import { arrSlice } from "./slice"; +import { _arrSliceToArr } from "./slice"; import { throwRangeError } from "../helpers/throw"; /** @@ -65,20 +65,23 @@ export function polyArrWith(theArray: ArrayLike, index: number, value: T): } const len = getLength(theArray); - let idx = index; - + // Convert the index (NaN becomes 0), any other value outside of the range is rejected by the check + let idx = +index || 0; + + // Check the bounds before truncating, which uses % as a bitwise operator would wrap the indexes of an array longer than 2^31 + if (!len || idx <= -len - 1 || idx >= len) { + throwRangeError("Index out of bounds"); + } + + idx -= idx % 1; + // Convert negative index to positive if (idx < 0) { idx = len + idx; } - // Check bounds - if (idx < 0 || idx >= len) { - throwRangeError("Index out of bounds"); - } - // Create a copy and set value - result = arrSlice(theArray); + result = _arrSliceToArr(theArray); result[idx] = value; return result; diff --git a/lib/src/helpers/encode.ts b/lib/src/helpers/encode.ts index 9173c5f7..23c45c86 100644 --- a/lib/src/helpers/encode.ts +++ b/lib/src/helpers/encode.ts @@ -6,7 +6,8 @@ * Licensed under the MIT license. */ -import { EMPTY, NULL_VALUE, TO_STRING, UNDEF_VALUE } from "../internal/constants"; +import { EMPTY, NULL_VALUE, StrCls, TO_STRING, UNDEF_VALUE } from "../internal/constants"; +import { _pureRef } from "../internal/treeshake_helpers"; import { asString } from "../string/as_string"; import { strCamelCase } from "../string/conversion"; import { strPadStart } from "../string/pad"; @@ -15,19 +16,83 @@ import { strRepeat } from "../string/repeat"; import { strSubstr } from "../string/substring"; import { strUpper } from "../string/upper_lower"; import { isNumber, isStrictNullOrUndefined, isString, isUndefined } from "./base"; -import { ICachedValue } from "./cache"; -import { safeGetDeferred } from "./safe_lazy"; +import { getInst } from "./environment"; import { dumpObj } from "./diagnostics"; const DBL_QUOTE = "\""; const INVALID_JS_NAME = /([^\w\d_$])/g; const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const HEX_CHARS = "0123456789abcdef"; -let _btoa: ICachedValue<(value: string) => string>; -let _atob: ICachedValue<(value: string) => string>; +const _fromCharCode = (/*#__PURE__*/_pureRef(StrCls, "fromCharCode")); +// The UTF-8 byte order mark, used to identify an encoded value as containing UTF-8 text +const UTF8_BOM = "\xEF\xBB\xBF"; +const NON_LATIN1 = /[^\0-\xFF]/; +const SURROGATES = /[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g; +const _btoa = (/*#__PURE__*/_createBase64Fn("btoa", _encodeBase64Polyfill)); +const _atob = (/*#__PURE__*/_createBase64Fn("atob", _decodeBase64Polyfill)); let _htmlEntityCache: { [key: string]: string}; let _base64Cache: { [key: string]: number }; +/** + * @internal + * @ignore + * Creates the function which calls the named native function when available, otherwise (or if it fails) the polyfill. + * The native function is called without a `this`, as it fails in a browser when called as a member of another object. + */ +/*#__NO_SIDE_EFFECTS__*/ +function _createBase64Fn(name: string, polyFn: (value: string) => string) { + let theFn: (value: string) => string; + + return (value: string) => { + try { + return (theFn || (theFn = getInst<(value: string) => string>(name) || polyFn))(value); + } catch (e) { + return polyFn(value); + } + }; +} + +/** + * @internal + * @ignore + * Identifies whether the value starts with the marker, checking the first byte first as very few values will have it + */ +function _hasUtf8Bom(value: string): boolean { + return value.charCodeAt(0) === 0xEF && strSubstr(value, 0, 3) === UTF8_BOM; +} + +/** + * @internal + * @ignore + * Converts the text into a string of bytes (one per character) to be encoded. Text which only contains Latin-1 + * characters already fits, otherwise (or if it would be mistaken for marked text) it becomes the UTF-8 byte order + * mark followed by the UTF-8 bytes, so that it can be identified and restored by {@link _fromBinary}. + */ +function _toBinary(value: string): string { + // An unpaired surrogate is not valid text and encodeURIComponent() rejects it, so like TextEncoder it becomes U+FFFD + return NON_LATIN1.test(value) || _hasUtf8Bom(value) ? + UTF8_BOM + strReplace(encodeURIComponent(strReplace(value, SURROGATES, (match) => match[1] ? match : "\uFFFD")), /%../g, (match) => _fromCharCode(parseInt(match.substring(1), 16))) : + value; +} + +/** + * @internal + * @ignore + * Converts decoded bytes back into text, the bytes are returned as-is unless they are the UTF-8 byte order mark + * followed by valid UTF-8. + */ +function _fromBinary(value: string): string { + if (value.length > 3 && _hasUtf8Bom(value)) { + try { + return decodeURIComponent(strReplace(strSubstr(value, 3), /[%\x80-\xFF]/g, (match) => "%" + match.charCodeAt(0)[TO_STRING](16))); + } catch (e) { + // Not valid UTF-8, so the bytes are returned as-is + } + } + + return value; +} + /** * Validates that the string name conforms to the JS IdentifierName specification and if not * normalizes the name so that it would. This method does not identify or change any keywords @@ -189,7 +254,9 @@ export function encodeAsHtml(value: string) { } /** - * Encode a string value as Base64 + * Encode a string value as Base64. + * Text containing characters above `U+00FF` is encoded as the UTF-8 byte order mark followed by its UTF-8 bytes, so + * it can be restored by the matching decode function. All other text is encoded as one byte per character. * @since 0.13.0 * @group Encoding * @group Conversion @@ -200,6 +267,7 @@ export function encodeAsHtml(value: string) { * encodeAsBase64("Hello"); // "SGVsbG8=" * encodeAsBase64("Hello World"); // "SGVsbG8gV29ybGQ=" * encodeAsBase64(""); // "" + * encodeAsBase64("€"); // "77u/4oKs" * ``` */ /*#__NO_SIDE_EFFECTS__*/ @@ -207,23 +275,16 @@ export function encodeAsBase64(value: string): string { let result: string; if (value || !isStrictNullOrUndefined(value)) { - result = asString(value); - - try { - // Use native btoa if available - !_btoa && (_btoa = safeGetDeferred(() => !isUndefined(btoa) ? btoa : _encodeBase64Polyfill, _encodeBase64Polyfill)); - result = _btoa.v(result); - } catch (e) { - // Use polyfill on error - result = _encodeBase64Polyfill(result); - } + result = _btoa(_toBinary(asString(value))); } return result || EMPTY; } /** - * Decode a Base64 encoded string + * Decode a Base64 encoded string. + * A value that starts with the UTF-8 byte order mark and contains valid UTF-8 is decoded as UTF-8 text, all other + * values are decoded as one character per byte. * @since 0.13.0 * @group Encoding * @group Conversion @@ -234,6 +295,7 @@ export function encodeAsBase64(value: string): string { * decodeBase64("SGVsbG8="); // "Hello" * decodeBase64("SGVsbG8gV29ybGQ="); // "Hello World" * decodeBase64(""); // "" + * decodeBase64("77u/4oKs"); // "€" * ``` */ /*#__NO_SIDE_EFFECTS__*/ @@ -241,23 +303,16 @@ export function decodeBase64(value: string): string { let result: string; if (value || !isStrictNullOrUndefined(value)) { - result = asString(value); - - try { - // Use native atob if available - !_atob && (_atob = safeGetDeferred(() => !isUndefined(atob) ? atob : _decodeBase64Polyfill, _decodeBase64Polyfill)); - result = _atob.v(result); - } catch (e) { - // Use polyfill on error - result = _decodeBase64Polyfill(result); - } + result = _fromBinary(_atob(asString(value))); } return result || value || EMPTY; } /** - * Encode a string as URL-safe Base64 + * Encode a string as URL-safe Base64. + * Text containing characters above `U+00FF` is encoded as the UTF-8 byte order mark followed by its UTF-8 bytes, so + * it can be restored by the matching decode function. All other text is encoded as one byte per character. * @since 0.13.0 * @group Encoding * @group Conversion @@ -281,7 +336,9 @@ export function encodeAsBase64Url(value: string): string { } /** - * Decode a URL-safe Base64 encoded string + * Decode a URL-safe Base64 encoded string. + * A value that starts with the UTF-8 byte order mark and contains valid UTF-8 is decoded as UTF-8 text, all other + * values are decoded as one character per byte. * @since 0.13.0 * @group Encoding * @group Conversion @@ -312,7 +369,9 @@ export function decodeBase64Url(value: string): string { } /** - * Encode a string as hexadecimal + * Encode a string as hexadecimal. + * Text containing characters above `U+00FF` is encoded as the UTF-8 byte order mark followed by its UTF-8 bytes, so + * it can be restored by the matching decode function. All other text is encoded as one byte per character. * @since 0.13.0 * @group Encoding * @group Conversion @@ -323,6 +382,7 @@ export function decodeBase64Url(value: string): string { * encodeAsHex("Hello"); // "48656c6c6f" * encodeAsHex("A"); // "41" * encodeAsHex(""); // "" + * encodeAsHex("€"); // "efbbbfe282ac" * ``` */ /*#__NO_SIDE_EFFECTS__*/ @@ -330,14 +390,13 @@ export function encodeAsHex(value: string): string { let result: string; if (value || !isStrictNullOrUndefined(value)) { - result = asString(value); + result = _toBinary(asString(value)); let hex = []; for (let idx = 0; idx < result.length; idx++) { let code = result.charCodeAt(idx); - hex.push(HEX_CHARS[(code >> 4) & 0xf]); - hex.push(HEX_CHARS[code & 0xf]); + hex.push(HEX_CHARS[(code >> 4) & 0xf], HEX_CHARS[code & 0xf]); } result = hex.join(""); @@ -347,7 +406,9 @@ export function encodeAsHex(value: string): string { } /** - * Decode a hexadecimal encoded string + * Decode a hexadecimal encoded string. + * A value that starts with the UTF-8 byte order mark and contains valid UTF-8 is decoded as UTF-8 text, all other + * values are decoded as one character per byte. * @since 0.13.0 * @group Encoding * @group Conversion @@ -358,6 +419,7 @@ export function encodeAsHex(value: string): string { * decodeHex("48656c6c6f"); // "Hello" * decodeHex("41"); // "A" * decodeHex(""); // "" + * decodeHex("efbbbfe282ac"); // "€" * ``` */ /*#__NO_SIDE_EFFECTS__*/ @@ -368,11 +430,11 @@ export function decodeHex(value: string): string { let theValue = asString(value); for (let idx = 0; idx < theValue.length; idx += 2) { - result.push(String.fromCharCode(parseInt(strSubstr(theValue, idx, 2), 16))); + result.push(_fromCharCode(parseInt(strSubstr(theValue, idx, 2), 16))); } } - return result.join(""); + return _fromBinary(result.join("")); } /** @@ -456,20 +518,12 @@ export function _encodeBase64Polyfill(str: string): string { let bitmap = (a << 16) | (b << 8) | c; - result.push(BASE64_CHARS[(bitmap >> 18) & 63]); - result.push(BASE64_CHARS[(bitmap >> 12) & 63]); - - if (hasB) { - result.push(BASE64_CHARS[(bitmap >> 6) & 63]); - } else { - result.push("="); - } - - if (hasC) { - result.push(BASE64_CHARS[bitmap & 63]); - } else { - result.push("="); - } + result.push( + BASE64_CHARS[(bitmap >> 18) & 63], + BASE64_CHARS[(bitmap >> 12) & 63], + hasB ? BASE64_CHARS[(bitmap >> 6) & 63] : "=", + hasC ? BASE64_CHARS[bitmap & 63] : "=" + ); } } @@ -504,14 +558,14 @@ export function _decodeBase64Polyfill(str: string): string { let d = _base64Cache[str[idx++]] || 0; let bitmap = (a << 18) | (b << 12) | (c << 6) | d; - result.push(String.fromCharCode((bitmap >> 16) & 255)); + result.push(_fromCharCode((bitmap >> 16) & 255)); if (str[idx - 2] !== "=") { - result.push(String.fromCharCode((bitmap >> 8) & 255)); + result.push(_fromCharCode((bitmap >> 8) & 255)); } if (str[idx - 1] !== "=") { - result.push(String.fromCharCode(bitmap & 255)); + result.push(_fromCharCode(bitmap & 255)); } } } diff --git a/lib/src/helpers/extend.ts b/lib/src/helpers/extend.ts index 90e1b456..ff98aff6 100644 --- a/lib/src/helpers/extend.ts +++ b/lib/src/helpers/extend.ts @@ -28,6 +28,8 @@ function _doExtend(target: T, theArgs: ArrayLike): any { /** * Create a new object by merging the passed arguments, this is effectively the same as calling `objExtend({}, ...theArgs)` where * all of the arguments are added to a new object that is returned. + * Only own enumerable properties are copied, inherited properties and properties named `__proto__`, `constructor` or + * `prototype` are not. * @group Object * @param target - The original object to be extended. * @param theArgs - The optional number of arguments to be copied @@ -38,6 +40,8 @@ export function deepExtend(target: T, ...theArgs: any): T & any; /** * Create a new object by merging the passed arguments, this is effectively the same as calling `objExtend({}, ...theArgs)` where * all of the arguments are added to a new object that is returned. + * Only own enumerable properties are copied, inherited properties and properties named `__proto__`, `constructor` or + * `prototype` are not. * @group Object * @param target - The original object to be extended. * @param objN - The optional number of arguments to be copied @@ -49,6 +53,8 @@ export function deepExtend(target: T, obj1?: T1, obj2 /** * Extend the target object by merging the passed arguments into it + * Only own enumerable properties are copied, inherited properties and properties named `__proto__`, `constructor` or + * `prototype` are not. * @group Object * @param target - The object to be extended or overwritten * @param theArgs - The optional number of arguments to be copied @@ -58,6 +64,8 @@ export function objExtend(target: T, ...theArgs: any): T & any; /** * Extend the target object by merging the passed arguments into it + * Only own enumerable properties are copied, inherited properties and properties named `__proto__`, `constructor` or + * `prototype` are not. * @group Object * @param target - The object to be extended or overwritten * @param objN - The optional number of arguments to be copied diff --git a/lib/src/internal/identity_map.ts b/lib/src/internal/identity_map.ts new file mode 100644 index 00000000..b73a447f --- /dev/null +++ b/lib/src/internal/identity_map.ts @@ -0,0 +1,68 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { arrIndexOf } from "../array/indexOf"; +import { UNDEFINED } from "./constants"; + +/** + * @internal + * @ignore + * A minimal map which compares its keys like a native Map, so objects, functions and symbols by identity and + * other primitives by value (NaN is equal to itself). + */ +export interface _IdentityMap { + /** + * Returns the existing value for the key, otherwise adds the value for the key and returns undefined + * @param key - The key to find + * @param value - The value (which must be truthy) to add when the key is not found + */ + (key: any, value: V): V | undefined; +} + +/** + * @internal + * @ignore + * Creates the fallback for runtimes without a native Map, which has to search the keys + */ +/*#__NO_SIDE_EFFECTS__*/ +function _createArrayMap() { + const keys: any[] = []; + const values: V[] = []; + + return { + // A key which is not found has an index of -1, which is never a value + get: (key: any) => values[arrIndexOf(keys, key)], + set: (key: any, value: V) => { + const idx = arrIndexOf(keys, key); + values[idx < 0 ? keys.push(key) - 1 : idx] = value; + } + }; +} + +/** + * @internal + * @ignore + * Creates an identity map using the native Map when available (so lookups don't get slower as it grows). The + * underlying storage is only created when the first value is added, as most callers won't add any. + */ +/*#__NO_SIDE_EFFECTS__*/ +export function _createIdentityMap(): _IdentityMap { + let theMap: any; + + return (key: any, value: V) => { + !theMap && (theMap = typeof Map !== UNDEFINED ? new Map() : _createArrayMap()); + + // NaN is the only value not equal to itself so the fallback can't find it, the map is used as its key instead + const theKey = key === key ? key : theMap; + const result: V = theMap.get(theKey); + + !result && theMap.set(theKey, value); + + return result; + }; +} diff --git a/lib/src/iterator/iterTake.ts b/lib/src/iterator/iterTake.ts index 8f4b5f03..904d667b 100644 --- a/lib/src/iterator/iterTake.ts +++ b/lib/src/iterator/iterTake.ts @@ -49,10 +49,6 @@ export function iterTake(iter: Iterator | Iterable, count: number): Ite } else { this.v = next.value; remaining--; - if (remaining <= 0) { - _complete(); - } - isDone = false; } } diff --git a/lib/src/object/copy.ts b/lib/src/object/copy.ts index 51b12a5d..c0e41e6d 100644 --- a/lib/src/object/copy.ts +++ b/lib/src/object/copy.ts @@ -6,9 +6,9 @@ * Licensed under the MIT license. */ -import { arrForEach } from "../array/forEach"; -import { isArray, isDate, isPrimitiveType } from "../helpers/base"; +import { isArray, isDate, isPrimitiveType, isStrictUndefined } from "../helpers/base"; import { CALL, FUNCTION, NULL_VALUE, OBJECT } from "../internal/constants"; +import { _IdentityMap, _createIdentityMap } from "../internal/identity_map"; import { objDefine } from "./define"; import { forEachOwnKeySafe } from "./forEachOwnKey"; import { isPlainObject } from "./is_plain_object"; @@ -32,7 +32,6 @@ interface _DeepCopyContext { * objects. */ interface _RecursiveVisitMap { - k: any; v: any; } @@ -71,31 +70,21 @@ const defaultDeepCopyHandlers: ObjDeepCopyHandler[] = [ * Helper function used for detecting and handling recursive properties * @param visitMap - The current map of objects that have been visited * @param source - The value (object) to be copied - * @param newPath - The new access path from the origin to the current property * @param cb - The callback function to call if the current object has not already been processed. * @returns The new deep copied property, may be incomplete as the object is recursive and is still in the process of being copied */ /*#__NO_SIDE_EFFECTS__*/ -function _getSetVisited(visitMap: _RecursiveVisitMap[], source: any, newPath: Array, cb: (newEntry: _RecursiveVisitMap) => void) { - let theEntry: _RecursiveVisitMap; - arrForEach(visitMap, (entry) => { - if (entry.k === source) { - theEntry = entry; - return -1; - } - }); +function _getSetVisited(visitMap: _IdentityMap<_RecursiveVisitMap>, source: any, cb: (newEntry: _RecursiveVisitMap) => void) { + const newEntry: _RecursiveVisitMap = { v: source }; - if (!theEntry) { - // Add the target to the visit map so that deep nested objects refer to the single instance - // Even if we have not finished processing it yet. - theEntry = { k: source, v: source }; - visitMap.push(theEntry); + // Add the target to the visit map so that deep nested objects refer to the single instance + // Even if we have not finished processing it yet. + const theEntry = visitMap(source, newEntry); - // Now call the copy callback so that it populates the target - cb(theEntry); - } + // Now call the copy callback so that it populates the target + !theEntry && cb(newEntry); - return theEntry.v; + return (theEntry || newEntry).v; } /** @@ -108,16 +97,10 @@ function _getSetVisited(visitMap: _RecursiveVisitMap[], source: any, newPath: Ar * @param key - [Optional] the current `key` for the value from the source object * @returns The new deep copied instance of the value. */ -function _deepCopy(visitMap: _RecursiveVisitMap[], value: T, ctx: _DeepCopyContext, key?: string | number | symbol): T { +function _deepCopy(visitMap: _IdentityMap<_RecursiveVisitMap>, value: T, ctx: _DeepCopyContext, key?: string | number | symbol): T { let userHandler = ctx.handler; - let newPath = ctx.path ? (key ? ctx.path.concat(key) : ctx.path) : []; - - let newCtx: _DeepCopyContext = { - handler: ctx.handler, - src: ctx.src, - path: newPath - }; + // Identify the value with at most one helper call (isPlainObject() for an object, isPrimitiveType() otherwise) const theType = typeof value; let isPlain = false; let isPrim = value === NULL_VALUE; @@ -129,6 +112,19 @@ function _deepCopy(visitMap: _RecursiveVisitMap[], value: T, ctx: _DeepCopyCo } } + if (!userHandler && isPrim) { + // A primitive is returned as-is and without a handler nothing can observe it, so don't create the context + return value; + } + + let newPath = ctx.path ? (isStrictUndefined(key) ? ctx.path : ctx.path.concat(key)) : []; + + let newCtx: _DeepCopyContext = { + handler: ctx.handler, + src: ctx.src, + path: newPath + }; + let details: IObjDeepCopyHandlerDetails = { type: theType, isPrim: isPrim, @@ -138,15 +134,15 @@ function _deepCopy(visitMap: _RecursiveVisitMap[], value: T, ctx: _DeepCopyCo path: newPath, origin: ctx.src, copy: (source: T, newKey?: string | number | symbol): T => { - return _deepCopy(visitMap, source, newKey ? newCtx : ctx, newKey); + return _deepCopy(visitMap, source, isStrictUndefined(newKey) ? ctx : newCtx, newKey); }, copyTo: (target: T, source: T): T => { return _copyProps(visitMap, target, source, newCtx); } }; - if (!details.isPrim) { - return _getSetVisited(visitMap, value, newPath, (newEntry) => { + if (!isPrim) { + return _getSetVisited(visitMap, value, (newEntry) => { // Use an accessor to set the new value onto the new entry objDefine(details, "result", { @@ -166,26 +162,22 @@ function _deepCopy(visitMap: _RecursiveVisitMap[], value: T, ctx: _DeepCopyCo }); } - // Allow the user handler to override the provided value - if (userHandler && userHandler[CALL](ctx, details)) { - return details.result; - } - - return value; + // Only a primitive with a user handler gets here, which can override the provided value + return userHandler[CALL](ctx, details) ? details.result : value; } /** * @internal * @ignore - * Internal helper to copy all of the enumerable properties from the source object to the new target object + * Internal helper to copy the own enumerable properties from the source object to the new target object * @param visitMap - The current map of objects that have been visited * @param target - The target object to copy the properties to. * @param source - The source object to copy the properties from. * @param ctx - The current deep copy context * @returns The populated target object */ -function _copyProps(visitMap: _RecursiveVisitMap[], target: T, source: T, ctx: _DeepCopyContext) { - if (!isUnsafeTarget(source)) { +function _copyProps(visitMap: _IdentityMap<_RecursiveVisitMap>, target: T, source: T, ctx: _DeepCopyContext) { + if (!isUnsafeTarget(target)) { // Copy own enumerable properties while skipping special object poisoning keys. forEachOwnKeySafe(source, (key, value) => { (target as any)[key] = _deepCopy(visitMap, value, ctx, key); @@ -196,8 +188,10 @@ function _copyProps(visitMap: _RecursiveVisitMap[], target: T, source: T, ctx } /** - * Object helper to copy all of the enumerable properties from the source object to the target, the - * properties are copied via {@link objDeepCopy}. Automatic handling of recursive properties was added in v0.4.4 + * Object helper to copy the own enumerable properties from the source object to the target, the properties are + * copied via {@link objDeepCopy}. Inherited properties and properties named `__proto__`, `constructor` or `prototype` + * are not copied, and a target identified by {@link isUnsafeTarget} (a built-in prototype) is not changed. + * Automatic handling of recursive properties was added in v0.4.4 * @group Object * @param target - The target object to populated * @param source - The source object to copy the properties from @@ -249,7 +243,7 @@ export function objCopyProps(target: T, source: any, handler?: ObjDeepCopyHan path: [] }; - return _copyProps([], target, source, ctx); + return _copyProps(_createIdentityMap<_RecursiveVisitMap>(), target, source, ctx); } /** @@ -337,7 +331,7 @@ export type ObjDeepCopyHandler = (details: IObjDeepCopyHandlerDetails) => boolea * Performs a deep copy of the source object, this is designed to work with base (plain) objects, arrays and primitives * if the source object contains class objects they will either be not cloned or may be considered non-operational after * performing a deep copy. ie. This is performing a deep copy of the objects properties so that altering the copy will - * not mutate the source object hierarchy. + * not mutate the source object hierarchy. Properties named `__proto__`, `constructor` or `prototype` are not copied. * Automatic handling of recursive properties was added in v0.4.4. * @group Object * @group Object - Deep Copy @@ -390,7 +384,7 @@ export function objDeepCopy(source: T, handler?: ObjDeepCopyHandler): T { src: source }; - return _deepCopy([], source, ctx); + return _deepCopy(_createIdentityMap<_RecursiveVisitMap>(), source, ctx); } /** diff --git a/lib/src/polyfills/array.ts b/lib/src/polyfills/array.ts index 2432225b..66192d5d 100644 --- a/lib/src/polyfills/array.ts +++ b/lib/src/polyfills/array.ts @@ -271,8 +271,9 @@ export function polyArrFindLast(theArray: ArrayLike, callback export function polyArrFindLastIndex(theArray: ArrayLike, callbackFn: ArrPredicateCallbackFn | ArrPredicateCallbackFn2, thisArg?: any): number { let result = -1; let len = theArray[LENGTH] >>> 0; + // Like the native function every index is visited (an empty slot is undefined), so there is no "in" check for (let idx = len - 1; idx >= 0; idx--) { - if (idx in theArray && callbackFn[CALL](isStrictNullOrUndefined(thisArg) ? theArray as any : thisArg, theArray[idx], idx, theArray)) { + if (callbackFn[CALL](isStrictNullOrUndefined(thisArg) ? theArray as any : thisArg, theArray[idx], idx, theArray)) { result = idx; break; } diff --git a/lib/src/string/at.ts b/lib/src/string/at.ts index b4909f70..ead5e597 100644 --- a/lib/src/string/at.ts +++ b/lib/src/string/at.ts @@ -10,7 +10,6 @@ import { polyArrAt } from "../array/at"; import { StrProto } from "../internal/constants"; import { _throwIfNullOrUndefined } from "../internal/throwIf"; import { _unwrapFunctionWithPoly } from "../internal/unwrapFunction"; -import { mathToInt } from "../math/to_int"; import { asString } from "./as_string"; /** @@ -71,5 +70,5 @@ export function polyStrAt(value: string, index: number): string | undefined { _throwIfNullOrUndefined(value); // Reuse the Array.at polyfill for index normalization and bounds behavior. - return polyArrAt(asString(value), mathToInt(index)); + return polyArrAt(asString(value), index); } diff --git a/lib/src/timer/microtasks/cancellableTask.ts b/lib/src/timer/microtasks/cancellableTask.ts index d4295411..a27818b5 100644 --- a/lib/src/timer/microtasks/cancellableTask.ts +++ b/lib/src/timer/microtasks/cancellableTask.ts @@ -16,21 +16,24 @@ import { ITimerHandler, _TimerHandler, _createTimerHandler } from "../handler"; export function _createCancellableTask(callback: () => void, queueFn: ScheduleMicrotaskFn, maxQueuedTasks?: number): ITimerHandler { let handler: _TimerHandler; let currentTask = 0; + let pendingTask = 0; function _scheduleTask() { - let taskId = ++currentTask; + let taskId = pendingTask = ++currentTask; let queued = queueFn(() => { if (taskId === currentTask) { + pendingTask = 0; handler.dn(); callback(); } }, maxQueuedTasks); - if (queued === false) { - handler.dn(); + if (queued === false && taskId === currentTask) { + pendingTask = 0; } - return taskId; + // Only a task which is still waiting to run is active (it may have been rejected or have already run) + return pendingTask; } function _cancelTask(taskId: number) { diff --git a/lib/test/bundle-size-check.js b/lib/test/bundle-size-check.js index fdb72c2c..f8ac3da5 100644 --- a/lib/test/bundle-size-check.js +++ b/lib/test/bundle-size-check.js @@ -7,25 +7,25 @@ const configs = [ { name: "es5-min-full", path: "../bundle/es5/umd/ts-utils.min.js", - limit: 42.5 * 1024, // 42.5 kb in bytes + limit: 43.75 * 1024, // 43.75 kb in bytes compress: false }, { name: "es6-min-full", path: "../bundle/es6/umd/ts-utils.min.js", - limit: 41.5 * 1024, // 41.5 kb in bytes + limit: 42.75 * 1024, // 42.75 kb in bytes compress: false }, { name: "es5-min-zip", path: "../bundle/es5/umd/ts-utils.min.js", - limit: 16 * 1024, // 16 kb in bytes + limit: 16.75 * 1024, // 16.75 kb in bytes compress: true }, { name: "es6-min-zip", path: "../bundle/es6/umd/ts-utils.min.js", - limit: 16 * 1024, // 16 kb in bytes + limit: 16.75 * 1024, // 16.75 kb in bytes compress: true }, { diff --git a/lib/test/src/common/array/at.test.ts b/lib/test/src/common/array/at.test.ts index 97c6a257..340a2eb5 100644 --- a/lib/test/src/common/array/at.test.ts +++ b/lib/test/src/common/array/at.test.ts @@ -105,3 +105,61 @@ describe("polyArrAt", () => { assert.equal(polyArrAt(arrayLike, -2), "y"); }); }); + +describe("polyArrAt index conversion", () => { + it("should convert the index to an integer like the native function", () => { + assert.equal(polyArrAt([1, 2, 3], 1.5), 2, "A fraction is truncated"); + assert.equal(polyArrAt([1, 2, 3], -1.5), 3, "A negative fraction is truncated"); + assert.equal(polyArrAt([1, 2, 3], NaN), 1, "NaN is treated as 0"); + assert.equal(polyArrAt([1, 2, 3], "1" as any), 2, "A numeric string is converted"); + assert.equal(polyArrAt([1, 2, 3], Infinity), undefined, "Infinity is out of bounds"); + }); +}); + +describe("polyArrAt compared to the native function", () => { + const nativeAt = (Array.prototype as any).at; + const values: any[] = [ + 0, 1, 2, 3, 4, 5, -1, -2, -3, -4, -5, -6, + 0.5, 1.5, 2.5, 3.5, -0.5, -1.5, -2.5, -3.5, -4.5, -5.5, + NaN, Infinity, -Infinity, "1", "-1", "1.5", "abc", "", null, undefined, true, false, + 2147483647, 2147483648, -2147483648, -2147483649, 4294967295, 4294967296, 4294967297, -4294967296, + 9007199254740991, -9007199254740991, { valueOf: () => 1 } + ]; + + (nativeAt ? it : it.skip)("should return the same result for every kind of index", () => { + const arrays: any[] = [[], [1], [1, 2, 3, 4], { length: 0, 0: "x" }, { length: 2, 0: "x", 1: "y" }]; + + for (let lp = 0; lp < arrays.length; lp++) { + for (let idx = 0; idx < values.length; idx++) { + assert.equal(polyArrAt(arrays[lp], values[idx]), nativeAt.call(arrays[lp], values[idx]), "length " + arrays[lp].length + " index [" + String(values[idx]) + "]"); + } + } + }); +}); + +describe("polyArrAt with indexes above 2^31", () => { + const BIG = 0x80000000; + + it("should not wrap a large index of an array-like", () => { + const arrayLike: any = { length: BIG + 1, 1: "wrong" }; + arrayLike[BIG] = "x"; + + assert.equal(polyArrAt(arrayLike, BIG), "x"); + assert.equal(polyArrAt(arrayLike, BIG + 0.5), "x", "A fractional index is still truncated"); + }); + + it("should not wrap a large negative index", () => { + const arrayLike: any = { length: BIG + 2, 1: "target" }; + + assert.equal(polyArrAt(arrayLike, -(BIG + 1)), "target"); + }); + + it("should support a sparse array longer than 2^31", () => { + const sparse: string[] = []; + sparse[1] = "wrong"; + sparse[BIG] = "x"; + + assert.equal(polyArrAt(sparse, BIG), "x"); + assert.equal(polyArrAt(sparse, -1), "x"); + }); +}); diff --git a/lib/test/src/common/array/fill.test.ts b/lib/test/src/common/array/fill.test.ts index e4c54fb1..7a94ea45 100644 --- a/lib/test/src/common/array/fill.test.ts +++ b/lib/test/src/common/array/fill.test.ts @@ -149,3 +149,66 @@ describe("polyArrFill", () => { assert.equal(arrayLike[3], 4); }); }); + +describe("polyArrFill index conversion", () => { + it("should convert the indexes to integers like the native function", () => { + assert.deepEqual(polyArrFill([1, 2, 3], 9, 0.5), [9, 9, 9], "A fractional start is truncated"); + assert.deepEqual(polyArrFill([1, 2, 3], 9, NaN), [9, 9, 9], "A NaN start is treated as 0"); + assert.deepEqual(polyArrFill([1, 2, 3], 9, 1, 2.5), [1, 9, 3], "A fractional end is truncated"); + assert.deepEqual(polyArrFill([1, 2, 3], 9, 0, NaN), [1, 2, 3], "A NaN end is treated as 0"); + }); + + it("should not add fractional properties", () => { + assert.deepEqual(Object.keys(polyArrFill([1, 2, 3], 9, 0.5)), ["0", "1", "2"]); + }); +}); + +describe("polyArrFill compared to the native function", () => { + const nativeFill = (Array.prototype as any).fill; + const values: any[] = [ + 0, 1, 2, 3, 4, 5, -1, -2, -3, -4, -5, -6, + 0.5, 1.5, 2.5, 3.5, -0.5, -1.5, -2.5, -3.5, -4.5, -5.5, + NaN, Infinity, -Infinity, "1", "-1", "1.5", "abc", "", null, undefined, true, false, + 2147483647, 2147483648, -2147483648, -2147483649, 4294967295, 4294967296, 4294967297, -4294967296, + 9007199254740991, -9007199254740991, { valueOf: () => 1 } + ]; + + (nativeFill ? it : it.skip)("should return the same result for every kind of start and end", () => { + const sources = [[], [1], [1, 2, 3, 4]]; + + for (let lp = 0; lp < sources.length; lp++) { + for (let start = 0; start < values.length; start++) { + for (let end = 0; end < values.length; end++) { + const actual = polyArrFill(sources[lp].slice(), 9, values[start], values[end]) as number[]; + const expected = nativeFill.call(sources[lp].slice(), 9, values[start], values[end]); + const details = "length " + sources[lp].length + " start [" + String(values[start]) + "] end [" + String(values[end]) + "]"; + + assert.deepEqual(actual, expected, details); + assert.equal(Object.keys(actual).length, sources[lp].length, "No extra properties: " + details); + } + } + } + }); +}); + +describe("polyArrFill with indexes above 2^31", () => { + const BIG = 0x80000000; + + function _filled(value: any) { + return Object.keys(value).filter((key) => key !== "length"); + } + + it("should not wrap a large index", () => { + const arrayLike: any = { length: BIG + 3 }; + + polyArrFill(arrayLike, 9, BIG, BIG + 1); + assert.deepEqual(_filled(arrayLike), [String(BIG)]); + }); + + it("should not wrap a large negative index", () => { + const arrayLike: any = { length: BIG + 3 }; + + polyArrFill(arrayLike, 9, -(BIG + 1), 3); + assert.deepEqual(_filled(arrayLike), ["2"]); + }); +}); diff --git a/lib/test/src/common/array/intersection.test.ts b/lib/test/src/common/array/intersection.test.ts index e2e41ffd..0606a330 100644 --- a/lib/test/src/common/array/intersection.test.ts +++ b/lib/test/src/common/array/intersection.test.ts @@ -50,4 +50,10 @@ describe("arrIntersection", () => { it("should remove duplicates from result", () => { assert.deepEqual(arrIntersection([1, 2, 2, 3, 3], [2, 3]), [2, 3]); }); + + it("should treat a later null or undefined array as empty", () => { + assert.deepEqual(arrIntersection([1, 2], null), []); + assert.deepEqual(arrIntersection([1, 2], [1], undefined), []); + assert.deepEqual(arrIntersection(null, [1, 2]), []); + }); }); diff --git a/lib/test/src/common/array/string_input.test.ts b/lib/test/src/common/array/string_input.test.ts new file mode 100644 index 00000000..10e4dd77 --- /dev/null +++ b/lib/test/src/common/array/string_input.test.ts @@ -0,0 +1,116 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire-chai"; +import { arrChunk } from "../../../../src/array/chunk"; +import { arrCompact } from "../../../../src/array/compact"; +import { arrDifference } from "../../../../src/array/difference"; +import { arrDrop } from "../../../../src/array/drop"; +import { arrDropWhile } from "../../../../src/array/drop_while"; +import { arrForEach } from "../../../../src/array/forEach"; +import { arrGroupBy } from "../../../../src/array/groupBy"; +import { arrIntersection } from "../../../../src/array/intersection"; +import { arrPartition } from "../../../../src/array/partition"; +import { arrRotate } from "../../../../src/array/rotate"; +import { arrShuffle } from "../../../../src/array/shuffle"; +import { arrTake } from "../../../../src/array/take"; +import { arrTakeWhile } from "../../../../src/array/take_while"; +import { arrUnique } from "../../../../src/array/unique"; +import { polyArrWith } from "../../../../src/array/with"; +import { polyArrFindLastIndex } from "../../../../src/polyfills/array"; + +// A string is an ArrayLike, so these helpers need to work when passed a string primitive +describe("array helpers with a string value", () => { + it("arrForEach should visit each character", () => { + const result: Array<[string, number]> = []; + arrForEach("abc", (value, index) => { + result.push([value, index]); + }); + + assert.deepEqual(result, [["a", 0], ["b", 1], ["c", 2]]); + }); + + it("arrForEach should still skip the holes of a sparse array", () => { + const sparse: number[] = [1]; + sparse[2] = 3; + const result: number[] = []; + arrForEach(sparse, (value) => { + result.push(value); + }); + + assert.deepEqual(result, [1, 3]); + }); + + it("arrChunk", () => { + assert.deepEqual(arrChunk("abc", 2), [["a", "b"], ["c"]]); + }); + + it("arrCompact", () => { + assert.deepEqual(arrCompact("ab"), ["a", "b"]); + }); + + it("arrUnique", () => { + assert.deepEqual(arrUnique("aab"), ["a", "b"]); + }); + + it("arrGroupBy", () => { + assert.deepEqual(arrGroupBy("aab", (value: string) => value), { a: ["a", "a"], b: ["b"] }); + }); + + it("arrPartition", () => { + assert.deepEqual(arrPartition("ab", (value: string) => value === "a"), [["a"], ["b"]]); + }); + + it("arrIntersection", () => { + assert.deepEqual(arrIntersection("ab", "bc"), ["b"]); + }); + + it("arrDifference", () => { + assert.deepEqual(arrDifference("ab", "b"), ["a"]); + }); + + it("arrShuffle should return an array of the characters", () => { + const result = arrShuffle("abc"); + + assert.ok(Array.isArray(result), "The result is an array"); + assert.deepEqual(result.sort(), ["a", "b", "c"]); + }); + + it("arrDropWhile should return an array", () => { + assert.deepEqual(arrDropWhile("abc", () => false), ["a", "b", "c"]); + assert.deepEqual(arrDropWhile("abc", (value: string) => value === "a"), ["b", "c"]); + assert.deepEqual(arrDropWhile("abc", () => true), []); + }); + + it("arrTakeWhile should return an array", () => { + assert.deepEqual(arrTakeWhile("abc", (value: string) => value !== "c"), ["a", "b"]); + }); + + it("arrDrop should return an array", () => { + assert.deepEqual(arrDrop("abc", 1), ["b", "c"]); + assert.deepEqual(arrDrop("abc", 0), ["a", "b", "c"]); + }); + + it("arrTake should return an array", () => { + assert.deepEqual(arrTake("abc", 2), ["a", "b"]); + }); + + it("arrRotate should return an array", () => { + assert.deepEqual(arrRotate("abc", 1), ["b", "c", "a"]); + assert.deepEqual(arrRotate("abc", 0), ["a", "b", "c"]); + assert.deepEqual(arrRotate("abc", 3), ["a", "b", "c"]); + }); + + it("polyArrWith should return an array of the characters", () => { + assert.deepEqual(polyArrWith("abc", 1, "z"), ["a", "z", "c"]); + }); + + it("polyArrFindLastIndex", () => { + assert.equal(polyArrFindLastIndex("abb", (value: string) => value === "b"), 2); + }); +}); diff --git a/lib/test/src/common/array/union.test.ts b/lib/test/src/common/array/union.test.ts index b6751c97..97137ded 100644 --- a/lib/test/src/common/array/union.test.ts +++ b/lib/test/src/common/array/union.test.ts @@ -45,4 +45,20 @@ describe("arrUnion", () => { it("should preserve insertion order", () => { assert.deepEqual(arrUnion([3, 1], [2, 4]), [3, 1, 2, 4]); }); + + it("should keep distinct objects", () => { + const a = { a: 1 }; + const b = { b: 2 }; + const result = arrUnion([a], [b, a]); + + assert.equal(result.length, 2); + assert.equal(result[0], a); + assert.equal(result[1], b); + }); + + it("should ignore null and undefined arrays", () => { + assert.deepEqual(arrUnion([1], null, [2], undefined), [1, 2]); + assert.deepEqual(arrUnion(null, [1]), [1]); + assert.deepEqual(arrUnion(null, undefined), []); + }); }); diff --git a/lib/test/src/common/array/unique.test.ts b/lib/test/src/common/array/unique.test.ts new file mode 100644 index 00000000..24768e18 --- /dev/null +++ b/lib/test/src/common/array/unique.test.ts @@ -0,0 +1,92 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire-chai"; +import { arrUnique } from "../../../../src/array/unique"; +import { getGlobal } from "../../../../src/helpers/environment"; + +describe("arrUnique", () => { + it("should keep distinct objects", () => { + const a = { a: 1 }; + const b = { b: 2 }; + const c = { c: 3 }; + const result = arrUnique([a, b, c]); + + assert.equal(result.length, 3); + assert.equal(result[0], a); + assert.equal(result[1], b); + assert.equal(result[2], c); + }); + + it("should remove repeated references and keep the first position", () => { + const a = { a: 1 }; + const b = { b: 2 }; + const result = arrUnique([b, a, b, a, a]); + + assert.equal(result.length, 2); + assert.equal(result[0], b); + assert.equal(result[1], a); + }); + + it("should compare arrays, dates and functions by identity", () => { + const list = [1, 2]; + const fn1 = function () { + return 1; + }; + const fn2 = function () { + return 1; + }; + + assert.equal(arrUnique([list, [1, 2], list]).length, 2, "arrays"); + assert.equal(arrUnique([new Date(1), new Date(1), new Date(2)]).length, 3, "dates"); + assert.equal(arrUnique([fn1, fn2, fn1]).length, 2, "functions"); + }); + + it("should support symbols", () => { + const s1 = Symbol("a"); + const s2 = Symbol("a"); + const result = arrUnique([s1, s2, s1]); + + assert.equal(result.length, 2); + assert.equal(result[0], s1); + assert.equal(result[1], s2); + }); + + it("should keep comparing primitives by value", () => { + assert.deepEqual(arrUnique([1, "1", true, "true", 1, "1", true]), [1, "1", true, "true"]); + assert.deepEqual(arrUnique([null, undefined, null, undefined, 0, "", false]), [null, undefined, 0, "", false]); + assert.equal(arrUnique([NaN, NaN]).length, 1, "NaN is only kept once"); + }); + + it("should handle a mix of primitives and objects", () => { + const a = { a: 1 }; + assert.deepEqual(arrUnique([a, "[object Object]", a, "[object Object]", 1]), [a, "[object Object]", 1]); + }); + + it("should work without a native Map", () => { + const a = { a: 1 }; + const b = { b: 2 }; + const s1 = Symbol("a"); + const theGlobal: any = getGlobal(); + const orgMap = theGlobal.Map; + let result: any[]; + + theGlobal.Map = undefined; + try { + result = arrUnique([a, b, a, s1, s1, 1, 1]); + } finally { + theGlobal.Map = orgMap; + } + + assert.equal(result.length, 4); + assert.equal(result[0], a); + assert.equal(result[1], b); + assert.equal(result[2], s1); + assert.equal(result[3], 1); + }); +}); diff --git a/lib/test/src/common/array/with.test.ts b/lib/test/src/common/array/with.test.ts index ce58006d..624b3394 100644 --- a/lib/test/src/common/array/with.test.ts +++ b/lib/test/src/common/array/with.test.ts @@ -189,3 +189,63 @@ describe("polyArrWith", () => { } }); }); + +describe("polyArrWith index conversion", () => { + it("should convert the index to an integer like the native function", () => { + assert.deepEqual(polyArrWith([1, 2, 3], 1.5, 9), [1, 9, 3], "A fraction is truncated"); + assert.deepEqual(polyArrWith([1, 2, 3], -1.5, 9), [1, 2, 9], "A negative fraction is truncated"); + assert.deepEqual(polyArrWith([1, 2, 3], NaN, 9), [9, 2, 3], "NaN is treated as 0"); + }); +}); + +describe("polyArrWith compared to the native function", () => { + const nativeWith = (Array.prototype as any).with; + const values: any[] = [ + 0, 1, 2, 3, 4, 5, -1, -2, -3, -4, -5, -6, + 0.5, 1.5, 2.5, 3.5, -0.5, -1.5, -2.5, -3.5, -4.5, -5.5, + NaN, Infinity, -Infinity, "1", "-1", "1.5", "abc", "", null, undefined, true, false, + 2147483647, 2147483648, -2147483648, -2147483649, 4294967295, 4294967296, 4294967297, -4294967296, + 9007199254740991, -9007199254740991, { valueOf: () => 1 } + ]; + + function _run(fn: () => number[]) { + try { + return fn(); + } catch (e) { + return (e as any).name; + } + } + + (nativeWith ? it : it.skip)("should return the same result or throw for every kind of index", () => { + const arrays = [[], [1], [1, 2, 3, 4]]; + + for (let lp = 0; lp < arrays.length; lp++) { + for (let idx = 0; idx < values.length; idx++) { + const theArray = arrays[lp]; + const value = values[idx]; + + assert.deepEqual(_run(() => polyArrWith(theArray, value, 9)), _run(() => nativeWith.call(theArray, value, 9)), "length " + theArray.length + " index [" + String(value) + "]"); + } + } + }); +}); + +describe("polyArrWith with indexes above 2^31", () => { + const BIG = 0x80000000; + + // The value provides its own copy, as actually copying this length would take too long + function _arrayLike(length: number): any { + return { + length: length, + slice: (): any[] => [] + }; + } + + it("should not wrap a large index", () => { + assert.deepEqual(Object.keys(polyArrWith(_arrayLike(BIG + 1), BIG, 9)), [String(BIG)]); + }); + + it("should not wrap a large negative index", () => { + assert.deepEqual(Object.keys(polyArrWith(_arrayLike(BIG + 2), -(BIG + 1), 9)), ["1"]); + }); +}); diff --git a/lib/test/src/common/helpers/encode.test.ts b/lib/test/src/common/helpers/encode.test.ts index 4977e37c..c5483df1 100644 --- a/lib/test/src/common/helpers/encode.test.ts +++ b/lib/test/src/common/helpers/encode.test.ts @@ -554,4 +554,87 @@ describe("Base64 Polyfill Verification Against Native Implementation", () => { assert.ok(hasNativeBase64, "Native btoa/atob should be available in this environment"); }); } -}); \ No newline at end of file +}); + +describe("encoding text which is not Latin-1", () => { + const texts = [ + "\u20AC", + "Price: \u20AC5", + "\u65E5\u672C\u8A9E", + "caf\u00E9 \u20AC", + "\uD83D\uDE00", + "a\uD83D\uDE00b\u20AC", + "100% \u20AC", + "%41 %E2%82%AC \u20AC", + "\u0100", + "\uFFFF", + "\uD7FF\uE000", + "\uFEFF", + "\uFEFFabc", + "\xEF\xBB\xBF", + "\xEF\xBB\xBFabc" + ]; + + function _roundTrip(name: string, encode: (value: string) => string, decode: (value: string) => string) { + it("round trips with " + name, () => { + for (let lp = 0; lp < texts.length; lp++) { + assert.equal(decode(encode(texts[lp])), texts[lp], "Text index " + lp); + } + }); + } + + _roundTrip("hex", encodeAsHex, decodeHex); + _roundTrip("base64", encodeAsBase64, decodeBase64); + _roundTrip("base64url", encodeAsBase64Url, decodeBase64Url); + + it("encodes as the UTF-8 byte order mark followed by the UTF-8 bytes", () => { + assert.equal(encodeAsHex("\u20AC"), "efbbbfe282ac"); + assert.equal(encodeAsBase64("\u20AC"), "77u/4oKs"); + assert.equal(encodeAsBase64Url("\u20AC"), "77u_4oKs"); + assert.equal(encodeAsHex("\uD83D\uDE00"), "efbbbff09f9880", "A surrogate pair is a single 4 byte character"); + }); + + it("encodes an unpaired surrogate as U+FFFD like TextEncoder", () => { + assert.equal(decodeHex(encodeAsHex("\uD83D")), "\uFFFD"); + assert.equal(decodeHex(encodeAsHex("\uDE00x")), "\uFFFDx"); + assert.equal(decodeBase64(encodeAsBase64("a\uD83Db\uDE00\uD83D\uDE00")), "a\uFFFDb\uFFFD\uD83D\uDE00"); + assert.equal(encodeAsHex("\uD83D"), "efbbbfefbfbd"); + }); + + it("does not change the encoding of Latin-1 text", () => { + assert.equal(encodeAsHex("Hello"), "48656c6c6f"); + assert.equal(encodeAsHex("\u00E9"), "e9"); + assert.equal(encodeAsHex("\u00FF"), "ff"); + assert.equal(encodeAsBase64("\u00E9"), "6Q=="); + assert.equal(encodeAsBase64("caf\u00E9"), "Y2Fm6Q=="); + assert.equal(decodeHex("e9"), "\u00E9"); + assert.equal(decodeBase64("6Q=="), "\u00E9"); + }); + + it("does not change how values without the marker are decoded", () => { + assert.equal(decodeHex("e282ac"), "\xE2\x82\xAC"); + assert.equal(decodeBase64("4oKs"), "\xE2\x82\xAC"); + assert.equal(decodeHex("c3a9"), "\xC3\xA9"); + }); + + it("returns the bytes when the marker is not followed by valid UTF-8", () => { + assert.equal(decodeHex("efbbbf"), "\xEF\xBB\xBF", "Only the marker"); + assert.equal(decodeHex("efbbbfff"), "\xEF\xBB\xBF\xFF", "Invalid byte"); + assert.equal(decodeHex("efbbbfe282"), "\xEF\xBB\xBF\xE2\x82", "Truncated sequence"); + assert.equal(decodeHex("efbbbfc0af"), "\xEF\xBB\xBF\xC0\xAF", "Overlong 2 byte sequence"); + assert.equal(decodeHex("efbbbfe08080"), "\xEF\xBB\xBF\xE0\x80\x80", "Overlong 3 byte sequence"); + assert.equal(decodeHex("efbbbff0808080"), "\xEF\xBB\xBF\xF0\x80\x80\x80", "Overlong 4 byte sequence"); + assert.equal(decodeHex("efbbbff4908080"), "\xEF\xBB\xBF\xF4\x90\x80\x80", "Above the maximum code point"); + }); + + it("decodes text with a byte order mark from another encoder", () => { + assert.equal(decodeHex("efbbbf48656c6c6f"), "Hello"); + assert.equal(decodeBase64("77u/SGVsbG8="), "Hello"); + }); + + (typeof atob === "function" ? it : it.skip)("decodes unpadded and wrapped values as the native function does", () => { + // A browser rejects the native function when called with the wrong `this`, which used the polyfill instead + assert.equal(decodeBase64("QQ"), "A"); + assert.equal(decodeBase64("SGVs\nbG8="), "Hello"); + }); +}); diff --git a/lib/test/src/common/internal/identity_map.test.ts b/lib/test/src/common/internal/identity_map.test.ts new file mode 100644 index 00000000..834f7590 --- /dev/null +++ b/lib/test/src/common/internal/identity_map.test.ts @@ -0,0 +1,62 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire-chai"; +import { getGlobal } from "../../../../src/helpers/environment"; +import { _createIdentityMap } from "../../../../src/internal/identity_map"; + +describe("_createIdentityMap", () => { + function _check(name: string, setup?: () => () => void) { + it(name, () => { + const restore = setup ? setup() : null; + let results: any[]; + + try { + const theMap = _createIdentityMap(); + const key1 = { a: 1 }; + const key2 = { a: 1 }; + const key3 = function () { + return 1; + }; + const key4 = Symbol("a"); + + // Adding a key returns undefined, as there was no existing value + results = [theMap(key1, 1), theMap(key2, 2), theMap(key3, 3), theMap(key4, 4)]; + + // Finding a key returns its value and doesn't replace it + results.push(theMap(key1, 9), theMap(key2, 9), theMap(key3, 9), theMap(key4, 9), theMap(key1, 8)); + + // Primitives are compared by value, and NaN is equal to itself + results.push(theMap(NaN, 5), theMap(NaN, 9), theMap(1, 6), theMap("1", 7), theMap(1, 9), theMap("1", 9)); + results.push(theMap(0, 8), theMap(-0, 9), theMap(null, 10), theMap(undefined, 11), theMap(null, 9), theMap(undefined, 9)); + } finally { + restore && restore(); + } + + assert.deepEqual(results, [ + undefined, undefined, undefined, undefined, + 1, 2, 3, 4, 1, + undefined, 5, undefined, undefined, 6, 7, + undefined, 8, undefined, undefined, 10, 11 + ]); + }); + } + + _check("should use the keys identity"); + + _check("should use the keys identity without a native Map", () => { + const theGlobal: any = getGlobal(); + const orgMap = theGlobal.Map; + + theGlobal.Map = undefined; + + return () => { + theGlobal.Map = orgMap; + }; + }); +}); diff --git a/lib/test/src/common/iterator/iterTake.test.ts b/lib/test/src/common/iterator/iterTake.test.ts index f6ad8a6d..1b0f30e2 100644 --- a/lib/test/src/common/iterator/iterTake.test.ts +++ b/lib/test/src/common/iterator/iterTake.test.ts @@ -46,4 +46,27 @@ describe("iterTake", () => { assert.equal(nextCount, 2, "source.next() should only be called for yielded values"); assert.equal(returnCount, 1, "source.return() should be called once when limit is reached"); }); + + it("closes the wrapped iterator after the consumer has received the last value", () => { + const log: string[] = []; + let value = 0; + const source: Iterator = { + next: () => ({ done: false, value: ++value }), + return: (result?: any) => { + log.push("closed"); + return { done: true, value: result }; + } + }; + + const iter = iterTake(source, 2); + log.push("got" + iter.next().value); + log.push("got" + iter.next().value); + + assert.deepEqual(log, ["got1", "got2"], "The source is still open when the last value is received"); + + assert.equal(iter.next().done, true, "The iteration is complete"); + assert.deepEqual(log, ["got1", "got2", "closed"], "The source is closed by the following next()"); + assert.equal(iter.next().done, true, "The iteration stays complete"); + assert.deepEqual(log, ["got1", "got2", "closed"], "The source is only closed once"); + }); }); diff --git a/lib/test/src/common/object/copy.test.ts b/lib/test/src/common/object/copy.test.ts index deb03ff1..24d5b29f 100644 --- a/lib/test/src/common/object/copy.test.ts +++ b/lib/test/src/common/object/copy.test.ts @@ -10,6 +10,8 @@ import { assert } from "@nevware21/tripwire-chai"; import { objCopyProps, objDeepCopy } from "../../../../src/object/copy"; import { isDate } from "../../../../src/helpers/base"; import { dumpObj } from "../../../../src/helpers/diagnostics"; +import { objExtend } from "../../../../src/helpers/extend"; +import { getGlobal } from "../../../../src/helpers/environment"; /** * Helper function to check if an array contains a deep equal item @@ -423,3 +425,147 @@ describe("object copy functions", () => { }); }); }); + +describe("objCopyProps and objDeepCopy behaviors", () => { + const POLLUTED = "tsUtilsPollutedTestValue"; + + afterEach(() => { + delete (Object.prototype as any)[POLLUTED]; + }); + + it("should not change a built-in prototype target", () => { + const source: any = {}; + source[POLLUTED] = 1; + + assert.equal(objCopyProps(Object.prototype, source), Object.prototype, "The target is returned"); + assert.equal(({} as any)[POLLUTED], undefined, "objCopyProps did not change Object.prototype"); + + objExtend(Object.prototype, source); + assert.equal(({} as any)[POLLUTED], undefined, "objExtend did not change Object.prototype"); + }); + + it("should still copy to the prototype of a class", () => { + function TestClass() { } + objCopyProps(TestClass.prototype, { value: 21 }); + + assert.equal(new (TestClass as any)().value, 21); + }); + + it("should only copy own enumerable properties", () => { + const source = Object.create({ inherited: 1 }); + source.own = 2; + + assert.deepEqual(objCopyProps({}, source), { own: 2 }); + assert.deepEqual(objExtend({}, source), { own: 2 }); + }); + + it("should not copy properties named constructor or prototype", () => { + assert.deepEqual(objDeepCopy({ constructor: "Acme", prototype: 1, a: 2 }), { a: 2 }); + }); + + it("should copy primitive values without a handler", () => { + const source = { a: 1, b: "two", c: true, d: null as any, e: undefined as any }; + const result = objDeepCopy(source); + + assert.deepEqual(result, source); + assert.ok(result !== source, "A new object is returned"); + assert.equal(objDeepCopy(21), 21); + assert.equal(objDeepCopy(null), null); + }); + + it("should still call a handler for primitive values", () => { + const result = objDeepCopy({ a: 1, b: 2 }, (details) => { + if (details.isPrim && details.value === 1) { + details.result = 42; + return true; + } + + return false; + }); + + assert.deepEqual(result, { a: 42, b: 2 }); + }); + + function _paths(source: any) { + const paths: string[] = []; + objDeepCopy(source, (details) => { + paths.push(JSON.stringify(details.path)); + return false; + }); + + return paths; + } + + it("should include an empty string key in the path", () => { + const source: any = { y: { z: 2 } }; + source[""] = { x: 1 }; + + const paths = _paths(source); + assert.ok(paths.indexOf("[\"\"]") !== -1, "The empty key is in the path: " + paths.join(" ")); + assert.ok(paths.indexOf("[\"\",\"x\"]") !== -1, "A child of the empty key is in the path: " + paths.join(" ")); + }); + + it("should include a key of 0 passed to the handlers copy function", () => { + const paths: string[] = []; + objDeepCopy({ a: 1 }, (details) => { + paths.push(JSON.stringify(details.path)); + if (details.path.length === 0) { + details.result = details.copy(details.value.a, 0); + return true; + } + + return false; + }); + + assert.ok(paths.indexOf("[0]") !== -1, "The key is in the path: " + paths.join(" ")); + }); + + function _checkReferences() { + const shared = { value: 1 }; + const source: any = { first: shared, second: shared }; + source.self = source; + + const result = objDeepCopy(source); + + return { + selfIsResult: result.self === result, + sameCopy: result.first === result.second, + notSource: result.first !== shared, + value: result.first.value + }; + } + + const expectedReferences = { selfIsResult: true, sameCopy: true, notSource: true, value: 1 }; + + it("should retain recursive and shared references", () => { + assert.deepEqual(_checkReferences(), expectedReferences); + }); + + it("should retain recursive and shared references without a native Map", () => { + const theGlobal: any = getGlobal(); + const orgMap = theGlobal.Map; + let result: any; + + theGlobal.Map = undefined; + try { + result = _checkReferences(); + } finally { + theGlobal.Map = orgMap; + } + + assert.deepEqual(result, expectedReferences); + }); + + it("should copy a large array of objects", () => { + const source: any[] = []; + for (let lp = 0; lp < 5000; lp++) { + source.push({ id: lp }); + } + + const result = objDeepCopy(source); + + assert.equal(result.length, 5000); + assert.equal(result[4999].id, 4999); + assert.ok(result[0] !== source[0], "The entries are copied"); + }); +}); diff --git a/lib/test/src/common/polyfills/array.test.ts b/lib/test/src/common/polyfills/array.test.ts index 2d9962d8..3c3b00a7 100644 --- a/lib/test/src/common/polyfills/array.test.ts +++ b/lib/test/src/common/polyfills/array.test.ts @@ -250,6 +250,26 @@ describe("array polyfills", () => { }); describe("polyArrFindLastIndex", () => { + it("visits every index of a sparse array like the native function", () => { + const sparse: Array = [1]; + sparse[2] = 3; + const visited: number[] = []; + assert.equal(polyArrFindLastIndex(sparse, (value, index) => { + visited.push(index); + return value === undefined; + }), 1, "The empty slot is treated as undefined"); + assert.deepEqual(visited, [2, 1]); + + if ((Array.prototype as any).findLastIndex) { + const nativeVisited: number[] = []; + assert.equal((sparse as any).findLastIndex((value: any, index: number) => { + nativeVisited.push(index); + return value === undefined; + }), 1); + assert.deepEqual(visited, nativeVisited, "Matches the native function"); + } + }); + it ("example 1", () => { const inventory: Array<{ name: string, quantity: number}> = [ { name: "apples", quantity: 2 }, diff --git a/lib/test/src/common/timer/microtask.test.ts b/lib/test/src/common/timer/microtask.test.ts index 39337568..4dbe926a 100644 --- a/lib/test/src/common/timer/microtask.test.ts +++ b/lib/test/src/common/timer/microtask.test.ts @@ -607,3 +607,92 @@ describe("microtask tests", () => { }); }); }); + +describe("microtask handler enabled state", () => { + let orgQueueMicrotask: any; + + beforeEach(() => { + orgQueueMicrotask = (getGlobal()).queueMicrotask; + (getGlobal()).queueMicrotask = undefined; + setBypassLazyCache(true); + }); + + afterEach(() => { + (getGlobal()).queueMicrotask = orgQueueMicrotask; + setBypassLazyCache(false); + }); + + it("is not enabled when the scheduleFn rejects the task", () => { + let called = 0; + const handler = scheduleMicrotask(() => { + called++; + }, { + scheduleFn: () => false + }); + + assert.equal(handler.enabled, false, "The rejected task is not active"); + assert.equal(called, 0, "The callback was not called"); + }); + + it("is not enabled when the scheduleFn runs the task synchronously", () => { + let called = 0; + const handler = scheduleMicrotask(() => { + called++; + }, { + scheduleFn: (cb) => { + cb(); + } + }); + + assert.equal(called, 1, "The callback was called"); + assert.equal(handler.enabled, false, "The completed task is not active"); + }); + + it("is enabled until a queued task has run", () => { + const queue: Array<() => void> = []; + let called = 0; + const handler = scheduleMicrotask(() => { + called++; + }, { + scheduleFn: (cb) => { + queue.push(cb); + } + }); + + assert.equal(handler.enabled, true, "The task is waiting to run"); + queue[0](); + assert.equal(called, 1, "The callback was called"); + assert.equal(handler.enabled, false, "The task has run"); + }); + + it("stays enabled when a synchronous callback schedules another task", () => { + const queue: Array<() => void> = []; + let runNow = false; + let called = 0; + const handler = scheduleMicrotask(() => { + called++; + if (called === 1) { + handler.refresh(); + } + }, { + scheduleFn: (cb) => { + if (runNow) { + runNow = false; + cb(); + } else { + queue.push(cb); + } + } + }); + + runNow = true; + handler.refresh(); + + assert.equal(called, 1, "The callback was called synchronously"); + assert.equal(handler.enabled, true, "The new task is waiting to run"); + + queue[queue.length - 1](); + assert.equal(called, 2, "The new task ran"); + assert.equal(handler.enabled, false, "There is nothing left to run"); + }); +});