Skip to content

Commit

Permalink
Fix deep equality and error diffs (#165)
Browse files Browse the repository at this point in the history
Co-authored-by: Krzysztof Kaczor <[email protected]>
  • Loading branch information
sz-piotr and krzkaczor authored Jan 14, 2022
1 parent 05013cd commit 3973df6
Show file tree
Hide file tree
Showing 78 changed files with 2,610 additions and 820 deletions.
5 changes: 5 additions & 0 deletions .changeset/perfect-plants-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'earljs': minor
---

Redo equality algorithm, improve error diffs
220 changes: 220 additions & 0 deletions packages/docs/advanced/equality-algorithm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
---
id: equality-algorithm
title: Equality Algorithm
---

The equality algorithm is an extensible and parameterized deep equality checker.
It is used by all functions that check deep equality inside earl.

## Algorithm

To determine the equality of two values the following checks are executed:

1. If the second value is a [Matcher](/guides/using-matchers.md) and it...
1. matches the first value, then the values are considered equal;
1. doesn't match the first value, then the values are considered unequal.
1. Every [custom equality rule](/advanced/plugin-development.md) is checked.
1. If it returns undefined the next rule is checked or the algorithm
advances.
1. If it returns success, then the values are considered equal.
1. If it returns error, then the values are considered unequal.
1. If any of the values is equal to one of it's ancestors (is a self-referencing
object)...
1. If the other value is equal to it's ancestor of the same distance (e.g.
grandparent and grandparent), then the values are considered equal.
2. Otherwise they are considered unequal.
1. A [category](#categories) is determined for each value based on the rules of
each category.
1. If the values have different categories, then they are considered unequal.
1. If the category is the same the values are compared according to rules of
that category.

Additionally the algorithm takes the following options:

- `uniqueNaNs` - Considers two `NaN` values unequal, defaults to `false`.
- `minusZero` - Considers `+0` and `-0` unequal, defaults to `false`.
- `ignorePrototypes` - Considers `new Vector2(1, 2)` and `{ x: 1, y: 2 }` equal,
defaults to `false`. This option is set to `true` when using
[`.toLooseEqual`](/api/api-reference.md#toLooseEqual-value-any).
- `compareErrorStack` - Compares the stack property of `Error` instances,
defaults to `false`.

## Categories

### Primitive

A value belongs to the Primitive category if `typeof value` returns any of the
following:

- `"undefined"`
- `"boolean"`
- `"number"`
- `"bigint"`
- `"string"`
- `"symbol"`
- `"object"`, but only if the value is `null`

The equality of values belonging to the Primitive category is determined in the
following way:

1. If both values are `NaN` and `uniqueNaNs` is set to `false`, then the values
are considered equal.
1. If `minusZero` is set to `true` then `0` is only considered equal to `0` and
`-0` is only considered equal to `-0`.
1. Otherwise it is determined by the `===` operator.

### Function, Promise, WeakMap, WeakSet

A value belongs to the...

- Function category if `typeof value` returns `"function"`.
- Promise category if `value instanceof Promise` returns `true`.
- WeakMap category if `value instanceof WeakMap` returns `true`.
- WeakSet category if `value instanceof WeakSet` returns `true`.

The equality of values belonging to those categories is determined by the `===`
operator.

### Array

A value belongs to the Array category if `Array.isArray(value)` returns `true`.

The equality of values belonging the the Array category is determined in the
following way:

1. If `ignorePrototypes` is set to `false` and the values have different
prototypes, then they are considered unequal.
1. If the values have different `length`s, then they are considered unequal.
1. The objects are compared using
[recursive object equality](#recursive-object-equality).

### Date, Number, Boolean

A value belongs to the...

- Date category if `value instanceof Date` returns `true`.
- Number category if `value instanceof Number` returns `true`.
- Boolean category if `value instanceof Boolean` returns `true`.

The equality of values belonging to those categories is determined in the
following way:

1. If `ignorePrototypes` is set to `false` and the values have different
prototypes, then they are considered unequal.
1. If `value.valueOf()` returns different values, then they are considered
unequal.
1. The objects are compared using
[recursive object equality](#recursive-object-equality).

### String

A value belongs to the String category if `value instanceof String` returns
`true`.

The equality of values belonging to the String category is determined in the
following way:

1. If `ignorePrototypes` is set to `false` and the values have different
prototypes, then they are considered unequal.
1. If `value.valueOf()` returns different values, then they are considered
unequal.
1. All number keys (e.g. `"1"`) are removed during recursive object equality
check.
1. The objects are compared using
[recursive object equality](#recursive-object-equality).

### RegExp

A value belongs to the RegExp category if `value instanceof RegExp` returns
`true`.

The equality of values belonging to the RegExp category is determined in the
following way:

1. If `ignorePrototypes` is set to `false` and the values have different
prototypes, then they are considered unequal.
1. If `value.toString()` returns different values, then they are considered
unequal.
1. The objects are compared using
[recursive object equality](#recursive-object-equality).

### Error

A value belongs to the Error category if `value instanceof Error` returns
`true`.

The equality of values belonging to the Error category is determined in the
following way:

1. If `ignorePrototypes` is set to `false` and the values have different
prototypes, then they are considered unequal.
1. If `compareErrorStack` is set to `false` then the `"stack"` key is removed
during recursive object equality check.
1. If `compareErrorStack` is set to `true` then the `"stack"` key is added
during recursive object equality check.
1. The `"name"` and `"message"` keys are added during recursive object equality
check.
1. The objects are compared using
[recursive object equality](#recursive-object-equality).

### Set

A value belongs to the Set category if `value instanceof Set` returns `true`.

The equality of values belonging to the Set category is determined in the
following way:

1. If `ignorePrototypes` is set to `false` and the values have different
prototypes, then they are considered unequal.
1. If the values have different `size`s, then they are considered unequal.
1. If `.has` returns `false` for any contained value of the other set, then they
are considered unequal.
1. The objects are compared using
[recursive object equality](#recursive-object-equality).

### Map

A value belongs to the Map category if `value instanceof Map` returns `true`.

The equality of values belonging to the Map category is determined in the
following way:

1. If `ignorePrototypes` is set to `false` and the values have different
prototypes, then they are considered unequal.
1. If the values have different `size`s, then they are considered unequal
1. If `.has` returns `false` for any map key of the other map, then they are
considered unequal.
1. The values of the objects are compared using the
[Equality Algorithm](#algorithm). The objects are considered unequal if any
of their values are considered unequal.
1. The objects are compared using
[recursive object equality](#recursive-object-equality).

### Object

A value belongs to the Object category if it doesn't belong to any other
category.

The equality of values belonging to the Object category is determined in the
following way:

1. If `ignorePrototypes` is set to `false` and the values have different
prototypes, then they are considered unequal.
1. The objects are compared using
[recursive object equality](#recursive-object-equality).

## Recursive object equality

When comparing objects recursively first an array of property keys is obtained
using `Object.keys`:

1. If the category specifies keys to be added to this array they are only added
if they are present on the object as determined by the `in` operator.
1. If the category specifies keys to be removed from this array then they are
only removed if they are already present in the array.
1. The keys of both objects are then sorted and compared. If the number of keys
or any of the keys is different then the objects are considered unequal.

After comparing the keys the properties at those keys are compared using the
[Equality Algorithm](#algorithm). The objects are considered equal if and only
if all of their properties are considered equal.
3 changes: 0 additions & 3 deletions packages/earljs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,11 @@
"dependencies": {
"debug": "^4.1.1",
"jest-snapshot": "^26.6.2",
"lodash": "^4.17.15",
"pretty-format": "^26.6.2",
"ts-essentials": "^6.0.5"
},
"devDependencies": {
"@microsoft/tsdoc": "^0.13.0",
"@types/debug": "^4.1.5",
"@types/lodash": "^4.14.150",
"error-stack-parser": "^2.0.6"
}
}
12 changes: 9 additions & 3 deletions packages/earljs/src/Control.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { AssertionError } from './errors'
import { getTestRunnerIntegration } from './testRunnerCtx'
import { ValidationResult } from './validators/common'

export interface ValidationResult {
success: boolean
hint?: string
reason: string
negatedReason: string
actual?: string
expected?: string
}

export class Control<T> {
public testRunnerCtx = getTestRunnerIntegration()
Expand All @@ -16,7 +24,6 @@ export class Control<T> {
actual: result.actual,
expected: result.expected,
extraMessage: this.extraMessage,
hint: result.hint,
})
}
}
Expand All @@ -28,7 +35,6 @@ export class Control<T> {
actual: result.actual,
expected: result.expected,
extraMessage: this.extraMessage,
hint: result.hint,
})
}
}
2 changes: 1 addition & 1 deletion packages/earljs/src/Expectation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Control } from './Control'
import { ExpectedEqual } from './isEqual/rules'
import { AnythingMatcher } from './matchers/Anything'
import { ErrorMatcher } from './matchers/Error'
import { MockArgs } from './mocks'
Expand All @@ -10,7 +11,6 @@ import { toBeAContainerWith, toBeAnArrayOfLength, toBeAnArrayWith, toBeAnObjectW
import { toBeExhausted, toHaveBeenCalledExactlyWith, toHaveBeenCalledWith } from './validators/mocks'
import { toBeGreaterThan, toBeGreaterThanOrEqualTo, toBeLessThan, toBeLessThanOrEqualTo } from './validators/numbers'
import { toBeDefined, toBeNullish } from './validators/optionals'
import { ExpectedEqual } from './validators/smartEq'
import { toMatchSnapshot } from './validators/snapshots/toMatchSnapshot'
import { toBeA } from './validators/toBeA'
import { toBeRejected } from './validators/toBeRejected'
Expand Down
4 changes: 0 additions & 4 deletions packages/earljs/src/errors/AssertionError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ interface AssertionErrorOptions {
actual?: string
expected?: string
extraMessage?: string
hint?: string
}

/**
Expand All @@ -22,9 +21,6 @@ export class AssertionError extends Error {
if (options.extraMessage) {
message += EOL + 'Extra message: ' + options.extraMessage
}
if (options.hint) {
message += EOL + 'Hint: ' + options.hint
}
super(message)
this.name = 'AssertionError'
this.actual = options.actual
Expand Down
38 changes: 38 additions & 0 deletions packages/earljs/src/format/FormatOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { DEFAULT_EQUALITY_OPTIONS, EqualityOptions, LOOSE_EQUALITY_OPTIONS } from '../isEqual'

export interface FormatOptions extends EqualityOptions {
/**
* Number of spaces added for each indentation level
*/
indentSize: number
/**
* Format inline instead of using indentation
*/
inline: boolean
/**
* Tries to keep the line length under a specific value when inline
*/
maxLineLength: number
/**
* Never replaces matcher with matched contents
*/
skipMatcherReplacement: boolean
/**
* Mark top-level objects that aren't strictly equal as different
*/
requireStrictEquality: boolean
}

export const DEFAULT_FORMAT_OPTIONS: FormatOptions = {
...DEFAULT_EQUALITY_OPTIONS,
indentSize: 2,
inline: false,
maxLineLength: Infinity,
skipMatcherReplacement: false,
requireStrictEquality: false,
}

export const LOOSE_FORMAT_OPTIONS = {
...DEFAULT_FORMAT_OPTIONS,
...LOOSE_EQUALITY_OPTIONS,
}
7 changes: 7 additions & 0 deletions packages/earljs/src/format/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { DEFAULT_FORMAT_OPTIONS } from './FormatOptions'
import { formatUnknown } from './formatUnknown'

export function format(value: unknown, sibling: unknown, options = DEFAULT_FORMAT_OPTIONS): string {
const lines = formatUnknown(value, sibling, options, [], [])
return lines.map(([n, str]) => ' '.repeat(n * options.indentSize) + str).join('\n')
}
Loading

0 comments on commit 3973df6

Please sign in to comment.