From 64176a09209930d11137243efe1dd32f59e9493b Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:03:46 -0400 Subject: [PATCH] [Test] Cleaned up enum test utils (#6653) * added type tests * Update test/test-utils/string-utils.ts * Update docs --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/utils/enums.ts | 2 +- test/test-utils/string-utils.ts | 200 ++++++++++++++++++++------------ test/utils/strings.test.ts | 179 +++++++++++++++++++++------- vitest.config.ts | 1 + 4 files changed, 269 insertions(+), 113 deletions(-) diff --git a/src/utils/enums.ts b/src/utils/enums.ts index 25ee864794c..3561ce0e674 100644 --- a/src/utils/enums.ts +++ b/src/utils/enums.ts @@ -56,7 +56,7 @@ export function getEnumValues(enumType: TSNumericEnum * two: 2, * } as const; * console.log(enumValueToKey(thing, 2)); // output: "two" - * @throws Error if an invalid enum value is passed to the function + * @throws Throws an {@linkcode Error} if an invalid enum value is passed to the function. * @remarks * If multiple keys map to the same value, the first one (in insertion order) will be retrieved, * but the return type will be the union of ALL their corresponding keys. diff --git a/test/test-utils/string-utils.ts b/test/test-utils/string-utils.ts index 85cc3446a10..e0830760732 100644 --- a/test/test-utils/string-utils.ts +++ b/test/test-utils/string-utils.ts @@ -1,6 +1,5 @@ import { getStatKey, type Stat } from "#enums/stat"; -import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types"; -import type { ObjectValues } from "#types/type-helpers"; +import type { EnumOrObject, NormalEnum } from "#types/enum-types"; import { enumValueToKey } from "#utils/enums"; import { toTitleCase } from "#utils/strings"; import type { MatcherState } from "@vitest/expect"; @@ -8,32 +7,56 @@ import i18next from "i18next"; type Casing = "Preserve" | "Title"; -interface getEnumStrOptions { +interface getEnumStrKeyOptions { /** * A string denoting the casing method to use. * @defaultValue "Preserve" */ casing?: Casing; /** - * If present, will be prepended to the beginning of the enum string. + * If present, will be prepended to the beginning of the key name string. */ prefix?: string; /** - * If present, will be added to the end of the enum string. + * If present, will be added to the end of the key name string. */ suffix?: string; +} + +interface getEnumStrValueOptions { /** - * Whether to omit the value from the text. - * @defaultValue Whether `E` is a non-string enum + * A numeric base that will be used to convert `val` into a number. + * Special formatting will be applied for binary, octal and hexadecimal to add base prefixes, + * and should be omitted if a string value is passed. + * @defaultValue `10` */ - omitValue?: boolean; + base?: number; + /** + * The amount of padding to add to the numeral representation. + * @defaultValue `0` + */ + padding?: number; } /** - * Return the name of an enum member or const object value, alongside its corresponding value. - * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from - * @param val - One of {@linkcode obj}'s values - * @param options - Options modifying the stringification process + * Options type for `getEnumStr` and company. + * + * Selectively includes properties based on the type of `Val` + */ +type getEnumStrOptions = { + /** + * Whether to omit the enum members' values from the output. + * @defaultValue Whether `Val` is a string + */ + excludeValues?: X; +} & getEnumStrKeyOptions & + ([Val] extends [string] ? unknown : [X] extends [true] ? unknown : getEnumStrValueOptions); + +/** + * Return the name of an enum member or `const object` value, alongside its corresponding value. + * @param obj - The `EnumOrObject` to source reverse mappings from + * @param val - One of `obj`'s values to stringify + * @param options - Optional parameters modifying the stringification process * @returns The stringified representation of `val` as dictated by the options. * @example * ```ts @@ -46,103 +69,134 @@ interface getEnumStrOptions { * getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)" * ``` */ -export function getEnumStr( +export function getEnumStr( obj: E, - val: ObjectValues, - options: getEnumStrOptions = {}, + val: V, + options: getEnumStrOptions = {}, ): string { - const { casing = "Preserve", prefix = "", suffix = "", omitValue = typeof val === "number" } = options; - let casingFunc: ((s: string) => string) | undefined; + const { + casing = "Preserve", + prefix = "", + suffix = "", + excludeValues = typeof val === "string", + base = 10, + padding = 0, + } = options as getEnumStrOptions; + + const keyPart = excludeValues ? "" : getKeyPart(obj, val, { casing, prefix, suffix }); + const valuePart = typeof val === "string" ? val : getValuePart(val, { base, padding }); + + return `${keyPart} ${valuePart}`.trim(); +} + +function getKeyPart( + obj: E, + val: V, + { casing, prefix, suffix }: Required, +): string { + let casingFunc: (s: string) => string; switch (casing) { case "Preserve": + casingFunc = s => s; break; case "Title": casingFunc = toTitleCase; break; } - let stringPart = + const keyName = obj[val] !== undefined ? // TS reverse mapped enum (obj[val] as string) - : // Normal enum/`const object` + : // Normal enum / `const object` + // TODO: Figure out a way to cache the names of commonly-used enum numbers for performance if needed (enumValueToKey(obj as NormalEnum, val) as string); - if (casingFunc) { - stringPart = casingFunc(stringPart); - } + return `${prefix}${casingFunc(keyName)}${suffix}`; +} - return `${prefix}${stringPart}${suffix}${omitValue ? "" : ` (=${val})`}`; +/** + * Helper function used by `getEnumStr` and company to format the "value" part of a numeric enum value. + * @param val - The value to be stringified + * @param options - Options modifying the stringification process + * @param addParen - Whether to add enclosing parentheses and `=` sign; default `true` + * @returns The stringified version of `val` + */ +function getValuePart(val: number, options: Required, addParen = true): string { + const { base, padding } = options; + const valFormatted = `${getPrefixForBase(base)}${val.toString(base).toUpperCase().padStart(padding, "0")}`; + + return addParen ? `(=${valFormatted})` : valFormatted; +} + +function getPrefixForBase(base: number): string { + switch (base) { + case 2: + return "0b"; + case 8: + return "0o"; + case 16: + return "0x"; + default: + return ""; + } } /** * Convert an array of enums or `const object`s into a readable string version. - * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from - * @param enums - An array of {@linkcode obj}'s values + * @param obj - The `EnumOrObject` to source reverse mappings from + * @param values - An array of `obj`'s values to convert into strings + * @param options - Optional parameters modifying the stringification process * @returns The stringified representation of `enums`. * @example * ```ts * enum fakeEnum { - * ONE: 1, - * TWO: 2, - * THREE: 3, + * ONE = 1, + * TWO = 2, + * THREE = 3, * } - * console.log(stringifyEnumArray(fakeEnum, [fakeEnum.ONE, fakeEnum.TWO, fakeEnum.THREE])); // Output: "[ONE, TWO, THREE] (=[1, 2, 3])" + * const vals = [fakeEnum.ONE, fakeEnum.TWO, fakeEnum.THREE] as const; + * + * console.log(stringifyEnumArray(fakeEnum, vals)); + * // Output: "[ONE, TWO, THREE] (=[1, 2, 3])"; + * console.log(stringifyEnumArray(fakeEnum, vals, {prefix: "Thing ", suffix: " Yeah", exclude: "values"})); + * // Output: "[Thing ONE Yeah, Thing TWO Yeah, Thing THREE Yeah]"; * ``` */ -export function stringifyEnumArray(obj: E, enums: E[keyof E][]): string { - if (enums.length === 0) { +export function stringifyEnumArray( + obj: E, + values: readonly V[], + options: getEnumStrOptions = {}, +): string { + if (values.length === 0) { return "[]"; } - const vals = enums.slice(); - /** An array of string names */ - let names: string[]; + const { + casing = "Preserve", + prefix = "", + suffix = "", + excludeValues = typeof values[0] === "string", + base = 10, + padding = 0, + } = options as getEnumStrOptions; - if (obj[enums[0]] !== undefined) { - // Reverse mapping exists - `obj` is a `TSNumericEnum` and its reverse mapped counterparts are strings - names = enums.map(e => (obj as TSNumericEnum)[e] as string); - } else { - // No reverse mapping exists means `obj` is a `NormalEnum`. - // NB: This (while ugly) should be more ergonomic than doing a repeated lookup for large `const object`s - // as the `enums` array should be significantly shorter than the corresponding enum type. - names = []; - for (const [k, v] of Object.entries(obj as NormalEnum)) { - if (names.length === enums.length) { - // No more names to get - break; - } - // Find all matches for the given enum, assigning their keys to the names array - findIndices(enums, v).forEach(matchIndex => { - names[matchIndex] = k; - }); - } + const keyPart = values.map(v => getKeyPart(obj, v, { casing, prefix, suffix })).join(", "); + if (excludeValues) { + return `[${keyPart}]`; } - return `[${names.join(", ")}] (=[${vals.join(", ")}])`; + const valuePart = + typeof values[0] === "string" + ? values.join(", ") + : (values as readonly number[]).map(v => getValuePart(v, { base, padding }, false)).join(", "); + + return `[${keyPart}] (=[${valuePart}])`; } /** - * Return the indices of all occurrences of a value in an array. - * @param arr - The array to search - * @param searchElement - The value to locate in the array - * @param fromIndex - The array index at which to begin the search. If fromIndex is omitted, the - * search starts at index 0 - */ -function findIndices(arr: T[], searchElement: T, fromIndex = 0): number[] { - const indices: number[] = []; - const arrSliced = arr.slice(fromIndex); - for (const [index, value] of arrSliced.entries()) { - if (value === searchElement) { - indices.push(index); - } - } - return indices; -} - -/** - * Convert a number into an English ordinal + * Convert a number into an English ordinal. * @param num - The number to convert into an ordinal - * @returns The ordinal representation of {@linkcode num}. + * @returns The ordinal representation of `num`. * @example * ```ts * console.log(getOrdinal(1)); // Output: "1st" diff --git a/test/utils/strings.test.ts b/test/utils/strings.test.ts index 3d6eb235ba8..f9f70a9bca4 100644 --- a/test/utils/strings.test.ts +++ b/test/utils/strings.test.ts @@ -1,47 +1,148 @@ +import { stringifyEnumArray } from "#test/test-utils/string-utils"; +import type { EnumOrObject } from "#types/enum-types"; import { splitWords } from "#utils/strings"; import { describe, expect, it } from "vitest"; -interface testCase { - input: string; - words: string[]; -} +describe("Utils - Strings", () => { + describe("Casing", () => { + describe("splitWords", () => { + interface testCase { + input: string; + words: string[]; + } -const testCases: testCase[] = [ - { - input: "Lorem ipsum dolor sit amet", - words: ["Lorem", "ipsum", "dolor", "sit", "amet"], - }, - { - input: "consectetur-adipiscing-elit", - words: ["consectetur", "adipiscing", "elit"], - }, - { - input: "sed_do_eiusmod_tempor_incididunt_ut_labore", - words: ["sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore"], - }, - { - input: "Et Dolore Magna Aliqua", - words: ["Et", "Dolore", "Magna", "Aliqua"], - }, - { - input: "BIG_ANGRY_TRAINER", - words: ["BIG", "ANGRY", "TRAINER"], - }, - { - input: "ApplesBananasOrangesAndAPear", - words: ["Apples", "Bananas", "Oranges", "And", "A", "Pear"], - }, - { - input: "mysteryEncounters/anOfferYouCantRefuse", - words: ["mystery", "Encounters/an", "Offer", "You", "Cant", "Refuse"], - }, -]; + const testCases: testCase[] = [ + { + input: "Lorem ipsum dolor sit amet", + words: ["Lorem", "ipsum", "dolor", "sit", "amet"], + }, + { + input: "consectetur-adipiscing-elit", + words: ["consectetur", "adipiscing", "elit"], + }, + { + input: "sed_do_eiusmod_tempor_incididunt_ut_labore", + words: ["sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore"], + }, + { + input: "Et Dolore Magna Aliqua", + words: ["Et", "Dolore", "Magna", "Aliqua"], + }, + { + input: "BIG_ANGRY_TRAINER", + words: ["BIG", "ANGRY", "TRAINER"], + }, + { + input: "ApplesBananasOrangesAndAPear", + words: ["Apples", "Bananas", "Oranges", "And", "A", "Pear"], + }, + { + input: "mysteryEncounters/anOfferYouCantRefuse", + words: ["mystery", "Encounters/an", "Offer", "You", "Cant", "Refuse"], + }, + ]; -describe("Utils - Casing -", () => { - describe("splitWords", () => { - it.each(testCases)("should split a string into its constituent words - $input", ({ input, words }) => { - const ret = splitWords(input); - expect(ret).toEqual(words); + it.each(testCases)("should split a string into its constituent words - $input", ({ input, words }) => { + const ret = splitWords(input); + expect(ret).toEqual(words); + }); + }); + }); + + describe("Test Enum Utils", () => { + //#region boilerplate + enum testEnumNum { + testN1 = 1, + testN2 = 2, + } + + enum testEnumString { + testS1 = "apple", + testS2 = "banana", + } + + const testObjNum = { testON1: 3, testON2: 4 } as const; + + const testObjString = { testOS1: "pear", testOS2: "orange" } as const; + + const testHexObj = { + XA1: 0x00a1, + ABCD: 0xabcd, + FFFD: 0xfffd, + } as const; + + //#endregion boilerplate + + describe("stringifyEnumArray", () => { + const cases = [ + { + obj: testEnumNum, + values: [testEnumNum.testN1, testEnumNum.testN2], + output: "[testN1, testN2] (=[1, 2])", + type: "numeric enum", + }, + { + obj: testEnumString, + values: [testEnumString.testS1, testEnumString.testS2], + output: "[testS1, testS2] (=[apple, banana])", + type: "string enum", + }, + { + obj: testObjNum, + values: [testObjNum.testON1, testObjNum.testON2], + output: "[testON1, testON2] (=[3, 4])", + type: "numeric const object", + }, + { + obj: testObjString, + values: [testObjString.testOS1, testObjString.testOS2], + output: "[testOS1, testOS2] (=[pear, orange])", + type: "string const object", + }, + ] as { obj: EnumOrObject; values: (string | number)[]; output: string; type: string }[]; + it.each(cases)("should stringify an array of enums or const objects - $type", ({ obj, values, output }) => { + const ret = stringifyEnumArray(obj, values, { excludeValues: false }); + expect(ret).toEqual(output); + }); + + it("should work if no values provided", () => { + const ret = stringifyEnumArray(testEnumNum, []); + expect(ret).toEqual("[]"); + }); + + it("should allow excluding values from result, defaulting to doing so for string enums", () => { + const num = stringifyEnumArray(testEnumNum, [testEnumNum.testN1, testEnumNum.testN2], { excludeValues: true }); + expect(num).toEqual("[testN1, testN2]"); + + const str = stringifyEnumArray(testEnumString, [testEnumString.testS1, testEnumString.testS2]); + expect(str).toEqual("[testS1, testS2]"); + }); + + it("should support custon formatting args", () => { + const ret = stringifyEnumArray(testHexObj, [testHexObj.ABCD, testHexObj.FFFD, testHexObj.XA1], { + base: 16, + casing: "Title", + prefix: "testHexObj.", + suffix: " blah", + padding: 5, + }); + expect(ret).toEqual( + "[testHexObj.Abcd blah, testHexObj.Fffd blah, testHexObj.Xa1 blah] (=[0x0ABCD, 0x0FFFD, 0x000A1])", + ); + }); + + it("should type correctly", () => { + // @ts-expect-error - value props should not be providable if values aren't being included + stringifyEnumArray(testEnumNum, [testEnumNum.testN1], { excludeValues: true, base: 10 }); + stringifyEnumArray(testEnumNum, [testEnumNum.testN1], { base: 10 }); + stringifyEnumArray(testEnumNum, [testEnumNum.testN1], { excludeValues: true, prefix: "12" }); + + // @ts-expect-error - value props should not be providable if values aren't being included + stringifyEnumArray(testEnumString, [testEnumString.testS1], { excludeValues: true, base: 10 }); + // @ts-expect-error - should not be able to specify base on string enum + stringifyEnumArray(testEnumString, [testEnumString.testS1], { excludeValues: false, base: 10 }); + stringifyEnumArray(testEnumString, [testEnumString.testS1], { excludeValues: true, suffix: "23" }); + }); }); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 60bc1271f89..317c1709895 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ import { defineConfig } from "vitest/config"; import { BaseSequencer, type TestSpecification } from "vitest/node"; import { defaultConfig } from "./vite.config"; +// biome-ignore lint/style/noDefaultExport: required for vitest export default defineConfig(({ mode }) => ({ ...defaultConfig, test: {