diff --git a/.ls-lint.yml b/.ls-lint.yml index 610218f8ed2..22f08f72938 100644 --- a/.ls-lint.yml +++ b/.ls-lint.yml @@ -11,14 +11,14 @@ _cfg: &cfg ls: <<: *cfg - src: + src: &src <<: *cfg .dir: kebab-case | regex:@types .js: exists:0 src/system/version-migration/versions: .ts: snake_case <<: *cfg - + test: *src ignore: - node_modules - .vscode diff --git a/package.json b/package.json index 9bba5e56f89..02d063389b7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/jsdom": "^21.1.7", "@types/node": "^22.16.3", "@vitest/coverage-istanbul": "^3.2.4", + "@vitest/expect": "^3.2.4", "chalk": "^5.4.1", "dependency-cruiser": "^16.10.4", "inquirer": "^12.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e77bf065fd5..86a7bb904a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@vitest/coverage-istanbul': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@22.16.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.3)(typescript@5.8.3))(yaml@2.8.0)) + '@vitest/expect': + specifier: ^3.2.4 + version: 3.2.4 chalk: specifier: ^5.4.1 version: 5.4.1 diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts new file mode 100644 index 00000000000..3a0a0fec9cf --- /dev/null +++ b/test/@types/vitest.d.ts @@ -0,0 +1,26 @@ +import type { Pokemon } from "#field/pokemon"; +import type { PokemonType } from "#enums/pokemon-type"; +import type { expect } from "vitest"; +import { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types"; + +declare module "vitest" { + interface Assertion { + /** + * Matcher to check if an array contains EXACTLY the given items (in any order). + * + * Different from {@linkcode expect.arrayContaining} as the latter only requires the array contain + * _at least_ the listed items. + * + * @param expected - The expected contents of the array, in any order. + * @see {@linkcode expect.arrayContaining} + */ + toEqualArrayUnsorted(expected: E[]): void; + /** + * Matcher to check if a {@linkcode Pokemon}'s current typing includes the given types. + * + * @param expected - The expected types (in any order). + * @param options - The options passed to the matcher. + */ + toHaveTypes(expected: PokemonType[], options?: toHaveTypesOptions): void; + } +} \ No newline at end of file diff --git a/test/matchers.setup.ts b/test/matchers.setup.ts new file mode 100644 index 00000000000..7fd5fcb463e --- /dev/null +++ b/test/matchers.setup.ts @@ -0,0 +1,14 @@ +import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted"; +import { toHaveTypes } from "#test/test-utils/matchers/to-have-types"; +import { expect } from "vitest"; + +/** + * @module + * Setup file for custom matchers. + * Make sure to define the call signatures in `test/@types/vitest.d.ts` too! + */ + +expect.extend({ + toEqualArrayUnsorted, + toHaveTypes, +}); diff --git a/test/test-utils/matchers/to-equal-array-unsorted.ts b/test/test-utils/matchers/to-equal-array-unsorted.ts new file mode 100644 index 00000000000..0627623bbd9 --- /dev/null +++ b/test/test-utils/matchers/to-equal-array-unsorted.ts @@ -0,0 +1,43 @@ +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +/** + * Matcher to check if an array contains exactly the given items, disregarding order. + * @param received - The object to check. Should be an array of elements. + * @returns The result of the matching + */ +export function toEqualArrayUnsorted(this: MatcherState, received: unknown, expected: unknown): SyncExpectationResult { + if (!Array.isArray(received)) { + return { + pass: this.isNot, + message: () => `Expected an array, but got ${this.utils.stringify(received)}!`, + }; + } + + if (!Array.isArray(expected)) { + return { + pass: this.isNot, + message: () => `Expected to recieve an array, but got ${this.utils.stringify(expected)}!`, + }; + } + + if (received.length !== expected.length) { + return { + pass: this.isNot, + message: () => `Expected to recieve array of length ${received.length}, but got ${expected.length}!`, + actual: received, + expected, + }; + } + + const gotSorted = received.slice().sort(); + const wantSorted = expected.slice().sort(); + const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]); + + return { + pass: this.isNot !== pass, + message: () => + `Expected ${this.utils.stringify(received)} to exactly equal ${this.utils.stringify(expected)} without order!`, + actual: gotSorted, + expected: wantSorted, + }; +} diff --git a/test/test-utils/matchers/to-have-types.ts b/test/test-utils/matchers/to-have-types.ts new file mode 100644 index 00000000000..d09f4fc5f76 --- /dev/null +++ b/test/test-utils/matchers/to-have-types.ts @@ -0,0 +1,64 @@ +import { PokemonType } from "#enums/pokemon-type"; +import { Pokemon } from "#field/pokemon"; +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +export interface toHaveTypesOptions { + /** + * Whether to enforce exact matches (`true`) or superset matches (`false`). + * @defaultValue `true` + */ + exact?: boolean; + /** + * Optional arguments to pass to {@linkcode Pokemon.getTypes}. + */ + args?: Parameters<(typeof Pokemon.prototype)["getTypes"]>; +} + +/** + * Matcher to check if an array contains exactly the given items, disregarding order. + * @param received - The object to check. Should be an array of one or more {@linkcode PokemonType}s. + * @param options - The {@linkcode toHaveTypesOptions | options} for this matcher + * @returns The result of the matching + */ +export function toHaveTypes( + this: MatcherState, + received: unknown, + expected: unknown, + options: toHaveTypesOptions = {}, +): SyncExpectationResult { + if (!(received instanceof Pokemon)) { + return { + pass: this.isNot, + message: () => `Expected a Pokemon, but got ${this.utils.stringify(received)}!`, + }; + } + + if (!Array.isArray(expected) || expected.length === 0) { + return { + pass: this.isNot, + message: () => `Expected to recieve an array with length >=1, but got ${this.utils.stringify(expected)}!`, + }; + } + + if (!expected.every((t): t is PokemonType => t in PokemonType)) { + return { + pass: this.isNot, + message: () => `Expected to recieve array of PokemonTypes but got ${this.utils.stringify(expected)}!`, + }; + } + + const gotSorted = pkmnTypeToStr(received.getTypes(...(options.args ?? []))); + const wantSorted = pkmnTypeToStr(expected.slice()); + const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]); + + return { + pass: this.isNot !== pass, + message: () => `Expected ${received.name} to have types ${this.utils.stringify(wantSorted)}, but got ${gotSorted}!`, + actual: gotSorted, + expected: wantSorted, + }; +} + +function pkmnTypeToStr(p: PokemonType[]): string[] { + return p.sort().map(type => PokemonType[type]); +} diff --git a/vitest.config.ts b/vitest.config.ts index e788302857b..65c5427e591 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineProject(({ mode }) => ({ TZ: "UTC", }, testTimeout: 20000, - setupFiles: ["./test/font-face.setup.ts", "./test/vitest.setup.ts"], + setupFiles: ["./test/font-face.setup.ts", "./test/vitest.setup.ts", "./test/matchers.setup.ts"], sequence: { sequencer: MySequencer, },