diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 60366dec..fad09f93 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -1055,3 +1055,135 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: pnpm --filter architectura ci:publish + + ## Assert + + lint-assert: + needs: setup + runs-on: ubuntu-latest + # if: ${{ needs.setup.outputs.ts-files-changed == 'true' }} + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: PNPM installation + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: 9 + run_install: false + + - name: Dependencies cache unpacking + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Building workspace dependencies + run: pnpm --filter testing-ground ci:build + + - name: Lint with ESlint + run: pnpm --filter assert ci:lint + + typescript-syntax-check-assert: + needs: setup + runs-on: ubuntu-latest + # if: ${{ needs.setup.outputs.ts-files-changed == 'true' }} + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: PNPM installation + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: 9 + run_install: false + + - name: Dependencies cache unpacking + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Building workspace dependencies + run: pnpm --filter testing-ground ci:build + + - name: Check TypeScript syntax + run: pnpm --filter assert ci:ts:check + + test-assert: + needs: setup + runs-on: ubuntu-latest + # if: ${{ needs.setup.outputs.ts-files-changed == 'true' }} + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: PNPM installation + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: 9 + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + + - name: Dependencies cache unpacking + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Building workspace dependencies + run: pnpm --filter testing-ground ci:build + + - name: Test with Node Test runner + run: pnpm --filter assert ci:test:unit + + publish-assert: + if: ${{ github.ref == 'refs/heads/main' && ! failure() && ! cancelled() && github.event_name == 'push' }} + needs: + [ + spell-check-global, + lint-assert, + typescript-syntax-check-assert, + test-assert, + ] + runs-on: ubuntu-latest + environment: npm + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + registry-url: "https://registry.npmjs.org" + + - name: PNPM installation + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: 9 + run_install: false + + - name: Dependencies cache unpacking + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Building workspace dependencies + run: pnpm --filter testing-ground ci:build + + - name: Package build + run: pnpm --filter assert ci:build + + - name: Publish package + id: package-version + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm --filter assert ci:publish diff --git a/.vscode/cspell.json b/.vscode/cspell.json index a49fcc1b..f5837341 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -13,10 +13,12 @@ "npm" ], "words": [ - "Vitruvius", - "architectura", + "fulfill", + "fulfills", ], "ignoreWords": [ + "Vitruvius", + "architectura", "Lebacq", "Zamralik", "vars", diff --git a/packages/assert/.c8rc.json b/packages/assert/.c8rc.json new file mode 100644 index 00000000..2e6a9d61 --- /dev/null +++ b/packages/assert/.c8rc.json @@ -0,0 +1,20 @@ +{ + "src": "./src", + "include": [ + "**/src/**" + ], + "extension": [ + ".mts" + ], + "reporter": [ + "html" + ], + "report-dir": "./reports/c8", + "skip-full": true, + "check-coverage": true, + "all": true, + "statements": 100, + "branches": 100, + "functions": 100, + "lines": 100 +} diff --git a/packages/assert/.gitignore b/packages/assert/.gitignore new file mode 100644 index 00000000..47904470 --- /dev/null +++ b/packages/assert/.gitignore @@ -0,0 +1,10 @@ +**/node_modules/** +build +temp/logs/*.log +yarn.lock +package-lock.json +nodemon.json +yarn-error.log +.vscode/ +**/database/** +coverage/ diff --git a/packages/assert/LICENSE b/packages/assert/LICENSE new file mode 100644 index 00000000..11ce4ee2 --- /dev/null +++ b/packages/assert/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 VitruviusLabs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/assert/eslint.config.mjs b/packages/assert/eslint.config.mjs new file mode 100644 index 00000000..bb6f95cb --- /dev/null +++ b/packages/assert/eslint.config.mjs @@ -0,0 +1,3 @@ +import configurations from "../../eslint.config.mjs"; + +export default configurations; diff --git a/packages/assert/mock/mock-assertion-instantiation.mts b/packages/assert/mock/mock-assertion-instantiation.mts new file mode 100644 index 00000000..0b54c8ee --- /dev/null +++ b/packages/assert/mock/mock-assertion-instantiation.mts @@ -0,0 +1,16 @@ +import type { AssertionInstantiationInterface } from "../src/definition/interface/assertion-instantiation.interface.mjs"; +import { FluentAssertion, RootAssertion } from "../src/assertion/_internal.mjs"; + +function mockAssertionInstantiation(): Required +{ + const ROOT: RootAssertion = new RootAssertion("root"); + const PARENT: FluentAssertion = new FluentAssertion({ root: ROOT, parent: ROOT, name: "parent" }); + + return { + root: ROOT, + parent: PARENT, + name: "the tested value", + }; +} + +export { mockAssertionInstantiation }; diff --git a/packages/assert/mock/mock-assertion.mts b/packages/assert/mock/mock-assertion.mts new file mode 100644 index 00000000..a90ab5dc --- /dev/null +++ b/packages/assert/mock/mock-assertion.mts @@ -0,0 +1,13 @@ +import { FluentAssertion } from "../src/assertion/_internal.mjs"; +import { mockAssertionInstantiation } from "./mock-assertion-instantiation.mjs"; + +function mockAssertion(value?: unknown): FluentAssertion +{ + const ASSERTION: FluentAssertion = new FluentAssertion(mockAssertionInstantiation()); + + Reflect.set(ASSERTION, "actualValue", value); + + return ASSERTION; +} + +export { mockAssertion }; diff --git a/packages/assert/mock/mock-child-assertion.mts b/packages/assert/mock/mock-child-assertion.mts new file mode 100644 index 00000000..287ae41e --- /dev/null +++ b/packages/assert/mock/mock-child-assertion.mts @@ -0,0 +1,17 @@ +import { FluentAssertion } from "../src/assertion/_internal.mjs"; + +function mockChildAssertion(parent: FluentAssertion, value: unknown, name: string): FluentAssertion +{ + const ASSERTION: FluentAssertion = new FluentAssertion({ + // @ts-expect-error: Access to private property. + root: parent.root, + parent: parent, + name: name, + }); + + Reflect.set(ASSERTION, "actualValue", value); + + return ASSERTION; +} + +export { mockChildAssertion }; diff --git a/packages/assert/mock/mock-void-assertion.mts b/packages/assert/mock/mock-void-assertion.mts new file mode 100644 index 00000000..86c33140 --- /dev/null +++ b/packages/assert/mock/mock-void-assertion.mts @@ -0,0 +1,12 @@ +import { type FluentAssertion, VoidAssertion } from "../src/assertion/_internal.mjs"; + +function mockVoidAssertion(parent: FluentAssertion): VoidAssertion +{ + return new VoidAssertion({ + // @ts-expect-error: Access to private property. + root: parent.root, + parent: parent, + }); +} + +export { mockVoidAssertion }; diff --git a/packages/assert/package.json b/packages/assert/package.json new file mode 100755 index 00000000..b8b0e9cd --- /dev/null +++ b/packages/assert/package.json @@ -0,0 +1,47 @@ +{ + "name": "@vitruvius-labs/assert", + "version": "0.1.0", + "description": "A fluent assertion library.", + "author": { + "name": "VitruviusLabs" + }, + "contributors": [ + "Nicolas \"SmashingQuasar\" Lebacq ", + "Benjamin Blum " + ], + "license": "MIT", + "private": false, + "type": "module", + "files": [ + "./build/**/*" + ], + "exports": { + ".": { + "import": "./build/esm/_index.mjs", + "types": "./build/types/_index.d.mts" + } + }, + "scripts": { + "clean": "rm -rf reports coverage build dist lib .eslintcache", + "compile": "tsc -p tsconfig.build.json", + "build": "pnpm clean && pnpm compile", + "eslint:check": "eslint", + "eslint:fix": "eslint --fix", + "test:unit": "tsx --test */**/*.spec.mts", + "test:unit:only": "tsx --test --test-only */**/*.spec.mts", + "test:unit:stryker": "pnpm test:unit", + "test:mutation": "stryker run", + "node:coverage": "NODE_V8_COVERAGE=./reports/node-coverage tsx --experimental-test-coverage --test */**/*.spec.mts", + "c8:coverage": "c8 tsx --test-reporter spec --test */**/*.spec.mts", + "ts:check": "tsc -p tsconfig.json --noEmit", + "spell:check": "cspell -c ../../.vscode/cspell.json .", + "ci:lint": "pnpm eslint:check", + "ci:ts:check": "pnpm ts:check", + "ci:spell:check": "pnpm spell:check", + "ci:test:unit": "pnpm test:unit", + "ci:full": "pnpm ci:lint && pnpm ci:ts:check && pnpm ci:spell:check && pnpm ci:test:unit", + "ci:publish": "pnpm publish --access public --no-git-checks", + "ci:publish:dry": "pnpm publish --access public --dry-run --no-git-checks", + "ci:build": "pnpm build" + } +} diff --git a/packages/assert/src/_index.mts b/packages/assert/src/_index.mts new file mode 100644 index 00000000..098f1b4e --- /dev/null +++ b/packages/assert/src/_index.mts @@ -0,0 +1,5 @@ +import type { FluentAssertion } from "./assertion/fluent-assertion.mjs"; +import type { VoidAssertion } from "./assertion/void-assertion.mjs"; +import { expect } from "./assertion/expect.mjs"; + +export { type FluentAssertion, type VoidAssertion, expect }; diff --git a/packages/assert/src/assertion/_internal.mts b/packages/assert/src/assertion/_internal.mts new file mode 100644 index 00000000..4f8ec2df --- /dev/null +++ b/packages/assert/src/assertion/_internal.mts @@ -0,0 +1,5 @@ +/* Guarantee the loading order */ +export * from "./base-assertion.mjs"; +export * from "./fluent-assertion.mjs"; +export * from "./root-assertion.mjs"; +export * from "./void-assertion.mjs"; diff --git a/packages/assert/src/assertion/base-assertion.mts b/packages/assert/src/assertion/base-assertion.mts new file mode 100644 index 00000000..78aa3dca --- /dev/null +++ b/packages/assert/src/assertion/base-assertion.mts @@ -0,0 +1,73 @@ +import type { BaseAssertionInstantiationInterface } from "../definition/interface/base-assertion-instantiation.interface.mjs"; +import { FluentAssertion, RootAssertion } from "./_internal.mjs"; + +/** + * Base assertion + * + * @internal + */ +abstract class BaseAssertion +{ + protected readonly root: RootAssertion; + protected readonly parent: FluentAssertion; + + public constructor(parameter: BaseAssertionInstantiationInterface) + { + const ROOT: BaseAssertion = parameter.root ?? this; + const PARENT: BaseAssertion = parameter.parent ?? this; + + if (!(ROOT instanceof RootAssertion)) + { + throw new Error("Root assertion is required"); + } + + if (!(PARENT instanceof FluentAssertion)) + { + throw new Error("Parent assertion is required"); + } + + this.root = ROOT; + this.parent = PARENT; + } + + /** + * @return the initial assertion. + * + * @throws if this is the root assertion + */ + public reset(): FluentAssertion + { + if (this instanceof RootAssertion) + { + throw new Error("Cannot backtrack from the root assertion"); + } + + return this.root; + } + + /** + * @return the parent assertion. + * + * @throws if this is the root assertion + */ + public rewind(): FluentAssertion + { + if (this instanceof RootAssertion) + { + throw new Error("Cannot backtrack from the root assertion"); + } + + return this.parent; + } + + /** + * @privateRemarks + * Allow awaiting the assertion without exposing the method + */ + protected then(resolve: (value: Promise | undefined) => void): void + { + resolve(this.root.promise); + } +} + +export { BaseAssertion }; diff --git a/packages/assert/src/assertion/expect.mts b/packages/assert/src/assertion/expect.mts new file mode 100644 index 00000000..6c892ee4 --- /dev/null +++ b/packages/assert/src/assertion/expect.mts @@ -0,0 +1,9 @@ +import type { FluentAssertion } from "./fluent-assertion.mjs"; +import { RootAssertion } from "./_internal.mjs"; + +function expect(value: unknown): FluentAssertion +{ + return new RootAssertion(value); +} + +export { expect }; diff --git a/packages/assert/src/assertion/fluent-assertion.mts b/packages/assert/src/assertion/fluent-assertion.mts new file mode 100644 index 00000000..dee4c1ac --- /dev/null +++ b/packages/assert/src/assertion/fluent-assertion.mts @@ -0,0 +1,1735 @@ +/* eslint-disable max-lines -- Fluency is verbose */ + +import type { SinonSpy, SinonSpyCall } from "sinon"; +import type { AssertionInstantiationInterface } from "../definition/interface/assertion-instantiation.interface.mjs"; +import type { AssertionFlagsInterface } from "../definition/interface/assertion-flags.interface.mjs"; +import { deepStrictEqual, doesNotMatch, doesNotReject, doesNotThrow, fail, match, notDeepStrictEqual, notStrictEqual, rejects, strictEqual, throws } from "node:assert"; +import { BaseAssertion } from "./base-assertion.mjs"; +import { VoidAssertion } from "./void-assertion.mjs"; +import { AssertionConstantEnum } from "../definition/enum/assertion-constant.enum.mjs"; +import { createErrorPredicate } from "../error-predicate/create-error-predicate.mjs"; +import { getType } from "../utility/get-type.mjs"; + +/* eslint-disable @ts/member-ordering -- Ordered more meaningfully */ +/* eslint-disable accessor-pairs -- Getters are used for fluent chaining */ +/* eslint-disable id-length -- Fluent chaining words can be short */ + +/** + * Fluent assertion + * + * @sealed + */ +class FluentAssertion extends BaseAssertion +{ + protected static readonly MISSING_VALUE: unique symbol = Symbol("MISSING_VALUE"); + + protected readonly name: string; + protected readonly actualValue: unknown; + protected negationFlag: boolean; + + public constructor(parameter: AssertionInstantiationInterface) + { + super(parameter); + + this.name = parameter.name; + this.actualValue = FluentAssertion.MISSING_VALUE; + this.negationFlag = false; + } + + protected static IsObject(value: unknown): value is object + { + return typeof value === "object" && value !== null; + } + + protected static IsFunction(value: unknown): value is (...args: Array) => unknown + { + return typeof value === "function"; + } + + protected static IsSpy(value: unknown): value is SinonSpy + { + if (!FluentAssertion.IsObject(value) && !FluentAssertion.IsFunction(value)) + { + return false; + } + + const SPY_KEYS: Array = [ + "callCount", + "called", + "notCalled", + "calledOnce", + "calledTwice", + "calledThrice", + "firstCall", + "secondCall", + "thirdCall", + "lastCall", + "thisValues", + "args", + "exceptions", + "returnValues", + ]; + + return SPY_KEYS.every( + (key: string): boolean => + { + return key in value; + } + ); + } + + protected static IsSpyCall(value: unknown): value is SinonSpyCall + { + if (!FluentAssertion.IsObject(value) && !FluentAssertion.IsFunction(value)) + { + return false; + } + + const SPY_CALL_KEYS: Array = [ + "args", + "thisValue", + "exception", + "returnValue", + "callback", + "firstArg", + "lastArg", + ]; + + return SPY_CALL_KEYS.every( + (key: string): boolean => + { + return key in value; + } + ); + } + + /** + * Set the negation flag + * + * @remarks + * - The flag is reset after each assertion + * - See which assertion supports negation + * + * @throws if the flag is already set + */ + public get not(): this + { + if (this.negationFlag) + { + throw new Error("Double negation"); + } + + this.negationFlag = true; + + return this; + } + + /** + * Assert that the value throw when invoked + * + * @returns A dead end assertion + * + * @throws if the value is not a function + * @throws if the value returns when invoked + * @throws if the thrown error does not match the predicate + */ + public throw(predicate?: Error | RegExp | string | typeof Error): VoidAssertion + { + if (this.negationFlag) + { + throw new Error('"throws" cannot be negated, use "returns" instead'); + } + + this.appendAction( + (): void => + { + if (!FluentAssertion.IsFunction(this.actualValue)) + { + fail(`Expected ${this.name} to be a function`); + } + + const MESSAGE: string = `Expected ${this.name} to throw`; + + throws(this.actualValue, createErrorPredicate(MESSAGE, predicate), MESSAGE); + } + ); + + return this.createVoidAssertion(); + } + + /** + * Assert that the value will return when invoked + * + * @returns A new assertion for the returned value + * + * @throws if the value is not a function + * @throws if the value throws when invoked + */ + public get return(): FluentAssertion + { + if (this.negationFlag) + { + throw new Error('"returns" cannot be negated, use "throws" instead'); + } + + const ASSERTION: FluentAssertion = this.createAssertion("returned value"); + + this.appendAction( + (): void => + { + const CALLABLE: unknown = this.actualValue; + + if (!FluentAssertion.IsFunction(CALLABLE)) + { + fail(`Expected ${this.name} to be a function`); + } + + let result: unknown = undefined; + + doesNotThrow( + (): void => + { + /* Retrieve the returned value */ + result = CALLABLE(); + }, + `Expected ${this.name} to return` + ); + + ASSERTION.setValue(result); + } + ); + + return ASSERTION; + } + + /** + * Assert that the value eventually rejects + * + * @returns A dead end assertion + * + * @throws if the value is not a Promise + * @throws if the promise is eventually fulfilled + * @throws if the promise rejection reason does not match the predicate + */ + public reject(predicate?: Error | RegExp | string | typeof Error): VoidAssertion + { + if (this.negationFlag) + { + throw new Error('"rejects" cannot be negated, use "fulfills" instead'); + } + + this.appendAction( + async (): Promise => + { + if (!(this.actualValue instanceof Promise)) + { + fail(`Expected ${this.name} to be a promise`); + } + + const MESSAGE: string = `Expected ${this.name} to reject`; + + await rejects(this.actualValue, createErrorPredicate(MESSAGE, predicate), MESSAGE); + } + ); + + return this.createVoidAssertion(); + } + + /** + * Assert that the value eventually fulfills + * + * @returns A new assertion with the promised value + * + * @throws if the value is not a Promise + * @throws if the value is eventually rejected + */ + public get fulfill(): FluentAssertion + { + if (this.negationFlag) + { + throw new Error('"fulfill" cannot be negated, use "reject" instead'); + } + + const ASSERTION: FluentAssertion = this.createAssertion("returned value"); + + this.appendAction( + async (): Promise => + { + if (!(this.actualValue instanceof Promise)) + { + fail(`Expected ${this.name} to be a promise`); + } + + await doesNotReject(this.actualValue, `Expected ${this.name} to fulfill`); + + ASSERTION.setValue(await this.actualValue); + } + ); + + return ASSERTION; + } + + /** + * Assert that the value is recursively similar to the expected value + * + * @remarks + * - Can be negated + * + * @throws if the value is not recursively similar to the expected value + */ + public resemble(expected: unknown): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + notDeepStrictEqual(this.actualValue, expected, `Expected ${this.name} to be different from the expected value`); + + return; + } + + deepStrictEqual(this.actualValue, expected, `Expected ${this.name} to resemble the expected value`); + } + ); + + return this; + } + + /** + * Assert that the value is exactly the expected value + * + * @remarks + * - Can be negated + * + * @throws if the value is not exactly the expected value + */ + public exactly(expected: unknown): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + notStrictEqual(this.actualValue, expected, `Expected ${this.name} to be strictly different from the expected value`); + + return; + } + + strictEqual(this.actualValue, expected, `Expected ${this.name} to be strictly equal to the expected value`); + } + ); + + return this; + } + + /** + * Assert that the value is undefined + * + * @remarks + * - Can be negated + * + * @throws if the value is not undefined + */ + public get undefined(): this + { + return this.exactly(undefined); + } + + /** + * Assert that the value is null + * + * @remarks + * - Can be negated + * + * @throws if the value is not null + */ + public get null(): this + { + return this.exactly(null); + } + + /** + * Assert that the value is NaN + * + * @remarks + * - Can be negated + * + * @throws if the value is not NaN + */ + public get NaN(): this + { + return this.exactly(NaN); + } + + /** + * Assert that the value is nullish + * + * @remarks + * - Can be negated + * + * @throws if the value is not undefined, null, or NaN + */ + public get nullish(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + const NULLISH_VALUES: Array = [undefined, null, NaN]; + + if (flags.negation) + { + if (NULLISH_VALUES.includes(this.actualValue)) + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to not be undefined, null, or NaN, but got ${TYPE}`); + } + + return; + } + + if (!NULLISH_VALUES.includes(this.actualValue)) + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be undefined, null, or NaN, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is not nullish + * + * @remarks + * - Alias of .not.nullish + * + * @throws if the value is undefined, null, or NaN + */ + public get defined(): this + { + return this.not.nullish; + } + + /** + * Assert that the value is a boolean + * + * @remarks + * - Can be negated + * + * @throws if the value is not a boolean + */ + public get boolean(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (typeof this.actualValue === "boolean") + { + fail(`Expected ${this.name} to not be a boolean`); + } + + return; + } + + if (typeof this.actualValue !== "boolean") + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be a boolean, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is false + * + * @remarks + * - Can be negated + * + * @throws if the value is not false + */ + public get false(): this + { + return this.exactly(false); + } + + /** + * Assert that the value is true + * + * @remarks + * - Can be negated + * + * @throws if the value is not true + */ + public get true(): this + { + return this.exactly(true); + } + + /** + * Assert that the value is a safe integer + * + * @remarks + * - Can be negated + * + * @throws if the value is not a safe integer + */ + public get integer(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (Number.isSafeInteger(this.actualValue)) + { + fail(`Expected ${this.name} to not be an integer`); + } + + return; + } + + if (!Number.isSafeInteger(this.actualValue)) + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be an integer, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is a finite number + * + * @remarks + * - Can be negated + * + * @throws if the value is not a finite number + */ + public get number(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (Number.isFinite(this.actualValue)) + { + fail(`Expected ${this.name} to not be a finite number`); + } + + return; + } + + if (!Number.isFinite(this.actualValue)) + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be a finite number, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is a mathematical value (a finite number or infinity) + * + * @remarks + * - Can be negated + * + * @throws if the value is not a mathematical value + */ + public get mathematical(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (typeof this.actualValue === "number") + { + fail(`Expected ${this.name} to not be a number`); + } + + return; + } + + if (typeof this.actualValue !== "number" || Number.isNaN(this.actualValue)) + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be a number, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is a bigint + * + * @remarks + * - Can be negated + * + * @throws if the value is not a bigint + */ + public get bigint(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (typeof this.actualValue === "bigint") + { + fail(`Expected ${this.name} to not be a bigint`); + } + + return; + } + + if (typeof this.actualValue !== "bigint") + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be a bigint, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is less than the expected value + * + * @throws if the value is not a number nor a bigint + * @throws if the value is less than the expected value + */ + public below(max: bigint | number): this + { + if (this.negationFlag) + { + throw new Error('"below" cannot be negated, use "least" instead'); + } + + this.appendAction( + (): void => + { + if (typeof this.actualValue !== "number" && typeof this.actualValue !== "bigint") + { + fail(`Expected ${this.name} to be a number or a bigint`); + } + + if (this.actualValue >= max) + { + fail(`Expected ${this.name} to be below ${max.toString()}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is greater than to the expected value + * + * @throws if the value is not a number nor a bigint + * @throws if the value is greater than to the expected value + */ + public above(min: bigint | number): this + { + if (this.negationFlag) + { + throw new Error('"above" cannot be negated, use "most" instead'); + } + + this.appendAction( + (): void => + { + if (typeof this.actualValue !== "number" && typeof this.actualValue !== "bigint") + { + fail(`Expected ${this.name} to be a number or a bigint`); + } + + if (this.actualValue <= min) + { + fail(`Expected ${this.name} to be above ${min.toString()}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is at less than or equal to the expected value + * + * @throws if the value is not a number nor a bigint + * @throws if the value is less than or equal to the expected value + */ + public most(max: bigint | number): this + { + if (this.negationFlag) + { + throw new Error('"most" cannot be negated, use "above" instead'); + } + + this.appendAction( + (): void => + { + if (typeof this.actualValue !== "number" && typeof this.actualValue !== "bigint") + { + fail(`Expected ${this.name} to be a number or a bigint`); + } + + if (this.actualValue > max) + { + fail(`Expected ${this.name} to be at most ${max.toString()}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is greater than or equal to the expected value + * + * @throws if the value is not a number nor a bigint + * @throws if the value is greater than or equal to the expected value + */ + public least(min: bigint | number): this + { + if (this.negationFlag) + { + throw new Error('"least" cannot be negated, use "below" instead'); + } + + this.appendAction( + (): void => + { + if (typeof this.actualValue !== "number" && typeof this.actualValue !== "bigint") + { + fail(`Expected ${this.name} to be a number or a bigint`); + } + + if (this.actualValue <= min) + { + fail(`Expected ${this.name} to be at least ${min.toString()}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is a string + * + * @remarks + * - Can be negated + * + * @throws if the value is not a string + */ + public get string(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (typeof this.actualValue === "string") + { + fail(`Expected ${this.name} to not be a string`); + } + + return; + } + + if (typeof this.actualValue !== "string") + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be a string, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Accessor for the value's length + * + * @returns A new assertion with the value's length + * + * @throws if the value is not a string + */ + public get length(): FluentAssertion + { + if (this.negationFlag) + { + throw new Error('"length" cannot be negated'); + } + + const ASSERTION: FluentAssertion = this.createAssertion("length"); + + this.appendAction( + (): void => + { + if (typeof this.actualValue !== "string") + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be a string, but got ${TYPE}`); + } + + ASSERTION.setValue(this.actualValue.length); + } + ); + + return ASSERTION; + } + + /** + * Assert that the value match the pattern + * + * @remarks + * - Can be negated + * + * @throws if the value is not a string + * @throws if the value does not match the pattern + */ + public match(pattern: RegExp): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (typeof this.actualValue !== "string") + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be a string, but got ${TYPE}`); + } + + if (flags.negation) + { + doesNotMatch(this.actualValue, pattern, `Expected ${this.name} to not match ${pattern.toString()}`); + + return; + } + + match(this.actualValue, pattern, `Expected ${this.name} to match ${pattern.toString()}`); + } + ); + + return this; + } + + /** + * Assert that the value is a numerical string + * + * @remarks + * - Can be negated + * + * @throws if the value is not a string + * @throws if the value is not a numerical string + */ + public get numerical(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + const NUMERICAL_PATTERN: RegExp = /^-?(?:[1-9][0-9]*|0)(?:\.[0-9]+)?$/; + + if (typeof this.actualValue !== "string") + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be a string, but got ${TYPE}`); + } + + if (flags.negation) + { + doesNotMatch(this.actualValue, NUMERICAL_PATTERN, `Expected ${this.name} to not be a numerical string`); + + return; + } + + match(this.actualValue, NUMERICAL_PATTERN, `Expected ${this.name} to be a numerical string`); + } + ); + + return this; + } + + /** + * Assert that the value is a stringified number + * + * @remarks + * - Can be negated + * + * @throws if the value is not a stringified number + */ + public get UUID(): this + { + return this.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + } + + public get UUIDv4(): this + { + return this.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + } + + public get randomUUID(): this + { + return this.UUIDv4; + } + + /** + * Assert that the value is an array + * + * @remarks + * - Can be negated + * + * @throws if the value is not an array + */ + public get array(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (Array.isArray(this.actualValue)) + { + fail(`Expected ${this.name} to not be an array`); + } + + return; + } + + if (!Array.isArray(this.actualValue)) + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be an array, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is an array + * + * @remarks + * - The index is 0-based + * + * @returns A new assertion with the value at the index + * + * @throws if the value is not an array + * @throws if the value's length is less than the index + */ + public at(index: number): FluentAssertion + { + if (this.negationFlag) + { + throw new Error('"at" cannot be negated'); + } + + const ITEM_NAME: string = `item #${index.toString()}`; + + const ASSERTION: FluentAssertion = this.createAssertion(ITEM_NAME); + + this.appendAction( + (): void => + { + if (!Array.isArray(this.actualValue)) + { + fail(`Expected ${this.name} to be an array`); + } + + if (index >= this.actualValue.length) + { + fail(`Expected ${this.name} to have a at least ${(index + 1).toString()} items`); + } + + ASSERTION.setValue(this.actualValue[index]); + } + ); + + return ASSERTION; + } + + /** + * Assert that the value is an object + * + * @remarks + * - Can be negated + * + * @throws if the value is not an object + */ + public get object(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (FluentAssertion.IsObject(this.actualValue)) + { + fail(`Expected ${this.name} to not be an object`); + } + + return; + } + + if (!FluentAssertion.IsObject(this.actualValue)) + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be an object, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value is extensible + * + * @remarks + * - Can be negated + * + * @throws if the value is not extensible + */ + public get extensible(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (Object.isExtensible(this.actualValue)) + { + fail(`Expected ${this.name} to not be extensible`); + } + + return; + } + + if (!Object.isExtensible(this.actualValue)) + { + fail(`Expected ${this.name} to be extensible`); + } + } + ); + + return this; + } + + /** + * Assert that the value is sealed + * + * @remarks + * - Can be negated + * + * @throws if the value is not sealed + */ + public get sealed(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (Object.isSealed(this.actualValue)) + { + fail(`Expected ${this.name} to not be sealed`); + } + + return; + } + + if (!Object.isSealed(this.actualValue)) + { + fail(`Expected ${this.name} to be sealed`); + } + } + ); + + return this; + } + + /** + * Assert that the value is frozen + * + * @remarks + * - Can be negated + * + * @throws if the value is not frozen + */ + public get frozen(): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (Object.isFrozen(this.actualValue)) + { + fail(`Expected ${this.name} to not be frozen`); + } + + return; + } + + if (!Object.isFrozen(this.actualValue)) + { + fail(`Expected ${this.name} to be frozen`); + } + } + ); + + return this; + } + + /** + * Assert that the value is an instance of the expected class + * + * @remarks + * - Can be negated + * + * @throws if the value is not an instance of the expected class + */ + public instanceOf(class_constructor: abstract new (...args: ReadonlyArray) => object): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (flags.negation) + { + if (this.actualValue instanceof class_constructor) + { + fail(`Expected ${this.name} to not be an instance of ${class_constructor.name}`); + } + + return; + } + + if (!(this.actualValue instanceof class_constructor)) + { + const TYPE: string = getType(this.actualValue); + + fail(`Expected ${this.name} to be an instance of ${class_constructor.name}, but got ${TYPE}`); + } + } + ); + + return this; + } + + /** + * Assert that the value has the specified member (property or method) + * + * @remarks + * - Can be negated + * + * @throws if the value is not an object + * @throws if the value does not have the expected member + */ + public member(key: string | symbol): this + { + let property_name: string = ""; + + if (typeof key === "symbol") + { + property_name = `property ${key.toString()}`; + } + else + { + property_name = `property "${key}"`; + } + + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (!FluentAssertion.IsObject(this.actualValue)) + { + fail(`Expected ${this.name} to be an object`); + } + + if (flags.negation) + { + if (key in this.actualValue) + { + fail(`Expected ${this.name} to not have a ${property_name}`); + } + + return; + } + + if (!(key in this.actualValue)) + { + fail(`Expected ${property_name} to exist`); + } + } + ); + + return this; + } + + /** + * Accessor for the specified property + * + * @returns a new assertion object for the property value + * + * @throws if the value is not an object + * @throws if the value does not have the expected property + */ + public property(key: string | symbol): FluentAssertion + { + if (this.negationFlag) + { + throw new Error('"property" cannot be negated'); + } + + let property_name: string = ""; + + if (typeof key === "symbol") + { + property_name = `property ${key.toString()}`; + } + else + { + property_name = `property "${key}"`; + } + + const ASSERTION: FluentAssertion = this.createAssertion(property_name); + + this.appendAction( + (): void => + { + if (!FluentAssertion.IsObject(this.actualValue)) + { + fail(`Expected ${this.name} to be an object`); + } + + if (!(key in this.actualValue)) + { + fail(`Expected ${property_name} to exist`); + } + + ASSERTION.setValue(Reflect.get(this.actualValue, key)); + } + ); + + return ASSERTION; + } + + /** + * Assert that the value has been called + * + * @remarks + * - Can be negated + * + * @throws if the value is not a SinonSpy + * @throws if the value has not been called + * @throws if the value has been called a different number of times thant the expected count + */ + public called(count?: number): this + { + if (count !== undefined && count < 0) + { + throw new RangeError("Call count must be a non-negative integer"); + } + + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (!FluentAssertion.IsSpy(this.actualValue)) + { + fail(`Expected ${this.name} to be a spy`); + } + + if (flags.negation) + { + if (count === undefined) + { + strictEqual(this.actualValue.called, false, `Expected ${this.name} to not have been called`); + + return; + } + + notStrictEqual(this.actualValue.callCount, count, `Expected ${this.name} call count to be different than ${count.toString()}`); + + return; + } + + if (count === undefined) + { + strictEqual(this.actualValue.called, true, `Expected ${this.name} to have been called at least once`); + + return; + } + + strictEqual(this.actualValue.callCount, count, `Expected ${this.name} call count to be exactly ${count.toString()}`); + } + ); + + return this; + } + + /** + * Accessor for the specified call + * + * @remarks + * - The call number is 1-based + * + * @throws if the value is not a SinonSpy + * @throws if the value if the specified call does not exist + */ + public call(nth: number | "last"): FluentAssertion + { + if (this.negationFlag) + { + throw new Error('"call" cannot be negated'); + } + + if (nth !== "last" && (!Number.isSafeInteger(nth) || nth < 1)) + { + throw new RangeError('Call number must be a positive integer or "last"'); + } + + let call_name: string = "last call"; + + if (nth !== "last") + { + call_name = `call #${nth.toString()}`; + } + + const ASSERTION: FluentAssertion = this.createAssertion(call_name); + + this.appendAction( + (): void => + { + if (!FluentAssertion.IsSpy(this.actualValue)) + { + fail(`Expected ${this.name} to be a spy`); + } + + if (nth === "last") + { + if (!this.actualValue.called) + { + fail(`Expected ${this.name} to have been called at least once`); + } + + ASSERTION.setValue(this.actualValue.lastCall); + + return; + } + + if (nth > this.actualValue.callCount) + { + fail(`Expected ${this.name} to have been called at least ${nth.toString()} times`); + } + + ASSERTION.setValue(this.actualValue.getCall(nth - 1)); + } + ); + + return ASSERTION; + } + + /** + * Accessor for the first call + * + * @remarks + * - Alias of call(1) + */ + public get firstCall(): FluentAssertion + { + return this.call(AssertionConstantEnum.FIRST_CALL); + } + + /** + * Accessor for the second call + * + * @remarks + * - Alias of call(2) + */ + public get secondCall(): FluentAssertion + { + return this.call(AssertionConstantEnum.SECOND_CALL); + } + + /** + * Accessor for the third call + * + * @remarks + * - Alias of call(3) + */ + public get thirdCall(): FluentAssertion + { + return this.call(AssertionConstantEnum.THIRD_CALL); + } + + /** + * Accessor for the last call + * + * @remarks + * - Alias of call("last") + */ + public get lastCall(): FluentAssertion + { + return this.call("last"); + } + + /** + * Assert that the value has been called with the expected context + * + * @remarks + * - Can be negated + * + * @throws if the value is not a SinonSpyCall + * @throws if the call thisValue is not exactly the expected context + */ + public context(thisArg: object | undefined): this + { + this.appendAction( + (flags: AssertionFlagsInterface): void => + { + if (!FluentAssertion.IsSpyCall(this.actualValue)) + { + fail(`Expected ${this.name} to be a spy call`); + } + + if (flags.negation) + { + notStrictEqual(this.actualValue.thisValue, thisArg, `Expected ${this.name} context to not be exactly the expected context`); + + return; + } + + strictEqual(this.actualValue.thisValue, thisArg, `Expected ${this.name} context to be exactly the expected context`); + } + ); + + return this; + } + + /** + * Accessor for the call arguments + * + * @throws if the value is not a SinonSpyCall + * @throws if the call arguments do not resemble the expected arguments + */ + public get arguments(): FluentAssertion + { + if (this.negationFlag) + { + throw new Error('"arguments" cannot be negated'); + } + + const ASSERTION: FluentAssertion = this.createAssertion("arguments"); + + this.appendAction( + (): void => + { + if (!FluentAssertion.IsSpyCall(this.actualValue)) + { + fail(`Expected ${this.name} to be a spy call`); + } + + ASSERTION.setValue(this.actualValue.args); + } + ); + + return ASSERTION; + } + + /** + * Chaining word + */ + public get a(): this + { + return this; + } + + /** + * Chaining word + */ + public get an(): this + { + return this; + } + + /** + * Chaining word + */ + public get the(): this + { + return this; + } + + /** + * Chaining word + */ + public get will(): this + { + return this; + } + + /** + * Chaining word + */ + public get be(): this + { + return this; + } + + /** + * Chaining word + */ + public get is(): this + { + return this; + } + + /** + * Chaining word + */ + public get are(): this + { + return this; + } + + /** + * Chaining word + */ + public get was(): this + { + return this; + } + + /** + * Chaining word + */ + public get been(): this + { + return this; + } + + /** + * Chaining word + */ + public get have(): this + { + return this; + } + + /** + * Chaining word + */ + public get has(): this + { + return this; + } + + /** + * Chaining word + */ + public get do(): this + { + return this; + } + + /** + * Chaining word + */ + public get does(): this + { + return this; + } + + /** + * Chaining word + */ + public get did(): this + { + return this; + } + + /** + * Chaining word + */ + public get to(): this + { + return this; + } + + /** + * Chaining word + */ + public get of(): this + { + return this; + } + + /** + * Chaining word + */ + public get that(): this + { + return this; + } + + /** + * Chaining word + */ + public get which(): this + { + return this; + } + + /** + * Chaining word + */ + public get and(): this + { + return this; + } + + /** + * Chaining word + */ + public get but(): this + { + return this; + } + + /** + * Chaining word + */ + public get still(): this + { + return this; + } + + /** + * Chaining word + */ + public get also(): this + { + return this; + } + + /** + * Chaining word + */ + public get with(): this + { + return this; + } + + /** + * Chaining word + */ + public get value(): this + { + return this; + } + + protected appendAction(action: (flags: AssertionFlagsInterface) => Promise | void): void + { + /* Because of the asynchronous chain, the flags can change before the action is executed */ + /* Snapshot of the flags when the action was appended */ + const FLAGS: AssertionFlagsInterface = { + negation: this.negationFlag, + }; + + /* Reset of the flags for the next action */ + this.negationFlag = false; + + if (this.root.promise === undefined) + { + const RESULT: Promise | void = action(FLAGS); + + if (RESULT instanceof Promise) + { + /* Beginning of the promise chain */ + this.root.promise = RESULT; + } + + return; + } + + /* Append action to the promise chain */ + this.root.promise = this.root.promise.then( + (): Promise | void => + { + /* Append to the promise chain */ + return action(FLAGS); + } + ); + } + + protected setValue(value: unknown): void + { + if (this.actualValue !== FluentAssertion.MISSING_VALUE) + { + throw new Error("Value already set"); + } + + Reflect.set(this, "actualValue", value); + } + + protected createVoidAssertion(): VoidAssertion + { + return new VoidAssertion({ + root: this.root, + parent: this, + }); + } + + protected createAssertion(name: string): FluentAssertion + { + return new FluentAssertion({ + root: this.root, + parent: this, + name: this.buildName(name), + }); + } + + protected buildName(suffix: string): string + { + if (this.name === "value") + { + return suffix; + } + + return `${this.name} ${suffix}`; + } +} + +export { FluentAssertion }; diff --git a/packages/assert/src/assertion/root-assertion.mts b/packages/assert/src/assertion/root-assertion.mts new file mode 100644 index 00000000..d12eb5c5 --- /dev/null +++ b/packages/assert/src/assertion/root-assertion.mts @@ -0,0 +1,50 @@ +import { FluentAssertion } from "./_internal.mjs"; + +/** + * Root assertion + * + * @internal + */ +class RootAssertion extends FluentAssertion +{ + /** + * The promise chain is shared between the whole assertion tree + **/ + public promise: Promise | undefined; + + public constructor(value: unknown) + { + super({ name: RootAssertion.GetName(value) }); + + this.setValue(value); + + this.promise = undefined; + } + + protected static GetName(value: unknown): string + { + if (RootAssertion.IsSpy(value)) + { + if (value.wrappedMethod as unknown !== undefined) + { + return `spy of "${value.wrappedMethod.name}"`; + } + + return "spy"; + } + + if (RootAssertion.IsSpyCall(value)) + { + return "spy call"; + } + + if (typeof value === "function") + { + return "callable"; + } + + return "value"; + } +} + +export { RootAssertion }; diff --git a/packages/assert/src/assertion/void-assertion.mts b/packages/assert/src/assertion/void-assertion.mts new file mode 100644 index 00000000..55b4ed64 --- /dev/null +++ b/packages/assert/src/assertion/void-assertion.mts @@ -0,0 +1,12 @@ +import { BaseAssertion } from "./_internal.mjs"; + +/** + * Void assertion + * + * @sealed + */ +class VoidAssertion extends BaseAssertion +{ +} + +export { VoidAssertion }; diff --git a/packages/assert/src/definition/enum/assertion-constant.enum.mts b/packages/assert/src/definition/enum/assertion-constant.enum.mts new file mode 100644 index 00000000..d24f422c --- /dev/null +++ b/packages/assert/src/definition/enum/assertion-constant.enum.mts @@ -0,0 +1,8 @@ +const enum AssertionConstantEnum +{ + FIRST_CALL = 1, + SECOND_CALL = 2, + THIRD_CALL = 3, +} + +export { AssertionConstantEnum }; diff --git a/packages/assert/src/definition/interface/assertion-flags.interface.mts b/packages/assert/src/definition/interface/assertion-flags.interface.mts new file mode 100644 index 00000000..9142e20d --- /dev/null +++ b/packages/assert/src/definition/interface/assertion-flags.interface.mts @@ -0,0 +1,6 @@ +interface AssertionFlagsInterface +{ + negation: boolean; +} + +export type { AssertionFlagsInterface }; diff --git a/packages/assert/src/definition/interface/assertion-instantiation.interface.mts b/packages/assert/src/definition/interface/assertion-instantiation.interface.mts new file mode 100644 index 00000000..0ca7f672 --- /dev/null +++ b/packages/assert/src/definition/interface/assertion-instantiation.interface.mts @@ -0,0 +1,8 @@ +import type { BaseAssertionInstantiationInterface } from "./base-assertion-instantiation.interface.mjs"; + +interface AssertionInstantiationInterface extends BaseAssertionInstantiationInterface +{ + name: string; +} + +export type { AssertionInstantiationInterface }; diff --git a/packages/assert/src/definition/interface/base-assertion-instantiation.interface.mts b/packages/assert/src/definition/interface/base-assertion-instantiation.interface.mts new file mode 100644 index 00000000..584ca294 --- /dev/null +++ b/packages/assert/src/definition/interface/base-assertion-instantiation.interface.mts @@ -0,0 +1,10 @@ +import type { FluentAssertion } from "../../assertion/fluent-assertion.mjs"; +import type { RootAssertion } from "../../assertion/root-assertion.mjs"; + +interface BaseAssertionInstantiationInterface +{ + root?: RootAssertion; + parent?: FluentAssertion; +} + +export type { BaseAssertionInstantiationInterface }; diff --git a/packages/assert/src/error-predicate/create-error-predicate.mts b/packages/assert/src/error-predicate/create-error-predicate.mts new file mode 100644 index 00000000..0b1eb7ca --- /dev/null +++ b/packages/assert/src/error-predicate/create-error-predicate.mts @@ -0,0 +1,76 @@ +import { deepStrictEqual } from "assert"; +import { getType } from "../utility/get-type.mjs"; +import { fixError } from "./fix-error.mjs"; + +function createErrorPredicate(base_sentence: string, expected?: Error | ErrorConstructor | RegExp | string): (value: unknown) => true +{ + if (expected instanceof Error) + { + if (expected.message.length === 0) + { + throw new Error("An error message must never be empty."); + } + } + else if (typeof expected === "string") + { + if (expected.length === 0) + { + throw new Error("An error message must never be empty."); + } + } + else if (typeof expected === "function") + { + if (expected !== Error && !(expected.prototype instanceof Error)) + { + throw new Error("The expected class must extends Error."); + } + } + + return (value: unknown): true => + { + if (typeof expected === "function") + { + if (!(value instanceof expected)) + { + const TYPE: string = getType(value); + + throw new Error(`${base_sentence} with an instance of ${expected.name}, but got ${TYPE}.`); + } + } + else if (!(value instanceof Error)) + { + const TYPE: string = getType(value); + + throw new Error(`${base_sentence} with an instance of Error, but got ${TYPE}.`); + } + + if (expected instanceof Error) + { + deepStrictEqual(fixError(value), fixError(expected), `${base_sentence} with an error that resemble the expected error`); + } + + if (expected instanceof RegExp) + { + if (!expected.test(value.message)) + { + throw new Error(`${base_sentence} with a message that match ${expected.toString()}, but got "${value.message}"`); + } + } + + if (typeof expected === "string") + { + if (value.message !== expected) + { + throw new Error(`${base_sentence} with the message "${expected}", but got "${value.message}".`); + } + } + else if (value.message.length === 0) + { + throw new Error(`${base_sentence} with a message.`); + } + + return true; + }; +} + +export { createErrorPredicate }; diff --git a/packages/assert/src/error-predicate/fix-error.mts b/packages/assert/src/error-predicate/fix-error.mts new file mode 100644 index 00000000..5e67f7c0 --- /dev/null +++ b/packages/assert/src/error-predicate/fix-error.mts @@ -0,0 +1,26 @@ +/** + * @privateRemarks + * Enumerability determines which properties will be compared +**/ +function fixError(error: Error): Error +{ + ["message", "cause", "errors", "stack"].forEach( + (key: string): void => + { + const DESCRIPTOR: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(error, key); + + if (DESCRIPTOR === undefined) + { + return; + } + + DESCRIPTOR.enumerable = key !== "stack"; + + Object.defineProperty(error, key, DESCRIPTOR); + } + ); + + return error; +} + +export { fixError }; diff --git a/packages/assert/src/utility/get-type.mts b/packages/assert/src/utility/get-type.mts new file mode 100644 index 00000000..bf8a38ac --- /dev/null +++ b/packages/assert/src/utility/get-type.mts @@ -0,0 +1,83 @@ +function getType(value: unknown): string +{ + switch (typeof value) + { + case "undefined": + return "undefined"; + + case "boolean": + return value ? "true" : "false"; + + case "number": + if (Number.isNaN(value)) + { + return "NaN"; + } + + if (value === Number.POSITIVE_INFINITY) + { + return "+Infinity"; + } + + if (value === Number.NEGATIVE_INFINITY) + { + return "-Infinity"; + } + + if (Number.isSafeInteger(value)) + { + return "an integer"; + } + + return "a number"; + + case "bigint": + return "a bigint"; + + case "string": + if (value.length === 0) + { + return "an empty string"; + } + + return "a string"; + + case "symbol": + return "a symbol"; + + case "function": + return "a function"; + + case "object": + { + if (value === null) + { + return "null"; + } + + // eslint-disable-next-line @ts/no-unsafe-assignment -- Prototype is badly typed + const PROTOTYPE: object | null = Object.getPrototypeOf(value); + + if (PROTOTYPE === null) + { + return "a null-prototype object"; + } + + if (PROTOTYPE.constructor === Object) + { + return "an object"; + } + + const CLASS_NAME: string = PROTOTYPE.constructor.name; + + if (CLASS_NAME.length > 0) + { + return `an instance of ${CLASS_NAME}`; + } + + return "an instance of an anonymous class"; + } + } +} + +export { getType }; diff --git a/packages/assert/test/assertion/base-assertion.spec.mts b/packages/assert/test/assertion/base-assertion.spec.mts new file mode 100644 index 00000000..fd279af7 --- /dev/null +++ b/packages/assert/test/assertion/base-assertion.spec.mts @@ -0,0 +1,97 @@ +import type { BaseAssertionInstantiationInterface } from "../../src/definition/interface/base-assertion-instantiation.interface.mjs"; +import { describe, it } from "node:test"; +import { doesNotReject, strictEqual, throws } from "node:assert"; +import { createErrorTest } from "@vitruvius-labs/testing-ground"; +import { BaseAssertion } from "../../src/assertion/_internal.mjs"; +import { mockAssertionInstantiation } from "../../mock/mock-assertion-instantiation.mjs"; + +describe("BaseAssertion", (): void => { + describe("constructor", (): void => { + it("should create a new instance", (): void => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + const ASSERTION: BaseAssertion = Reflect.construct(BaseAssertion, [PARAMETERS]); + + strictEqual(Reflect.get(ASSERTION, "root"), PARAMETERS.root); + strictEqual(Reflect.get(ASSERTION, "parent"), PARAMETERS.parent); + }); + + it("should throw if the root is not a RootAssertion", (): void => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + throws( + (): void => { Reflect.construct(BaseAssertion, [{ root: undefined, parent: PARAMETERS.parent }]); }, + createErrorTest() + ); + }); + + it("should throw if the parent is not a FluentAssertion", (): void => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + throws( + (): void => { Reflect.construct(BaseAssertion, [{ root: PARAMETERS.root, parent: undefined }]); }, + createErrorTest() + ); + }); + }); + + describe("reset", (): void => { + it("should throw an error if the root assertion is reached", (): void => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + throws( + (): void => { PARAMETERS.root.reset(); }, + createErrorTest() + ); + }); + + it("should return the root assertion", (): void => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + const ASSERTION: BaseAssertion = Reflect.construct(BaseAssertion, [PARAMETERS]); + + const RESULT: unknown = ASSERTION.reset(); + + strictEqual(RESULT, PARAMETERS.root); + }); + }); + + describe("rewind", (): void => { + it("should throw an error if the root assertion is reached", (): void => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + throws( + (): void => { PARAMETERS.root.rewind(); }, + createErrorTest() + ); + }); + + it("should return the parent assertion", (): void => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + const ASSERTION: BaseAssertion = Reflect.construct(BaseAssertion, [PARAMETERS]); + + const RESULT: unknown = ASSERTION.rewind(); + + strictEqual(RESULT, PARAMETERS.parent); + }); + }); + + describe("then", (): void => { + it("should make the assertion resolve as a promise", async (): Promise => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + const ASSERTION: BaseAssertion = Reflect.construct(BaseAssertion, [PARAMETERS]); + + Reflect.set(PARAMETERS.root, "promise", Promise.resolve("promise")); + + const PROMISE: Promise = Promise.resolve(ASSERTION); + + await doesNotReject(PROMISE); + + const RESULT: unknown = await PROMISE; + + strictEqual(RESULT, "promise"); + }); + }); +}); diff --git a/packages/assert/test/assertion/expect.spec.mts b/packages/assert/test/assertion/expect.spec.mts new file mode 100644 index 00000000..bc669a85 --- /dev/null +++ b/packages/assert/test/assertion/expect.spec.mts @@ -0,0 +1,14 @@ +import { describe, it } from "node:test"; +import { deepStrictEqual } from "node:assert"; +import { RootAssertion } from "../../src/assertion/root-assertion.mjs"; +import { expect } from "../../src/assertion/expect.mjs"; + +describe("expect", (): void => { + it("should return an initialized root assertion", (): void => { + const SYMBOL: unique symbol = Symbol("test"); + + const RESULT: unknown = expect(SYMBOL); + + deepStrictEqual(RESULT, new RootAssertion(SYMBOL)); + }); +}); diff --git a/packages/assert/test/assertion/fluent-assertion.spec.mts b/packages/assert/test/assertion/fluent-assertion.spec.mts new file mode 100644 index 00000000..a8a49070 --- /dev/null +++ b/packages/assert/test/assertion/fluent-assertion.spec.mts @@ -0,0 +1,344 @@ +import type { AssertionInstantiationInterface } from "../../src/definition/interface/assertion-instantiation.interface.mjs"; +import { describe, it } from "node:test"; +import { deepStrictEqual, doesNotReject, doesNotThrow, fail, rejects, strictEqual, throws } from "node:assert"; +import { type SinonStub, stub } from "sinon"; +import { FluentAssertion } from "../../src/assertion/_internal.mjs"; +import { mockAssertionInstantiation } from "../../mock/mock-assertion-instantiation.mjs"; +import { mockAssertion } from "../../mock/mock-assertion.mjs"; +import { mockChildAssertion } from "../../mock/mock-child-assertion.mjs"; +import { mockVoidAssertion } from "../../mock/mock-void-assertion.mjs"; +import { createErrorPredicate } from "../../src/error-predicate/create-error-predicate.mjs"; + +describe("FluentAssertion", (): void => { + describe("constructor", (): void => { + it("should create a new instance", (): void => { + const PARAMETERS: Required = mockAssertionInstantiation(); + + const ASSERTION: FluentAssertion = Reflect.construct(FluentAssertion, [PARAMETERS]); + + strictEqual(Reflect.get(ASSERTION, "root"), PARAMETERS.root); + strictEqual(Reflect.get(ASSERTION, "parent"), PARAMETERS.parent); + strictEqual(Reflect.get(ASSERTION, "name"), PARAMETERS.name); + strictEqual(Reflect.get(ASSERTION, "actualValue"), Reflect.get(FluentAssertion, "MISSING_VALUE")); + }); + }); + + describe("not", (): void => { + it("should keep the chain going", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + const RESULT: unknown = ASSERTION.not; + + strictEqual(RESULT, ASSERTION); + }); + + it("should set the negation flag", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + ASSERTION.not; + + strictEqual(Reflect.get(ASSERTION, "negationFlag"), true); + }); + + it("should throw if the flag is already set", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + doesNotThrow((): void => { ASSERTION.not; }); + + throws( + (): void => { ASSERTION.not; }, + createErrorPredicate("Double negation") + ); + + throws( + (): void => { ASSERTION.not; }, + createErrorPredicate("Double negation") + ); + }); + + it("should return itself", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + strictEqual(ASSERTION.not, ASSERTION, "Expected the getter to return this"); + }); + }); + + describe("throws", (): void => { + it("should throw if negated", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + throws( + (): void => { ASSERTION.not.throw(); }, + createErrorPredicate('"throws" cannot be negated, use "returns" instead') + ); + }); + + it("should throw if the value is not a callable", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + throws( + (): void => { ASSERTION.throw(); }, + createErrorPredicate("Expected the tested value to be a function") + ); + }); + + it("should throw if the value does not throw", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.returns(undefined); + + throws( + (): void => { ASSERTION.throw(); }, + createErrorPredicate("Missing expected exception: Expected the tested value to throw") + ); + + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + }); + + it("should throw if the thrown error does not match the default predicate", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.throws(42); + + throws( + (): void => { ASSERTION.throw(); }, + createErrorPredicate("Expected the tested value to throw with an instance of Error, but got an integer.") + ); + + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + }); + + it("should throw if the thrown error does not match the custom predicate", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.throws(new Error("Test")); + + throws( + (): void => { ASSERTION.throw(RangeError); }, + createErrorPredicate("Expected the tested value to throw with an instance of RangeError, but got an instance of Error.") + ); + + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + }); + + it("should return if the thrown error match the default predicate", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.throws(new RangeError("Test")); + + doesNotThrow((): void => { ASSERTION.throw(); }); + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + }); + + it("should return if the thrown error match the custom predicate", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.throws(new RangeError("Test")); + + doesNotThrow((): void => { ASSERTION.throw(RangeError); }); + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + }); + + it("should return a VoidAssertion", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.throws(new Error("Test")); + + deepStrictEqual(ASSERTION.throw(), mockVoidAssertion(ASSERTION)); + }); + }); + + describe("returns", (): void => { + it("should throw if negated", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + throws( + (): void => { ASSERTION.not.return; }, + createErrorPredicate('"returns" cannot be negated, use "throws" instead') + ); + }); + + it("should throw if the value is not a callable", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + throws( + (): void => { ASSERTION.return; }, + createErrorPredicate("Expected the tested value to be a function") + ); + }); + + it("should throw if the value throws", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.throws(); + + throws( + (): void => { ASSERTION.return; }, + createErrorPredicate("Got unwanted exception: Expected the tested value to return") + ); + + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + }); + + it("should return if the value returns", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.returns(undefined); + + doesNotThrow((): void => { ASSERTION.return; }); + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + }); + + it("should return a new assertion for the returned value", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.returns(undefined); + + let result: unknown = undefined; + + doesNotThrow((): void => { result = ASSERTION.return; }); + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + + if (result === ASSERTION || !(result instanceof FluentAssertion)) + { + fail("Expected the returned value to be a new FluentAssertion"); + } + + strictEqual(Reflect.get(result, "parent"), ASSERTION); + }); + + it("should return a new FluentAssertion for the returned value", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.returns(42); + + deepStrictEqual(ASSERTION.return, mockChildAssertion(ASSERTION, 42, "the tested value returned value")); + }); + }); + + describe("rejects", (): void => { + it("should throw if negated", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + throws( + (): void => { ASSERTION.not.reject(); }, + createErrorPredicate('"rejects" cannot be negated, use "fulfills" instead') + ); + }); + + it("should reject if the value is not a Promise", async (): Promise => { + const ASSERTION: FluentAssertion = mockAssertion(); + + await rejects( + async (): Promise => { await ASSERTION.reject(); }, + createErrorPredicate("Expected the tested value to be a function") + ); + }); + + it("should rejects if the value does not reject", async (): Promise => { + const ASSERTION: FluentAssertion = mockAssertion(Promise.resolve()); + + await rejects( + async (): Promise => { await ASSERTION.reject(); }, + createErrorPredicate("Missing expected exception: Expected the tested value to throw") + ); + }); + + it("should rejects if the thrown error does not match the default predicate", async (): Promise => { + const ASSERTION: FluentAssertion = mockAssertion(Promise.reject(42)); + + await rejects( + async (): Promise => { await ASSERTION.reject(); }, + createErrorPredicate("Expected the tested value to throw with an instance of Error, but got an integer.") + ); + }); + + it("should rejects if the thrown error does not match the custom predicate", async (): Promise => { + const ASSERTION: FluentAssertion = mockAssertion(Promise.reject(new Error("Test"))); + + await rejects( + async (): Promise => { await ASSERTION.reject(RangeError); }, + createErrorPredicate("Expected the tested value to throw with an instance of RangeError, but got an instance of Error.") + ); + }); + + it("should fulfills if the thrown error match the default predicate", async (): Promise => { + const ASSERTION: FluentAssertion = mockAssertion(Promise.reject(new Error("Test"))); + + await doesNotReject(async (): Promise => { await ASSERTION.reject(); }); + }); + + it("should fulfills if the thrown error match the custom predicate", async (): Promise => { + const ASSERTION: FluentAssertion = mockAssertion(Promise.reject(new RangeError("Test"))); + + await doesNotReject(async (): Promise => { await ASSERTION.reject(RangeError); }); + }); + + it("should return a VoidAssertion", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(Promise.reject(new Error("Test"))); + + const RESULT: unknown = ASSERTION.reject(); + + deepStrictEqual(RESULT, mockVoidAssertion(ASSERTION)); + }); + }); + + describe("fulfills", (): void => { + it("should throw if negated", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + throws( + (): void => { ASSERTION.not.fulfill; }, + createErrorPredicate('"fulfills" cannot be negated, use "rejects" instead') + ); + }); + + it("should reject if the value is not a Promise", (): void => { + const ASSERTION: FluentAssertion = mockAssertion(); + + rejects( + async (): Promise => { await ASSERTION.fulfill; }, + createErrorPredicate("Expected the tested value to be a Promise") + ); + }); + + it("should reject if the value rejects", async (): Promise => { + const ASSERTION: FluentAssertion = mockAssertion(Promise.reject(new Error("Test"))); + + await rejects( + async (): Promise => { await ASSERTION.fulfill; }, + createErrorPredicate("Got unwanted exception: Expected the tested value to return") + ); + }); + + it("should return if the value returns", (): void => { + const STUB: SinonStub = stub(); + const ASSERTION: FluentAssertion = mockAssertion(STUB); + + STUB.returns(undefined); + + doesNotThrow((): void => { ASSERTION.return; }); + strictEqual(STUB.callCount, 1, "Expected the callable to be called"); + }); + + it("should return a new FluentAssertion for the promised value", async (): Promise => { + const ASSERTION: FluentAssertion = mockAssertion(Promise.resolve(42)); + + const RESULT: unknown = ASSERTION.fulfill; + + await RESULT; + + deepStrictEqual(RESULT, mockChildAssertion(ASSERTION, 42, "the tested value returned value")); + }); + }); +}); diff --git a/packages/assert/test/assertion/root-assertion.spec.mts b/packages/assert/test/assertion/root-assertion.spec.mts new file mode 100644 index 00000000..d7f169df --- /dev/null +++ b/packages/assert/test/assertion/root-assertion.spec.mts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test"; +import { strictEqual } from "node:assert"; +import { type SinonSpy, type SinonSpyCall, type SinonStub, spy, stub } from "sinon"; +import { RootAssertion } from "../../src/assertion/_internal.mjs"; + +describe("RootAssertion", (): void => { + describe("constructor", (): void => { + it("should create a new instance", (): void => { + const SYMBOL: unique symbol = Symbol("test"); + + const ASSERTION: RootAssertion = new RootAssertion(SYMBOL); + + strictEqual(Reflect.get(ASSERTION, "root"), ASSERTION); + strictEqual(Reflect.get(ASSERTION, "parent"), ASSERTION); + strictEqual(Reflect.get(ASSERTION, "actualValue"), SYMBOL); + strictEqual(Reflect.get(ASSERTION, "name"), "value"); + }); + + it("should create a new instance (callable)", (): void => { + const CALLABLE = (): void => {}; + + const ASSERTION: RootAssertion = new RootAssertion(CALLABLE); + + strictEqual(Reflect.get(ASSERTION, "actualValue"), CALLABLE); + strictEqual(Reflect.get(ASSERTION, "name"), "callable"); + }); + + it("should create a new instance (SinonSpy)", (): void => { + class Dummy + { + public method(): void {} + } + + const DUMMY: Dummy = new Dummy(); + const SPY_NAMED: SinonSpy = spy(DUMMY, "method"); + const SPY_ANONYMOUS: SinonSpy = spy(); + + const ASSERTION_NAMED: RootAssertion = new RootAssertion(SPY_NAMED); + const ASSERTION_ANONYMOUS: RootAssertion = new RootAssertion(SPY_ANONYMOUS); + + strictEqual(Reflect.get(ASSERTION_NAMED, "actualValue"), SPY_NAMED); + strictEqual(Reflect.get(ASSERTION_NAMED, "name"), 'spy of "method"'); + + strictEqual(Reflect.get(ASSERTION_ANONYMOUS, "actualValue"), SPY_ANONYMOUS); + strictEqual(Reflect.get(ASSERTION_ANONYMOUS, "name"), "spy"); + }); + + it("should create a new instance (SinonSpyCall)", (): void => { + class Dummy + { + public method(): void {} + } + + const DUMMY: Dummy = new Dummy(); + const SPY: SinonSpy = spy(DUMMY, "method"); + + DUMMY.method(); + + const SPY_CALL: SinonSpyCall = SPY.firstCall; + + const ASSERTION_NAMED: RootAssertion = new RootAssertion(SPY_CALL); + + strictEqual(Reflect.get(ASSERTION_NAMED, "actualValue"), SPY_CALL); + strictEqual(Reflect.get(ASSERTION_NAMED, "name"), "spy call"); + }); + + it("should create a new instance (SinonStub)", (): void => { + class Dummy + { + public method(): void {} + } + + const DUMMY: Dummy = new Dummy(); + const SPY_NAMED: SinonStub = stub(DUMMY, "method"); + const SPY_ANONYMOUS: SinonStub = stub(); + + const ASSERTION_NAMED: RootAssertion = new RootAssertion(SPY_NAMED); + const ASSERTION_ANONYMOUS: RootAssertion = new RootAssertion(SPY_ANONYMOUS); + + strictEqual(Reflect.get(ASSERTION_NAMED, "actualValue"), SPY_NAMED); + strictEqual(Reflect.get(ASSERTION_NAMED, "name"), 'spy of "method"'); + + strictEqual(Reflect.get(ASSERTION_ANONYMOUS, "actualValue"), SPY_ANONYMOUS); + strictEqual(Reflect.get(ASSERTION_ANONYMOUS, "name"), "spy"); + }); + }); +}); diff --git a/packages/assert/test/assertion/void-assertion.spec.mts b/packages/assert/test/assertion/void-assertion.spec.mts new file mode 100644 index 00000000..bdd258f9 --- /dev/null +++ b/packages/assert/test/assertion/void-assertion.spec.mts @@ -0,0 +1,23 @@ +import type { BaseAssertionInstantiationInterface } from "../../src/definition/interface/base-assertion-instantiation.interface.mjs"; +import { describe, it } from "node:test"; +import { strictEqual } from "node:assert"; +import { FluentAssertion, RootAssertion, VoidAssertion } from "../../src/assertion/_internal.mjs"; + +describe("VoidAssertion", (): void => { + describe("constructor", (): void => { + it("should create a new instance", (): void => { + const ROOT: RootAssertion = new RootAssertion("root"); + const PARENT: FluentAssertion = new FluentAssertion({ root: ROOT, parent: ROOT, name: "parent" }); + + const PARAMETERS: BaseAssertionInstantiationInterface = { + root: ROOT, + parent: PARENT, + }; + + const ASSERTION: VoidAssertion = new VoidAssertion(PARAMETERS); + + strictEqual(Reflect.get(ASSERTION, "root"), ROOT); + strictEqual(Reflect.get(ASSERTION, "parent"), PARENT); + }); + }); +}); diff --git a/packages/assert/test/error-predicate/create-error-predicate.spec.mts b/packages/assert/test/error-predicate/create-error-predicate.spec.mts new file mode 100644 index 00000000..156a146e --- /dev/null +++ b/packages/assert/test/error-predicate/create-error-predicate.spec.mts @@ -0,0 +1,178 @@ +import { describe, it } from "node:test"; +import { AssertionError, strictEqual, throws } from "node:assert"; +import { createErrorPredicate } from "../../src/error-predicate/create-error-predicate.mjs"; +import { getType } from "../../src/utility/get-type.mjs"; + +describe("createErrorPredicate", (): void => { + const BASE_MESSAGE: string = "Expected the tested value to throw"; + + describe("No argument", (): void => { + it("should return a predicate function that throws if the value is not an instance of Error", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE); + + strictEqual(typeof PREDICATE, "function"); + + const VALUES: Array = [ + undefined, + null, + false, + true, + NaN, + 1, + 1n, + "Test", + Symbol.iterator, + [], + Object.create(null), + {}, + new Date(), + { message: "Test" }, + ]; + + for (const VALUE of VALUES) + { + throws( + (): void => { PREDICATE(VALUE); }, + new Error(`Expected the tested value to throw with an instance of Error, but got ${getType(VALUE)}.`) + ); + } + }); + + it("should return a predicate function that throws if the error's message is empty", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE); + + strictEqual(typeof PREDICATE, "function"); + + throws( + (): void => { PREDICATE(new Error("")); }, + new Error("Expected the tested value to throw with a message.") + ); + }); + + it("should return a predicate function that return true if the value is an error with a proper message", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE); + + strictEqual(typeof PREDICATE, "function"); + strictEqual(PREDICATE(new Error("Test")), true); + }); + }); + + describe("String argument", (): void => { + it("should return a predicate function that throws if the message doesn't match the expected message", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, "Test"); + + strictEqual(typeof PREDICATE, "function"); + + throws( + (): void => { PREDICATE(new Error("Lorem ipsum")); }, + new Error('Expected the tested value to throw with the message "Test", but got "Lorem ipsum".') + ); + }); + + it("should return a predicate function that returns true if the message matches the expected message", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, "Test"); + + strictEqual(typeof PREDICATE, "function"); + + strictEqual(PREDICATE(new Error("Test")), true); + }); + }); + + describe("RegExp argument", (): void => { + it("should return a predicate function that throws if the message doesn't match the expected pattern", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, /Test/); + + strictEqual(typeof PREDICATE, "function"); + + throws( + (): void => { PREDICATE(new Error("Lorem ipsum")); }, + new Error('Expected the tested value to throw with a message that match /Test/, but got "Lorem ipsum"') + ); + }); + + it("should return a predicate function that throws if the message is empty even if it match the expected pattern", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, /.*/); + + strictEqual(typeof PREDICATE, "function"); + + throws( + (): void => { PREDICATE(new Error("")); }, + new Error("Expected the tested value to throw with a message.") + ); + }); + + it("should return a predicate function that returns true if message matches the expected pattern", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, /Test/); + + strictEqual(typeof PREDICATE, "function"); + strictEqual(PREDICATE(new Error("Test")), true); + }); + }); + + describe("Class argument", (): void => { + it("should return a predicate function that throws if the value is not an instance of the expected class", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, TypeError); + + strictEqual(typeof PREDICATE, "function"); + + throws( + (): void => { PREDICATE(new Error("Test")); }, + new Error("Expected the tested value to throw with an instance of TypeError, but got an instance of Error.") + ); + }); + + it("should return a predicate function that return if the value is an instance of the expected class", (): void => { + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, TypeError); + + strictEqual(typeof PREDICATE, "function"); + strictEqual(PREDICATE(new TypeError("Test")), true); + }); + }); + + describe("Instance argument", (): void => { + it("should return a predicate function that throws if the value is an instance of a different class than the expected error", (): void => { + const ACTUAL: Error = new Error("Test"); + const EXPECTED: Error = new TypeError("Test"); + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, EXPECTED); + + strictEqual(typeof PREDICATE, "function"); + + throws( + (): void => { PREDICATE(ACTUAL); }, + new AssertionError({ + message: "Expected the tested value to throw with an error that resemble the expected error", + actual: ACTUAL, + expected: EXPECTED, + operator: "deepStrictEqual", + }) + ); + }); + + it("should return a predicate function that throws if the value has a different message than the expected error", (): void => { + const ACTUAL: Error = new TypeError("Lorem ipsum"); + const EXPECTED: Error = new TypeError("Test"); + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, EXPECTED); + + strictEqual(typeof PREDICATE, "function"); + + throws( + (): void => { PREDICATE(ACTUAL); }, + new AssertionError({ + message: "Expected the tested value to throw with an error that resemble the expected error", + actual: ACTUAL, + expected: EXPECTED, + operator: "deepStrictEqual", + }) + ); + }); + + it("should return a predicate function that returns true if the value is recursively similar", (): void => { + const ACTUAL: Error = new TypeError("Test"); + const EXPECTED: Error = new TypeError("Test"); + const PREDICATE: (value: unknown) => true = createErrorPredicate(BASE_MESSAGE, EXPECTED); + + strictEqual(typeof PREDICATE, "function"); + strictEqual(PREDICATE(ACTUAL), true); + }); + }); +}); diff --git a/packages/assert/test/error-predicate/fix-error.spec.mts b/packages/assert/test/error-predicate/fix-error.spec.mts new file mode 100644 index 00000000..4a4c3391 --- /dev/null +++ b/packages/assert/test/error-predicate/fix-error.spec.mts @@ -0,0 +1,16 @@ +import { describe, it } from "node:test"; +import { deepStrictEqual } from "node:assert"; +import { fixError } from "../../src/error-predicate/fix-error.mjs"; + +describe("fixError", (): void => { + it('should make the error properties enumerable, except "stack", when present', (): void => { + const ERROR: Error = new Error("Test", { cause: new Error("Cause") }); + const AGGREGATE_ERROR: Error = new AggregateError([new Error("Cause")], "Test"); + + const FIXED: Error = fixError(ERROR); + const AGGREGATE_FIXED: Error = fixError(AGGREGATE_ERROR); + + deepStrictEqual(Object.keys(FIXED), ["message", "cause"]); + deepStrictEqual(Object.keys(AGGREGATE_FIXED), ["message", "errors"]); + }); +}); diff --git a/packages/assert/test/utility/get-type.spec.mts b/packages/assert/test/utility/get-type.spec.mts new file mode 100644 index 00000000..5da296e8 --- /dev/null +++ b/packages/assert/test/utility/get-type.spec.mts @@ -0,0 +1,93 @@ +import { describe, it } from "node:test"; +import { strictEqual } from "node:assert"; +import { getType } from "../../src/utility/get-type.mjs"; + +describe("getType", (): void => { + it('should return "undefined" for undefined', (): void => { + strictEqual(getType(undefined), "undefined"); + }); + + it('should return "null" for null', (): void => { + strictEqual(getType(null), "null"); + }); + + it('should return "false" for false', (): void => { + strictEqual(getType(false), "false"); + }); + + it('should return "true" for true', (): void => { + strictEqual(getType(true), "true"); + }); + + it('should return "NaN" for NaN', (): void => { + strictEqual(getType(NaN), "NaN"); + }); + + it('should return "+Infinity" for +Infinity', (): void => { + strictEqual(getType(Number.POSITIVE_INFINITY), "+Infinity"); + }); + + it('should return "-Infinity" for -Infinity', (): void => { + strictEqual(getType(Number.NEGATIVE_INFINITY), "-Infinity"); + }); + + it('should return "an integer" for a safe integer', (): void => { + strictEqual(getType(0), "an integer"); + strictEqual(getType(1), "an integer"); + strictEqual(getType(-1), "an integer"); + }); + + it('should return "a number" for a finite number', (): void => { + strictEqual(getType(0.1), "a number"); + strictEqual(getType(-0.1), "a number"); + }); + + it('should return "a bigint" for a bigint', (): void => { + strictEqual(getType(0n), "a bigint"); + strictEqual(getType(1n), "a bigint"); + strictEqual(getType(-1n), "a bigint"); + }); + + it('should return "an empty string" for an empty string', (): void => { + strictEqual(getType(""), "an empty string"); + }); + + it('should return "a string" for a string', (): void => { + strictEqual(getType("Lorem ipsum"), "a string"); + }); + + it('should return "a symbol" for a symbol', (): void => { + strictEqual(getType(Symbol("test")), "a symbol"); + strictEqual(getType(Symbol.iterator), "a symbol"); + }); + + it('should return "a function" for a function', (): void => { + // eslint-disable-next-line prefer-arrow-callback -- Test + strictEqual(getType(function(): void {}), "a function"); + // eslint-disable-next-line prefer-arrow-callback -- Test + strictEqual(getType(function named(): void {}), "a function"); + strictEqual(getType((): void => {}), "a function"); + strictEqual(getType(class {}), "a function"); + strictEqual(getType(class Named {}), "a function"); + strictEqual(getType(function* (): Generator {}), "a function"); + strictEqual(getType(function* named(): Generator {}), "a function"); + }); + + it('should return "a null-prototype object" for a null-prototype object', (): void => { + strictEqual(getType(Object.create(null)), "a null-prototype object"); + }); + + it('should return "an object" for an object', (): void => { + strictEqual(getType({}), "an object"); + }); + + it('should return "an instance of ClassName" for an instance of a named class', (): void => { + strictEqual(getType(new Date()), "an instance of Date"); + strictEqual(getType(new Map()), "an instance of Map"); + strictEqual(getType(new Set()), "an instance of Set"); + }); + + it('should return "an instance of an anonymous class" for an instance of an unamed class', (): void => { + strictEqual(getType(new class {}()), "an instance of an anonymous class"); + }); +}); diff --git a/packages/assert/tsconfig.build.json b/packages/assert/tsconfig.build.json new file mode 100644 index 00000000..093cc419 --- /dev/null +++ b/packages/assert/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./build/esm", + "declarationDir": "./build/types", + "rootDir": "./src", + "declaration": true, + "declarationMap": false, + "removeComments": false, + "sourceMap": false, + "noEmit": false, + "listFiles": false, + "listEmittedFiles": false + }, + "include": [ + "./src" + ] +} diff --git a/packages/assert/tsconfig.eslint.json b/packages/assert/tsconfig.eslint.json new file mode 100644 index 00000000..b8af99ac --- /dev/null +++ b/packages/assert/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "include": [ + "./src", + "./test" + ] +} diff --git a/packages/assert/tsconfig.json b/packages/assert/tsconfig.json new file mode 100755 index 00000000..4066ce62 --- /dev/null +++ b/packages/assert/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowSyntheticDefaultImports": true + }, + "include": [ + "./src", + "./test", + "./mock" + ], + "exclude": [ + "./node_modules" + ] +} diff --git a/packages/assert/tsconfig.mocha.json b/packages/assert/tsconfig.mocha.json new file mode 100644 index 00000000..9c0a3ad2 --- /dev/null +++ b/packages/assert/tsconfig.mocha.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [".", "./**/.*", "./**/*.json"], + "ts-node": { + "files": true, + "swc": true + } + } diff --git a/packages/testing-ground/src/utils/compare-errors.mts b/packages/testing-ground/src/utils/compare-errors.mts index 3d1f46ba..e32027a1 100644 --- a/packages/testing-ground/src/utils/compare-errors.mts +++ b/packages/testing-ground/src/utils/compare-errors.mts @@ -8,7 +8,7 @@ function compareErrors(value: unknown, expected: Error): void if (!(value instanceof CONSTRUCTOR_CLASS)) { - throw new Error(`An ${CONSTRUCTOR_CLASS.name} must be thrown.`); + throw new Error(`An instance of ${CONSTRUCTOR_CLASS.name} must be thrown.`); } if (value.message !== expected.message) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08b8fe69..79c97afe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,8 @@ importers: specifier: workspace:^ version: link:../ts-predicate + packages/assert: {} + packages/aws-s3: dependencies: '@vitruvius-labs/aws-signature-v4': diff --git a/vitruvius-labs-typescript.code-workspace b/vitruvius-labs-typescript.code-workspace index 6bcc31fa..8d24f9c7 100644 --- a/vitruvius-labs-typescript.code-workspace +++ b/vitruvius-labs-typescript.code-workspace @@ -16,6 +16,10 @@ "name": "toolbox", "path": "packages/toolbox" }, + { + "name": "assert", + "path": "packages/assert" + }, { "name": "mockingbird", "path": "packages/mockingbird"