diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml new file mode 100644 index 0000000..423e065 --- /dev/null +++ b/.github/workflows/test-coverage.yml @@ -0,0 +1,43 @@ +name: Test Coverage + +on: + push: + branches: + - master + workflow_dispatch: + +env: + HUSKY: 0 + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - id: pnpm-store + run: echo value=$(pnpm store path) >> $GITHUB_OUTPUT + + - id: pnpm-cache + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.value }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests with coverage + run: pnpm test:coverage + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + path: coverage + retention-days: 30 diff --git a/.github/workflows/test-package.yml b/.github/workflows/test-package.yml index 9e62114..e0bb1aa 100644 --- a/.github/workflows/test-package.yml +++ b/.github/workflows/test-package.yml @@ -6,6 +6,10 @@ on: env: HUSKY: 0 +permissions: + contents: read + pull-requests: write + jobs: test: runs-on: ubuntu-latest @@ -29,5 +33,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run tests - run: pnpm test + - name: Run tests with coverage + run: pnpm test:coverage + + - name: Report Coverage + uses: davelosert/vitest-coverage-report-action@v2 diff --git a/README.md b/README.md index 6f4dd32..5a2a233 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![NPM](https://img.shields.io/npm/v/@iamsquare/complex.js.svg?style=flat-square)](https://www.npmjs.com/package/@iamsquare/complex.js) [![GitHub issues](https://img.shields.io/github/issues-raw/iamsquare/complex.js.svg?style=flat-square)](https://github.com/iamsquare/complex.js/issues) [![GitHub License](https://img.shields.io/github/license/mashape/apistatus.svg?style=flat-square)](https://github.com/iamsquare/complex.js/blob/master/LICENSE) +[![Coverage](https://img.shields.io/github/actions/workflow/status/iamsquare/complex.js/test-coverage.yml?label=coverage&style=flat-square)](https://github.com/iamsquare/complex.js/actions/workflows/test-coverage.yml) [![NPM](https://nodei.co/npm/@iamsquare/complex.js.png?mini=true)](https://nodei.co/npm/@iamsquare/complex.js) > A powerful, type-safe complex numbers library for JavaScript and TypeScript. Works seamlessly in browsers and Node.js. @@ -17,6 +18,7 @@ - **Trigonometric Functions** - sin, cos, tan, sec, csc, cot and their inverses - **Hyperbolic Functions** - sinh, cosh, tanh, sech, csch, coth and their inverses - **Mathematical Functions** - Exponentiation, logarithms, powers, square roots +- **Numerically Stable** - Uses robust floating-point comparisons (combining absolute and relative error) and stable algorithms to handle precision errors - **TypeScript Support** - Full type definitions included, no `@types` package needed - **Universal** - Works in both browsers and Node.js - **Zero Dependencies** - Lightweight and fast diff --git a/docusaurus/docs/intro.md b/docusaurus/docs/intro.md index cdb6516..ec0ee01 100644 --- a/docusaurus/docs/intro.md +++ b/docusaurus/docs/intro.md @@ -12,6 +12,7 @@ sidebar_position: 1 - **Trigonometric Functions** - sin, cos, tan, sec, csc, cot and their inverses - **Hyperbolic Functions** - sinh, cosh, tanh, sech, csch, coth and their inverses - **Mathematical Functions** - Exponentiation, logarithms, powers, square roots +- **Numerically Stable** - Uses robust floating-point comparisons (combining absolute and relative error) and stable algorithms to handle precision errors - **TypeScript Support** - Full type definitions included, no `@types` package needed - **Universal** - Works in both browsers and Node.js - **Zero Dependencies** - Lightweight and fast @@ -154,7 +155,7 @@ const conj = conjugate(z); // 3 - 4i // Unit vector const unitVec = unit(z); // 0.6 + 0.8i -// Equality checks +// Equality checks (uses epsilon-based comparison for floating-point precision) const isEqual = equals(z, new Complex(3, 4)); // true const isRealNum = isReal(z); // false const isZeroNum = isZero(z); // false @@ -193,6 +194,7 @@ Complex.EPSILON; // Machine epsilon - [`unit(z)`](/operations/utility#unit) - Unit vector - [`equals(z, w)`](/operations/utility#equals) - Equality check - [`notEquals(z, w)`](/operations/utility#notequals) - Inequality check +- [`isApproximatelyEqual(a, b, epsilon?)`](/operations/utility#isapproximatelyequal) - Compare floating-point numbers ### Type Checking diff --git a/docusaurus/docs/operations/arithmetic.md b/docusaurus/docs/operations/arithmetic.md index 78541c0..211a7d5 100644 --- a/docusaurus/docs/operations/arithmetic.md +++ b/docusaurus/docs/operations/arithmetic.md @@ -168,7 +168,10 @@ console.log(realProduct.toString()); // => "5 + 10i" Divides two complex numbers or a complex number by a real number: $\frac{z}{w}$. Uses a [modified Smith's Method](http://forge.scilab.org/index.php/p/compdiv/source/tree/21/doc/improved_cdiv.pdf) -to avoid numerical overflow and underflow issues in complex division. +to avoid numerical overflow and underflow issues in complex division. The implementation uses +numerically stable addition and subtraction algorithms, along with scaling techniques to handle +extreme values, to further improve precision and robustness. + Also accepts real numbers, which are treated as complex numbers with zero imaginary part. ```typescript diff --git a/docusaurus/docs/operations/type-checking.md b/docusaurus/docs/operations/type-checking.md index c39e7e0..dd52104 100644 --- a/docusaurus/docs/operations/type-checking.md +++ b/docusaurus/docs/operations/type-checking.md @@ -6,10 +6,15 @@ sidebar_position: 3 Type checking operations help identify special properties of complex numbers. +All comparison operations use epsilon-based floating-point comparison that combines absolute and relative error to handle precision errors robustly. + ## isReal Checks if a complex number is a real number (has zero imaginary part). +A complex number is real if its imaginary part is approximately zero (within epsilon tolerance), +meaning it lies on the real axis. + ```typescript isReal(z: Complex): boolean ``` @@ -20,7 +25,7 @@ isReal(z: Complex): boolean ### Returns -`true` if the number is real (imaginary part is zero), `false` otherwise. +`true` if the number is real (imaginary part is approximately zero), `false` otherwise. ### Example @@ -39,8 +44,8 @@ console.log(isReal(w)); // => false Checks if a complex number is purely imaginary (has zero real part and non-zero imaginary part). -A complex number is purely imaginary if its real part is exactly zero and its imaginary part is non-zero. -Note that zero (0 + 0i) is not considered purely imaginary. +A complex number is purely imaginary if its real part is approximately zero (within epsilon tolerance) +and its imaginary part is not approximately zero. Note that zero (0 + 0i) is not considered purely imaginary. ```typescript isPureImaginary(z: Complex): boolean @@ -52,7 +57,7 @@ isPureImaginary(z: Complex): boolean ### Returns -`true` if z is purely imaginary (real part is zero and imaginary part is non-zero), `false` otherwise. +`true` if z is purely imaginary (real part is approximately zero and imaginary part is not approximately zero), `false` otherwise. ### Example @@ -73,7 +78,10 @@ console.log(isPureImaginary(z3)); // => false (zero is not purely imaginary) ## isZero -Checks if a complex number is zero. +Checks if a complex number is zero: $z = 0$. + +A complex number is zero if both its real and imaginary parts are approximately zero +(within epsilon tolerance). ```typescript isZero(z: Complex): boolean @@ -85,7 +93,7 @@ isZero(z: Complex): boolean ### Returns -`true` if the number is zero (both real and imaginary parts are zero), `false` otherwise. +`true` if the number is zero (both real and imaginary parts are approximately zero), `false` otherwise. ### Example diff --git a/docusaurus/docs/operations/utility.md b/docusaurus/docs/operations/utility.md index 2d6085d..1f2cbe4 100644 --- a/docusaurus/docs/operations/utility.md +++ b/docusaurus/docs/operations/utility.md @@ -137,9 +137,16 @@ console.log(argument(u)); // => same as argument(z) Checks if two complex numbers are equal: $z = w$. -Uses floating-point comparison with epsilon tolerance (Complex.EPSILON) to account -for floating-point precision errors. Two numbers are considered equal if both their -real and imaginary parts differ by at most Complex.EPSILON. +Uses a robust floating-point comparison that combines absolute and relative error +to account for floating-point precision errors. For values near zero, uses absolute error: +$|a - b| < \epsilon$. For values away from zero, uses relative error: +$|a - b| < \epsilon \cdot \max(|a|, |b|)$. Two numbers are considered equal if both their +real and imaginary parts are approximately equal using this method. + +**Special cases:** + +- Two infinite numbers are considered equal. +- NaN is never equal to anything, including itself. ```typescript equals(z: Complex, w: Complex): boolean @@ -165,6 +172,10 @@ console.log(equals(z1, z2)); // => true const z3 = new Complex(1.0000001, 2); console.log(equals(z1, z3)); // => true (within epsilon) + +const z4 = Complex.INFINITY; +const z5 = Complex.INFINITY; +console.log(equals(z4, z5)); // => true ``` --- @@ -200,3 +211,50 @@ console.log(notEquals(z1, z2)); // => true const z3 = new Complex(1, 2); console.log(notEquals(z1, z3)); // => false ``` + +--- + +## isApproximatelyEqual + +Checks if two floating-point numbers are approximately equal using a combination +of absolute and relative error. This is more robust than simple epsilon comparison. + +This is the underlying function used by [`equals`](#equals) and other comparison operations. +For values near zero, uses absolute error: $|a - b| < \epsilon$. +For values away from zero, uses relative error: $|a - b| < \epsilon \cdot \max(|a|, |b|)$. + +```typescript +isApproximatelyEqual(a: number, b: number, epsilon?: number): boolean +``` + +### Parameters + +- `a` - First number +- `b` - Second number +- `epsilon` - Maximum allowed error (defaults to `Complex.EPSILON`) + +### Returns + +`true` if the numbers are approximately equal (within epsilon tolerance), `false` otherwise. + +### Special Cases + +- Returns `false` if either number is NaN +- Returns `true` if both numbers are the same infinite value (positive or negative) +- Returns `false` if one is infinite and the other is not + +### Example + +```typescript +import { isApproximatelyEqual, Complex } from '@iamsquare/complex.js'; + +// Handle floating-point precision errors +console.log(isApproximatelyEqual(0.1 + 0.2, 0.3)); // => true + +// Custom epsilon tolerance +console.log(isApproximatelyEqual(1, 1.0001, 0.001)); // => true +console.log(isApproximatelyEqual(1, 1.0001, 0.00001)); // => false + +// Different numbers +console.log(isApproximatelyEqual(1, 2)); // => false +``` diff --git a/src/complex.ts b/src/complex.ts index 89d6100..e6b2244 100644 --- a/src/complex.ts +++ b/src/complex.ts @@ -1,6 +1,3 @@ -// TODO: test and fix eventual precision errors -// TODO: add rounding function - import { type Cartesian, isCartesian, isPolar, type Polar } from '~/helpers'; import { isPureImaginary } from '~/operations'; import { argument } from '~/operations/argument'; @@ -190,7 +187,7 @@ export class Complex { const sign = this.im > 0 ? ' + ' : ' - '; - return `${this.re}${sign}${this.im} i`; + return `${this.re}${sign}${Math.abs(this.im)} i`; } /** diff --git a/src/helpers.ts b/src/helpers.ts index 8adac20..dc74f07 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,3 +1,5 @@ +import { Complex } from '~/complex'; + const isNumber = (x: unknown): x is number => typeof x === 'number'; const isNullish = (x: unknown): x is null | undefined => x === undefined || x === null; const isObject = (x: unknown): x is Record => typeof x === 'object' && x !== null; @@ -82,13 +84,13 @@ export function addStable(x: number, y: number): number { const xAbs = Math.abs(x); const yAbs = Math.abs(y); - // If magnitudes are similar, add directly - if (xAbs === 0) return y; - if (yAbs === 0) return x; + const xIsZero = isApproximatelyEqual(xAbs, 0); + const yIsZero = isApproximatelyEqual(yAbs, 0); + + if (xIsZero) return yIsZero ? 0 : y; + if (yIsZero) return xIsZero ? 0 : x; - // If magnitudes are very different (ratio < 0.1), add smaller to larger - // Otherwise, add directly (they're similar enough) - return Math.min(xAbs, yAbs) / Math.max(xAbs, yAbs) < 0.1 ? (xAbs < yAbs ? y + x : x + y) : x + y; + return Math.min(xAbs, yAbs) < 0.1 * Math.max(xAbs, yAbs) ? (xAbs < yAbs ? y + x : x + y) : x + y; } /** @@ -105,16 +107,16 @@ export function subtractStable(x: number, y: number): number { const xAbs = Math.abs(x); const yAbs = Math.abs(y); - if (xAbs === 0) return y === 0 ? 0 : -y; - if (yAbs === 0) return x; + const xIsZero = isApproximatelyEqual(xAbs, 0); + const yIsZero = isApproximatelyEqual(yAbs, 0); + + if (xIsZero) return yIsZero ? 0 : -y; + if (yIsZero) return xIsZero ? 0 : x; const minAbs = Math.min(xAbs, yAbs); const maxAbs = Math.max(xAbs, yAbs); - // If magnitudes are similar, use stable subtraction by subtracting a common value - if (minAbs / maxAbs > 0.5) { - // Choose the value with smaller absolute magnitude as the common value to subtract - // This minimizes the magnitude of the intermediate results + if (minAbs > 0.5 * maxAbs) { const m = xAbs < yAbs ? x : y; return x - m - (y - m); @@ -122,3 +124,37 @@ export function subtractStable(x: number, y: number): number { return x - y; } + +/** + * Checks if two floating-point numbers are approximately equal using a combination + * of absolute and relative error. This is more robust than simple epsilon comparison. + * + * For values near zero, uses absolute error: |a - b| < ε + * For values away from zero, uses relative error: |a - b| < ε · max(|a|, |b|) + * + * @param a - First number + * @param b - Second number + * @param epsilon - Maximum allowed error (defaults to Complex.EPSILON) + * @returns True if numbers are approximately equal + * + * @example + * ```typescript + * isApproximatelyEqual(0.1 + 0.2, 0.3); // => true + * isApproximatelyEqual(1, 1.0001, 0.001); // => true + * isApproximatelyEqual(1, 2); // => false + * ``` + */ +export function isApproximatelyEqual(a: number, b: number, epsilon: number = Complex.EPSILON): boolean { + if (Number.isNaN(a) || Number.isNaN(b)) return false; + if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b; + + if (a === b) return true; + + const absA = Math.abs(a); + const absB = Math.abs(b); + const maxAbs = Math.max(absA, absB); + + if (maxAbs < 1) return Math.abs(a - b) < epsilon; + + return Math.abs(a - b) < epsilon * maxAbs; +} diff --git a/src/operations/add.ts b/src/operations/add.ts index 3fe85d9..f808f98 100644 --- a/src/operations/add.ts +++ b/src/operations/add.ts @@ -26,10 +26,5 @@ export function add(z: Complex, w: Complex) { } if (isInfinite(z) || isInfinite(w)) return Complex.INFINITY; - const a = z.getRe(); - const b = z.getIm(); - const c = w.getRe(); - const d = w.getIm(); - - return new Complex(addStable(a, c), addStable(b, d)); + return new Complex(addStable(z.getRe(), w.getRe()), addStable(z.getIm(), w.getIm())); } diff --git a/src/operations/divide.ts b/src/operations/divide.ts index 4c651eb..8820e31 100644 --- a/src/operations/divide.ts +++ b/src/operations/divide.ts @@ -1,14 +1,17 @@ import { Complex } from '~/complex'; -import { addStable, subtractStable } from '~/helpers'; +import { isApproximatelyEqual } from '~/helpers'; import { isInfinite } from '~/operations/isInfinite'; import { isNaNC } from '~/operations/isNaNC'; import { isZero } from '~/operations/isZero'; +import { multiply } from '~/operations/multiply'; /** * Divides two complex numbers or a complex number by a real number: z / w. * - * Uses a [modified Smith's Method](http://forge.scilab.org/index.php/p/compdiv/source/tree/21/doc/improved_cdiv.pdf) - * to avoid numerical overflow and underflow issues in complex division. + * Uses a [modified Smith's Method](http://www.finetune.co.jp/~lyuka/technote/cdiv/cdiv.html) + * to avoid numerical overflow and underflow issues in complex division. The implementation uses + * numerically stable addition and subtraction algorithms, along with scaling techniques to handle + * extreme values, to further improve precision and robustness. * Also accepts real numbers, which are treated as complex numbers with zero imaginary part. * * @param z - The complex number to divide (dividend). @@ -26,8 +29,6 @@ import { isZero } from '~/operations/isZero'; * const realQuotient = divide(z, 2); * console.log(realQuotient.toString()); // => "0.5 + 1i" * ``` - * - * @todo Test if this implementation is actually SO better than the original Smith's method. */ export function divide(z: Complex | number, w: Complex | number) { const zc = z instanceof Complex ? z : new Complex(z, 0); @@ -36,33 +37,50 @@ export function divide(z: Complex | number, w: Complex | number) { if ((isZero(zc) && isZero(wc)) || (isInfinite(zc) && isInfinite(wc)) || isNaNC(zc) || isNaNC(wc)) { return Complex.NAN; } - if (isInfinite(zc) || isZero(wc)) return Complex.INFINITY; if (isZero(zc) || isInfinite(wc)) return Complex.ZERO; - const a = zc.getRe(); - const b = zc.getIm(); - const c = wc.getRe(); - const d = wc.getIm(); + const overflowBoundary = Number.MAX_VALUE / 2; + const underflowBoundary = (Number.MIN_VALUE * 2) / Number.EPSILON; + const scalingFactor = 2 / (Number.EPSILON * Number.EPSILON); - let r; - let t; + const AB = Math.max(Math.abs(zc.getRe()), Math.abs(zc.getIm())); + const CD = Math.max(Math.abs(wc.getRe()), Math.abs(wc.getIm())); - if (Math.abs(d) < Math.abs(c)) { - r = d / c; - t = 1 / addStable(c, d * r); + const xScaleDown = AB >= overflowBoundary ? 2 : 1; + const yScaleDown = CD >= overflowBoundary ? 0.5 : 1; + const xScaleUp = AB < underflowBoundary ? 1 / scalingFactor : 1; + const yScaleUp = CD < underflowBoundary ? scalingFactor : 1; - if (r === 0) { - return new Complex(addStable(a, d * (b / c)) * t, subtractStable(b, d * (a / c)) * t); - } - return new Complex(addStable(a, b * r) * t, subtractStable(b, a * r) * t); - } + const scaledX = multiply(zc, xScaleDown * xScaleUp); + const scaledY = multiply(wc, yScaleDown * yScaleUp); + const scale = xScaleDown * yScaleDown * xScaleUp * yScaleUp; + + if (Math.abs(scaledY.getIm()) <= Math.abs(scaledY.getRe())) { + const r = scaledY.getIm() / scaledY.getRe(); + const t = scaledY.getRe() + scaledY.getIm() * r; - r = c / d; - t = 1 / addStable(c * r, d); + return multiply( + isApproximatelyEqual(r, 0) + ? new Complex( + (scaledX.getRe() + scaledY.getIm() * (scaledX.getIm() / scaledY.getRe())) / t, + (scaledX.getIm() - scaledY.getIm() * (scaledX.getRe() / scaledY.getRe())) / t, + ) + : new Complex((scaledX.getRe() + scaledX.getIm() * r) / t, (scaledX.getIm() - scaledX.getRe() * r) / t), + scale, + ); + } else { + const r = scaledY.getRe() / scaledY.getIm(); + const t = scaledY.getRe() * r + scaledY.getIm(); - if (r === 0) { - return new Complex(addStable(c * (a / d), b) * t, subtractStable(c * (b / d), a) * t); + return multiply( + isApproximatelyEqual(r, 0) + ? new Complex( + (scaledY.getRe() * (scaledX.getRe() / scaledY.getIm()) + scaledX.getIm()) / t, + (scaledY.getRe() * (scaledX.getIm() / scaledY.getIm()) - scaledX.getRe()) / t, + ) + : new Complex((scaledX.getRe() * r + scaledX.getIm()) / t, (scaledX.getIm() * r - scaledX.getRe()) / t), + scale, + ); } - return new Complex(addStable(a * r, b) * t, subtractStable(b * r, a) * t); } diff --git a/src/operations/equals.ts b/src/operations/equals.ts index b2eb13b..2a3beed 100644 --- a/src/operations/equals.ts +++ b/src/operations/equals.ts @@ -1,13 +1,20 @@ -import { Complex } from '~/complex'; +import { type Complex } from '~/complex'; +import { isApproximatelyEqual } from '~/helpers'; import { isInfinite } from '~/operations/isInfinite'; import { isNaNC } from '~/operations/isNaNC'; /** - * Checks if two complex numbers are equal: z === w. + * Checks if two complex numbers are equal: z = w. * - * Uses floating-point comparison with epsilon tolerance (Complex.EPSILON) to account - * for floating-point precision errors. Two numbers are considered equal if both their - * real and imaginary parts differ by at most Complex.EPSILON. + * Uses a robust floating-point comparison that combines absolute and relative error + * to account for precision errors. For values near zero, uses absolute error: + * |a - b| < ε. For values away from zero, uses relative error: + * |a - b| < ε · max(|a|, |b|). Two numbers are considered equal if both their + * real and imaginary parts are approximately equal using this method. + * + * Special cases: + * - Two infinite numbers are considered equal. + * - NaN is never equal to anything, including itself. * * @param z - The first complex number. * @param w - The second complex number. @@ -27,11 +34,5 @@ export function equals(z: Complex, w: Complex) { if (isInfinite(z) && isInfinite(w)) return true; if (isNaNC(z) || isNaNC(w)) return false; - const a = z.getRe(); - const b = z.getIm(); - const c = w.getRe(); - const d = w.getIm(); - - // TODO: check if it should be done like this - return Math.abs(a - c) <= Complex.EPSILON && Math.abs(b - d) <= Complex.EPSILON; + return isApproximatelyEqual(z.getRe(), w.getRe()) && isApproximatelyEqual(z.getIm(), w.getIm()); } diff --git a/src/operations/isInfinite.ts b/src/operations/isInfinite.ts index 8e88649..759a89f 100644 --- a/src/operations/isInfinite.ts +++ b/src/operations/isInfinite.ts @@ -2,7 +2,7 @@ import type { Complex } from '~/complex'; import { isNaNC } from '~/operations/isNaNC'; /** - * Checks if a complex number is infinite: z === ∞. + * Checks if a complex number is infinite: z = ∞. * * A complex number is infinite if either its real or imaginary part is infinite * (and it is not NaN). diff --git a/src/operations/isNaNC.ts b/src/operations/isNaNC.ts index deb0023..e65dcc5 100644 --- a/src/operations/isNaNC.ts +++ b/src/operations/isNaNC.ts @@ -1,7 +1,7 @@ import type { Complex } from '~/complex'; /** - * Checks if a complex number is NaN: z === NaN. + * Checks if a complex number is NaN: z = NaN. * * A complex number is NaN if either its real or imaginary part is NaN. * diff --git a/src/operations/isPureImaginary.ts b/src/operations/isPureImaginary.ts index 90ad85b..67de5d2 100644 --- a/src/operations/isPureImaginary.ts +++ b/src/operations/isPureImaginary.ts @@ -1,26 +1,24 @@ import type { Complex } from '~/complex'; +import { isApproximatelyEqual } from '~/helpers'; /** * Checks if a complex number is purely imaginary (has zero real part and non-zero imaginary part). * - * A complex number is purely imaginary if its real part is exactly zero and its imaginary part is non-zero. - * Note that zero (0 + 0i) is not considered purely imaginary. + * A complex number is purely imaginary if its real part is approximately zero (within epsilon tolerance) + * and its imaginary part is not approximately zero. Note that zero (0 + 0i) is not considered purely imaginary. * * @param z - The complex number to check. - * @returns `true` if z is purely imaginary (real part is zero and imaginary part is non-zero), `false` otherwise. + * @returns `true` if z is purely imaginary, `false` otherwise. * * @example * ```typescript * const z1 = new Complex(0, 5); * console.log(isPureImaginary(z1)); // => true * - * const z2 = new Complex(3, 5); + * const z2 = new Complex(0, 0); * console.log(isPureImaginary(z2)); // => false - * - * const z3 = new Complex(0, 0); - * console.log(isPureImaginary(z3)); // => false (zero is not purely imaginary) * ``` */ export function isPureImaginary(z: Complex) { - return z.getRe() === 0 && z.getIm() !== 0; + return isApproximatelyEqual(z.getRe(), 0) && !isApproximatelyEqual(z.getIm(), 0); } diff --git a/src/operations/isReal.ts b/src/operations/isReal.ts index 62fe8fd..3b25b22 100644 --- a/src/operations/isReal.ts +++ b/src/operations/isReal.ts @@ -1,12 +1,14 @@ import type { Complex } from '~/complex'; +import { isApproximatelyEqual } from '~/helpers'; /** * Checks if a complex number is a real number (has zero imaginary part). * - * A complex number is real if its imaginary part is exactly zero, meaning it lies on the real axis. + * A complex number is real if its imaginary part is approximately zero (within epsilon tolerance), + * meaning it lies on the real axis. * * @param z - The complex number to check. - * @returns `true` if z is a real number (imaginary part is zero), `false` otherwise. + * @returns `true` if z is a real number, `false` otherwise. * * @example * ```typescript @@ -18,5 +20,5 @@ import type { Complex } from '~/complex'; * ``` */ export function isReal(z: Complex) { - return z.getIm() === 0; + return isApproximatelyEqual(z.getIm(), 0); } diff --git a/src/operations/isZero.ts b/src/operations/isZero.ts index 3a1e8fa..1a33af9 100644 --- a/src/operations/isZero.ts +++ b/src/operations/isZero.ts @@ -1,9 +1,11 @@ -import type { Complex } from '~/complex'; +import { Complex } from '~/complex'; +import { equals } from '~/operations/equals'; /** - * Checks if a complex number is zero: z === 0. + * Checks if a complex number is zero: z = 0. * - * A complex number is zero if both its real and imaginary parts are exactly zero. + * A complex number is zero if both its real and imaginary parts are approximately zero + * (within epsilon tolerance). * * @param z - The complex number to check. * @returns `true` if z is zero (0 + 0i), `false` otherwise. @@ -18,5 +20,5 @@ import type { Complex } from '~/complex'; * ``` */ export function isZero(z: Complex) { - return z.getRe() === 0 && z.getIm() === 0; + return equals(z, Complex.ZERO); } diff --git a/src/operations/notEquals.ts b/src/operations/notEquals.ts index 02643d5..f30eb2d 100644 --- a/src/operations/notEquals.ts +++ b/src/operations/notEquals.ts @@ -2,7 +2,7 @@ import type { Complex } from '~/complex'; import { equals } from '~/operations/equals'; /** - * Checks if two complex numbers are not equal: z !== w. + * Checks if two complex numbers are not equal: z ≠ w. * * This is the logical negation of the `equals` function. * diff --git a/src/operations/sum.ts b/src/operations/sum.ts index 139fa1e..d41f2fe 100644 --- a/src/operations/sum.ts +++ b/src/operations/sum.ts @@ -49,5 +49,5 @@ export function sum(...numbers: Complex[]) { if (numbers.some(isNaNC)) return Complex.NAN; if (numbers.some(isInfinite)) return Complex.INFINITY; - return numbers.reduce((acc, num) => add(acc, num), Complex.ZERO); + return numbers.reduce(add, Complex.ZERO); } diff --git a/tests/complex.spec.ts b/tests/complex.spec.ts index 93cfa43..a04d184 100644 --- a/tests/complex.spec.ts +++ b/tests/complex.spec.ts @@ -1,105 +1,481 @@ import { describe, expect, test } from 'vitest'; -import { type Cartesian, isCartesian, isPolar, type Polar } from '~/helpers'; -import { Complex } from '~/index'; +import { Complex } from '~/complex'; +import { type Cartesian, type Polar } from '~/helpers'; +import { expectComplexCloseTo, expectPolarCloseTo } from '~/tests/utils/test-utils'; -const ONE = Complex.ONE; const ZERO = Complex.ZERO; +const ONE = Complex.ONE; +const I = Complex.I; const INFINITY = Complex.INFINITY; const NAN = Complex.NAN; -describe('Complex constructor', () => { - test('Should Exist', () => { - expect(Complex).toBeDefined(); - }); +describe('Complex', () => { + describe('Constructor', () => { + describe('from numeric arguments', () => { + test('new Complex() creates zero', () => { + const z = new Complex(); + + expect(z).toEqual(ZERO); + expect(z.getRe()).toBe(0); + expect(z.getIm()).toBe(0); + }); + + test('new Complex(re) creates real number', () => { + const z = new Complex(5); + + expect(z.getRe()).toBe(5); + expect(z.getIm()).toBe(0); + }); + + test('new Complex(re, im) creates complex number', () => { + const z = new Complex(3, 4); + + expect(z.getRe()).toBe(3); + expect(z.getIm()).toBe(4); + }); - describe('Value is Complex', () => { - test('new Complex(z: Complex) => re: z.re, im: z.im', () => { - const z: Complex = new Complex(1, 0); - const w: Complex = new Complex(z); + test('new Complex(re, im) handles negative values', () => { + const z = new Complex(-5, -3); - expect(w).toEqual(z); + expect(z.getRe()).toBe(-5); + expect(z.getIm()).toBe(-3); + }); + + test('new Complex(re, im) handles decimal values', () => { + const z = new Complex(0.1, 0.2); + + expect(z.getRe()).toBe(0.1); + expect(z.getIm()).toBe(0.2); + }); + + test('new Complex(re, im) handles very small values', () => { + expectComplexCloseTo(new Complex(1e-15, 2e-15), new Complex(1e-15, 2e-15)); + }); + + test('new Complex(re, im) handles very large values', () => { + expectComplexCloseTo(new Complex(1e15, 2e15), new Complex(1e15, 2e15)); + }); }); - }); - describe('Value is a number', () => { - test('new Complex() => re: 0, im: 0', () => { - const z: Complex = new Complex(); + describe('from Complex instance', () => { + test('new Complex(z) creates copy', () => { + const z = new Complex(3, 4); + const w = new Complex(z); + + expect(w).toEqual(z); + expect(w).not.toBe(z); + expect(w.getRe()).toBe(3); + expect(w.getIm()).toBe(4); + }); + + test('new Complex(z) copies special values correctly', () => { + expect(new Complex(INFINITY)).toEqual(INFINITY); + expect(new Complex(NAN)).toEqual(NAN); + expect(new Complex(ZERO)).toEqual(ZERO); + expect(new Complex(ONE)).toEqual(ONE); + expect(new Complex(I)).toEqual(I); + }); + }); + + describe('from Cartesian coordinates', () => { + test('new Complex({x, y}) creates complex number', () => { + const cart: Cartesian = { x: 3, y: 4 }; + const z = new Complex(cart); + + expect(z.getRe()).toBe(3); + expect(z.getIm()).toBe(4); + }); + + test('new Complex({x, y}) handles negative values', () => { + const cart: Cartesian = { x: -5, y: -3 }; + const z = new Complex(cart); + + expect(z.getRe()).toBe(-5); + expect(z.getIm()).toBe(-3); + }); + + test('new Complex({x, y}) handles zero', () => { + const cart: Cartesian = { x: 0, y: 0 }; + + expect(new Complex(cart)).toEqual(ZERO); + }); + }); + + describe('from Polar coordinates', () => { + test('new Complex({r, p}) converts to Cartesian correctly', () => { + const polar: Polar = { r: 1, p: Math.PI / 2 }; + + expectComplexCloseTo(new Complex(polar), new Complex(0, 1)); + }); + + test('new Complex({r, p}) handles zero radius', () => { + const polar: Polar = { r: 0, p: 0 }; + + expect(new Complex(polar)).toEqual(ZERO); + }); + + test('new Complex({r, p}) handles unit circle', () => { + const angles = [0, Math.PI / 4, Math.PI / 2, Math.PI, -Math.PI / 2]; + const expected = [ + new Complex(1, 0), + new Complex(Math.SQRT2 / 2, Math.SQRT2 / 2), + new Complex(0, 1), + new Complex(-1, 0), + new Complex(0, -1), + ]; + + angles.forEach((angle, i) => { + expectComplexCloseTo(new Complex({ r: 1, p: angle }), expected[i]); + }); + }); + + test('new Complex({r, p}) handles different quadrants', () => { + const testCases = [ + { r: Math.SQRT2, p: Math.PI / 4, expected: new Complex(1, 1) }, + { r: Math.SQRT2, p: (3 * Math.PI) / 4, expected: new Complex(-1, 1) }, + { r: Math.SQRT2, p: -(3 * Math.PI) / 4, expected: new Complex(-1, -1) }, + { r: Math.SQRT2, p: -Math.PI / 4, expected: new Complex(1, -1) }, + ]; - expect(z).toEqual(ZERO); + testCases.forEach(({ r, p, expected }) => { + expectComplexCloseTo(new Complex({ r, p }), expected); + }); + }); }); - test('new Complex(1) => re: 1, im: 0', () => { - const z: Complex = new Complex(1); + describe('NaN handling', () => { + test('new Complex(NaN) creates NAN', () => { + expect(new Complex(NaN)).toEqual(NAN); + }); - expect(z).toEqual(ONE); + test('new Complex(NaN, NaN) creates NAN', () => { + expect(new Complex(NaN, NaN)).toEqual(NAN); + }); + + test('new Complex(NaN, value) creates NAN', () => { + expect(new Complex(NaN, 5)).toEqual(NAN); + }); + + test('new Complex(value, NaN) creates NAN', () => { + expect(new Complex(5, NaN)).toEqual(NAN); + }); }); - test('new Complex(1, 1) => re: 1, im: 0', () => { - const z: Complex = new Complex(1, 1); + describe('Infinity handling', () => { + test('new Complex(Infinity) creates INFINITY', () => { + expect(new Complex(Infinity)).toEqual(INFINITY); + }); + + test('new Complex(Infinity, Infinity) creates INFINITY', () => { + expect(new Complex(Infinity, Infinity)).toEqual(INFINITY); + }); + + test('new Complex(Infinity, value) creates INFINITY', () => { + expect(new Complex(Infinity, 5)).toEqual(INFINITY); + }); - expect(z.getRe()).toEqual(1); - expect(z.getIm()).toEqual(1); + test('new Complex(value, Infinity) creates INFINITY', () => { + expect(new Complex(5, Infinity)).toEqual(INFINITY); + }); + + test('new Complex(-Infinity) creates INFINITY', () => { + expect(new Complex(-Infinity)).toEqual(INFINITY); + }); + + test('new Complex(-Infinity, -Infinity) creates INFINITY', () => { + expect(new Complex(-Infinity, -Infinity)).toEqual(INFINITY); + }); }); }); - describe('Special inputs', () => { - const cartesian: Cartesian = { x: 1, y: 1 }; - const polar: Polar = { r: 1, p: Math.PI / 2 }; - describe('Value is a Polar coordinate', () => { - test('isCartesian({r: number, p: number})', () => { - expect(isPolar(cartesian)).toBeFalsy(); - expect(isPolar(polar)).toBeTruthy(); + describe('Getters', () => { + describe('getRe()', () => { + test('returns real part for standard complex number', () => { + expect(new Complex(3, 4).getRe()).toBe(3); }); - test('new Complex({r: 1, p: Math.PI / 2}) => re: 0, im: 1', () => { - const z: Complex = new Complex(polar); + test('returns negative real part', () => { + expect(new Complex(-5, 3).getRe()).toBe(-5); + }); + + test('returns zero for pure imaginary number', () => { + expect(I.getRe()).toBe(0); + expect(new Complex(0, 5).getRe()).toBe(0); + }); - expect(Math.abs(z.getRe())).toBeLessThan(1e-15); - expect(z.getIm()).toBeCloseTo(1, 16); + test('handles special values', () => { + expect(ZERO.getRe()).toBe(0); + expect(ONE.getRe()).toBe(1); + expect(INFINITY.getRe()).toBe(Infinity); + expect(Number.isNaN(NAN.getRe())).toBe(true); }); }); - describe('Value is a Cartesian coordinate', () => { - test('isCartesian({x: number, y: number})', () => { - expect(isCartesian(cartesian)).toBeTruthy(); - expect(isCartesian(polar)).toBeFalsy(); + describe('getIm()', () => { + test('returns imaginary part for standard complex number', () => { + expect(new Complex(3, 4).getIm()).toBe(4); + }); + + test('returns negative imaginary part', () => { + expect(new Complex(5, -3).getIm()).toBe(-3); }); - test('new Complex({x: 1, y: 1}) => re: 1, im: 1', () => { - const z: Complex = new Complex(cartesian); + test('returns zero for real number', () => { + expect(ONE.getIm()).toBe(0); + expect(new Complex(5, 0).getIm()).toBe(0); + }); - expect(z.getRe()).toEqual(1); - expect(z.getIm()).toEqual(1); + test('handles special values', () => { + expect(ZERO.getIm()).toBe(0); + expect(I.getIm()).toBe(1); + expect(INFINITY.getIm()).toBe(Infinity); + expect(Number.isNaN(NAN.getIm())).toBe(true); }); }); }); - describe('Value is NaN', () => { - test('new Complex(NaN) => re: NaN, im: NaN', () => { - const z: Complex = new Complex(NaN); + describe('toString()', () => { + describe('special values', () => { + test('ZERO.toString() returns "0"', () => { + expect(ZERO.toString()).toBe('0'); + }); + + test('NAN.toString() returns "NaN"', () => { + expect(NAN.toString()).toBe('NaN'); + }); + + test('INFINITY.toString() returns "Infinite"', () => { + expect(INFINITY.toString()).toBe('Infinite'); + }); + }); - expect(z).toEqual(NAN); + describe('real numbers', () => { + test('returns number string for positive real numbers', () => { + expect(ONE.toString()).toBe('1'); + expect(new Complex(5, 0).toString()).toBe('5'); + }); + + test('returns number string for negative real numbers', () => { + expect(new Complex(-3, 0).toString()).toBe('-3'); + expect(new Complex(-100, 0).toString()).toBe('-100'); + }); }); - test('new Complex(NaN, NaN) => re: NaN, im: NaN', () => { - const z: Complex = new Complex(NaN, NaN); + describe('pure imaginary numbers', () => { + test('returns "bi" for positive imaginary part', () => { + expect(I.toString()).toBe('1 i'); + expect(new Complex(0, 5).toString()).toBe('5 i'); + }); - expect(z).toEqual(NAN); + test('returns "-bi" for negative imaginary part', () => { + expect(new Complex(0, -3).toString()).toBe('-3 i'); + expect(new Complex(0, -100).toString()).toBe('-100 i'); + }); + }); + + describe('complex numbers with both parts', () => { + test('returns "a + bi" for positive imaginary part', () => { + expect(new Complex(3, 4).toString()).toBe('3 + 4 i'); + expect(new Complex(-2, 5).toString()).toBe('-2 + 5 i'); + }); + + test('returns "a - bi" for negative imaginary part', () => { + expect(new Complex(3, -4).toString()).toBe('3 - 4 i'); + expect(new Complex(-2, -5).toString()).toBe('-2 - 5 i'); + }); + }); + + describe('edge cases', () => { + test('handles small numbers', () => { + expect(new Complex(0.1, 0.2).toString()).toBe('0.1 + 0.2 i'); + }); + + test('handles large numbers', () => { + expect(new Complex(1000, 2000).toString()).toBe('1000 + 2000 i'); + }); + + test('handles very small numbers', () => { + expect(new Complex(1e-10, 2e-10).toString()).toBe('1e-10 + 2e-10 i'); + }); }); }); - describe('Value is Infinity', () => { - test('new Complex(Infinity) => re: Infinity, im: Infinity', () => { - const z: Complex = new Complex(Infinity); + describe('toCartesian()', () => { + test('returns correct Cartesian coordinates', () => { + expect(new Complex(3, 4).toCartesian()).toEqual({ x: 3, y: 4 }); + }); - expect(z).toEqual(INFINITY); + test('returns negative coordinates', () => { + expect(new Complex(-5, -3).toCartesian()).toEqual({ x: -5, y: -3 }); }); - test('new Complex(Infinity, Infinity) => re: Infinity, im: Infinity', () => { - const z: Complex = new Complex(Infinity, Infinity); + test('handles special values', () => { + expect(ZERO.toCartesian()).toEqual({ x: 0, y: 0 }); + expect(ONE.toCartesian()).toEqual({ x: 1, y: 0 }); + expect(I.toCartesian()).toEqual({ x: 0, y: 1 }); + + const infCart = INFINITY.toCartesian(); + + expect(infCart.x).toBe(Infinity); + expect(infCart.y).toBe(Infinity); + + const nanCart = NAN.toCartesian(); + + expect(Number.isNaN(nanCart.x)).toBe(true); + expect(Number.isNaN(nanCart.y)).toBe(true); + }); + }); + + describe('toPolar()', () => { + test('returns correct polar coordinates for standard complex number', () => { + const polar = new Complex(1, 1).toPolar(); + + expectPolarCloseTo(polar, { r: Math.SQRT2, p: Math.PI / 4 }); + }); + + test('handles zero', () => { + expect(ZERO.toPolar()).toEqual({ r: 0, p: 0 }); + }); - expect(z).toEqual(INFINITY); + test('handles real numbers', () => { + expect(ONE.toPolar()).toEqual({ r: 1, p: 0 }); + + expectPolarCloseTo(new Complex(-1, 0).toPolar(), { r: 1, p: Math.PI }); + }); + + test('handles pure imaginary numbers', () => { + const polar = I.toPolar(); + expectPolarCloseTo(polar, { r: 1, p: Math.PI / 2 }); + + expectPolarCloseTo(new Complex(0, -1).toPolar(), { r: 1, p: -Math.PI / 2 }); + }); + + test('handles all quadrants', () => { + const testCases = [ + { z: new Complex(1, 1), r: Math.SQRT2, p: Math.PI / 4 }, + { z: new Complex(-1, 1), r: Math.SQRT2, p: (3 * Math.PI) / 4 }, + { z: new Complex(-1, -1), r: Math.SQRT2, p: -(3 * Math.PI) / 4 }, + { z: new Complex(1, -1), r: Math.SQRT2, p: -Math.PI / 4 }, + ]; + + testCases.forEach(({ z, r, p }) => { + const polar = z.toPolar(); + + expectPolarCloseTo(polar, { r, p }); + }); + }); + + test('handles special values', () => { + const infPolar = INFINITY.toPolar(); + + expect(infPolar.r).toBe(Infinity); + expect(infPolar.p).toBe(Infinity); + + const nanPolar = NAN.toPolar(); + + expect(Number.isNaN(nanPolar.r)).toBe(true); + expect(Number.isNaN(nanPolar.p)).toBe(true); + }); + }); + + describe('Static properties', () => { + describe('ZERO', () => { + test('has correct real and imaginary parts', () => { + expect(Complex.ZERO.getRe()).toBe(0); + expect(Complex.ZERO.getIm()).toBe(0); + }); + + test('equals new Complex(0, 0)', () => { + expect(Complex.ZERO).toEqual(new Complex(0, 0)); + }); + }); + + describe('ONE', () => { + test('has correct real and imaginary parts', () => { + expect(Complex.ONE.getRe()).toBe(1); + expect(Complex.ONE.getIm()).toBe(0); + }); + + test('equals new Complex(1, 0)', () => { + expect(Complex.ONE).toEqual(new Complex(1, 0)); + }); + }); + + describe('I', () => { + test('has correct real and imaginary parts', () => { + expect(Complex.I.getRe()).toBe(0); + expect(Complex.I.getIm()).toBe(1); + }); + + test('equals new Complex(0, 1)', () => { + expect(Complex.I).toEqual(new Complex(0, 1)); + }); + }); + + describe('PI', () => { + test('has correct real and imaginary parts', () => { + expect(Complex.PI.getRe()).toBe(Math.PI); + expect(Complex.PI.getIm()).toBe(0); + }); + + test('equals new Complex(Math.PI, 0)', () => { + expect(Complex.PI).toEqual(new Complex(Math.PI, 0)); + }); + }); + + describe('HALFPI', () => { + test('has correct real and imaginary parts', () => { + expect(Complex.HALFPI.getRe()).toBe(Math.PI / 2); + expect(Complex.HALFPI.getIm()).toBe(0); + }); + + test('equals new Complex(Math.PI / 2, 0)', () => { + expect(Complex.HALFPI).toEqual(new Complex(Math.PI / 2, 0)); + }); + }); + + describe('E', () => { + test('has correct real and imaginary parts', () => { + expect(Complex.E.getRe()).toBe(Math.E); + expect(Complex.E.getIm()).toBe(0); + }); + + test('equals new Complex(Math.E, 0)', () => { + expect(Complex.E).toEqual(new Complex(Math.E, 0)); + }); + }); + + describe('INFINITY', () => { + test('has correct real and imaginary parts', () => { + expect(Complex.INFINITY.getRe()).toBe(Infinity); + expect(Complex.INFINITY.getIm()).toBe(Infinity); + }); + + test('equals new Complex(Infinity, Infinity)', () => { + expect(Complex.INFINITY).toEqual(new Complex(Infinity, Infinity)); + }); + }); + + describe('NAN', () => { + test('has NaN real and imaginary parts', () => { + expect(Number.isNaN(Complex.NAN.getRe())).toBe(true); + expect(Number.isNaN(Complex.NAN.getIm())).toBe(true); + }); + + test('equals new Complex(NaN, NaN)', () => { + expect(Complex.NAN).toEqual(new Complex(NaN, NaN)); + }); + }); + + describe('EPSILON', () => { + test('equals Number.EPSILON', () => { + expect(Complex.EPSILON).toBe(Number.EPSILON); + }); + + test('is a number', () => { + expect(typeof Complex.EPSILON).toBe('number'); + }); }); }); }); diff --git a/tests/functions.spec.ts b/tests/functions.spec.ts index 386e36a..5631149 100644 --- a/tests/functions.spec.ts +++ b/tests/functions.spec.ts @@ -54,6 +54,9 @@ describe('Functions', () => { }); describe('Special Cases', () => { + test('Zero', () => { + expect(sqrt(ZERO)).toEqual(ZERO); + }); test('Infinity', () => { expect(sqrt(INFINITY)).toEqual(INFINITY); }); @@ -77,6 +80,9 @@ describe('Functions', () => { test('Zero', () => { expect(log(ZERO)).toEqual(NAN); }); + test('One', () => { + expect(log(ONE)).toEqual(ZERO); + }); test('Infinity', () => { expect(log(INFINITY)).toEqual(INFINITY); }); @@ -89,6 +95,7 @@ describe('Functions', () => { describe('Exponential', () => { const ez: Complex = exp(z); const ew: Complex = exp(w); + test('exp(z)', () => { expect(ez.getRe()).toBeCloseTo(1.468693939915885, 15); expect(ez.getIm()).toBeCloseTo(2.287355287178842, 15); @@ -113,6 +120,7 @@ describe('Functions', () => { const ez: Complex = pow(z, w); const en: Complex = pow(z, 3); const ei: Complex = pow(z, new Complex(0, 4)); + test('z^w', () => { expect(ez.getRe()).toBeCloseTo(-0.163450932107355, 15); expect(ez.getIm()).toBeCloseTo(0.0960049836089489, 15); @@ -154,6 +162,12 @@ describe('Functions', () => { test('z^∞', () => { expect(pow(z, INFINITY)).toEqual(INFINITY); }); + + test('pow with number arguments', () => { + expect(pow(2, 3)).toEqual(new Complex(8, 0)); + expect(pow(z, 2)).toEqual(new Complex(0, 2)); + expect(pow(2, z)).toEqual(new Complex(1.5384778027279442, 1.2779225526272695)); + }); }); }); @@ -476,6 +490,10 @@ describe('Functions', () => { }); describe('Special Cases', () => { + test('Zero', () => { + expect(sinh(ZERO)).toEqual(ZERO); + }); + test('Infinity', () => { expect(sinh(INFINITY)).toEqual(NAN); }); diff --git a/tests/helpers.spec.ts b/tests/helpers.spec.ts index adac670..fa996a5 100644 --- a/tests/helpers.spec.ts +++ b/tests/helpers.spec.ts @@ -1,32 +1,589 @@ import { describe, expect, test } from 'vitest'; -import { type Cartesian, isCartesian, isPolar, type Polar } from '~/index'; +import { Complex } from '~/complex'; +import { + addStable, + type Cartesian, + isApproximatelyEqual, + isCartesian, + isPolar, + type Polar, + subtractStable, +} from '~/helpers'; describe('Helpers', () => { - test('They should exist', () => { - expect(isCartesian).toBeDefined(); - expect(isPolar).toBeDefined(); + describe('isCartesian', () => { + describe('valid Cartesian coordinates', () => { + test('returns true for standard Cartesian coordinates', () => { + const cart: Cartesian = { x: 10, y: 20 }; + + expect(isCartesian(cart)).toBe(true); + }); + + test('returns true for zero coordinates', () => { + const cart: Cartesian = { x: 0, y: 0 }; + + expect(isCartesian(cart)).toBe(true); + }); + + test('returns true for negative coordinates', () => { + const cart: Cartesian = { x: -5, y: -3 }; + + expect(isCartesian(cart)).toBe(true); + }); + + test('returns true for decimal coordinates', () => { + const cart: Cartesian = { x: 0.1, y: 0.2 }; + + expect(isCartesian(cart)).toBe(true); + }); + + test('returns true for very large numbers', () => { + const cart: Cartesian = { x: 1e15, y: 2e15 }; + + expect(isCartesian(cart)).toBe(true); + }); + + test('returns true for very small numbers', () => { + const cart: Cartesian = { x: 1e-15, y: 2e-15 }; + + expect(isCartesian(cart)).toBe(true); + }); + + test('returns true for Infinity values', () => { + const cart: Cartesian = { x: Infinity, y: Infinity }; + + expect(isCartesian(cart)).toBe(true); + }); + + test('returns true for NaN values', () => { + const cart: Cartesian = { x: NaN, y: NaN }; + + expect(isCartesian(cart)).toBe(true); + }); + }); + + describe('invalid inputs', () => { + test('returns false for undefined', () => { + expect(isCartesian(undefined)).toBe(false); + }); + + test('returns false for null', () => { + expect(isCartesian(null)).toBe(false); + }); + + test('returns false for non-object types', () => { + const testCases = [5, 'string', true, []]; + + testCases.forEach((value) => { + expect(isCartesian(value)).toBe(false); + }); + }); + + test('returns false for objects missing x property', () => { + expect(isCartesian({ y: 5 })).toBe(false); + }); + + test('returns false for objects missing y property', () => { + expect(isCartesian({ x: 5 })).toBe(false); + }); + + test('returns false for objects with non-number x', () => { + const testCases = [ + { x: '5', y: 10 }, + { x: null, y: 10 }, + { x: undefined, y: 10 }, + ]; + + testCases.forEach((obj) => { + expect(isCartesian(obj)).toBe(false); + }); + }); + + test('returns false for objects with non-number y', () => { + const testCases = [ + { x: 5, y: '10' }, + { x: 5, y: null }, + { x: 5, y: undefined }, + ]; + + testCases.forEach((obj) => { + expect(isCartesian(obj)).toBe(false); + }); + }); + + test('returns false for Polar coordinates', () => { + const polar: Polar = { r: 1, p: Math.PI }; + + expect(isCartesian(polar)).toBe(false); + }); + + test('returns true for objects with extra properties when x and y are present', () => { + expect(isCartesian({ x: 5, y: 10, z: 15 })).toBe(true); + }); + + test('returns false for objects with wrong property names', () => { + const testCases = [ + { a: 5, b: 10 }, + { real: 5, imag: 10 }, + ]; + + testCases.forEach((obj) => { + expect(isCartesian(obj)).toBe(false); + }); + }); + }); + }); + + describe('isPolar', () => { + describe('valid Polar coordinates', () => { + test('returns true for standard Polar coordinates', () => { + const polar: Polar = { r: 11, p: Math.PI }; + + expect(isPolar(polar)).toBe(true); + }); + + test('returns true for zero radius', () => { + const polar: Polar = { r: 0, p: 0 }; + + expect(isPolar(polar)).toBe(true); + }); + + test('returns true for negative phase', () => { + const polar: Polar = { r: 5, p: -Math.PI / 2 }; + + expect(isPolar(polar)).toBe(true); + }); + + test('returns true for large phase values', () => { + const polar: Polar = { r: 1, p: 2 * Math.PI }; + + expect(isPolar(polar)).toBe(true); + }); + + test('returns true for decimal values', () => { + const polar: Polar = { r: 0.5, p: 0.1 }; + + expect(isPolar(polar)).toBe(true); + }); + + test('returns true for very large numbers', () => { + const polar: Polar = { r: 1e15, p: 1e15 }; + + expect(isPolar(polar)).toBe(true); + }); + + test('returns true for very small numbers', () => { + const polar: Polar = { r: 1e-15, p: 1e-15 }; + + expect(isPolar(polar)).toBe(true); + }); + + test('returns true for Infinity values', () => { + const polar: Polar = { r: Infinity, p: Infinity }; + + expect(isPolar(polar)).toBe(true); + }); + + test('returns true for NaN values', () => { + const polar: Polar = { r: NaN, p: NaN }; + + expect(isPolar(polar)).toBe(true); + }); + }); + + describe('invalid inputs', () => { + test('returns false for undefined', () => { + expect(isPolar(undefined)).toBe(false); + }); + + test('returns false for null', () => { + expect(isPolar(null)).toBe(false); + }); + + test('returns false for non-object types', () => { + const testCases = [5, 'string', true, []]; + + testCases.forEach((value) => { + expect(isPolar(value)).toBe(false); + }); + }); + + test('returns false for objects missing r property', () => { + expect(isPolar({ p: Math.PI })).toBe(false); + }); + + test('returns false for objects missing p property', () => { + expect(isPolar({ r: 5 })).toBe(false); + }); + + test('returns false for objects with non-number r', () => { + const testCases = [ + { r: '5', p: Math.PI }, + { r: null, p: Math.PI }, + { r: undefined, p: Math.PI }, + ]; + + testCases.forEach((obj) => { + expect(isPolar(obj)).toBe(false); + }); + }); + + test('returns false for objects with non-number p', () => { + const testCases = [ + { r: 5, p: 'Math.PI' }, + { r: 5, p: null }, + { r: 5, p: undefined }, + ]; + + testCases.forEach((obj) => { + expect(isPolar(obj)).toBe(false); + }); + }); + + test('returns false for Cartesian coordinates', () => { + const cart: Cartesian = { x: 5, y: 10 }; + + expect(isPolar(cart)).toBe(false); + }); + + test('returns true for objects with extra properties when r and p are present', () => { + expect(isPolar({ r: 5, p: Math.PI, q: 10 })).toBe(true); + }); + + test('returns false for objects with wrong property names', () => { + const testCases = [ + { radius: 5, phase: Math.PI }, + { magnitude: 5, angle: Math.PI }, + ]; + + testCases.forEach((obj) => { + expect(isPolar(obj)).toBe(false); + }); + }); + }); + }); + + describe('addStable', () => { + describe('basic addition', () => { + test('adds two positive numbers', () => { + expect(addStable(3, 4)).toBe(7); + }); + + test('adds two negative numbers', () => { + expect(addStable(-3, -4)).toBe(-7); + }); + + test('adds positive and negative numbers', () => { + const testCases = [ + { x: 5, y: -3, expected: 2 }, + { x: -5, y: 3, expected: -2 }, + ]; + + testCases.forEach(({ x, y, expected }) => { + expect(addStable(x, y)).toBe(expected); + }); + }); + + test('handles zero', () => { + const testCases = [ + { x: 0, y: 5, expected: 5 }, + { x: 5, y: 0, expected: 5 }, + { x: 0, y: 0, expected: 0 }, + ]; + + testCases.forEach(({ x, y, expected }) => { + expect(addStable(x, y)).toBe(expected); + }); + }); + }); + + describe('numerically stable cases', () => { + test('handles similar magnitudes correctly', () => { + const testCases = [ + { x: 1.0, y: 1.1, expected: 2.1 }, + { x: 100, y: 101, expected: 201 }, + ]; + + testCases.forEach(({ x, y, expected }) => { + expect(addStable(x, y)).toBe(expected); + }); + }); + + test('handles very different magnitudes by adding smaller to larger for stability', () => { + expect(addStable(1e15, 1)).toBe(1000000000000001); + }); + + test('handles very small numbers', () => { + expect(addStable(1e-15, 2e-15)).toBeCloseTo(3e-15, 15); + }); + + test('handles very large numbers', () => { + expect(addStable(1e15, 2e15)).toBe(3e15); + }); + + test('handles decimal precision', () => { + expect(addStable(0.1, 0.2)).toBeCloseTo(0.3, 15); + }); + }); + + describe('edge cases', () => { + test('handles Infinity', () => { + const testCases = [ + { x: Infinity, y: 5, expected: Infinity }, + { x: 5, y: Infinity, expected: Infinity }, + { x: Infinity, y: Infinity, expected: Infinity }, + { x: -Infinity, y: -Infinity, expected: -Infinity }, + ]; + + testCases.forEach(({ x, y, expected }) => { + expect(addStable(x, y)).toBe(expected); + }); + }); + + test('handles NaN', () => { + const testCases = [ + { x: NaN, y: 5 }, + { x: 5, y: NaN }, + { x: NaN, y: NaN }, + ]; + + testCases.forEach(({ x, y }) => { + expect(Number.isNaN(addStable(x, y))).toBe(true); + }); + }); + + test('handles mixed Infinity and NaN', () => { + const testCases = [ + { x: Infinity, y: NaN }, + { x: NaN, y: Infinity }, + ]; + + testCases.forEach(({ x, y }) => { + expect(Number.isNaN(addStable(x, y))).toBe(true); + }); + }); + }); + }); + + describe('subtractStable', () => { + describe('basic subtraction', () => { + test('subtracts two positive numbers', () => { + expect(subtractStable(5, 3)).toBe(2); + }); + + test('subtracts two negative numbers', () => { + expect(subtractStable(-5, -3)).toBe(-2); + }); + + test('subtracts positive and negative numbers', () => { + const testCases = [ + { x: 5, y: -3, expected: 8 }, + { x: -5, y: 3, expected: -8 }, + ]; + + testCases.forEach(({ x, y, expected }) => { + expect(subtractStable(x, y)).toBe(expected); + }); + }); + + test('handles zero', () => { + const testCases = [ + { x: 0, y: 5, expected: -5 }, + { x: 5, y: 0, expected: 5 }, + { x: 0, y: 0, expected: 0 }, + ]; + + testCases.forEach(({ x, y, expected }) => { + expect(subtractStable(x, y)).toBe(expected); + }); + }); + }); + + describe('numerically stable cases', () => { + test('handles similar magnitudes with stable subtraction when ratio is greater than 0.5', () => { + expect(subtractStable(1.0, 0.9)).toBeCloseTo(0.1, 15); + }); + + test('handles very different magnitudes by subtracting normally', () => { + expect(subtractStable(1e15, 1)).toBe(999999999999999); + }); + + test('handles very small numbers', () => { + expect(subtractStable(2e-15, 1e-15)).toBe(1e-15); + }); + + test('handles very large numbers', () => { + expect(subtractStable(2e15, 1e15)).toBe(1e15); + }); + + test('handles decimal precision', () => { + expect(subtractStable(0.3, 0.1)).toBeCloseTo(0.2, 15); + }); + + test('uses stable algorithm for similar magnitudes when min to max ratio is greater than 0.5', () => { + expect(subtractStable(100.0, 60.0)).toBeCloseTo(40.0, 15); + }); + }); + + describe('edge cases', () => { + test('handles Infinity', () => { + const testCases = [ + { x: Infinity, y: 5, expected: Infinity }, + { x: 5, y: Infinity, expected: -Infinity }, + ]; + + testCases.forEach(({ x, y, expected }) => { + expect(subtractStable(x, y)).toBe(expected); + }); + + expect(Number.isNaN(subtractStable(Infinity, Infinity))).toBe(true); + expect(Number.isNaN(subtractStable(-Infinity, -Infinity))).toBe(true); + }); + + test('handles NaN', () => { + const testCases = [ + { x: NaN, y: 5 }, + { x: 5, y: NaN }, + { x: NaN, y: NaN }, + ]; + + testCases.forEach(({ x, y }) => { + expect(Number.isNaN(subtractStable(x, y))).toBe(true); + }); + }); + + test('handles mixed Infinity and NaN', () => { + const testCases = [ + { x: Infinity, y: NaN }, + { x: NaN, y: Infinity }, + ]; + + testCases.forEach(({ x, y }) => { + expect(Number.isNaN(subtractStable(x, y))).toBe(true); + }); + }); + }); }); - const c: Cartesian = { x: 10, y: 20 }; - const p: Polar = { r: 11, p: Math.PI }; - const cz: Cartesian = { x: 0, y: 0 }; - const pz: Polar = { r: 0, p: 0 }; - const u: void = undefined; - - describe('Helper functions', () => { - test('isCartesian(object)', () => { - expect(isCartesian(c)).toBeTruthy(); - expect(isCartesian(cz)).toBeTruthy(); - expect(isCartesian(p)).toBeFalsy(); - expect(isCartesian(u)).toBeFalsy(); - }); - - test('isPolar(object)', () => { - expect(isPolar(c)).toBeFalsy(); - expect(isPolar(p)).toBeTruthy(); - expect(isPolar(pz)).toBeTruthy(); - expect(isPolar(u)).toBeFalsy(); + describe('isApproximatelyEqual', () => { + const EPSILON = Complex.EPSILON; + const SMALL_DIFF = EPSILON / 2; + const LARGE_DIFF = EPSILON * 2; + + describe('exact equality', () => { + test('returns true for identical numbers', () => { + expect(isApproximatelyEqual(0, 0)).toBe(true); + expect(isApproximatelyEqual(1, 1)).toBe(true); + expect(isApproximatelyEqual(-1, -1)).toBe(true); + expect(isApproximatelyEqual(3.14159, 3.14159)).toBe(true); + }); + }); + + describe('values within epsilon', () => { + test('returns true for numbers very close to each other', () => { + expect(isApproximatelyEqual(0, SMALL_DIFF)).toBe(true); + expect(isApproximatelyEqual(1, 1 + SMALL_DIFF)).toBe(true); + expect(isApproximatelyEqual(-1, -1 - SMALL_DIFF)).toBe(true); + }); + + test('returns true for numbers near zero using absolute error', () => { + const value = 0.5; + + expect(isApproximatelyEqual(value, value + SMALL_DIFF)).toBe(true); + expect(isApproximatelyEqual(value, value - SMALL_DIFF)).toBe(true); + }); + + test('returns true for numbers away from zero using relative error', () => { + const value = 100; + + expect(isApproximatelyEqual(value, value + SMALL_DIFF * value)).toBe(true); + expect(isApproximatelyEqual(value, value - SMALL_DIFF * value)).toBe(true); + }); + }); + + describe('values outside epsilon', () => { + test('returns false for numbers too far apart', () => { + expect(isApproximatelyEqual(0, LARGE_DIFF)).toBe(false); + expect(isApproximatelyEqual(1, 1 + LARGE_DIFF)).toBe(false); + expect(isApproximatelyEqual(-1, -1 - LARGE_DIFF)).toBe(false); + }); + + test('returns false for clearly different numbers', () => { + expect(isApproximatelyEqual(1, 2)).toBe(false); + expect(isApproximatelyEqual(0.1, 0.2)).toBe(false); + expect(isApproximatelyEqual(-5, 5)).toBe(false); + }); + }); + + describe('NaN handling', () => { + test('returns false when first argument is NaN', () => { + expect(isApproximatelyEqual(NaN, 0)).toBe(false); + expect(isApproximatelyEqual(NaN, 1)).toBe(false); + expect(isApproximatelyEqual(NaN, NaN)).toBe(false); + }); + + test('returns false when second argument is NaN', () => { + expect(isApproximatelyEqual(0, NaN)).toBe(false); + expect(isApproximatelyEqual(1, NaN)).toBe(false); + }); + }); + + describe('Infinity handling', () => { + test('returns true for identical infinities', () => { + expect(isApproximatelyEqual(Infinity, Infinity)).toBe(true); + expect(isApproximatelyEqual(-Infinity, -Infinity)).toBe(true); + }); + + test('returns false for different infinities', () => { + expect(isApproximatelyEqual(Infinity, -Infinity)).toBe(false); + expect(isApproximatelyEqual(-Infinity, Infinity)).toBe(false); + }); + + test('returns false when comparing infinity to finite numbers', () => { + expect(isApproximatelyEqual(Infinity, 0)).toBe(false); + expect(isApproximatelyEqual(Infinity, 1)).toBe(false); + expect(isApproximatelyEqual(-Infinity, 0)).toBe(false); + expect(isApproximatelyEqual(-Infinity, -1)).toBe(false); + }); + }); + + describe('custom epsilon', () => { + test('uses custom epsilon when provided', () => { + expect(isApproximatelyEqual(1, 1.05, 0.1)).toBe(true); + expect(isApproximatelyEqual(1, 1.15, 0.1)).toBe(false); + }); + + test('works with very small custom epsilon', () => { + expect(isApproximatelyEqual(1, 1 + 1e-15, 1e-20)).toBe(false); + }); + + test('works with large custom epsilon', () => { + expect(isApproximatelyEqual(1, 1.5, 1)).toBe(true); + expect(isApproximatelyEqual(1, 2, 0.5)).toBe(false); + }); + }); + + describe('edge cases', () => { + test('handles very small numbers', () => { + const tiny = 1e-15; + + expect(isApproximatelyEqual(tiny, tiny + SMALL_DIFF)).toBe(true); + expect(isApproximatelyEqual(tiny, tiny + LARGE_DIFF)).toBe(false); + }); + + test('handles very large numbers', () => { + const large = 1e15; + const diff = (EPSILON * large) / 2; + + expect(isApproximatelyEqual(large, large + diff)).toBe(true); + expect(isApproximatelyEqual(large, large + diff * 2)).toBe(false); + }); + + test('handles numbers just below and above 1', () => { + expect(isApproximatelyEqual(0.999, 1.001)).toBe(false); + expect(isApproximatelyEqual(0.9999, 1.0001, 0.01)).toBe(true); + }); }); }); }); diff --git a/tests/operations.spec.ts b/tests/operations.spec.ts index 1a3e25e..f48b728 100644 --- a/tests/operations.spec.ts +++ b/tests/operations.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { Complex } from '~/index'; +import { Complex } from '~/complex'; import { add, argument, @@ -22,413 +22,560 @@ import { unit, } from '~/operations'; -const NAN = Complex.NAN; -const INFINITY = Complex.INFINITY; const ZERO = Complex.ZERO; const ONE = Complex.ONE; const I = Complex.I; +const INFINITY = Complex.INFINITY; +const NAN = Complex.NAN; + +const labels = { + NAN: 'Complex.NAN', + INFINITY: 'Complex.INFINITY', + ZERO: 'Complex.ZERO', + ONE: 'Complex.ONE', + I: 'Complex.I', + z: 'z', +} as const; + +const getLabel = (input: Complex): string => { + if (isNaNC(input)) return labels.NAN; + if (isInfinite(input)) return labels.INFINITY; + if (equals(input, ZERO)) return labels.ZERO; + if (equals(input, ONE)) return labels.ONE; + if (equals(input, I)) return labels.I; + return labels.z; +}; describe('Operators', () => { const z: Complex = new Complex(1, 1); const w: Complex = new Complex(2, 3); describe('Addition', () => { - test('z + w', () => { - const c: Complex = add(z, w); - expect(c.getRe()).toEqual(3); - expect(c.getIm()).toEqual(4); + test('adds two complex numbers', () => { + const result = add(z, w); + + expect(result.getRe()).toBe(3); + expect(result.getIm()).toBe(4); }); describe('Special Cases', () => { - test('Infinite + Infinite', () => { - expect(add(INFINITY, INFINITY)).toEqual(NAN); - }); + const testCases = [ + { a: INFINITY, b: INFINITY, expected: NAN }, + { a: z, b: INFINITY, expected: INFINITY }, + { a: NAN, b: NAN, expected: NAN }, + { a: z, b: NAN, expected: NAN }, + ]; - test('Value + Infinite', () => { - expect(add(z, INFINITY)).toEqual(INFINITY); - }); - - test('NaN + NaN', () => { - expect(add(NAN, NAN)).toEqual(NAN); - }); - - test('Value + NaN', () => { - expect(add(z, NAN)).toEqual(NAN); + testCases.forEach(({ a, b, expected }) => { + test(`add(${getLabel(a)}, ${getLabel(b)})`, () => { + expect(add(a, b)).toEqual(expected); + }); }); }); }); describe('Subtraction', () => { - test('z - w', () => { - const c: Complex = subtract(z, w); - expect(c.getRe()).toEqual(-1); - expect(c.getIm()).toEqual(-2); + test('subtracts two complex numbers', () => { + const result = subtract(z, w); + + expect(result.getRe()).toBe(-1); + expect(result.getIm()).toBe(-2); }); describe('Special Cases', () => { - test('Infinite - Infinite', () => { - expect(subtract(INFINITY, INFINITY)).toEqual(NAN); - }); - - test('Value - Infinite', () => { - expect(subtract(z, INFINITY)).toEqual(INFINITY); - }); - - test('NaN - NaN', () => { - expect(subtract(NAN, NAN)).toEqual(NAN); - }); + const testCases = [ + { a: INFINITY, b: INFINITY, expected: NAN }, + { a: z, b: INFINITY, expected: INFINITY }, + { a: NAN, b: NAN, expected: NAN }, + { a: z, b: NAN, expected: NAN }, + ]; - test('Value - NaN', () => { - expect(subtract(z, NAN)).toEqual(NAN); + testCases.forEach(({ a, b, expected }) => { + test(`subtract(${getLabel(a)}, ${getLabel(b)})`, () => { + expect(subtract(a, b)).toEqual(expected); + }); }); }); }); describe('Sum', () => { - test('sum(z, w)', () => { - const c: Complex = sum(z, w); - expect(c.getRe()).toEqual(3); - expect(c.getIm()).toEqual(4); + test('sums two complex numbers', () => { + const result = sum(z, w); + + expect(result.getRe()).toBe(3); + expect(result.getIm()).toBe(4); }); - test('sum(z, w, z)', () => { - const c: Complex = sum(z, w, z); - expect(c.getRe()).toEqual(4); - expect(c.getIm()).toEqual(5); + test('sums multiple complex numbers', () => { + const result = sum(z, w, z); + + expect(result.getRe()).toBe(4); + expect(result.getIm()).toBe(5); }); - test('sum() - no arguments', () => { - const c: Complex = sum(); - expect(c).toEqual(ZERO); + test('returns zero for no arguments', () => { + expect(sum()).toEqual(ZERO); }); - test('sum(z) - single argument', () => { - const c: Complex = sum(z); - expect(c).toEqual(z); + test('returns the argument for single argument', () => { + expect(sum(z)).toEqual(z); }); - test('sum with negate - z + (-z) = 0', () => { - const negatedZ = negate(z); - const c: Complex = sum(z, negatedZ); - expect(c).toEqual(ZERO); + test('sums with negated values to zero', () => { + expect(sum(z, negate(z))).toEqual(ZERO); }); - test('sum with negate - multiple numbers', () => { - const negatedZ = negate(z); - const negatedW = negate(w); - const c: Complex = sum(z, w, negatedZ, negatedW); - expect(c).toEqual(ZERO); + test('sums multiple numbers with negated values to zero', () => { + expect(sum(z, w, negate(z), negate(w))).toEqual(ZERO); }); describe('Special Cases', () => { - test('Infinite + Infinite', () => { - expect(sum(INFINITY, INFINITY)).toEqual(INFINITY); - }); - - test('Value + Infinite', () => { - expect(sum(z, INFINITY)).toEqual(INFINITY); - }); - - test('Multiple values + Infinite', () => { - expect(sum(z, w, INFINITY)).toEqual(INFINITY); - }); + test('handles Infinity', () => { + const testCases = [ + { args: [INFINITY, INFINITY], expected: INFINITY }, + { args: [z, INFINITY], expected: INFINITY }, + { args: [z, w, INFINITY], expected: INFINITY }, + ]; - test('NaN + NaN', () => { - expect(sum(NAN, NAN)).toEqual(NAN); + testCases.forEach(({ args, expected }) => { + expect(sum(...args)).toEqual(expected); + }); }); - test('Value + NaN', () => { - expect(sum(z, NAN)).toEqual(NAN); - }); + test('handles NaN', () => { + const testCases = [ + { args: [NAN, NAN], expected: NAN }, + { args: [z, NAN], expected: NAN }, + { args: [z, w, NAN], expected: NAN }, + ]; - test('Multiple values + NaN', () => { - expect(sum(z, w, NAN)).toEqual(NAN); + testCases.forEach(({ args, expected }) => { + expect(sum(...args)).toEqual(expected); + }); }); - test('NaN + Infinite (NaN takes precedence)', () => { + test('NaN takes precedence over Infinity', () => { expect(sum(NAN, INFINITY)).toEqual(NAN); }); }); }); describe('Multiplication', () => { - test('z * w', () => { - const c: Complex = multiply(z, w); - const d: Complex = multiply(z, 2); - expect(c.getRe()).toEqual(-1); - expect(c.getIm()).toEqual(5); - expect(d.getRe()).toEqual(2); - expect(d.getIm()).toEqual(2); - }); - - describe('Special cases', () => { - test('Infinite * Infinite', () => { - expect(multiply(INFINITY, INFINITY)).toEqual(INFINITY); - }); + test('multiplies two complex numbers', () => { + const result = multiply(z, w); - test('Value * Infinite', () => { - expect(multiply(z, INFINITY)).toEqual(INFINITY); - }); + expect(result.getRe()).toBe(-1); + expect(result.getIm()).toBe(5); + }); - test('NaN * NaN', () => { - expect(multiply(NAN, NAN)).toEqual(NAN); - }); + test('multiplies complex number by scalar', () => { + const result = multiply(z, 2); - test('Value * NaN', () => { - expect(multiply(z, NAN)).toEqual(NAN); - }); + expect(result.getRe()).toBe(2); + expect(result.getIm()).toBe(2); }); - }); - describe('Division', () => { - test('z / w', () => { - const c: Complex = divide(z, w); - const d: Complex = divide(z, 2); - expect(c.getRe()).toBeCloseTo(5 / 13, 15); - expect(c.getIm()).toBeCloseTo(-1 / 13, 15); - expect(d.getRe()).toEqual(0.5); - expect(d.getIm()).toEqual(0.5); + test('multiplies scalar by complex number', () => { + const result = multiply(2, z); + + expect(result.getRe()).toBe(2); + expect(result.getIm()).toBe(2); }); - describe('Special Cases', () => { - test('Infinite / Infinite', () => { - expect(divide(INFINITY, INFINITY)).toEqual(NAN); - }); + test('multiplies two scalars', () => { + const result = multiply(3, 4); - test('Value / Infinite', () => { - expect(divide(z, INFINITY)).toEqual(ZERO); - }); + expect(result.getRe()).toBe(12); + expect(result.getIm()).toBe(0); + }); - test('NaN / NaN', () => { - expect(divide(NAN, NAN)).toEqual(NAN); - }); + test('multiplies with zero as second argument', () => { + const result = multiply(z, ZERO); - test('Value / NaN', () => { - expect(divide(z, NAN)).toEqual(NAN); - }); + expect(result).toEqual(ZERO); }); - }); - describe('Negation', () => { - test('-z', () => { - const c: Complex = negate(z); - expect(c.getRe()).toEqual(-1); - expect(c.getIm()).toEqual(-1); + test('check numerical stability', () => { + const a = new Complex(1.1, 1); + const b = new Complex(1, 1.1); + const result = multiply(a, b); + + expect(result.getRe()).toBeCloseTo(0, 16); + expect(result.getIm()).toBeCloseTo(2.21, 16); }); describe('Special Cases', () => { - test('-Infinite', () => { - expect(negate(INFINITY)).toEqual(INFINITY); - }); + const testCases = [ + { a: ZERO, b: INFINITY, expected: NAN }, + { a: INFINITY, b: ZERO, expected: NAN }, + { a: INFINITY, b: INFINITY, expected: INFINITY }, + { a: z, b: INFINITY, expected: INFINITY }, + { a: NAN, b: NAN, expected: NAN }, + { a: z, b: NAN, expected: NAN }, + ]; - test('-NaN', () => { - expect(negate(NAN)).toEqual(NAN); + testCases.forEach(({ a, b, expected }) => { + test(`multiply(${getLabel(a)}, ${getLabel(b)})`, () => { + expect(multiply(a, b)).toEqual(expected); + }); }); }); }); - describe('Conjugate', () => { - test('Conj(z)', () => { - const c: Complex = conjugate(z); - expect(c.getRe()).toEqual(z.getRe()); - expect(c.getIm()).toEqual(-z.getIm()); + describe('Division', () => { + test('divides two complex numbers', () => { + const result = divide(z, w); + + expect(result.getRe()).toBeCloseTo(5 / 13, 15); + expect(result.getIm()).toBeCloseTo(-1 / 13, 15); + }); + + test('divides complex number by scalar', () => { + const result = divide(z, 2); + + expect(result.getRe()).toBe(0.5); + expect(result.getIm()).toBe(0.5); + }); + + test('divides scalar by complex number', () => { + const result = divide(2, z); + + expect(result.getRe()).toBe(1); + expect(result.getIm()).toBe(-1); + }); + + test('divides two scalars', () => { + const result = divide(6, 3); + + expect(result.getRe()).toBe(2); + expect(result.getIm()).toBe(0); + }); + + test('divides when imaginary part is larger', () => { + const result = divide(new Complex(1, 100), new Complex(0.1, 10)); + + expect(result.getRe()).toBeCloseTo(10, 14); + expect(result.getIm()).toBeCloseTo(0, 14); + }); + + test('divides with approximately zero ratio', () => { + const result = divide(new Complex(1, 1), new Complex(1, 1e-15)); + + expect(result.getRe()).toBeCloseTo(1, 14); + expect(result.getIm()).toBeCloseTo(1, 14); + }); + + test('divides with approximately zero ratio when imaginary part is larger', () => { + const result = divide(new Complex(1, 1), new Complex(1e-15, 1)); + + expect(result.getRe()).toBeCloseTo(1, 14); + expect(result.getIm()).toBeCloseTo(-1, 14); }); describe('Special Cases', () => { - test('Infinity', () => { - expect(conjugate(INFINITY)).toEqual(INFINITY); + const testCases = [ + { a: ZERO, b: ZERO, expected: NAN }, + { a: INFINITY, b: INFINITY, expected: NAN }, + { a: z, b: INFINITY, expected: ZERO }, + { a: INFINITY, b: z, expected: INFINITY }, + { a: ZERO, b: z, expected: ZERO }, + { a: NAN, b: NAN, expected: NAN }, + { a: z, b: NAN, expected: NAN }, + ]; + + testCases.forEach(({ a, b, expected }) => { + test(`divide(${getLabel(a)}, ${getLabel(b)})`, () => { + expect(divide(a, b)).toEqual(expected); + }); }); + }); + + describe("Extended Mc'Larlen's difficult division test", () => { + const SAFE_LARGE_CONSTANT = Math.pow(2, 30); + + const testCases = [ + { k: 0, complex: new Complex(1, 0) }, + { k: 1, complex: new Complex(1.5, -0.5) }, + { k: 26, complex: new Complex(33554432.5, -33554431.5) }, + { k: 27, complex: new Complex(67108864.5, -67108863.5) }, + ]; + + testCases.forEach(({ k, complex }) => { + test(`k=2^${k}, expected=(${complex.getRe()}, ${complex.getIm()})`, () => { + const power = Math.pow(2, k); + const g = SAFE_LARGE_CONSTANT / power; - test('NaN', () => { - expect(conjugate(NAN)).toEqual(NAN); + const x = new Complex(power, 1); + const y = new Complex(1, 1); + + const xScaled = multiply(x, g); + const yScaled = multiply(y, g); + + const result = divide(xScaled, yScaled); + + expect(equals(result, complex)).toBe(true); + }); }); }); }); - describe('Equality', () => { - test('eq()', () => { - expect(equals(z, z)).toBeTruthy(); - expect(equals(z, w)).toBeFalsy(); - }); + describe('Negation', () => { + test('negates complex number', () => { + const result = negate(z); - test('neq()', () => { - expect(notEquals(z, z)).toBeFalsy(); - expect(notEquals(z, w)).toBeTruthy(); + expect(result.getRe()).toBe(-1); + expect(result.getIm()).toBe(-1); }); describe('Special Cases', () => { - test('Infinity', () => { - expect(equals(INFINITY, INFINITY)).toBeTruthy(); - expect(notEquals(INFINITY, INFINITY)).toBeFalsy(); - }); + const testCases = [ + { input: ZERO, expected: ZERO }, + { input: INFINITY, expected: INFINITY }, + { input: NAN, expected: NAN }, + ]; - test('NaN', () => { - expect(equals(NAN, NAN)).toBeFalsy(); - expect(notEquals(NAN, NAN)).toBeTruthy(); + testCases.forEach(({ input, expected }) => { + test(`negate(${getLabel(input)})`, () => { + expect(negate(input)).toEqual(expected); + }); }); }); }); - describe('Real Part', () => { - test('Re(z)', () => { - expect(z.getRe()).toEqual(1); + describe('Conjugate', () => { + test('returns conjugate of complex number', () => { + const result = conjugate(z); + + expect(result.getRe()).toBe(z.getRe()); + expect(result.getIm()).toBe(-z.getIm()); }); describe('Special Cases', () => { - test('Infinity', () => { - expect(INFINITY.getRe()).toEqual(Infinity); - }); + const testCases = [ + { input: INFINITY, expected: INFINITY }, + { input: NAN, expected: NAN }, + { input: ZERO, expected: ZERO }, + ]; - test('NaN', () => { - expect(Number.isNaN(NAN.getRe())).toBeTruthy(); + testCases.forEach(({ input, expected }) => { + test(`conjugate(${getLabel(input)})`, () => { + expect(conjugate(input)).toEqual(expected); + }); }); }); }); - describe('Imaginary Part', () => { - test('Im(z)', () => { - expect(z.getIm()).toEqual(1); + describe('Equality', () => { + test('equals returns true for equal numbers', () => { + expect(equals(z, z)).toBe(true); + }); + + test('equals returns false for different numbers', () => { + expect(equals(z, w)).toBe(false); + }); + + test('notEquals returns false for equal numbers', () => { + expect(notEquals(z, z)).toBe(false); + }); + + test('notEquals returns true for different numbers', () => { + expect(notEquals(z, w)).toBe(true); }); describe('Special Cases', () => { - test('Infinity', () => { - expect(INFINITY.getIm()).toEqual(Infinity); + test('handles Infinity', () => { + expect(equals(INFINITY, INFINITY)).toBe(true); + expect(notEquals(INFINITY, INFINITY)).toBe(false); }); - test('NaN', () => { - expect(Number.isNaN(NAN.getIm())).toBeTruthy(); + test('handles NaN', () => { + expect(equals(NAN, NAN)).toBe(false); + expect(notEquals(NAN, NAN)).toBe(true); }); }); }); describe('Modulus', () => { - test('|z|', () => { - expect(modulus(z)).toBeCloseTo(Math.SQRT2, 15); - expect(modulus(w)).toBeCloseTo(Math.sqrt(13), 15); + test('returns modulus of complex number', () => { + const testCases = [ + { z, expected: Math.SQRT2 }, + { z: w, expected: Math.sqrt(13) }, + ]; + + testCases.forEach(({ z: input, expected }) => { + expect(modulus(input)).toBeCloseTo(expected, 15); + }); }); describe('Special Cases', () => { - test('Infinity', () => { - expect(modulus(INFINITY)).toEqual(Infinity); - }); + const testCases = [ + { input: INFINITY, expected: Infinity }, + { input: NAN, expected: NaN }, + ]; - test('NaN', () => { - expect(modulus(NAN)).toEqual(NaN); + testCases.forEach(({ input, expected }) => { + test(`modulus(${getLabel(input)})`, () => { + expect(modulus(input)).toBe(expected); + }); }); }); }); describe('Argument', () => { - test('∠z', () => { + test('returns argument of complex number', () => { expect(argument(z)).toBeCloseTo(Math.PI / 4, 15); expect(argument(w)).toBeCloseTo(Math.atan2(w.getIm(), w.getRe()), 15); }); describe('Special Cases', () => { - test('Infinity', () => { - expect(argument(INFINITY)).toEqual(Infinity); - }); + const testCases = [ + { input: INFINITY, expected: Infinity }, + { input: NAN, expected: NaN }, + ]; - test('NaN', () => { - expect(argument(NAN)).toEqual(NaN); + testCases.forEach(({ input, expected }) => { + test(`argument(${getLabel(input)})`, () => { + expect(argument(input)).toBe(expected); + }); }); }); }); describe('Unit', () => { - const zu = unit(z); - const wu = unit(w); - test('ẑ', () => { + test('returns unit vector of complex number', () => { + const zu = unit(z); + const wu = unit(w); + expect(zu.getRe()).toBeCloseTo(Math.SQRT1_2, 15); expect(zu.getIm()).toBeCloseTo(Math.SQRT1_2, 15); expect(wu.getRe()).toBeCloseTo(2 / Math.sqrt(13), 15); expect(wu.getIm()).toBeCloseTo(3 / Math.sqrt(13), 15); }); - describe('Special Cases', () => { - test('Zero', () => { - expect(unit(ZERO)).toEqual(NAN); - }); - - test('Infinity', () => { - expect(unit(INFINITY)).toEqual(INFINITY); - }); - - test('NaN', () => { - expect(unit(NAN)).toEqual(NAN); - }); - }); - }); - - describe('Pythagoras', () => { - test('a^2 + b^2', () => { - expect(pythagoras(z)).toEqual(2); - expect(pythagoras(w)).toEqual(13); - }); describe('Special Cases', () => { - test('Infinity', () => { - expect(pythagoras(INFINITY)).toEqual(Infinity); - }); + const testCases = [ + { input: ZERO, expected: NAN }, + { input: INFINITY, expected: INFINITY }, + { input: NAN, expected: NAN }, + ]; - test('NaN', () => { - expect(pythagoras(NAN)).toEqual(NaN); + testCases.forEach(({ input, expected }) => { + test(`unit(${getLabel(input)})`, () => { + expect(unit(input)).toEqual(expected); + }); }); }); }); - describe('Misc.', () => { - describe('isReal()', () => { - test('isReal()', () => { - expect(isReal(z)).toBeFalsy(); - expect(isReal(ZERO)).toBeTruthy(); - expect(isReal(ONE)).toBeTruthy(); - expect(isReal(I)).toBeFalsy(); - expect(isReal(INFINITY)).toBeFalsy(); - expect(isReal(NAN)).toBeFalsy(); - }); - }); + describe('Pythagoras', () => { + test('returns sum of squares', () => { + const testCases = [ + { z, expected: 2 }, + { z: w, expected: 13 }, + ]; - describe('isPureImaginary()', () => { - test('isPureImaginary()', () => { - expect(isPureImaginary(z)).toBeFalsy(); - expect(isPureImaginary(ZERO)).toBeFalsy(); - expect(isPureImaginary(ONE)).toBeFalsy(); - expect(isPureImaginary(I)).toBeTruthy(); - expect(isPureImaginary(INFINITY)).toBeFalsy(); - expect(isPureImaginary(NAN)).toBeFalsy(); + testCases.forEach(({ z: input, expected }) => { + expect(pythagoras(input)).toBe(expected); }); }); - describe('isZero()', () => { - test('isZero()', () => { - expect(isZero(z)).toBeFalsy(); - expect(isZero(ZERO)).toBeTruthy(); - expect(isZero(ONE)).toBeFalsy(); - expect(isZero(I)).toBeFalsy(); - expect(isZero(INFINITY)).toBeFalsy(); - expect(isZero(NAN)).toBeFalsy(); - }); - }); + describe('Special Cases', () => { + const testCases = [ + { input: INFINITY, expected: Infinity }, + { input: NAN, expected: NaN }, + ]; - describe('isInfinite()', () => { - test('isInfinite()', () => { - expect(isInfinite(z)).toBeFalsy(); - expect(isInfinite(ZERO)).toBeFalsy(); - expect(isInfinite(ONE)).toBeFalsy(); - expect(isInfinite(I)).toBeFalsy(); - expect(isInfinite(INFINITY)).toBeTruthy(); - expect(isInfinite(NAN)).toBeFalsy(); + testCases.forEach(({ input, expected }) => { + test(`pythagoras(${getLabel(input)})`, () => { + expect(pythagoras(input)).toBe(expected); + }); }); }); + }); - describe('isNaN()', () => { - test('isNaN()', () => { - expect(isNaNC(z)).toBeFalsy(); - expect(isNaNC(ZERO)).toBeFalsy(); - expect(isNaNC(ONE)).toBeFalsy(); - expect(isNaNC(I)).toBeFalsy(); - expect(isNaNC(INFINITY)).toBeFalsy(); - expect(isNaNC(NAN)).toBeTruthy(); + describe('Type Checking', () => { + describe('isReal', () => { + const testCases = [ + { input: z, expected: false }, + { input: ZERO, expected: true }, + { input: ONE, expected: true }, + { input: I, expected: false }, + { input: INFINITY, expected: false }, + { input: NAN, expected: false }, + ]; + + testCases.forEach(({ input, expected }) => { + test(`isReal(${getLabel(input)})`, () => { + expect(isReal(input)).toBe(expected); + }); + }); + }); + + describe('isPureImaginary', () => { + const testCases = [ + { input: z, expected: false }, + { input: ZERO, expected: false }, + { input: ONE, expected: false }, + { input: I, expected: true }, + { input: INFINITY, expected: false }, + { input: NAN, expected: false }, + ]; + + testCases.forEach(({ input, expected }) => { + test(`isPureImaginary(${getLabel(input)})`, () => { + expect(isPureImaginary(input)).toBe(expected); + }); + }); + }); + + describe('isZero', () => { + const testCases = [ + { input: z, expected: false }, + { input: ZERO, expected: true }, + { input: ONE, expected: false }, + { input: I, expected: false }, + { input: INFINITY, expected: false }, + { input: NAN, expected: false }, + ]; + + testCases.forEach(({ input, expected }) => { + test(`isZero(${getLabel(input)})`, () => { + expect(isZero(input)).toBe(expected); + }); + }); + }); + + describe('isInfinite', () => { + const testCases = [ + { input: z, expected: false }, + { input: ZERO, expected: false }, + { input: ONE, expected: false }, + { input: I, expected: false }, + { input: INFINITY, expected: true }, + { input: NAN, expected: false }, + ]; + + testCases.forEach(({ input, expected }) => { + test(`isInfinite(${getLabel(input)})`, () => { + expect(isInfinite(input)).toBe(expected); + }); + }); + }); + + describe('isNaNC', () => { + const testCases = [ + { input: z, expected: false }, + { input: ZERO, expected: false }, + { input: ONE, expected: false }, + { input: I, expected: false }, + { input: INFINITY, expected: false }, + { input: NAN, expected: true }, + ]; + + testCases.forEach(({ input, expected }) => { + test(`isNaNC(${getLabel(input)})`, () => { + expect(isNaNC(input)).toBe(expected); + }); }); }); }); diff --git a/tests/utils/test-utils.spec.ts b/tests/utils/test-utils.spec.ts new file mode 100644 index 0000000..918fa00 --- /dev/null +++ b/tests/utils/test-utils.spec.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from 'vitest'; + +import { Complex } from '~/complex'; +import { type Polar } from '~/helpers'; +import { expectComplexCloseTo, expectPolarCloseTo, isApproximatelyEqual, TEST_EPSILON } from '~/tests/utils/test-utils'; + +const ZERO = Complex.ZERO; +const SMALL_DIFF = TEST_EPSILON / 2; +const LARGE_DIFF = TEST_EPSILON * 2; + +describe('test-utils', () => { + describe('isApproximatelyEqual', () => { + describe('exact equality', () => { + test('returns true for identical numbers', () => { + expect(isApproximatelyEqual(0, 0)).toBe(true); + expect(isApproximatelyEqual(1, 1)).toBe(true); + expect(isApproximatelyEqual(-1, -1)).toBe(true); + expect(isApproximatelyEqual(3.14159, 3.14159)).toBe(true); + }); + }); + + describe('values within epsilon', () => { + test('returns true for numbers very close to each other', () => { + expect(isApproximatelyEqual(0, SMALL_DIFF)).toBe(true); + expect(isApproximatelyEqual(1, 1 + SMALL_DIFF)).toBe(true); + expect(isApproximatelyEqual(-1, -1 - SMALL_DIFF)).toBe(true); + }); + + test('returns true for numbers near zero using absolute error', () => { + const value = 0.5; + + expect(isApproximatelyEqual(value, value + SMALL_DIFF)).toBe(true); + expect(isApproximatelyEqual(value, value - SMALL_DIFF)).toBe(true); + }); + + test('returns true for numbers away from zero using relative error', () => { + const value = 100; + + expect(isApproximatelyEqual(value, value + SMALL_DIFF * value)).toBe(true); + expect(isApproximatelyEqual(value, value - SMALL_DIFF * value)).toBe(true); + }); + }); + + describe('values outside epsilon', () => { + test('returns false for numbers too far apart', () => { + expect(isApproximatelyEqual(0, LARGE_DIFF)).toBe(false); + expect(isApproximatelyEqual(1, 1 + LARGE_DIFF)).toBe(false); + expect(isApproximatelyEqual(-1, -1 - LARGE_DIFF)).toBe(false); + }); + + test('returns false for clearly different numbers', () => { + expect(isApproximatelyEqual(1, 2)).toBe(false); + expect(isApproximatelyEqual(0.1, 0.2)).toBe(false); + expect(isApproximatelyEqual(-5, 5)).toBe(false); + }); + }); + + describe('NaN handling', () => { + test('returns false when first argument is NaN', () => { + expect(isApproximatelyEqual(NaN, 0)).toBe(false); + expect(isApproximatelyEqual(NaN, 1)).toBe(false); + expect(isApproximatelyEqual(NaN, NaN)).toBe(false); + }); + + test('returns false when second argument is NaN', () => { + expect(isApproximatelyEqual(0, NaN)).toBe(false); + expect(isApproximatelyEqual(1, NaN)).toBe(false); + }); + }); + + describe('Infinity handling', () => { + test('returns true for identical infinities', () => { + expect(isApproximatelyEqual(Infinity, Infinity)).toBe(true); + expect(isApproximatelyEqual(-Infinity, -Infinity)).toBe(true); + }); + + test('returns false for different infinities', () => { + expect(isApproximatelyEqual(Infinity, -Infinity)).toBe(false); + expect(isApproximatelyEqual(-Infinity, Infinity)).toBe(false); + }); + + test('returns false when comparing infinity to finite numbers', () => { + expect(isApproximatelyEqual(Infinity, 0)).toBe(false); + expect(isApproximatelyEqual(Infinity, 1)).toBe(false); + expect(isApproximatelyEqual(-Infinity, 0)).toBe(false); + expect(isApproximatelyEqual(-Infinity, -1)).toBe(false); + }); + }); + + describe('custom epsilon', () => { + test('uses custom epsilon when provided', () => { + const customEpsilon = 0.1; + + expect(isApproximatelyEqual(1, 1.05, customEpsilon)).toBe(true); + expect(isApproximatelyEqual(1, 1.15, customEpsilon)).toBe(false); + }); + + test('works with very small custom epsilon', () => { + const tinyEpsilon = 1e-20; + const diff = 1e-15; + + expect(isApproximatelyEqual(1, 1 + diff, tinyEpsilon)).toBe(false); + }); + + test('works with large custom epsilon', () => { + const largeEpsilon = 1; + + expect(isApproximatelyEqual(1, 1.5, largeEpsilon)).toBe(true); + expect(isApproximatelyEqual(1, 2, 0.5)).toBe(false); + }); + }); + + describe('edge cases', () => { + test('handles very small numbers', () => { + const tiny = 1e-15; + + expect(isApproximatelyEqual(tiny, tiny + SMALL_DIFF)).toBe(true); + expect(isApproximatelyEqual(tiny, tiny + LARGE_DIFF)).toBe(false); + }); + + test('handles very large numbers', () => { + const large = 1e15; + const diff = (TEST_EPSILON * large) / 2; + + expect(isApproximatelyEqual(large, large + diff)).toBe(true); + expect(isApproximatelyEqual(large, large + diff * 2)).toBe(false); + }); + + test('handles numbers just below and above 1', () => { + expect(isApproximatelyEqual(0.999, 1.001)).toBe(false); + expect(isApproximatelyEqual(0.9999, 1.0001, 0.01)).toBe(true); + }); + }); + }); + + describe('expectComplexCloseTo', () => { + describe('equal complex numbers', () => { + test('does not throw for identical complex numbers', () => { + const z1 = new Complex(1, 2); + const z2 = new Complex(1, 2); + + expect(() => expectComplexCloseTo(z1, z2)).not.toThrow(); + }); + + test('does not throw for zero complex numbers', () => { + expect(() => expectComplexCloseTo(ZERO, ZERO)).not.toThrow(); + }); + }); + + describe('close complex numbers', () => { + test('does not throw for complex numbers within epsilon', () => { + const z1 = new Complex(1, 2); + const z2 = new Complex(1 + SMALL_DIFF, 2 + SMALL_DIFF); + + expect(() => expectComplexCloseTo(z1, z2)).not.toThrow(); + }); + + test('does not throw for complex numbers with small differences', () => { + const z1 = new Complex(0.1, 0.2); + const z2 = new Complex(0.1 + SMALL_DIFF, 0.2 - SMALL_DIFF); + + expect(() => expectComplexCloseTo(z1, z2)).not.toThrow(); + }); + }); + + describe('different complex numbers', () => { + test('throws for complex numbers with different real parts', () => { + const z1 = new Complex(1, 2); + const z2 = new Complex(2, 2); + + expect(() => expectComplexCloseTo(z1, z2)).toThrow(); + }); + + test('throws for complex numbers with different imaginary parts', () => { + const z1 = new Complex(1, 2); + const z2 = new Complex(1, 3); + + expect(() => expectComplexCloseTo(z1, z2)).toThrow(); + }); + + test('throws for complex numbers with both parts different', () => { + const z1 = new Complex(1, 2); + const z2 = new Complex(3, 4); + + expect(() => expectComplexCloseTo(z1, z2)).toThrow(); + }); + }); + + describe('custom epsilon', () => { + test('uses custom epsilon when provided', () => { + const customEpsilon = 0.1; + const z1 = new Complex(1, 2); + const z2 = new Complex(1.05, 2.05); + + expect(() => expectComplexCloseTo(z1, z2, customEpsilon)).not.toThrow(); + }); + + test('throws with custom epsilon when values are too different', () => { + const customEpsilon = 0.01; + const z1 = new Complex(1, 2); + const z2 = new Complex(1.1, 2.1); + + expect(() => expectComplexCloseTo(z1, z2, customEpsilon)).toThrow(); + }); + }); + + describe('edge cases', () => { + test('handles very small complex numbers', () => { + const z1 = new Complex(1e-15, 2e-15); + const z2 = new Complex(1e-15 + SMALL_DIFF, 2e-15 + SMALL_DIFF); + + expect(() => expectComplexCloseTo(z1, z2)).not.toThrow(); + }); + + test('handles very large complex numbers', () => { + const z1 = new Complex(1e15, 2e15); + const z2 = new Complex(1e15 + (TEST_EPSILON * 1e15) / 2, 2e15 + (TEST_EPSILON * 2e15) / 2); + + expect(() => expectComplexCloseTo(z1, z2)).not.toThrow(); + }); + + test('handles negative complex numbers', () => { + const z1 = new Complex(-1, -2); + const z2 = new Complex(-1 - SMALL_DIFF, -2 - SMALL_DIFF); + + expect(() => expectComplexCloseTo(z1, z2)).not.toThrow(); + }); + }); + }); + + describe('expectPolarCloseTo', () => { + describe('equal polar coordinates', () => { + test('does not throw for identical polar coordinates', () => { + const p1: Polar = { r: 1, p: Math.PI / 4 }; + const p2: Polar = { r: 1, p: Math.PI / 4 }; + + expect(() => expectPolarCloseTo(p1, p2)).not.toThrow(); + }); + + test('does not throw for zero polar coordinates', () => { + const p1: Polar = { r: 0, p: 0 }; + const p2: Polar = { r: 0, p: 0 }; + + expect(() => expectPolarCloseTo(p1, p2)).not.toThrow(); + }); + }); + + describe('close polar coordinates', () => { + test('does not throw for polar coordinates within epsilon', () => { + const p1: Polar = { r: 1, p: Math.PI / 4 }; + const p2: Polar = { r: 1 + SMALL_DIFF, p: Math.PI / 4 + SMALL_DIFF }; + + expect(() => expectPolarCloseTo(p1, p2)).not.toThrow(); + }); + + test('does not throw for polar coordinates with small differences', () => { + const p1: Polar = { r: 0.5, p: 0.1 }; + const p2: Polar = { r: 0.5 + SMALL_DIFF, p: 0.1 - SMALL_DIFF }; + + expect(() => expectPolarCloseTo(p1, p2)).not.toThrow(); + }); + }); + + describe('different polar coordinates', () => { + test('throws for polar coordinates with different radius', () => { + const p1: Polar = { r: 1, p: Math.PI / 4 }; + const p2: Polar = { r: 2, p: Math.PI / 4 }; + + expect(() => expectPolarCloseTo(p1, p2)).toThrow(); + }); + + test('throws for polar coordinates with different phase', () => { + const p1: Polar = { r: 1, p: Math.PI / 4 }; + const p2: Polar = { r: 1, p: Math.PI / 2 }; + + expect(() => expectPolarCloseTo(p1, p2)).toThrow(); + }); + + test('throws for polar coordinates with both parts different', () => { + const p1: Polar = { r: 1, p: Math.PI / 4 }; + const p2: Polar = { r: 2, p: Math.PI / 2 }; + + expect(() => expectPolarCloseTo(p1, p2)).toThrow(); + }); + }); + + describe('custom epsilon', () => { + test('uses custom epsilon when provided', () => { + const customEpsilon = 0.1; + const p1: Polar = { r: 1, p: Math.PI / 4 }; + const p2: Polar = { r: 1.05, p: Math.PI / 4 + 0.05 }; + + expect(() => expectPolarCloseTo(p1, p2, customEpsilon)).not.toThrow(); + }); + + test('throws with custom epsilon when values are too different', () => { + const customEpsilon = 0.01; + const p1: Polar = { r: 1, p: Math.PI / 4 }; + const p2: Polar = { r: 1.1, p: Math.PI / 4 + 0.1 }; + + expect(() => expectPolarCloseTo(p1, p2, customEpsilon)).toThrow(); + }); + }); + + describe('edge cases', () => { + test('handles very small polar coordinates', () => { + const p1: Polar = { r: 1e-15, p: 1e-15 }; + const p2: Polar = { r: 1e-15 + SMALL_DIFF, p: 1e-15 + SMALL_DIFF }; + + expect(() => expectPolarCloseTo(p1, p2)).not.toThrow(); + }); + + test('handles very large polar coordinates', () => { + const p1: Polar = { r: 1e15, p: Math.PI }; + const p2: Polar = { r: 1e15 + (TEST_EPSILON * 1e15) / 2, p: Math.PI + SMALL_DIFF }; + + expect(() => expectPolarCloseTo(p1, p2)).not.toThrow(); + }); + + test('handles negative radius', () => { + const p1: Polar = { r: -1, p: Math.PI }; + const p2: Polar = { r: -1 - SMALL_DIFF, p: Math.PI }; + + expect(() => expectPolarCloseTo(p1, p2)).not.toThrow(); + }); + + test('handles phase wrapping around 2π', () => { + const p1: Polar = { r: 1, p: 0 }; + const p2: Polar = { r: 1, p: 2 * Math.PI }; + + expect(() => expectPolarCloseTo(p1, p2)).toThrow(); + }); + }); + }); +}); diff --git a/tests/utils/test-utils.ts b/tests/utils/test-utils.ts new file mode 100644 index 0000000..bbb489c --- /dev/null +++ b/tests/utils/test-utils.ts @@ -0,0 +1,93 @@ +import { expect } from 'vitest'; + +import { type Complex } from '~/complex'; +import { type Polar } from '~/helpers'; + +/** + * Epsilon value for floating-point comparisons. + * Using a slightly larger epsilon than Number.EPSILON for practical comparisons. + */ +export const TEST_EPSILON = Number.EPSILON * 10; + +/** + * Checks if two floating-point numbers are approximately equal using a combination + * of absolute and relative error. This is more robust than simple epsilon comparison. + * + * For values near zero, uses absolute error: |a - b| < epsilon + * For values away from zero, uses relative error: |a - b| < epsilon * max(|a|, |b|) + * + * @param a - First number + * @param b - Second number + * @param epsilon - Maximum allowed error (defaults to TEST_EPSILON) + * @returns True if numbers are approximately equal + * + * @example + * ```typescript + * isApproximatelyEqual(0.1 + 0.2, 0.3); // => true + * isApproximatelyEqual(1, 1.0001, 0.001); // => true + * isApproximatelyEqual(1, 2); // => false + * ``` + */ +export function isApproximatelyEqual(a: number, b: number, epsilon: number = TEST_EPSILON): boolean { + if (Number.isNaN(a) || Number.isNaN(b)) return false; + if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b; + + if (a === b) return true; + + const absA = Math.abs(a); + const absB = Math.abs(b); + const maxAbs = Math.max(absA, absB); + + if (maxAbs < 1) return Math.abs(a - b) < epsilon; + + return Math.abs(a - b) < epsilon * maxAbs; +} + +/** + * Checks if a complex number's real and imaginary parts are approximately equal + * to an expected Complex number using epsilon-based comparison. + * + * @param z - Complex number to check + * @param expected - Expected Complex number + * @param epsilon - Maximum allowed error (defaults to EPSILON) + */ +export function expectComplexCloseTo(z: Complex, expected: Complex, epsilon: number = TEST_EPSILON) { + const actualRe = z.getRe(); + const actualIm = z.getIm(); + const expectedRe = expected.getRe(); + const expectedIm = expected.getIm(); + + expect( + isApproximatelyEqual(actualRe, expectedRe, epsilon), + `Real part: expected ${expectedRe}, got ${actualRe} (difference: ${Math.abs(actualRe - expectedRe)})`, + ).toBe(true); + + expect( + isApproximatelyEqual(actualIm, expectedIm, epsilon), + `Imaginary part: expected ${expectedIm}, got ${actualIm} (difference: ${Math.abs(actualIm - expectedIm)})`, + ).toBe(true); +} + +/** + * Checks if a polar coordinate is approximately equal to an expected Polar coordinate. + * + * @param polar - Polar coordinate to check + * @param expected - Expected Polar coordinate + * @param epsilon - Maximum allowed error (defaults to TEST_EPSILON) + */ +export function expectPolarCloseTo(polar: Polar, expected: Polar, epsilon: number = TEST_EPSILON) { + const actualR = polar.r; + const actualP = polar.p; + const expectedR = expected.r; + const expectedP = expected.p; + + expect( + isApproximatelyEqual(actualR, expectedR, epsilon), + `Radius: expected ${expectedR}, got ${actualR} (difference: ${Math.abs(actualR - expectedR)})`, + ).toBe(true); + + expect( + isApproximatelyEqual(actualP, expectedP, epsilon), + `Phase: expected ${expectedP}, got ${actualP} (difference: ${Math.abs(actualP - expectedP)})`, + ).toBe(true); +} diff --git a/vitest.config.ts b/vitest.config.ts index cc9eae2..ec27074 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,14 +2,20 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [tsconfigPaths()], + plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], test: { globals: true, include: ['tests/**/*.{test,spec}.{ts,tsx}'], environment: 'node', coverage: { provider: 'istanbul', - reporter: ['text', 'json', 'html'], + reporter: ['text', 'json', 'json-summary', 'html'], + thresholds: { + lines: 80, + branches: 80, + functions: 80, + statements: 80, + }, }, }, });