-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Deep utils (deepMerge, deepEquals, deepHash) (#11)
- Loading branch information
Showing
13 changed files
with
516 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import { test, expect, describe } from "bun:test"; | ||
import deepEquals from "./util-deep-equals"; | ||
|
||
describe("value equality", () => { | ||
test("null", () => expect(deepEquals(null, null)).toBe(true)); | ||
test("undefined", () => expect(deepEquals(undefined, undefined)).toBe(true)); | ||
test("string", () => expect(deepEquals("a", "a")).toBe(true)); | ||
test("number", () => expect(deepEquals(1, 1)).toBe(true)); | ||
test("boolean", () => expect(deepEquals(true, true)).toBe(true)); | ||
test("empty array", () => expect(deepEquals([], [])).toBe(true)); | ||
test("empty object", () => expect(deepEquals({}, {})).toBe(true)); | ||
}); | ||
|
||
describe("value inequality", () => { | ||
test("null vs undefined", () => | ||
expect(deepEquals(null, undefined)).toBe(false)); | ||
test("null vs string", () => expect(deepEquals(null, "a")).toBe(false)); | ||
test("null vs number", () => expect(deepEquals(null, 1)).toBe(false)); | ||
test("null vs boolean", () => expect(deepEquals(null, true)).toBe(false)); | ||
test("null vs array", () => expect(deepEquals(null, [])).toBe(false)); | ||
test("null vs object", () => expect(deepEquals(null, {})).toBe(false)); | ||
test("undefined vs string", () => | ||
expect(deepEquals(undefined, "a")).toBe(false)); | ||
test("undefined vs number", () => | ||
expect(deepEquals(undefined, 1)).toBe(false)); | ||
test("undefined vs boolean", () => | ||
expect(deepEquals(undefined, true)).toBe(false)); | ||
test("undefined vs array", () => | ||
expect(deepEquals(undefined, [])).toBe(false)); | ||
test("undefined vs object", () => | ||
expect(deepEquals(undefined, {})).toBe(false)); | ||
test("string vs number", () => expect(deepEquals("a", 1)).toBe(false)); | ||
test("string vs boolean", () => expect(deepEquals("a", true)).toBe(false)); | ||
test("string vs array", () => expect(deepEquals("a", [])).toBe(false)); | ||
test("string vs object", () => expect(deepEquals("a", {})).toBe(false)); | ||
test("number vs boolean", () => expect(deepEquals(1, true)).toBe(false)); | ||
test("number vs array", () => expect(deepEquals(1, [])).toBe(false)); | ||
test("number vs object", () => expect(deepEquals(1, {})).toBe(false)); | ||
test("boolean vs array", () => expect(deepEquals(true, [])).toBe(false)); | ||
test("boolean vs object", () => expect(deepEquals(true, {})).toBe(false)); | ||
test("array vs object", () => expect(deepEquals([], {})).toBe(false)); | ||
}); | ||
|
||
describe("array equality", () => { | ||
test("single element", () => expect(deepEquals([1], [1])).toBe(true)); | ||
test("multiple elements", () => | ||
expect(deepEquals([1, 2, 3], [1, 2, 3])).toBe(true)); | ||
test("nested arrays", () => | ||
expect(deepEquals([1, [2, 3], 4], [1, [2, 3], 4])).toBe(true)); | ||
}); | ||
|
||
describe("array inequality", () => { | ||
test("different lengths", () => | ||
expect(deepEquals([1, 2], [1, 2, 3])).toBe(false)); | ||
test("different elements", () => | ||
expect(deepEquals([1, 2, 3], [1, 2, 4])).toBe(false)); | ||
test("different nested arrays", () => | ||
expect(deepEquals([1, [2, 3], 4], [1, [2, 4], 4])).toBe(false)); | ||
}); | ||
|
||
describe("object equality", () => { | ||
test("single key", () => expect(deepEquals({ a: 1 }, { a: 1 })).toBe(true)); | ||
test("multiple keys", () => | ||
expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true)); | ||
test("nested objects", () => | ||
expect(deepEquals({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe( | ||
true | ||
)); | ||
}); | ||
|
||
describe("object inequality", () => { | ||
test("different keys", () => | ||
expect(deepEquals({ a: 1 }, { b: 1 })).toBe(false)); | ||
test("different values", () => | ||
expect(deepEquals({ a: 1 }, { a: 2 })).toBe(false)); | ||
test("different nested objects", () => | ||
expect(deepEquals({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 3 } })).toBe( | ||
false | ||
)); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
import isObject from "./util-is-object"; | ||
|
||
/** An object that is either a plain array or plain object. */ | ||
type Indexed = { [key: string | number]: any }; | ||
|
||
/** | ||
* Checks if the given values are equal. | ||
* | ||
* Note: Only plain objects and arrays are compared recursively. | ||
* Classes and other objects are compared with `Object.is`. | ||
* @param left The left value to compare. | ||
* @param right The right value to compare. | ||
* @example | ||
* ```ts | ||
* // Both true: | ||
* deepEquals( | ||
* { a: 1 }, | ||
* { a: 1 } | ||
* ) | ||
* deepEquals( | ||
* { a: 1, b: { c: 2 } }, | ||
* { a: 1, b: { c: 2 } } | ||
* ) | ||
* | ||
* // Both false: | ||
* deepEquals( | ||
* { a: 1 }, | ||
* { a: 2, b: 2 } | ||
* ) | ||
* deepEquals( | ||
* { a: 1, b: { c: [] } }, | ||
* { a: 1, b: { c: [] } } | ||
* ) | ||
* ``` | ||
*/ | ||
const deepEquals = <T>(left: T, right: unknown): right is T => { | ||
if (Object.is(left, right)) return true; | ||
if (!isIndexed(left) || !isIndexed(right)) return false; | ||
if (!isSamePrototype(left, right)) return false; | ||
|
||
const leftKeys = Object.keys(left); | ||
const rightKeys = Object.keys(right); | ||
|
||
if (leftKeys.length !== rightKeys.length) return false; | ||
|
||
for (const key of leftKeys) { | ||
if (!(key in right)) return false; | ||
if (!deepEquals(left[key], right[key])) return false; | ||
} | ||
|
||
return true; | ||
}; | ||
|
||
const isIndexed = (value: unknown): value is Indexed => | ||
Array.isArray(value) || isObject(value); | ||
|
||
const isSamePrototype = <T>(left: T, right: unknown): right is T => | ||
Object.getPrototypeOf(left) === Object.getPrototypeOf(right); | ||
|
||
export default deepEquals; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
import { expect, test } from "bun:test"; | ||
import fc from "fast-check"; | ||
import deepHash from "./util-deep-hash"; | ||
import { deepEquals } from "bun"; | ||
|
||
const VERBOSE = false; | ||
const COLLISION_TEST = true; | ||
const ITERATIONS = process.env.CI ? 10_000 : 100_000; | ||
|
||
test("clones have the same hash", () => { | ||
fc.assert( | ||
fc.property( | ||
fc.anything(), | ||
(value) => deepHash(value) === deepHash(structuredClone(value)) | ||
) | ||
); | ||
}); | ||
|
||
test("true !== false", () => { | ||
expect(deepHash(true)).not.toBe(deepHash(false)); | ||
}); | ||
|
||
test("null !== undefined", () => { | ||
expect(deepHash(null)).not.toBe(deepHash(undefined)); | ||
}); | ||
|
||
test("increment changes", () => { | ||
for (let i = -10000000; i < 10000000; i += 2) { | ||
expect(deepHash(i)).not.toBe(deepHash(i + 1)); | ||
} | ||
}); | ||
|
||
test.if(!process.env.CI)("16-bit range", () => { | ||
const map = new Map<number, number>(); | ||
|
||
for (let i = -32768; i < -32767; i++) { | ||
const hash = deepHash(i); | ||
expect(map.has(hash)).toBe(false); | ||
map.set(hash, i); | ||
} | ||
}); | ||
|
||
const fuzzTest = (arb: fc.Arbitrary<any>, count = 1) => { | ||
return (name: string, runs: number) => { | ||
test.skipIf(!COLLISION_TEST)(`hash ${name} (x${runs})`, () => { | ||
let collisions = 0; | ||
let identical = 0; | ||
|
||
fc.assert( | ||
fc.property( | ||
fc.array(arb, { minLength: count, maxLength: count }), | ||
fc.array(arb, { minLength: count, maxLength: count }), | ||
(left, right) => { | ||
if (deepEquals(left, right)) { | ||
identical++; | ||
if (VERBOSE) { | ||
console.log("Identical:", left, "=", right); | ||
} | ||
return; | ||
} | ||
|
||
if (VERBOSE) { | ||
console.log("Test:", left, "!==", right); | ||
} | ||
|
||
const leftHash = deepHash(...left); | ||
const rightHash = deepHash(...right); | ||
|
||
if (leftHash === rightHash) { | ||
console.error("FAIL:", left, "===", right); | ||
collisions++; | ||
} | ||
} | ||
), | ||
{ | ||
numRuns: runs, | ||
seed: Date.now(), | ||
ignoreEqualValues: true, | ||
} | ||
); | ||
|
||
console.log( | ||
`Collisions: ${collisions}/${runs - identical}. ` + | ||
`(${identical} identical)` | ||
); | ||
expect(collisions).toBe(0); | ||
}); | ||
}; | ||
}; | ||
|
||
fuzzTest(fc.integer({ min: -25_000_000, max: 25_000_000 }))( | ||
"reasonable integers", | ||
ITERATIONS | ||
); | ||
|
||
fuzzTest(fc.double({ min: -25_000_000, max: 25_000_000 }))( | ||
"reasonable doubles", | ||
ITERATIONS | ||
); | ||
|
||
fuzzTest(fc.string({ minLength: 1, maxLength: 32 }), 10)( | ||
"short strings", | ||
ITERATIONS | ||
); | ||
|
||
fuzzTest(fc.string({ minLength: 32, maxLength: 256 }), 10)( | ||
"long strings", | ||
ITERATIONS | ||
); | ||
|
||
fuzzTest(fc.unicodeString({ minLength: 1, maxLength: 32 }), 10)( | ||
"short unicode strings", | ||
ITERATIONS | ||
); | ||
|
||
fuzzTest(fc.unicodeString({ minLength: 32, maxLength: 256 }), 10)( | ||
"long unicode strings", | ||
ITERATIONS | ||
); | ||
|
||
fuzzTest( | ||
fc.record({ | ||
id: fc.uuid(), | ||
email: fc.emailAddress({ size: "small" }), | ||
username: fc.option(fc.asciiString({ minLength: 1, maxLength: 12 })), | ||
}) | ||
)("user states", ITERATIONS); | ||
|
||
fuzzTest( | ||
fc.array( | ||
fc.record({ | ||
id: fc.uuid(), | ||
text: fc.string({ minLength: 1, maxLength: 32 }), | ||
completed: fc.boolean(), | ||
}) | ||
) | ||
)("todo lists", ITERATIONS); |
Oops, something went wrong.