From 1b4aad3537bf450f892e96c6c4a480a9eb141e44 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 28 Sep 2023 13:16:08 -0400 Subject: [PATCH] Add tests of canonicalStringify and helper lookupSortedKeys. The lookupSortedKeys function is not intended to be used directly, but seemed worth unit testing nevertheless. --- .../common/__tests__/canonicalStringify.ts | 132 ++++++++++++++++++ src/utilities/common/canonicalStringify.ts | 2 +- src/utilities/index.ts | 2 +- 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 src/utilities/common/__tests__/canonicalStringify.ts diff --git a/src/utilities/common/__tests__/canonicalStringify.ts b/src/utilities/common/__tests__/canonicalStringify.ts new file mode 100644 index 00000000000..4322bdb5bca --- /dev/null +++ b/src/utilities/common/__tests__/canonicalStringify.ts @@ -0,0 +1,132 @@ +import { + canonicalStringify, + lookupSortedKeys, +} from "../canonicalStringify"; + +function forEachPermutation( + keys: string[], + callback: (permutation: string[]) => void, +) { + if (keys.length <= 1) { + callback(keys); + return; + } + const first = keys[0]; + const rest = keys.slice(1); + forEachPermutation(rest, (permutation) => { + for (let i = 0; i <= permutation.length; ++i) { + callback([ + ...permutation.slice(0, i), + first, + ...permutation.slice(i), + ]); + } + }); +} + +function allObjectPermutations>(obj: T) { + const keys = Object.keys(obj); + const permutations: T[] = []; + forEachPermutation(keys, permutation => { + const permutationObj = + Object.create(Object.getPrototypeOf(obj)); + permutation.forEach(key => { + permutationObj[key] = obj[key]; + }); + permutations.push(permutationObj); + }); + return permutations; +} + +describe("canonicalStringify", () => { + beforeEach(() => { + canonicalStringify.reset(); + }); + + it("should not modify original object", () => { + const obj = { c: 3, a: 1, b: 2 }; + expect(canonicalStringify(obj)).toBe('{"a":1,"b":2,"c":3}'); + expect(Object.keys(obj)).toEqual(["c", "a", "b"]); + }); + + it("forEachPermutation should work", () => { + const permutations: string[][] = []; + forEachPermutation(["a", "b", "c"], (permutation) => { + permutations.push(permutation); + }); + expect(permutations).toEqual([ + ["a", "b", "c"], + ["b", "a", "c"], + ["b", "c", "a"], + ["a", "c", "b"], + ["c", "a", "b"], + ["c", "b", "a"], + ]); + }); + + it("canonicalStringify should stably stringify all permutations of an object", () => { + const unstableStrings = new Set(); + const stableStrings = new Set(); + + allObjectPermutations({ + c: 3, + a: 1, + b: 2, + }).forEach(obj => { + unstableStrings.add(JSON.stringify(obj)); + stableStrings.add(canonicalStringify(obj)); + + expect(canonicalStringify(obj)).toBe('{"a":1,"b":2,"c":3}'); + + allObjectPermutations({ + z: "z", + y: ["y", obj, "why"], + x: "x", + }).forEach(parent => { + expect(canonicalStringify(parent)).toBe( + '{"x":"x","y":["y",{"a":1,"b":2,"c":3},"why"],"z":"z"}', + ); + }); + }); + + expect(unstableStrings.size).toBe(6); + expect(stableStrings.size).toBe(1); + }); + + it("lookupSortedKeys(keys, false) should reuse same sorted array for all permutations", () => { + const keys = ["z", "a", "c", "b"]; + const sorted = lookupSortedKeys(["z", "a", "b", "c"], false); + expect(sorted).toEqual(["a", "b", "c", "z"]); + forEachPermutation(keys, permutation => { + expect(lookupSortedKeys(permutation, false)).toBe(sorted); + }); + }); + + it("lookupSortedKeys(keys, true) should return same array if already sorted", () => { + const keys = ["a", "b", "c", "x", "y", "z"].sort(); + const sorted = lookupSortedKeys(keys, true); + expect(sorted).toBe(keys); + + forEachPermutation(keys, permutation => { + const sortedTrue = lookupSortedKeys(permutation, true); + const sortedFalse = lookupSortedKeys(permutation, false); + + expect(sortedTrue).toEqual(sorted); + expect(sortedFalse).toEqual(sorted); + + const wasPermutationSorted = + permutation.every((key, i) => key === keys[i]); + + if (wasPermutationSorted) { + expect(sortedTrue).toBe(permutation); + expect(sortedTrue).not.toBe(sorted); + } else { + expect(sortedTrue).not.toBe(permutation); + expect(sortedTrue).toBe(sorted); + } + + expect(sortedFalse).not.toBe(permutation); + expect(sortedFalse).toBe(sorted); + }); + }); +}); diff --git a/src/utilities/common/canonicalStringify.ts b/src/utilities/common/canonicalStringify.ts index 2b36648cf7b..566dc7988e5 100644 --- a/src/utilities/common/canonicalStringify.ts +++ b/src/utilities/common/canonicalStringify.ts @@ -70,7 +70,7 @@ const sortingTrieRoot: SortingTrie = new Map; // Sort the given keys using a lookup trie, with an option to return the same // (===) array in case it was already sorted, so we can avoid always creating a // new object in the replacer function above. -function lookupSortedKeys( +export function lookupSortedKeys( keys: readonly string[], returnKeysIfAlreadySorted: boolean, ): readonly string[] { diff --git a/src/utilities/index.ts b/src/utilities/index.ts index adbd5e26c1e..ea660b1cafe 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -120,8 +120,8 @@ export * from "./common/makeUniqueId.js"; export * from "./common/stringifyForDisplay.js"; export * from "./common/mergeOptions.js"; export * from "./common/incrementalResult.js"; -export * from "./common/canonicalStringify.js"; +export { canonicalStringify } from "./common/canonicalStringify.js"; export { omitDeep } from "./common/omitDeep.js"; export { stripTypename } from "./common/stripTypename.js";