diff --git a/package.json b/package.json index 9a20d512ed..bb87105f13 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@typescript-eslint/parser": "^7.7.0", "@vitest/browser": "^1.5.0", "@vitest/coverage-v8": "^1.5.0", + "@vitest/expect": "^1.5.0", "@vitest/web-worker": "^1.5.0", "babel-plugin-annotate-pure-calls": "^0.4.0", "eslint": "^8.57.0", diff --git a/packages/effect/src/HashMap.ts b/packages/effect/src/HashMap.ts index 81621f77e4..61e1d82d56 100644 --- a/packages/effect/src/HashMap.ts +++ b/packages/effect/src/HashMap.ts @@ -3,6 +3,7 @@ */ import type { Equal } from "./Equal.js" +import type { Equivalence } from "./Equivalence.js" import type { HashSet } from "./HashSet.js" import type { Inspectable } from "./Inspectable.js" import * as HM from "./internal/hashMap.js" @@ -419,3 +420,11 @@ export const findFirst: { (self: HashMap, predicate: (a: A, k: K) => a is B): Option<[K, B]> (self: HashMap, predicate: (a: A, k: K) => boolean): Option<[K, A]> } = HM.findFirst + +/** + * @category equivalence + * @since 3.1.0 + */ +export const getEquivalence: ( + equivalences: { key: Equivalence; value: Equivalence } +) => Equivalence> = HM.getEquivalence diff --git a/packages/effect/src/HashSet.ts b/packages/effect/src/HashSet.ts index eaf50e25e5..3252ac5a34 100644 --- a/packages/effect/src/HashSet.ts +++ b/packages/effect/src/HashSet.ts @@ -3,6 +3,7 @@ */ import type { Equal } from "./Equal.js" +import type { Equivalence } from "./Equivalence.js" import type { Inspectable } from "./Inspectable.js" import * as HS from "./internal/hashSet.js" import type { Pipeable } from "./Pipeable.js" @@ -298,3 +299,11 @@ export const partition: { ): [excluded: HashSet>, satisfying: HashSet] (self: HashSet, predicate: Predicate): [excluded: HashSet, satisfying: HashSet] } = HS.partition + +/** + * @category equivalence + * @since 3.1.0 + */ +export const getEquivalence: ( + equivalence: Equivalence +) => Equivalence> = HS.getEquivalence diff --git a/packages/effect/src/internal/hashMap.ts b/packages/effect/src/internal/hashMap.ts index 07353303ce..1fc80439d8 100644 --- a/packages/effect/src/internal/hashMap.ts +++ b/packages/effect/src/internal/hashMap.ts @@ -1,4 +1,5 @@ import * as Equal from "../Equal.js" +import * as Equivalence from "../Equivalence.js" import * as Dual from "../Function.js" import { identity, pipe } from "../Function.js" import * as Hash from "../Hash.js" @@ -538,3 +539,33 @@ export const findFirst: { return Option.none() } ) + +/** @internal */ +export const getEquivalence = ( + { key, value }: { key: Equivalence.Equivalence; value: Equivalence.Equivalence } +): Equivalence.Equivalence> => { + // TODO: + return Equivalence.make((self, that) => { + if (size(that) !== size(self)) { + return false + } + + for (const [k1, v1] of self) { + let found = false + for (const [k2, v2] of that) { + if (key(k1, k2)) { + if (!value(v1, v2)) { + return false + } + found = true + break + } + } + if (!found) { + return false + } + } + + return true + }) +} diff --git a/packages/effect/src/internal/hashSet.ts b/packages/effect/src/internal/hashSet.ts index 95b8875778..6ee9ff1ba4 100644 --- a/packages/effect/src/internal/hashSet.ts +++ b/packages/effect/src/internal/hashSet.ts @@ -1,4 +1,5 @@ import * as Equal from "../Equal.js" +import * as Equivalence from "../Equivalence.js" import { dual } from "../Function.js" import * as Hash from "../Hash.js" import type { HashMap } from "../HashMap.js" @@ -320,3 +321,12 @@ export const partition: { } return [endMutation(left), endMutation(right)] }) + +/** @internal */ +export const getEquivalence = ( + equivalence: Equivalence.Equivalence +): Equivalence.Equivalence> => { + const hmEq = HM.getEquivalence({ key: equivalence, value: () => true }) + + return Equivalence.make((self, that) => hmEq((self as HashSetImpl)._keyMap, (that as HashSetImpl)._keyMap)) +} diff --git a/packages/effect/test/HashMap.test.ts b/packages/effect/test/HashMap.test.ts index acd4ef9ddc..84f9965fca 100644 --- a/packages/effect/test/HashMap.test.ts +++ b/packages/effect/test/HashMap.test.ts @@ -425,4 +425,29 @@ describe("HashMap", () => { expect(HM.findFirst(map1, (v, _k) => v.s === "bb")).toStrictEqual(Option.some([key(1), value("bb")])) expect(HM.findFirst(map1, (v, k) => k.n === 0 && v.s === "bb")).toStrictEqual(Option.none()) }) + + it("equivalence", () => { + const eq = HM.getEquivalence({ key: Equal.equals, value: Equal.equals }) + + expect(eq(HM.empty(), HM.empty())).toBe(true) + expect(eq(HM.empty(), HM.make([1, 1]))).toBe(false) + expect(eq(HM.make([1, 1]), HM.make([1, 1]))).toBe(true) + expect(eq(HM.make([1, 1], [2, 2]), HM.make([2, 2], [1, 1]))).toBe(true) + expect(eq(HM.make([1, 1]), HM.make([1, 2]))).toBe(false) + expect(eq(HM.make([1, 1]), HM.make([1, 1], [2, 2]))).toBe(false) + }) + + it("vitest equality", () => { + expect(HM.empty()).toStrictEqual(HM.empty()) + expect(HM.make([1, 1])).toStrictEqual(HM.make([1, 1])) + expect(HM.make([[1, 2], 2])).toStrictEqual(HM.make([[1, 2], 2])) + expect(HM.make([1, [1, 2]])).toStrictEqual(HM.make([1, [1, 2]])) + + expect(HM.empty()).not.toStrictEqual(HM.make([1, 1])) + expect(HM.make([1, 1])).not.toStrictEqual(HM.make([2, 2])) + expect(HM.make([1, 1])).not.toStrictEqual(HM.make([1, 1], [2, 2])) + expect(HM.make([1, 1])).not.toStrictEqual(HM.make([1, 2])) + expect(HM.make([[1, 1], 2])).not.toStrictEqual(HM.make([[1, 2], 2])) + expect(HM.make([1, [1, 1]])).not.toStrictEqual(HM.make([1, [1, 2]])) + }) }) diff --git a/packages/effect/test/HashSet.test.ts b/packages/effect/test/HashSet.test.ts index 5a3ebd0468..cb519a6ea0 100644 --- a/packages/effect/test/HashSet.test.ts +++ b/packages/effect/test/HashSet.test.ts @@ -228,4 +228,21 @@ describe("HashSet", () => { expect(HashSet.isHashSet(null)).toBe(false) expect(HashSet.isHashSet({})).toBe(false) }) + + it("equivalence", () => { + const eq = HashSet.getEquivalence(Equal.equals) + expect(eq(HashSet.empty(), HashSet.empty())).toBe(true) + expect(eq(HashSet.empty(), HashSet.make(1))).toBe(false) + expect(eq(HashSet.make(1), HashSet.make(1))).toBe(true) + expect(eq(HashSet.make(2, 1), HashSet.make(1, 2))).toBe(true) + expect(eq(HashSet.make(1, 2), HashSet.make(1, 2, 3))).toBe(false) + }) + + it("vitest equality", () => { + expect(HashSet.empty()).toStrictEqual(HashSet.empty()) + expect(HashSet.make(1)).toStrictEqual(HashSet.make(1)) + + expect(HashSet.empty()).not.toStrictEqual(HashSet.make(1)) + expect(HashSet.make(1)).not.toStrictEqual(HashSet.make(1, 2)) + }) }) diff --git a/packages/vitest/src/index.ts b/packages/vitest/src/index.ts index ff155f15aa..8720594b16 100644 --- a/packages/vitest/src/index.ts +++ b/packages/vitest/src/index.ts @@ -1,11 +1,17 @@ /** * @since 1.0.0 */ +import type { Tester, TesterContext } from "@vitest/expect" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" +import * as Either from "effect/Either" +import type * as Equivalence from "effect/Equivalence" import { pipe } from "effect/Function" +import * as HashMap from "effect/HashMap" +import * as HashSet from "effect/HashSet" import * as Layer from "effect/Layer" import * as Logger from "effect/Logger" +import * as Option from "effect/Option" import * as Schedule from "effect/Schedule" import type * as Scope from "effect/Scope" import * as TestEnvironment from "effect/TestContext" @@ -22,6 +28,55 @@ const TestEnv = TestEnvironment.TestContext.pipe( Layer.provide(Logger.remove(Logger.defaultLogger)) ) +/** @internal */ +const makeCustomTesters = (equivalence: Equivalence.Equivalence): ReadonlyArray< + { is: (u: unknown) => boolean; equals: (a: any, b: any) => boolean } +> => [ + { + is: Option.isOption, + equals: Option.getEquivalence(equivalence) + }, + { + is: Either.isEither, + equals: Either.getEquivalence({ left: equivalence, right: equivalence }) + }, + { + is: HashSet.isHashSet, + equals: HashSet.getEquivalence(equivalence) + }, + { + is: HashMap.isHashMap, + equals: HashMap.getEquivalence({ key: equivalence, value: equivalence }) + } +] + +/** @internal */ +function customTester(this: TesterContext, a: unknown, b: unknown, customTesters: Array) { + const testers = makeCustomTesters((a, b) => this.equals(a, b, customTesters)) + + for (const { equals, is } of testers) { + const isA = is(a) + const isB = is(b) + + if (isA && isB) { + return equals(a, b) + } + + if (isA || isB) { + return false + } + } + + return undefined +} + +/** + * @since 1.0.0 + */ +export const addEqualityTesters = () => { + V.expect.addEqualityTesters([customTester]) +} + /** * @since 1.0.0 */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13876e2899..4925565fb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: '@vitest/coverage-v8': specifier: ^1.5.0 version: 1.5.0(vitest@1.5.0(@edge-runtime/vm@3.2.0)(@types/node@20.12.7)(@vitest/browser@1.5.0)(happy-dom@14.7.1)(terser@5.30.3)) + '@vitest/expect': + specifier: ^1.5.0 + version: 1.5.2 '@vitest/web-worker': specifier: ^1.5.0 version: 1.5.0(vitest@1.5.0(@edge-runtime/vm@3.2.0)(@types/node@20.12.7)(@vitest/browser@1.5.0)(happy-dom@14.7.1)(terser@5.30.3)) @@ -2633,6 +2636,9 @@ packages: '@vitest/expect@1.5.0': resolution: {integrity: sha512-0pzuCI6KYi2SIC3LQezmxujU9RK/vwC1U9R0rLuGlNGcOuDWxqWKu6nUdFsX9tH1WU0SXtAxToOsEjeUn1s3hA==} + '@vitest/expect@1.5.2': + resolution: {integrity: sha512-rf7MTD1WCoDlN3FfYJ9Llfp0PbdtOMZ3FIF0AVkDnKbp3oiMW1c8AmvRZBcqbAhDUAvF52e9zx4WQM1r3oraVA==} + '@vitest/runner@1.5.0': resolution: {integrity: sha512-7HWwdxXP5yDoe7DTpbif9l6ZmDwCzcSIK38kTSIt6CFEpMjX4EpCgT6wUmS0xTXqMI6E/ONmfgRKmaujpabjZQ==} @@ -2642,9 +2648,15 @@ packages: '@vitest/spy@1.5.0': resolution: {integrity: sha512-vu6vi6ew5N5MMHJjD5PoakMRKYdmIrNJmyfkhRpQt5d9Ewhw9nZ5Aqynbi3N61bvk9UvZ5UysMT6ayIrZ8GA9w==} + '@vitest/spy@1.5.2': + resolution: {integrity: sha512-xCcPvI8JpCtgikT9nLpHPL1/81AYqZy1GCy4+MCHBE7xi8jgsYkULpW5hrx5PGLgOQjUpb6fd15lqcriJ40tfQ==} + '@vitest/utils@1.5.0': resolution: {integrity: sha512-BDU0GNL8MWkRkSRdNFvCUCAVOeHaUlVJ9Tx0TYBZyXaaOTmGtUFObzchCivIBrIwKzvZA7A9sCejVhXM2aY98A==} + '@vitest/utils@1.5.2': + resolution: {integrity: sha512-sWOmyofuXLJ85VvXNsroZur7mOJGiQeM0JN3/0D1uU8U9bGFM69X1iqHaRXl6R8BwaLY6yPCogP257zxTzkUdA==} + '@vitest/web-worker@1.5.0': resolution: {integrity: sha512-WxX5VAgp8knJGTZknTDUICAVtNiDsBDQN1z/ldsDFqhRUFp09WLhRxWnKqCQc+CXk1W+kpUZmWDjxaD3i8QBmA==} peerDependencies: @@ -8660,6 +8672,12 @@ snapshots: '@vitest/utils': 1.5.0 chai: 4.4.1 + '@vitest/expect@1.5.2': + dependencies: + '@vitest/spy': 1.5.2 + '@vitest/utils': 1.5.2 + chai: 4.4.1 + '@vitest/runner@1.5.0': dependencies: '@vitest/utils': 1.5.0 @@ -8676,6 +8694,10 @@ snapshots: dependencies: tinyspy: 2.2.1 + '@vitest/spy@1.5.2': + dependencies: + tinyspy: 2.2.1 + '@vitest/utils@1.5.0': dependencies: diff-sequences: 29.6.3 @@ -8683,6 +8705,13 @@ snapshots: loupe: 2.3.7 pretty-format: 29.7.0 + '@vitest/utils@1.5.2': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + '@vitest/web-worker@1.5.0(vitest@1.5.0(@edge-runtime/vm@3.2.0)(@types/node@20.12.7)(@vitest/browser@1.5.0)(happy-dom@14.7.1)(terser@5.30.3))': dependencies: debug: 4.3.4 diff --git a/setupTests.ts b/setupTests.ts index 47e9260714..28e90c2b99 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1,30 +1,3 @@ -import { equals } from "@vitest/expect" -import { expect } from "vitest" +import * as effectVitest from "@effect/vitest" -// workaround for https://github.com/vitest-dev/vitest/issues/5620 - -function hasIterator(object: any) { - return !!(object !== null && object[Symbol.iterator]) -} - -expect.addEqualityTesters([(a: unknown, b: unknown) => { - if ( - typeof a !== "object" || - typeof b !== "object" || - Array.isArray(a) || - Array.isArray(b) || - !hasIterator(a) || - !hasIterator(b) || - a === null || - b === null - ) { - return undefined - } - - const aEntries = Object.entries(a) - const bEntries = Object.entries(b) - - if (!equals(aEntries, bEntries)) return false - - return undefined -}]) +effectVitest.addEqualityTesters()