[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>
This commit is contained in:
Bertie690 2025-10-26 14:03:46 -04:00 committed by GitHub
parent 3cfbb695e9
commit 64176a0920
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 269 additions and 113 deletions

View File

@ -56,7 +56,7 @@ export function getEnumValues<E extends EnumOrObject>(enumType: TSNumericEnum<E>
* two: 2, * two: 2,
* } as const; * } as const;
* console.log(enumValueToKey(thing, 2)); // output: "two" * 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 * @remarks
* If multiple keys map to the same value, the first one (in insertion order) will be retrieved, * 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. * but the return type will be the union of ALL their corresponding keys.

View File

@ -1,6 +1,5 @@
import { getStatKey, type Stat } from "#enums/stat"; import { getStatKey, type Stat } from "#enums/stat";
import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types"; import type { EnumOrObject, NormalEnum } from "#types/enum-types";
import type { ObjectValues } from "#types/type-helpers";
import { enumValueToKey } from "#utils/enums"; import { enumValueToKey } from "#utils/enums";
import { toTitleCase } from "#utils/strings"; import { toTitleCase } from "#utils/strings";
import type { MatcherState } from "@vitest/expect"; import type { MatcherState } from "@vitest/expect";
@ -8,32 +7,56 @@ import i18next from "i18next";
type Casing = "Preserve" | "Title"; type Casing = "Preserve" | "Title";
interface getEnumStrOptions { interface getEnumStrKeyOptions {
/** /**
* A string denoting the casing method to use. * A string denoting the casing method to use.
* @defaultValue "Preserve" * @defaultValue "Preserve"
*/ */
casing?: Casing; 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; 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; suffix?: string;
}
interface getEnumStrValueOptions {
/** /**
* Whether to omit the value from the text. * A numeric base that will be used to convert `val` into a number.
* @defaultValue Whether `E` is a non-string enum * 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. * Options type for `getEnumStr` and company.
* @param obj - The {@linkcode EnumOrObject} to source reverse mappings from *
* @param val - One of {@linkcode obj}'s values * Selectively includes properties based on the type of `Val`
* @param options - Options modifying the stringification process */
type getEnumStrOptions<Val extends string | number = string | number, X extends boolean = boolean> = {
/**
* 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. * @returns The stringified representation of `val` as dictated by the options.
* @example * @example
* ```ts * ```ts
@ -46,103 +69,134 @@ interface getEnumStrOptions {
* getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)" * getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)"
* ``` * ```
*/ */
export function getEnumStr<E extends EnumOrObject>( export function getEnumStr<E extends EnumOrObject, V extends E[keyof E], X extends boolean>(
obj: E, obj: E,
val: ObjectValues<E>, val: V,
options: getEnumStrOptions = {}, options: getEnumStrOptions<V, X> = {},
): string { ): string {
const { casing = "Preserve", prefix = "", suffix = "", omitValue = typeof val === "number" } = options; const {
let casingFunc: ((s: string) => string) | undefined; 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<E extends EnumOrObject, V extends E[keyof E]>(
obj: E,
val: V,
{ casing, prefix, suffix }: Required<getEnumStrKeyOptions>,
): string {
let casingFunc: (s: string) => string;
switch (casing) { switch (casing) {
case "Preserve": case "Preserve":
casingFunc = s => s;
break; break;
case "Title": case "Title":
casingFunc = toTitleCase; casingFunc = toTitleCase;
break; break;
} }
let stringPart = const keyName =
obj[val] !== undefined obj[val] !== undefined
? // TS reverse mapped enum ? // TS reverse mapped enum
(obj[val] as string) (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<E>, val) as string); (enumValueToKey(obj as NormalEnum<E>, val) as string);
if (casingFunc) { return `${prefix}${casingFunc(keyName)}${suffix}`;
stringPart = casingFunc(stringPart); }
}
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<getEnumStrValueOptions>, 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. * 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 obj - The `EnumOrObject` to source reverse mappings from
* @param enums - An array of {@linkcode obj}'s values * @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`. * @returns The stringified representation of `enums`.
* @example * @example
* ```ts * ```ts
* enum fakeEnum { * enum fakeEnum {
* ONE: 1, * ONE = 1,
* TWO: 2, * TWO = 2,
* THREE: 3, * 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<E extends EnumOrObject>(obj: E, enums: E[keyof E][]): string { export function stringifyEnumArray<E extends EnumOrObject, V extends E[keyof E], X extends boolean>(
if (enums.length === 0) { obj: E,
values: readonly V[],
options: getEnumStrOptions<V, X> = {},
): string {
if (values.length === 0) {
return "[]"; return "[]";
} }
const vals = enums.slice(); const {
/** An array of string names */ casing = "Preserve",
let names: string[]; prefix = "",
suffix = "",
excludeValues = typeof values[0] === "string",
base = 10,
padding = 0,
} = options as getEnumStrOptions;
if (obj[enums[0]] !== undefined) { const keyPart = values.map(v => getKeyPart(obj, v, { casing, prefix, suffix })).join(", ");
// Reverse mapping exists - `obj` is a `TSNumericEnum` and its reverse mapped counterparts are strings if (excludeValues) {
names = enums.map(e => (obj as TSNumericEnum<E>)[e] as string); return `[${keyPart}]`;
} 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<E>)) {
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;
});
}
} }
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. * Convert a number into an English ordinal.
* @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<T>(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
* @param num - The number to convert into an ordinal * @param num - The number to convert into an ordinal
* @returns The ordinal representation of {@linkcode num}. * @returns The ordinal representation of `num`.
* @example * @example
* ```ts * ```ts
* console.log(getOrdinal(1)); // Output: "1st" * console.log(getOrdinal(1)); // Output: "1st"

View File

@ -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 { splitWords } from "#utils/strings";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
interface testCase { describe("Utils - Strings", () => {
input: string; describe("Casing", () => {
words: string[]; describe("splitWords", () => {
} interface testCase {
input: string;
words: string[];
}
const testCases: testCase[] = [ const testCases: testCase[] = [
{ {
input: "Lorem ipsum dolor sit amet", input: "Lorem ipsum dolor sit amet",
words: ["Lorem", "ipsum", "dolor", "sit", "amet"], words: ["Lorem", "ipsum", "dolor", "sit", "amet"],
}, },
{ {
input: "consectetur-adipiscing-elit", input: "consectetur-adipiscing-elit",
words: ["consectetur", "adipiscing", "elit"], words: ["consectetur", "adipiscing", "elit"],
}, },
{ {
input: "sed_do_eiusmod_tempor_incididunt_ut_labore", input: "sed_do_eiusmod_tempor_incididunt_ut_labore",
words: ["sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore"], words: ["sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore"],
}, },
{ {
input: "Et Dolore Magna Aliqua", input: "Et Dolore Magna Aliqua",
words: ["Et", "Dolore", "Magna", "Aliqua"], words: ["Et", "Dolore", "Magna", "Aliqua"],
}, },
{ {
input: "BIG_ANGRY_TRAINER", input: "BIG_ANGRY_TRAINER",
words: ["BIG", "ANGRY", "TRAINER"], words: ["BIG", "ANGRY", "TRAINER"],
}, },
{ {
input: "ApplesBananasOrangesAndAPear", input: "ApplesBananasOrangesAndAPear",
words: ["Apples", "Bananas", "Oranges", "And", "A", "Pear"], words: ["Apples", "Bananas", "Oranges", "And", "A", "Pear"],
}, },
{ {
input: "mysteryEncounters/anOfferYouCantRefuse", input: "mysteryEncounters/anOfferYouCantRefuse",
words: ["mystery", "Encounters/an", "Offer", "You", "Cant", "Refuse"], words: ["mystery", "Encounters/an", "Offer", "You", "Cant", "Refuse"],
}, },
]; ];
describe("Utils - Casing -", () => { it.each(testCases)("should split a string into its constituent words - $input", ({ input, words }) => {
describe("splitWords", () => { const ret = splitWords(input);
it.each(testCases)("should split a string into its constituent words - $input", ({ input, words }) => { expect(ret).toEqual(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" });
});
}); });
}); });
}); });

View File

@ -8,6 +8,7 @@ import { defineConfig } from "vitest/config";
import { BaseSequencer, type TestSpecification } from "vitest/node"; import { BaseSequencer, type TestSpecification } from "vitest/node";
import { defaultConfig } from "./vite.config"; import { defaultConfig } from "./vite.config";
// biome-ignore lint/style/noDefaultExport: required for vitest
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
...defaultConfig, ...defaultConfig,
test: { test: {