diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d08c14f1..e98951c2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node_version: ['10', '12', '14', '15', '16'] + node_version: ['12', '14', '15', '16'] os: [ubuntu-latest] steps: diff --git a/packages/equality/package-lock.json b/packages/equality/package-lock.json index 7282a910..9e29dae6 100644 --- a/packages/equality/package-lock.json +++ b/packages/equality/package-lock.json @@ -9,12 +9,27 @@ "version": "0.5.2", "license": "MIT", "dependencies": { + "@wry/trie": "file:../trie", "tslib": "^2.3.0" }, "engines": { "node": ">=8" } }, + "../trie": { + "version": "0.3.1", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "resolved": "../trie", + "link": true + }, "node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", @@ -22,6 +37,12 @@ } }, "dependencies": { + "@wry/trie": { + "version": "file:../trie", + "requires": { + "tslib": "^2.3.0" + } + }, "tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", diff --git a/packages/equality/package.json b/packages/equality/package.json index a93471b4..93cffa32 100644 --- a/packages/equality/package.json +++ b/packages/equality/package.json @@ -26,6 +26,7 @@ "test": "npm run build && npm run mocha" }, "dependencies": { + "@wry/trie": "file:../trie", "tslib": "^2.3.0" }, "engines": { diff --git a/packages/equality/rollup.config.js b/packages/equality/rollup.config.js index 159e9473..728cf0a5 100644 --- a/packages/equality/rollup.config.js +++ b/packages/equality/rollup.config.js @@ -5,6 +5,7 @@ const globals = { __proto__: null, tslib: "tslib", assert: "assert", + "@wry/trie": "wryTrie", }; function external(id) { diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts new file mode 100644 index 00000000..a407d6b5 --- /dev/null +++ b/packages/equality/src/checker.ts @@ -0,0 +1,328 @@ +import { Trie } from "@wry/trie"; + +import { + definedKeys, + fnToStr, + hasOwn, + isEquatable, + isNativeCode, + isNonNullObject, + isPlainObject, + objToStr, + deepEquals, + DeepEqualsHelper, +} from "./helpers"; + +type Checker = ( + checker: DeepChecker, + a: T, + b: T, + tag: string, +) => boolean; + +const CHECKERS_BY_TAG = new Map>() + .set('[object Array]', checkArrays) + .set('[object Object]', checkObjects) + .set('[object Error]', checkErrors) + + .set('[object Number]', checkNumbers) + .set('[object Boolean]', checkNumbers) + .set('[object Date]', checkNumbers) + + .set('[object RegExp]', checkStringsOrRegExps) + .set('[object String]', checkStringsOrRegExps) + + .set('[object Map]', checkMapsOrSets) + .set('[object Set]', checkMapsOrSets) + + .set('[object Uint16Array]', checkArrayBuffers) + .set('[object Uint8Array]', checkArrayBuffers) + .set('[object Uint32Array]', checkArrayBuffers) + .set('[object Int32Array]', checkArrayBuffers) + .set('[object Int8Array]', checkArrayBuffers) + .set('[object Int16Array]', checkArrayBuffers) + .set('[object ArrayBuffer]', checkArrayBuffers) + // DataView doesn't need the checkArrayBuffers conversions, but the equality + // check is otherwise the same. + .set('[object DataView]', checkBytes) + + .set('[object AsyncFunction]', checkFunctions) + .set('[object GeneratorFunction]', checkFunctions) + .set('[object AsyncGeneratorFunction]', checkFunctions) + .set('[object Function]', checkFunctions); + +type ComparisonTrie = Trie<{ + equal?: boolean; +}>; + +// Initializing checker.comparisons and checker.boundCheck as proper members of +// the DeepChecker class makes creating DeepChecker objects considerably more +// expensive in some environments, even if we initialize them to null and then +// upgrade them lazily, when needed. Instead, we store these two items of state +// in a separate Map, which gets cleaned up in the DeepChecker#release method. +const privateStateMap = new Map(); + +function getPrivateState(checker: DeepChecker) { + let state = privateStateMap.get(checker)!; + if (!state) privateStateMap.set(checker, state = Object.create(null)); + return state; +} + +function getComparisons(checker: DeepChecker): ComparisonTrie { + const state = getPrivateState(checker); + return state.comparisons || (state.comparisons = new Trie(false)); +} + +function getBoundCheck(checker: DeepChecker): DeepEqualsHelper { + const state = getPrivateState(checker); + return state.boundCheck || ( + state.boundCheck = (a, b) => checker.check(a, b) + ); +} + +const checkerPool: DeepChecker[] = []; +const CHECKER_POOL_TARGET_SIZE = 5; + +export class DeepChecker { + // Use DeepChecker.acquire() instead of new DeepChecker. + protected constructor() {} + + static acquire() { + return checkerPool.pop() || new DeepChecker(); + } + + public release() { + // If privateStateMap was a WeakMap, we wouldn't necessarily need to perform + // this cleanup, but not all environments have a (performant) implementation + // of WeakMap, and the cleanup is easy enough: + privateStateMap.delete(this); + + if (checkerPool.length < CHECKER_POOL_TARGET_SIZE) { + checkerPool.push(this); + } + } + + public check(a: any, b: any): boolean { + // If the two values are strictly equal, our job is easy. + if (a === b) { + return true; + } + + // Object.prototype.toString returns a representation of the runtime type of + // the given value that is considerably more precise than typeof. + const aTag = objToStr.call(a); + const bTag = objToStr.call(b); + + // If the runtime types of a and b are different, they could maybe be equal + // under some interpretation of equality, but for simplicity and performance + // we just return false instead. + if (aTag !== bTag) { + return false; + } + + const bothNonNullObjects = + isNonNullObject(a) && + isNonNullObject(b); + + const found = + bothNonNullObjects && + getComparisons(this).lookup(a, b); + + // Though cyclic references can make an object graph appear infinite from + // the perspective of a depth-first traversal, the graph still contains a + // finite number of distinct object references. We use this.comparisons as a + // cache to avoid comparing the same pair of object references more than + // once, which guarantees termination (even if we end up comparing every + // object in one graph to every object in the other graph, which is + // extremely unlikely), while still allowing weird isomorphic structures + // (like rings with different lengths) a chance to pass the equality test. + if (found) { + if (typeof found.equal === "boolean") { + return found.equal; + } + // Although we don't know the actual answer yet, we are about to find out, + // so we can cheat by telling anyone else who asks that a equals b. This + // provisional found.equal trick is important to prevent infinite cycle + // traversals, but does not affect the final answer, since only one + // traversal should be necessary to visit/examine all comparable parts of + // the input objects and determine the correct result. + found.equal = true; + } + + const checker = CHECKERS_BY_TAG.get(aTag); + + const result: boolean = + checker ? checker(this, a, b, aTag) : + bothNonNullObjects ? tryEqualsMethod(this, a, b) : + false; + + if (found) { + found.equal = result; + } + + return result; + } +} + +function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { + return ( + isEquatable(a) && + isEquatable(b) && + a[deepEquals](b, getBoundCheck(checker)) && + // Verify symmetry. If a[deepEquals] is not exactly the same function as + // b[deepEquals], b[deepEquals](a) can legitimately disagree with + // a[deepEquals](b), so we must check both. However, in the common case + // where a[deepEquals] === b[deepEquals], the additional check should be + // redundant, unless that method is itself somehow + // non-commutative/asymmetric. + (a[deepEquals] === b[deepEquals] || + b[deepEquals](a, getBoundCheck(checker))) + ); +} + +function checkArrays(checker: DeepChecker, a: any[], b: any[]): boolean { + const aLen = a.length; + if (aLen !== b.length) return false; + + for (let i = 0; i < aLen; ++i) { + if (!checker.check(a[i], b[i])) { + return false; + } + } + + return true; +} + +function checkObjects(checker: DeepChecker, a: object, b: object): boolean { + if (!isPlainObject(a) || + !isPlainObject(b)) { + return tryEqualsMethod(checker, a, b); + } + + const aKeys = definedKeys(a); + const bKeys = definedKeys(b); + + // If `a` and `b` have a different number of enumerable keys, they + // must be different. + const keyCount = aKeys.length; + if (keyCount !== bKeys.length) return false; + + // Now make sure they have the same keys. + for (let k = 0; k < keyCount; ++k) { + if (!hasOwn.call(b, aKeys[k])) { + return false; + } + } + + // Finally, check deep equality of all child properties. + for (let k = 0; k < keyCount; ++k) { + const key = aKeys[k]; + if (!checker.check(a[key], b[key])) { + return false; + } + } + + return true; +} + +function checkErrors(_: DeepChecker, a: Error, b: Error): boolean { + return a.name === b.name && a.message === b.message; +} + +function checkNumbers(_: DeepChecker, a: number, b: number): boolean { + return a !== a + ? b !== b // Handle NaN, which is !== itself. + : +a === +b; +} + +function checkStringsOrRegExps( + _: DeepChecker, + a: T, + b: T, +): boolean { + return a == `${b}`; +} + +function checkMapsOrSets | Set>( + checker: DeepChecker, + a: T, + b: T, + tag: string, +): boolean { + if (a.size !== b.size) return false; + + const aIterator = a.entries(); + const isMap = tag === '[object Map]'; + + while (true) { + const info = aIterator.next(); + if (info.done) break; + + // If a instanceof Set, aValue === aKey. + const [aKey, aValue] = info.value; + + // So this works the same way for both Set and Map. + if (!b.has(aKey)) { + return false; + } + + // However, we care about deep equality of values only when dealing + // with Map structures. + if (isMap && !checker.check(aValue, (b as Map).get(aKey))) { + return false; + } + } + + return true; +} + +function checkArrayBuffers(checker: DeepChecker, a: ArrayBuffer, b: ArrayBuffer): boolean { + return checkBytes( + checker, + new Uint8Array(a), + new Uint8Array(b), + ); +} + +function checkBytes(_: DeepChecker, a: Uint8Array, b: Uint8Array): boolean { + let len = a.byteLength; + if (len === b.byteLength) { + while (len-- && a[len] === b[len]) { + // Keep looping as long as the bytes are equal. + } + } + return len === -1; +} + +function checkFunctions(_: DeepChecker, a: any, b: any): boolean { + const aCode = fnToStr.call(a); + if (aCode !== fnToStr.call(b)) { + return false; + } + + // We consider non-native functions equal if they have the same code (native + // functions require === because their code is censored). Note that this + // behavior is not entirely sound, since !== function objects with the same + // code can behave differently depending on their closure scope. However, any + // function can behave differently depending on the values of its input + // arguments (including this) and its calling context (including its closure + // scope), even though the function object is === to itself; and it is + // entirely possible for functions that are not === to behave exactly the same + // under all conceivable circumstances. Because none of these factors are + // statically decidable in JavaScript, JS function equality is not + // well-defined. This ambiguity allows us to consider the best possible + // heuristic among various imperfect options, and equating non-native + // functions that have the same code has enormous practical benefits, such as + // when comparing functions that are repeatedly passed as fresh function + // expressions within objects that are otherwise deeply equal. Since any + // function created from the same syntactic expression (in the same code + // location) will always stringify to the same code according to fnToStr.call, + // we can reasonably expect these repeatedly passed function expressions to + // have the same code, and thus behave "the same" (with all the caveats + // mentioned above), even though the runtime function objects are !== to one + // another. + return !isNativeCode(aCode); +} diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index 00b03c43..755c0df3 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -1,218 +1,23 @@ -const { toString, hasOwnProperty } = Object.prototype; -const fnToStr = Function.prototype.toString; -const previousComparisons = new Map>(); +import { DeepChecker } from "./checker"; + +export { + Equatable, + isEquatable, + deepEquals, +} from "./helpers"; /** * Performs a deep equality check on two JavaScript values, tolerating cycles. */ export function equal(a: any, b: any): boolean { + if (a === b) return true; + const checker = DeepChecker.acquire(); try { - return check(a, b); + return checker.check(a, b); } finally { - previousComparisons.clear(); + checker.release(); } } // Allow default imports as well. export default equal; - -function check(a: any, b: any): boolean { - // If the two values are strictly equal, our job is easy. - if (a === b) { - return true; - } - - // Object.prototype.toString returns a representation of the runtime type of - // the given value that is considerably more precise than typeof. - const aTag = toString.call(a); - const bTag = toString.call(b); - - // If the runtime types of a and b are different, they could maybe be equal - // under some interpretation of equality, but for simplicity and performance - // we just return false instead. - if (aTag !== bTag) { - return false; - } - - switch (aTag) { - case '[object Array]': - // Arrays are a lot like other objects, but we can cheaply compare their - // lengths as a short-cut before comparing their elements. - if (a.length !== b.length) return false; - // Fall through to object case... - case '[object Object]': { - if (previouslyCompared(a, b)) return true; - - const aKeys = definedKeys(a); - const bKeys = definedKeys(b); - - // If `a` and `b` have a different number of enumerable keys, they - // must be different. - const keyCount = aKeys.length; - if (keyCount !== bKeys.length) return false; - - // Now make sure they have the same keys. - for (let k = 0; k < keyCount; ++k) { - if (!hasOwnProperty.call(b, aKeys[k])) { - return false; - } - } - - // Finally, check deep equality of all child properties. - for (let k = 0; k < keyCount; ++k) { - const key = aKeys[k]; - if (!check(a[key], b[key])) { - return false; - } - } - - return true; - } - - case '[object Error]': - return a.name === b.name && a.message === b.message; - - case '[object Number]': - // Handle NaN, which is !== itself. - if (a !== a) return b !== b; - // Fall through to shared +a === +b case... - case '[object Boolean]': - case '[object Date]': - return +a === +b; - - case '[object RegExp]': - case '[object String]': - return a == `${b}`; - - case '[object Map]': - case '[object Set]': { - if (a.size !== b.size) return false; - if (previouslyCompared(a, b)) return true; - - const aIterator = a.entries(); - const isMap = aTag === '[object Map]'; - - while (true) { - const info = aIterator.next(); - if (info.done) break; - - // If a instanceof Set, aValue === aKey. - const [aKey, aValue] = info.value; - - // So this works the same way for both Set and Map. - if (!b.has(aKey)) { - return false; - } - - // However, we care about deep equality of values only when dealing - // with Map structures. - if (isMap && !check(aValue, b.get(aKey))) { - return false; - } - } - - return true; - } - - case '[object Uint16Array]': - case '[object Uint8Array]': // Buffer, in Node.js. - case '[object Uint32Array]': - case '[object Int32Array]': - case '[object Int8Array]': - case '[object Int16Array]': - case '[object ArrayBuffer]': - // DataView doesn't need these conversions, but the equality check is - // otherwise the same. - a = new Uint8Array(a); - b = new Uint8Array(b); - // Fall through... - case '[object DataView]': { - let len = a.byteLength; - if (len === b.byteLength) { - while (len-- && a[len] === b[len]) { - // Keep looping as long as the bytes are equal. - } - } - return len === -1; - } - - case '[object AsyncFunction]': - case '[object GeneratorFunction]': - case '[object AsyncGeneratorFunction]': - case '[object Function]': { - const aCode = fnToStr.call(a); - if (aCode !== fnToStr.call(b)) { - return false; - } - - // We consider non-native functions equal if they have the same code - // (native functions require === because their code is censored). - // Note that this behavior is not entirely sound, since !== function - // objects with the same code can behave differently depending on - // their closure scope. However, any function can behave differently - // depending on the values of its input arguments (including this) - // and its calling context (including its closure scope), even - // though the function object is === to itself; and it is entirely - // possible for functions that are not === to behave exactly the - // same under all conceivable circumstances. Because none of these - // factors are statically decidable in JavaScript, JS function - // equality is not well-defined. This ambiguity allows us to - // consider the best possible heuristic among various imperfect - // options, and equating non-native functions that have the same - // code has enormous practical benefits, such as when comparing - // functions that are repeatedly passed as fresh function - // expressions within objects that are otherwise deeply equal. Since - // any function created from the same syntactic expression (in the - // same code location) will always stringify to the same code - // according to fnToStr.call, we can reasonably expect these - // repeatedly passed function expressions to have the same code, and - // thus behave "the same" (with all the caveats mentioned above), - // even though the runtime function objects are !== to one another. - return !endsWith(aCode, nativeCodeSuffix); - } - } - - // Otherwise the values are not equal. - return false; -} - -function definedKeys(obj: TObject) { - // Remember that the second argument to Array.prototype.filter will be - // used as `this` within the callback function. - return Object.keys(obj).filter(isDefinedKey, obj); -} -function isDefinedKey( - this: TObject, - key: keyof TObject, -) { - return this[key] !== void 0; -} - -const nativeCodeSuffix = "{ [native code] }"; - -function endsWith(full: string, suffix: string) { - const fromIndex = full.length - suffix.length; - return fromIndex >= 0 && - full.indexOf(suffix, fromIndex) === fromIndex; -} - -function previouslyCompared(a: object, b: object): boolean { - // Though cyclic references can make an object graph appear infinite from the - // perspective of a depth-first traversal, the graph still contains a finite - // number of distinct object references. We use the previousComparisons cache - // to avoid comparing the same pair of object references more than once, which - // guarantees termination (even if we end up comparing every object in one - // graph to every object in the other graph, which is extremely unlikely), - // while still allowing weird isomorphic structures (like rings with different - // lengths) a chance to pass the equality test. - let bSet = previousComparisons.get(a); - if (bSet) { - // Return true here because we can be sure false will be returned somewhere - // else if the objects are not equivalent. - if (bSet.has(b)) return true; - } else { - previousComparisons.set(a, bSet = new Set); - } - bSet.add(b); - return false; -} diff --git a/packages/equality/src/helpers.ts b/packages/equality/src/helpers.ts new file mode 100644 index 00000000..cf8167b8 --- /dev/null +++ b/packages/equality/src/helpers.ts @@ -0,0 +1,67 @@ +import { DeepChecker } from "./checker"; + +export type DeepEqualsHelper = DeepChecker["check"]; + +export const deepEquals = + Symbol.for("@wry/equality:deepEquals"); + +export interface Equatable { + [deepEquals](that: T, helper: DeepChecker["check"]): boolean; +} + +export function isEquatable(obj: any): obj is Equatable { + return ( + isNonNullObject(obj) && + // Using `in` instead of `hasOwn` because the method could be inherited from + // the prototype chain. + deepEquals in obj + ); +} + +export const fnToStr = Function.prototype.toString; + +export const { + getPrototypeOf, + prototype: { + toString: objToStr, + hasOwnProperty: hasOwn, + }, +} = Object; + +export function isNonNullObject(obj: any): obj is Record { + return obj !== null && typeof obj === "object"; +} + +export function isPlainObject(obj: any): obj is Record { + if (isNonNullObject(obj)) { + const proto = getPrototypeOf(obj); + return proto === null || proto === Object.prototype; + } + return false; +} + +export function definedKeys>(obj: TObject) { + const keys = Object.keys(obj); + const { length } = keys; + let definedCount = 0; + for (let k = 0; k < length; ++k) { + const key = keys[k]; + if (obj[key] !== void 0) { + keys[definedCount++] = key; + } + } + keys.length = definedCount; + return keys; +} + +const nativeCodeSuffix = "{ [native code] }"; + +export function isNativeCode(code: string): boolean { + return endsWith(code, nativeCodeSuffix); +} + +export function endsWith(full: string, suffix: string) { + const fromIndex = full.length - suffix.length; + return fromIndex >= 0 && + full.indexOf(suffix, fromIndex) === fromIndex; +} diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 618efbc1..38098566 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -1,5 +1,11 @@ import assert from "assert"; import defaultEqual, { equal } from "./equality"; +import { + Equatable, + DeepEqualsHelper, + deepEquals, + objToStr, +} from "./helpers"; function toStr(value: any) { try { @@ -10,7 +16,7 @@ function toStr(value: any) { } function assertEqual(a: any, b: any) { - assert.strictEqual(equal(a, b), true, `unexpectedly not equal(${toStr(a)}}, ${toStr(b)})`); + assert.strictEqual(equal(a, b), true, `unexpectedly not equal(${toStr(a)}, ${toStr(b)})`); assert.strictEqual(equal(b, a), true, `unexpectedly not equal(${toStr(b)}, ${toStr(a)})`); } @@ -75,6 +81,32 @@ describe("equality", function () { ); }); + it("should treat array holes the same as undefined elements", function () { + assertEqual( + [void 0], + Array(1), + ); + + assertEqual( + [void 0], + [/*hole*/,], + ); + + assertNotEqual([void 0], []); + assertNotEqual(Array(1), []); + assertNotEqual([/*hole*/,], []); + + assertEqual( + [1, /*hole*/, 3], + [1, void 0, 3], + ); + + assertEqual( + [1, /*hole*/, 3, void 0], + [1, void 0, 3, /*hole*/,], + ); + }); + it("should work for objects", function () { assertEqual({ a: 1, @@ -120,6 +152,174 @@ describe("equality", function () { }, {}); }); + it("should not equate !== objects with custom prototypes", function () { + class Custom { + constructor(public readonly number: number) {} + } + + const c1 = new Custom(1234); + const c2 = new Custom(1234); + const c3 = new Custom(2345); + + assertEqual(Object.keys(c1), ["number"]); + assertEqual(Object.keys(c2), ["number"]); + assertEqual(Object.keys(c3), ["number"]); + + assert.strictEqual(objToStr.call(c1), "[object Object]"); + assert.strictEqual(objToStr.call(c2), "[object Object]"); + assert.strictEqual(objToStr.call(c3), "[object Object]"); + + assertEqual(c1, c1); + assertEqual(c2, c2); + assertEqual(c3, c3); + assertNotEqual(c1, c2); + assertNotEqual(c1, c3); + assertNotEqual(c2, c3); + }); + + it("should respect custom a[deepEquals](b) methods for unknown Symbol.toStringTag", function () { + class Tagged { + [Symbol.toStringTag] = "Tagged"; + + constructor(private value: any) {} + + [deepEquals](that: Tagged) { + return this.value === that.value; + } + } + + const t1a = new Tagged(1); + const t1b = new Tagged(1); + const t2a = new Tagged(2); + const t2b = new Tagged(2); + + assert.strictEqual(objToStr.call(t1a), "[object Tagged]"); + assert.strictEqual(objToStr.call(t2b), "[object Tagged]"); + + assertEqual(t1a, t1b); + assertEqual(t2a, t2b); + + assertNotEqual(t1a, t2a); + assertNotEqual(t1a, t2b); + assertNotEqual(t1b, t2a); + assertNotEqual(t1b, t2b); + }); + + it("should respect asymmetric a[deepEquals](b) methods", function () { + class Point2D implements Equatable { + constructor( + public readonly x: number, + public readonly y: number, + ) {} + + // It's a shame that we have to provide the parameter types explicitly. + [deepEquals](that: Point2D, equal: DeepEqualsHelper) { + return this === that || ( + equal(this.x, that.x) && + equal(this.y, that.y) + ); + } + } + + class Point3D extends Point2D implements Equatable { + constructor( + x: number, + y: number, + public readonly z: number, + ) { + super(x, y); + } + + [deepEquals](that: Point3D, equal: DeepEqualsHelper) { + return this === that || ( + super[deepEquals](that, equal) && + equal(this.z, that.z) + ); + } + } + + const x1y2 = new Point2D(1, 2); + const x2y1 = new Point2D(2, 1); + const x1y2z0 = new Point3D(1, 2, 0); + const x1y2z3 = new Point3D(1, 2, 3); + + assertEqual(x1y2, x1y2); + assertEqual(x2y1, x2y1); + assertEqual(x1y2z0, x1y2z0); + assertEqual(x1y2z3, x1y2z3); + + assert.strictEqual(x1y2[deepEquals](x1y2, equal), true); + assert.strictEqual(x2y1[deepEquals](x2y1, equal), true); + assert.strictEqual(x1y2z0[deepEquals](x1y2z0, equal), true); + assert.strictEqual(x1y2z3[deepEquals](x1y2z3, equal), true); + + assertEqual(x1y2, new Point2D(1, 2)); + assertEqual(x2y1, new Point2D(2, 1)); + assertEqual(x1y2z0, new Point3D(1, 2, 0)); + assertEqual(x1y2z3, new Point3D(1, 2, 3)); + + assertNotEqual(x1y2, x2y1); + assertNotEqual(x1y2, x1y2z3); + assertNotEqual(x2y1, x1y2z0); + assertNotEqual(x2y1, x1y2z3); + assertNotEqual(x1y2z0, x1y2z3); + + // These are the most interesting cases, because x1y2 thinks it's equal to + // both x1y2z0 and x1y2z3, but the equal(a, b) function enforces symmetry. + assertNotEqual(x1y2, x1y2z0); + assert.strictEqual(x1y2[deepEquals](x1y2z0, equal), true); + assert.strictEqual(x1y2[deepEquals](x1y2z3, equal), true); + assert.strictEqual(x1y2z0[deepEquals](x1y2 as Point3D, equal), false); + assert.strictEqual(x1y2z3[deepEquals](x1y2 as Point3D, equal), false); + }); + + it("can check cyclic structures of objects with deepEquals methods", function () { + class Node implements Equatable> { + constructor( + public value: T, + public next?: Node, + ) {} + + static cycle(n: number) { + const head = new Node(n); + let node = head; + while (--n >= 0) { + node = new Node(n, node); + } + return head.next = node; + } + + [deepEquals](that: Node, equal: DeepEqualsHelper) { + return this === that || ( + equal(this.value, that.value) && + equal(this.next, that.next) + ); + } + } + + const cycles = [ + Node.cycle(0), + Node.cycle(1), + Node.cycle(2), + Node.cycle(3), + Node.cycle(4), + ]; + + cycles.forEach((cycleToCheck, i) => { + const sameSizeCycle = Node.cycle(i); + assert.notStrictEqual(cycleToCheck, sameSizeCycle); + assertEqual(cycleToCheck, sameSizeCycle); + + cycles.forEach((otherCycle, j) => { + if (i === j) { + assert.strictEqual(cycleToCheck, otherCycle); + } else { + assertNotEqual(cycleToCheck, otherCycle); + } + }); + }); + }); + it("should work for Error objects", function () { assertEqual(new Error("oyez"), new Error("oyez")); assertNotEqual(new Error("oyez"), new Error("onoz"));