diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index 7e2c0c5d73b1..b1425aa275d0 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -3,7 +3,7 @@ import { GLOBAL_EXPECT } from './constants' import { getState } from './state' import { diff, getMatcherUtils, stringify } from './jest-matcher-utils' -import { equals, isA, iterableEquality, subsetEquality } from './jest-utils' +import { equals, isA, iterableEquality, pluralize, subsetEquality } from './jest-utils' export interface AsymmetricMatcherInterface { asymmetricMatch(other: unknown): boolean @@ -266,6 +266,56 @@ export class StringMatching extends AsymmetricMatcher { } } +class CloseTo extends AsymmetricMatcher { + private readonly precision: number + + constructor(sample: number, precision = 2, inverse = false) { + if (!isA('Number', sample)) + throw new Error('Expected is not a Number') + + if (!isA('Number', precision)) + throw new Error('Precision is not a Number') + + super(sample) + this.inverse = inverse + this.precision = precision + } + + asymmetricMatch(other: number) { + if (!isA('Number', other)) + return false + + let result = false + if (other === Infinity && this.sample === Infinity) { + result = true // Infinity - Infinity is NaN + } + else if (other === -Infinity && this.sample === -Infinity) { + result = true // -Infinity - -Infinity is NaN + } + else { + result + = Math.abs(this.sample - other) < 10 ** -this.precision / 2 + } + return this.inverse ? !result : result + } + + toString() { + return `Number${this.inverse ? 'Not' : ''}CloseTo` + } + + override getExpectedType() { + return 'number' + } + + override toAsymmetricMatcher(): string { + return [ + this.toString(), + this.sample, + `(${pluralize('digit', this.precision)})`, + ].join(' ') + } +} + export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { utils.addMethod( chai.expect, @@ -303,11 +353,18 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { (expected: any) => new StringMatching(expected), ) + utils.addMethod( + chai.expect, + 'closeTo', + (expected: any, precision?: number) => new CloseTo(expected, precision), + ) + // defineProperty does not work ;(chai.expect as any).not = { stringContaining: (expected: string) => new StringContaining(expected, true), objectContaining: (expected: any) => new ObjectContaining(expected, true), arrayContaining: (expected: Array) => new ArrayContaining(expected, true), stringMatching: (expected: string | RegExp) => new StringMatching(expected, true), + closeTo: (expected: any, precision?: number) => new CloseTo(expected, precision, true), } } diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index 6dc0d593a2ff..7a02c3fd99a1 100644 --- a/packages/expect/src/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -446,12 +446,12 @@ export const subsetEquality = ( seenReferences.set(subset[key], true) } const result - = object != null - && hasPropertyInObject(object, key) - && equals(object[key], subset[key], [ - iterableEquality, - subsetEqualityWithContext(seenReferences), - ]) + = object != null + && hasPropertyInObject(object, key) + && equals(object[key], subset[key], [ + iterableEquality, + subsetEqualityWithContext(seenReferences), + ]) // The main goal of using seenReference is to avoid circular node on tree. // It will only happen within a parent and its child, not a node and nodes next to it (same level) // We should keep the reference for a parent and its child only @@ -522,3 +522,7 @@ export const generateToBeMessage = ( return toBeMessage } + +export const pluralize = (word: string, count: number): string => { + return `${count} ${word}${count === 1 ? '' : 's'}` +} diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index bfcd580dfef2..ef3c686093bb 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -62,6 +62,7 @@ declare global { objectContaining(expected: any): any arrayContaining(expected: Array): any stringMatching(expected: string | RegExp): any + closeTo(expected: any, precision?: number): any } interface JestAssertion extends jest.Matchers { diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index ae44403dc0e2..9953c294f7db 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -133,6 +133,29 @@ describe('jest-expect', () => { expect('Mohammad').toEqual(expect.stringMatching(/Moh/)) expect('Mohammad').not.toEqual(expect.stringMatching(/jack/)) + expect({ + title: '0.1 + 0.2', + sum: 0.1 + 0.2, + }).toEqual({ + title: '0.1 + 0.2', + sum: expect.closeTo(0.3, 5), + }) + + expect({ + title: '0.1 + 0.2', + sum: 0.1 + 0.2, + }).not.toEqual({ + title: '0.1 + 0.2', + sum: expect.closeTo(0.4, 5), + }) + + expect({ + title: '0.1 + 0.2', + sum: 0.1 + 0.2, + }).toEqual({ + title: '0.1 + 0.2', + sum: expect.not.closeTo(0.4, 5), + }) // TODO: support set // expect(new Set(['bar'])).not.toEqual(new Set([expect.stringContaining('zoo')]))