Skip to content

Commit

Permalink
Allow structural regions in equality for testing & update vitest to 1…
Browse files Browse the repository at this point in the history
….5.3 (#2670)

Co-authored-by: Michael Arnaldi <michael.arnaldi@effectful.co>
  • Loading branch information
tim-smart and mikearnaldi committed May 1, 2024
1 parent 81c8bff commit e5e56d1
Show file tree
Hide file tree
Showing 16 changed files with 783 additions and 683 deletions.
5 changes: 5 additions & 0 deletions .changeset/flat-lobsters-hunt.md
@@ -0,0 +1,5 @@
---
"@effect/vitest": minor
---

Introduce `addEqualityTesters` function which adds a custom vitest tester
5 changes: 5 additions & 0 deletions .changeset/loud-llamas-provide.md
@@ -0,0 +1,5 @@
---
"effect": patch
---

Allow structural regions in equality for testing
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -52,6 +52,7 @@
"@typescript-eslint/parser": "^7.8.0",
"@vitest/browser": "^1.5.2",
"@vitest/coverage-v8": "^1.5.2",
"@vitest/expect": "^1.5.3",
"@vitest/web-worker": "^1.5.2",
"babel-plugin-annotate-pure-calls": "^0.4.0",
"eslint": "^8.57.0",
Expand All @@ -70,7 +71,7 @@
"tsx": "^4.7.3",
"typescript": "^5.4.5",
"vite": "^5.2.10",
"vitest": "^1.5.2"
"vitest": "^1.5.3"
},
"pnpm": {
"patchedDependencies": {
Expand Down
39 changes: 30 additions & 9 deletions packages/effect/src/Equal.ts
Expand Up @@ -4,6 +4,7 @@
import type { Equivalence } from "./Equivalence.js"
import * as Hash from "./Hash.js"
import { hasProperty } from "./Predicate.js"
import { structuralRegionState } from "./Utils.js"

/**
* @since 2.0.0
Expand Down Expand Up @@ -32,24 +33,44 @@ export function equals(): any {
return compareBoth(arguments[0], arguments[1])
}

function compareBoth(self: unknown, that: unknown) {
function compareBoth(self: unknown, that: unknown): boolean {
if (self === that) {
return true
}
const selfType = typeof self
if (selfType !== typeof that) {
return false
}
if (
(selfType === "object" || selfType === "function") &&
self !== null &&
that !== null
) {
if (isEqual(self) && isEqual(that)) {
return Hash.hash(self) === Hash.hash(that) && self[symbol](that)
if (selfType === "object" || selfType === "function") {
if (self !== null && that !== null) {
if (isEqual(self) && isEqual(that)) {
return Hash.hash(self) === Hash.hash(that) && self[symbol](that)
}
}
if (structuralRegionState.enabled) {
if (Array.isArray(self) && Array.isArray(that)) {
return self.length === that.length && self.every((v, i) => compareBoth(v, that[i]))
}
if (Object.getPrototypeOf(self) === Object.prototype && Object.getPrototypeOf(self) === Object.prototype) {
const keysSelf = Object.keys(self as any)
const keysThat = Object.keys(that as any)
if (keysSelf.length === keysThat.length) {
for (const key of keysSelf) {
// @ts-expect-error
if (!(key in that && compareBoth(self[key], that[key]))) {
return structuralRegionState.tester ? structuralRegionState.tester(self, that) : false
}
}
return true
}
}
return structuralRegionState.tester ? structuralRegionState.tester(self, that) : false
}
}
return false

return structuralRegionState.enabled && structuralRegionState.tester
? structuralRegionState.tester(self, that)
: false
}

/**
Expand Down
43 changes: 30 additions & 13 deletions packages/effect/src/Hash.ts
Expand Up @@ -4,7 +4,7 @@
import { pipe } from "./Function.js"
import { globalValue } from "./GlobalValue.js"
import { hasProperty } from "./Predicate.js"
import { PCGRandom } from "./Utils.js"
import { PCGRandom, structuralRegionState } from "./Utils.js"

/** @internal */
const randomHashCache = globalValue(
Expand Down Expand Up @@ -72,6 +72,9 @@ export const hash: <A>(self: A) => number = <A>(self: A) => {
* @category hashing
*/
export const random: <A extends object>(self: A) => number = (self) => {
if (structuralRegionState.enabled === true) {
return 0
}
if (!randomHashCache.has(self)) {
randomHashCache.set(self, number(pcgr.integer(Number.MAX_SAFE_INTEGER)))
}
Expand Down Expand Up @@ -168,22 +171,36 @@ export const cached: {
if (arguments.length === 1) {
const self = arguments[0] as object
return function(hash: number) {
Object.defineProperty(self, symbol, {
value() {
return hash
},
enumerable: false
})
// @ts-expect-error
const original = self[symbol].bind(self)
if (structuralRegionState.enabled === false) {
Object.defineProperty(self, symbol, {
value() {
if (structuralRegionState.enabled === true) {
return original()
}
return hash
},
enumerable: false
})
}
return hash
} as any
}
const self = arguments[0] as object
const hash = arguments[1] as number
Object.defineProperty(self, symbol, {
value() {
return hash
},
enumerable: false
})
// @ts-expect-error
const original = self[symbol].bind(self)
if (structuralRegionState.enabled === false) {
Object.defineProperty(self, symbol, {
value() {
if (structuralRegionState.enabled === true) {
return original()
}
return hash
},
enumerable: false
})
}
return hash
}
38 changes: 38 additions & 0 deletions packages/effect/src/Utils.ts
Expand Up @@ -2,6 +2,7 @@
* @since 2.0.0
*/
import { identity } from "./Function.js"
import { globalValue } from "./GlobalValue.js"
import type { Kind, TypeLambda } from "./HKT.js"
import { getBugErrorMessage } from "./internal/errors.js"
import { isNullable, isObject } from "./Predicate.js"
Expand Down Expand Up @@ -746,3 +747,40 @@ export function yieldWrapGet<T>(self: YieldWrap<T>): T {
}
throw new Error(getBugErrorMessage("yieldWrapGet"))
}

/**
* Note: this is an experimental feature made available to allow custom matchers in tests, not to be directly used yet in user code
*
* @since 3.1.1
* @status experimental
* @category modifiers
*/
export const structuralRegionState = globalValue(
"effect/Utils/isStructuralRegion",
(): { enabled: boolean; tester: ((a: unknown, b: unknown) => boolean) | undefined } => ({
enabled: false,
tester: undefined
})
)

/**
* Note: this is an experimental feature made available to allow custom matchers in tests, not to be directly used yet in user code
*
* @since 3.1.1
* @status experimental
* @category modifiers
*/
export const structuralRegion = <A>(body: () => A, tester?: (a: unknown, b: unknown) => boolean): A => {
const current = structuralRegionState.enabled
const currentTester = structuralRegionState.tester
structuralRegionState.enabled = true
if (tester) {
structuralRegionState.tester = tester
}
try {
return body()
} finally {
structuralRegionState.enabled = current
structuralRegionState.tester = currentTester
}
}
24 changes: 20 additions & 4 deletions packages/effect/src/internal/core.ts
Expand Up @@ -197,10 +197,18 @@ class EffectPrimitiveFailure {
this._tag = _op
}
[Equal.symbol](this: {}, that: unknown) {
return this === that
return exitIsExit(that) && that._op === "Failure" &&
// @ts-expect-error
Equal.equals(this.effect_instruction_i0, that.effect_instruction_i0)
}
[Hash.symbol](this: {}) {
return Hash.cached(this, Hash.random(this))
return pipe(
// @ts-expect-error
Hash.string(this._tag),
// @ts-expect-error
Hash.combine(Hash.hash(this.effect_instruction_i0)),
Hash.cached(this)
)
}
get cause() {
return this.effect_instruction_i0
Expand Down Expand Up @@ -238,10 +246,18 @@ class EffectPrimitiveSuccess {
this._tag = _op
}
[Equal.symbol](this: {}, that: unknown) {
return this === that
return exitIsExit(that) && that._op === "Success" &&
// @ts-expect-error
Equal.equals(this.effect_instruction_i0, that.effect_instruction_i0)
}
[Hash.symbol](this: {}) {
return Hash.cached(this, Hash.random(this))
return pipe(
// @ts-expect-error
Hash.string(this._tag),
// @ts-expect-error
Hash.combine(Hash.hash(this.effect_instruction_i0)),
Hash.cached(this)
)
}
get value() {
return this.effect_instruction_i0
Expand Down
10 changes: 10 additions & 0 deletions packages/effect/test/Either.test.ts
Expand Up @@ -364,4 +364,14 @@ describe("Either", () => {
Either.left("d")
)
})

it("vitest equality", () => {
expect(Either.right(1)).toStrictEqual(Either.right(1))
expect(Either.left(1)).toStrictEqual(Either.left(1))

expect(Either.right(2)).not.toStrictEqual(Either.right(1))
expect(Either.left(2)).not.toStrictEqual(Either.left(1))
expect(Either.left(1)).not.toStrictEqual(Either.right(1))
expect(Either.left(1)).not.toStrictEqual(Either.right(2))
})
})
15 changes: 15 additions & 0 deletions packages/effect/test/Exit.test.ts
@@ -1,3 +1,4 @@
import * as Cause from "effect/Cause"
import * as Exit from "effect/Exit"
import { describe, expect, it } from "vitest"

Expand Down Expand Up @@ -73,4 +74,18 @@ describe("Exit", () => {
}`)
})
})

it("vitest equality", () => {
expect(Exit.succeed(1)).toEqual(Exit.succeed(1))
expect(Exit.fail("failure")).toEqual(Exit.fail("failure"))
expect(Exit.die("defect")).toEqual(Exit.die("defect"))

expect(Exit.succeed(1)).not.toEqual(Exit.succeed(2))
expect(Exit.fail("failure")).not.toEqual(Exit.fail("failure1"))
expect(Exit.die("failure")).not.toEqual(Exit.fail("failure1"))
expect(Exit.die("failure")).not.toEqual(Exit.fail("failure1"))
expect(Exit.failCause(Cause.sequential(Cause.fail("f1"), Cause.fail("f2")))).not.toEqual(
Exit.failCause(Cause.sequential(Cause.fail("f1"), Cause.fail("f3")))
)
})
})
27 changes: 27 additions & 0 deletions packages/effect/test/Hash.test.ts
@@ -1,9 +1,36 @@
import * as Equal from "effect/Equal"
import { absurd, identity } from "effect/Function"
import * as Hash from "effect/Hash"
import * as HashSet from "effect/HashSet"
import * as Option from "effect/Option"
import * as Utils from "effect/Utils"
import { describe, expect, it } from "vitest"

describe("Hash", () => {
it("structural", () => {
const a = { foo: { bar: "ok", baz: { arr: [0, 1, 2] } } }
const b = { foo: { bar: "ok", baz: { arr: [0, 1, 2] } } }
expect(Hash.hash(a)).not.toBe(Hash.hash(b))
expect(Equal.equals(a, b)).toBe(false)
Utils.structuralRegion(() => {
expect(Hash.hash(a)).toBe(Hash.hash(b))
expect(Equal.equals(a, b)).toBe(true)
})
expect(Hash.hash(a)).not.toBe(Hash.hash(b))
expect(Equal.equals(a, b)).toBe(false)
})
it("structural cached", () => {
const a = Option.some({ foo: { bar: "ok", baz: { arr: [0, 1, 2] } } })
const b = Option.some({ foo: { bar: "ok", baz: { arr: [0, 1, 2] } } })
expect(Hash.hash(a)).not.toBe(Hash.hash(b))
expect(Equal.equals(a, b)).toBe(false)
Utils.structuralRegion(() => {
expect(Hash.hash(a)).toBe(Hash.hash(b))
expect(Equal.equals(a, b)).toBe(true)
})
expect(Hash.hash(a)).not.toBe(Hash.hash(b))
expect(Equal.equals(a, b)).toBe(false)
})
it("exports", () => {
expect(Hash.string).exist
expect(Hash.structureKeys).exist
Expand Down
8 changes: 8 additions & 0 deletions packages/effect/test/Option.test.ts
Expand Up @@ -496,4 +496,12 @@ describe("Option", () => {
expect(f(_.none(), _.some(2))).toStrictEqual(_.none())
expect(f(_.some(1), _.some(2))).toStrictEqual(_.some(3))
})

it("vitest equality", () => {
expect(_.some(2)).toStrictEqual(_.some(2))
expect(_.none()).toStrictEqual(_.none())

expect(_.some(2)).not.toStrictEqual(_.some(1))
expect(_.none()).not.toStrictEqual(_.some(1))
})
})
2 changes: 1 addition & 1 deletion packages/vitest/package.json
Expand Up @@ -33,6 +33,6 @@
},
"devDependencies": {
"effect": "workspace:^",
"vitest": "^1.5.2"
"vitest": "^1.5.3"
}
}
14 changes: 14 additions & 0 deletions packages/vitest/src/index.ts
@@ -1,15 +1,18 @@
/**
* @since 1.0.0
*/
import type { TesterContext } from "@vitest/expect"
import * as Duration from "effect/Duration"
import * as Effect from "effect/Effect"
import * as Equal from "effect/Equal"
import { pipe } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Logger from "effect/Logger"
import * as Schedule from "effect/Schedule"
import type * as Scope from "effect/Scope"
import * as TestEnvironment from "effect/TestContext"
import type * as TestServices from "effect/TestServices"
import * as Utils from "effect/Utils"
import type { TestAPI } from "vitest"
import * as V from "vitest"

Expand All @@ -21,6 +24,17 @@ export type API = TestAPI<{}>
const TestEnv = TestEnvironment.TestContext.pipe(
Layer.provide(Logger.remove(Logger.defaultLogger))
)
/** @internal */
function customTester(this: TesterContext, a: unknown, b: unknown) {
return Utils.structuralRegion(() => Equal.equals(a, b), (x, y) => this.equals(x, y))
}

/**
* @since 1.0.0
*/
export const addEqualityTesters = () => {
V.expect.addEqualityTesters([customTester])
}

/**
* @since 1.0.0
Expand Down

0 comments on commit e5e56d1

Please sign in to comment.