adding monkeytype
Some checks failed
Mark Stale PRs / stale (push) Has been cancelled

This commit is contained in:
Benjamin Falch
2026-04-23 13:53:44 +02:00
parent e214a2fd35
commit 2bc741fb78
1930 changed files with 7590652 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
import { describe, it, expect } from "vitest";
import { hexToRgb, blendTwoHexColors } from "../../src/ts/utils/colors";
describe("colors.ts", () => {
describe("hexToRgb", () => {
it("Invalid hex values", () => {
expect(hexToRgb("ffff")).toEqual(undefined);
expect(hexToRgb("fff0000")).toEqual(undefined);
expect(hexToRgb("#ff")).toEqual(undefined);
expect(hexToRgb("ffffff")).toEqual(undefined);
expect(hexToRgb("fff")).toEqual(undefined);
expect(hexToRgb("#ffffffffff")).toEqual(undefined); // Too long
});
it("Valid hex value without alpha", () => {
expect(hexToRgb("#ffffff")).toEqual({
r: 255,
g: 255,
b: 255,
});
expect(hexToRgb("#000000")).toEqual({
r: 0,
g: 0,
b: 0,
});
expect(hexToRgb("#fff")).toEqual({
r: 255,
g: 255,
b: 255,
});
expect(hexToRgb("#000")).toEqual({
r: 0,
g: 0,
b: 0,
});
expect(hexToRgb("#ff0000")).toEqual({
r: 255,
g: 0,
b: 0,
});
expect(hexToRgb("#00ff00")).toEqual({
r: 0,
g: 255,
b: 0,
});
expect(hexToRgb("#0000ff")).toEqual({
r: 0,
g: 0,
b: 255,
});
expect(hexToRgb("#f00")).toEqual({
r: 255,
g: 0,
b: 0,
});
expect(hexToRgb("#0f0")).toEqual({
r: 0,
g: 255,
b: 0,
});
expect(hexToRgb("#00f")).toEqual({
r: 0,
g: 0,
b: 255,
});
expect(hexToRgb("#123456")).toEqual({
r: 18,
g: 52,
b: 86,
});
});
it("Valid hex value with alpha (RGBA format)", () => {
expect(hexToRgb("#ffff")).toEqual({
r: 255,
g: 255,
b: 255,
a: 1,
});
expect(hexToRgb("#fff0")).toEqual({
r: 255,
g: 255,
b: 255,
a: 0,
});
expect(hexToRgb("#f008")).toEqual({
r: 255,
g: 0,
b: 0,
a: 0.5333333333333333, // 0x88 / 255
});
});
it("Valid hex value with alpha (RRGGBBAA format)", () => {
expect(hexToRgb("#ffffffff")).toEqual({
r: 255,
g: 255,
b: 255,
a: 1,
});
expect(hexToRgb("#ffffff00")).toEqual({
r: 255,
g: 255,
b: 255,
a: 0,
});
expect(hexToRgb("#ff000080")).toEqual({
r: 255,
g: 0,
b: 0,
a: 0.5019607843137255, // 0x80 / 255
});
expect(hexToRgb("#00000000")).toEqual({
r: 0,
g: 0,
b: 0,
a: 0,
});
expect(hexToRgb("#123456ff")).toEqual({
r: 18,
g: 52,
b: 86,
a: 1,
});
});
});
describe("blendTwoHexColors", () => {
const cases = [
{
color1: "#ffffff",
color2: "#000000",
alpha: 0.5,
expected: "#808080",
display: "no opacity",
},
{
color1: "#ffffff00",
color2: "#000000",
alpha: 0.5,
expected: "#80808080",
display: "mixed opacity",
},
{
color1: "#ffffffff",
color2: "#00000000",
alpha: 0.5,
expected: "#80808080",
display: "with opacity",
},
];
it.each(cases)(
"should blend colors correctly ($display)",
({ color1, color2, alpha, expected }) => {
const result = blendTwoHexColors(color1, color2, alpha);
expect(result).toBe(expected);
},
);
// cases.forEach(({ color1, color2, alpha, expected }) => {
// const result = blendTwoHexColors(color1, color2, alpha);
// expect(result).toBe(expected);
// });
});
});

View File

@@ -0,0 +1,252 @@
import { describe, it, expect } from "vitest";
import { getDefaultConfig } from "../../src/ts/constants/default-config";
import { migrateConfig } from "../../src/ts/config/utils";
import { PartialConfig } from "@monkeytype/schemas/configs";
const defaultConfig = getDefaultConfig();
describe("config.ts", () => {
describe("migrateConfig", () => {
it("should carry over properties from the default config", () => {
const partialConfig = {} as PartialConfig;
const result = migrateConfig(partialConfig);
expect(result).toEqual(expect.objectContaining(getDefaultConfig()));
for (const [key, value] of Object.entries(getDefaultConfig())) {
expect(result).toHaveProperty(key, value);
}
});
it("should not merge properties which are not in the default config (legacy properties)", () => {
const partialConfig = {
legacy: true,
} as PartialConfig;
const result = migrateConfig(partialConfig);
expect(result).toEqual(expect.objectContaining(getDefaultConfig()));
expect(result).not.toHaveProperty("legacy");
});
it("should correctly merge properties of various types", () => {
const partialConfig = {
mode: "quote",
hideExtraLetters: true,
time: 120,
accountChart: ["off", "off", "off", "off"],
} as PartialConfig;
const result = migrateConfig(partialConfig);
expect(result.mode).toEqual("quote");
expect(result.hideExtraLetters).toEqual(true);
expect(result.time).toEqual(120);
expect(result.accountChart).toEqual(["off", "off", "off", "off"]);
});
describe("should replace value with default config if invalid", () => {
it.for([
{
given: { theme: "invalid" },
expected: { theme: defaultConfig.theme },
},
{
given: { minWpm: "invalid" },
expected: { minWpm: defaultConfig.minWpm },
},
{
given: { customThemeColors: ["#ffffff"] },
expected: { customThemeColors: defaultConfig.customThemeColors },
},
{
given: { accountChart: [true, false, false, true] },
expected: { accountChart: defaultConfig.accountChart },
},
{
given: {
favThemes: ["nord", "invalid", "serika_dark", "invalid2", "8008"],
},
expected: { favThemes: ["nord", "serika_dark", "8008"] },
},
])(`$given`, ({ given, expected }) => {
const description = `given: ${JSON.stringify(
given,
)}, expected: ${JSON.stringify(expected)} `;
const result = migrateConfig(given);
expect(result, description).toEqual(expect.objectContaining(expected));
});
});
describe("should not convert legacy values if current values are already present", () => {
it.for([
{
given: { showLiveAcc: true, timerStyle: "mini", liveAccStyle: "off" },
expected: { liveAccStyle: "off" },
},
{
given: {
showLiveBurst: true,
timerStyle: "mini",
liveBurstStyle: "off",
},
expected: { liveBurstStyle: "off" },
},
{
given: { quickTab: true, quickRestart: "enter" },
expected: { quickRestart: "enter" },
},
{
given: { swapEscAndTab: true, quickRestart: "enter" },
expected: { quickRestart: "enter" },
},
{
given: { alwaysShowCPM: true, typingSpeedUnit: "wpm" },
expected: { typingSpeedUnit: "wpm" },
},
{
given: { showTimerProgress: true, timerStyle: "mini" },
expected: { timerStyle: "mini" },
},
])(`$given`, ({ given, expected }) => {
//WHEN
const description = `given: ${JSON.stringify(
given,
)}, expected: ${JSON.stringify(expected)} `;
const result = migrateConfig(given);
expect(result, description).toEqual(expect.objectContaining(expected));
});
});
describe("should convert legacy values", () => {
it.for([
{ given: { quickTab: true }, expected: { quickRestart: "tab" } },
{ given: { smoothCaret: true }, expected: { smoothCaret: "medium" } },
{ given: { smoothCaret: false }, expected: { smoothCaret: "off" } },
{ given: { swapEscAndTab: true }, expected: { quickRestart: "esc" } },
{
given: { alwaysShowCPM: true },
expected: { typingSpeedUnit: "cpm" },
},
{ given: { showAverage: "wpm" }, expected: { showAverage: "speed" } },
{
given: { playSoundOnError: true },
expected: { playSoundOnError: "1" },
},
{
given: { playSoundOnError: false },
expected: { playSoundOnError: "off" },
},
{
given: { showTimerProgress: false },
expected: { timerStyle: "off" },
},
{
given: { showLiveWpm: true, timerStyle: "text" },
expected: { liveSpeedStyle: "text" },
},
{
given: { showLiveWpm: true, timerStyle: "bar" },
expected: { liveSpeedStyle: "mini" },
},
{
given: { showLiveWpm: true, timerStyle: "off" },
expected: { liveSpeedStyle: "mini" },
},
{
given: { showLiveBurst: true, timerStyle: "text" },
expected: { liveBurstStyle: "text" },
},
{
given: { showLiveBurst: true, timerStyle: "bar" },
expected: { liveBurstStyle: "mini" },
},
{
given: { showLiveBurst: true, timerStyle: "off" },
expected: { liveBurstStyle: "mini" },
},
{
given: { showLiveAcc: true, timerStyle: "text" },
expected: { liveAccStyle: "text" },
},
{
given: { showLiveAcc: true, timerStyle: "bar" },
expected: { liveAccStyle: "mini" },
},
{
given: { showLiveAcc: true, timerStyle: "off" },
expected: { liveAccStyle: "mini" },
},
{ given: { soundVolume: "0.5" }, expected: { soundVolume: 0.5 } },
{ given: { funbox: "none" }, expected: { funbox: [] } },
{
given: { funbox: "58008#read_ahead" },
expected: { funbox: ["58008", "read_ahead"] },
},
{
given: { customLayoutfluid: "qwerty#qwertz" },
expected: { customLayoutfluid: ["qwerty", "qwertz"] },
},
{ given: { indicateTypos: false }, expected: { indicateTypos: "off" } },
{
given: { indicateTypos: true },
expected: { indicateTypos: "replace" },
},
{
given: {
favThemes: ["purpurite", "80s_after_dark", "luna", "pulse"],
},
expected: {
favThemes: ["80s_after_dark", "luna", "pulse"],
},
},
{
given: { fontSize: "2" },
expected: { fontSize: 2 },
},
{
given: { fontSize: "15" },
expected: { fontSize: 1.5 },
},
{
given: { fontSize: "125" },
expected: { fontSize: 1.25 },
},
{
given: { fontSize: 15 },
expected: { fontSize: 15 },
},
{
given: { fontSize: -0.5 },
expected: { fontSize: 1 },
},
{
given: { tapeMargin: 9.5 },
expected: { tapeMargin: 10 },
},
{
given: { tapeMargin: 25 },
expected: { tapeMargin: 25 },
},
{
given: { tapeMargin: 90.5 },
expected: { tapeMargin: 90 },
},
{
given: { maxLineWidth: 0 },
expected: { maxLineWidth: 0 },
},
{
given: { maxLineWidth: 19 },
expected: { maxLineWidth: 20 },
},
{
given: { maxLineWidth: 1001 },
expected: { maxLineWidth: 1000 },
},
])(`$given`, ({ given, expected }) => {
const description = `given: ${JSON.stringify(
given,
)}, expected: ${JSON.stringify(expected)} `;
const result = migrateConfig(given);
expect(result, description).toEqual(expect.objectContaining(expected));
});
});
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import * as DateAndTime from "../../src/ts/utils/date-and-time";
describe("date-and-time", () => {
const testCases = [
{ locale: "en-US", firstDayOfWeek: 0 },
{ locale: "en", firstDayOfWeek: 0 },
{ locale: "de-DE", firstDayOfWeek: 1 },
{ locale: "en-DE", firstDayOfWeek: 1, firefoxFirstDayOfWeek: 0 },
{ locale: "de-AT", firstDayOfWeek: 1 },
{ locale: "ps-AF", firstDayOfWeek: 6, firefoxFirstDayOfWeek: 0 },
{ locale: "de-unknown", firstDayOfWeek: 1 },
{ locale: "xx-yy", firstDayOfWeek: 1, firefoxFirstDayOfWeek: 0 },
];
describe("getFirstDayOfTheWeek", () => {
const languageMock = vi.spyOn(window.navigator, "language", "get");
const localeMock = vi.spyOn(Intl, "Locale");
beforeEach(() => {
languageMock.mockClear();
localeMock.mockClear();
});
it("fallback to sunday for missing language", () => {
//GIVEN
languageMock.mockReturnValue(null as any);
//WHEN / THEN
expect(DateAndTime.getFirstDayOfTheWeek()).toEqual(0);
});
describe("with weekInfo", () => {
it.for(testCases)(`$locale`, ({ locale, firstDayOfWeek }) => {
//GIVEN
languageMock.mockReturnValue(locale);
localeMock.mockImplementation(function (this: any) {
return { weekInfo: { firstDay: firstDayOfWeek } } as any;
});
//WHEN/THEN
expect(DateAndTime.getFirstDayOfTheWeek()).toEqual(firstDayOfWeek);
});
});
describe("with getWeekInfo", () => {
it("with getWeekInfo on monday", () => {
languageMock.mockReturnValue("en-US");
localeMock.mockImplementationOnce(function (this: any) {
return { getWeekInfo: () => ({ firstDay: 1 }) } as any;
});
//WHEN/THEN
expect(DateAndTime.getFirstDayOfTheWeek()).toEqual(1);
});
it("with getWeekInfo on sunday", () => {
languageMock.mockReturnValue("en-US");
localeMock.mockImplementationOnce(function (this: any) {
return { getWeekInfo: () => ({ firstDay: 7 }) } as any;
});
//WHEN/THEN
expect(DateAndTime.getFirstDayOfTheWeek()).toEqual(0);
});
});
describe("without weekInfo (firefox)", () => {
beforeEach(() => {
localeMock.mockImplementation(function (this: any) {
return {} as any;
});
});
it.for(testCases)(
`$locale`,
({ locale, firstDayOfWeek, firefoxFirstDayOfWeek }) => {
//GIVEN
languageMock.mockReturnValue(locale);
//WHEN/THEN
expect(DateAndTime.getFirstDayOfTheWeek()).toEqual(
firefoxFirstDayOfWeek ?? firstDayOfWeek,
);
},
);
});
});
});

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/dom";
import { userEvent } from "@testing-library/user-event";
import {
ElementWithUtils,
qsr,
onDOMReady,
__testing,
} from "../../src/ts/utils/dom";
const resetReady = __testing.resetReady;
describe("dom", () => {
describe("ElementWithUtils", () => {
describe("onChild", () => {
const handler = vi.fn();
function registerOnChild(
event: string,
selector: string,
options?: {
parent?: ElementWithUtils;
},
): void {
const parent = options?.parent ?? qsr("#parent");
parent?.onChild(event, selector, (e) =>
handler({
target: e.target,
childTarget: e.childTarget,
currentTarget: e.currentTarget,
}),
);
}
beforeEach(() => {
handler.mockClear();
document.body.innerHTML = "";
const root = document.createElement("div");
root.innerHTML = `
<div id="parent" data-testid="parent">
<section id="decoy">
<div id="mid1" data-testid="mid1" class="middle">
<div id="inner1" class="inner">test</div>
<div id="inner2" data-testid="inner2" class="inner">
test
<button id="button" data-testid="button">
click me
<i id="icon" data-testid="icon">test</i>
</button>
</div>
</div>
<div id="mid2" class="middle">
<div id="inner3" class="inner">test</div>
<div id="inner4" class="inner">test</div>
</div>
</section>
</div>
`;
document.body.appendChild(root);
});
it("should not fire when parent element is clicked", async () => {
//GIVEN
registerOnChild("click", "div");
//WHEN
await userEvent.click(screen.getByTestId("parent"));
//THEN
expect(handler).not.toHaveBeenCalled();
});
it("should not fire when selector doesnt match", async () => {
//GIVEN
const buttonEl = qsr("#button");
registerOnChild("click", "div", { parent: buttonEl });
//WHEN
await userEvent.click(screen.getByTestId("icon"));
//THEN
expect(handler).not.toHaveBeenCalled();
});
it("should fire when selector is clicked", async () => {
//GIVEN
registerOnChild("click", "div");
//WHEN
const clickTarget = screen.getByTestId("mid1");
await userEvent.click(clickTarget);
//THEN
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
target: clickTarget,
childTarget: clickTarget,
currentTarget: screen.getByTestId("parent"),
}),
);
});
it("should fire when child of selector is clicked", async () => {
//GIVEN
registerOnChild("click", "div.middle");
//WHEN
const selectorTarget = screen.getByTestId("mid1");
const clickTarget = screen.getByTestId("button");
await userEvent.click(clickTarget);
//THEN
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
target: clickTarget,
childTarget: selectorTarget,
currentTarget: screen.getByTestId("parent"),
}),
);
});
it("should fire on each element matching the selector from the child up to the parent", async () => {
//GIVEN
registerOnChild("click", "div.middle, div.inner");
//WHEN
let clickTarget = screen.getByTestId("button");
await userEvent.click(clickTarget);
//THEN
expect(handler).toHaveBeenCalledTimes(2);
//First call is for childTarget inner2 (grand child of parent)
expect(handler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
target: clickTarget,
childTarget: screen.getByTestId("inner2"),
currentTarget: screen.getByTestId("parent"),
}),
);
//Second call is for childTarget mid1 (child of parent)
expect(handler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
target: clickTarget,
childTarget: screen.getByTestId("mid1"),
currentTarget: screen.getByTestId("parent"),
}),
);
//WHEN click on mid1 handler is only called one time
handler.mockClear();
clickTarget = screen.getByTestId("mid1");
await userEvent.click(clickTarget);
//THEN
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
target: clickTarget,
childTarget: clickTarget,
currentTarget: screen.getByTestId("parent"),
}),
);
});
});
});
describe("onDOMReady", () => {
beforeEach(() => {
document.body.innerHTML = "";
resetReady();
vi.useFakeTimers();
});
function dispatchEvent(event: "DOMContextLoaded" | "load"): void {
if (event === "DOMContextLoaded") {
document.dispatchEvent(new Event("DOMContentLoaded"));
} else {
window.dispatchEvent(new Event("load"));
}
vi.runAllTimers();
}
it("executes callbacks when DOMContentLoaded fires", () => {
const spy = vi.fn();
onDOMReady(spy);
expect(spy).not.toHaveBeenCalled();
dispatchEvent("DOMContextLoaded");
expect(spy).toHaveBeenCalledOnce();
});
it("executes callbacks added before ready in order", () => {
const calls: number[] = [];
onDOMReady(() => calls.push(1));
onDOMReady(() => calls.push(2));
dispatchEvent("DOMContextLoaded");
expect(calls).toEqual([1, 2]);
});
it("executes callbacks asynchronously when DOM is already ready", () => {
const spy = vi.fn();
Object.defineProperty(document, "readyState", {
value: "complete",
configurable: true,
});
onDOMReady(spy);
expect(spy).not.toHaveBeenCalled();
vi.runAllTimers();
expect(spy).toHaveBeenCalledOnce();
});
it("executes callbacks added after ready asynchronously", () => {
const calls: string[] = [];
onDOMReady(() => calls.push("ready"));
dispatchEvent("DOMContextLoaded");
onDOMReady(() => calls.push("late"));
expect(calls).toEqual(["ready"]);
vi.runAllTimers();
expect(calls).toEqual(["ready", "late"]);
});
it("executes callbacks added during ready execution", () => {
const calls: number[] = [];
onDOMReady(() => {
calls.push(1);
onDOMReady(() => calls.push(3));
});
onDOMReady(() => calls.push(2));
dispatchEvent("DOMContextLoaded");
expect(calls).toEqual([1, 2, 3]);
});
it("does not execute ready callbacks more than once", () => {
const spy = vi.fn();
onDOMReady(spy);
dispatchEvent("DOMContextLoaded");
dispatchEvent("load");
expect(spy).toHaveBeenCalledOnce();
});
it("falls back to window load event if DOMContentLoaded does not fire", () => {
const spy = vi.fn();
onDOMReady(spy);
dispatchEvent("load");
expect(spy).toHaveBeenCalledOnce();
});
});
});

View File

@@ -0,0 +1,280 @@
import { describe, it, expect } from "vitest";
import { getDefaultConfig } from "../../src/ts/constants/default-config";
import { Formatting } from "../../src/ts/utils/format";
import { Config } from "@monkeytype/schemas/configs";
describe("format.ts", () => {
describe("typingsSpeed", () => {
it("should format with typing speed and decimalPlaces from configuration", () => {
//wpm, no decimals
const wpmNoDecimals = getInstance({
typingSpeedUnit: "wpm",
alwaysShowDecimalPlaces: false,
});
expect(wpmNoDecimals.typingSpeed(12.5)).toEqual("13");
expect(wpmNoDecimals.typingSpeed(0)).toEqual("0");
//cpm, no decimals
const cpmNoDecimals = getInstance({
typingSpeedUnit: "cpm",
alwaysShowDecimalPlaces: false,
});
expect(cpmNoDecimals.typingSpeed(12.5)).toEqual("63");
expect(cpmNoDecimals.typingSpeed(0)).toEqual("0");
//wpm, with decimals
const wpmWithDecimals = getInstance({
typingSpeedUnit: "wpm",
alwaysShowDecimalPlaces: true,
});
expect(wpmWithDecimals.typingSpeed(12.5)).toEqual("12.50");
expect(wpmWithDecimals.typingSpeed(0)).toEqual("0.00");
//cpm, with decimals
const cpmWithDecimals = getInstance({
typingSpeedUnit: "cpm",
alwaysShowDecimalPlaces: true,
});
expect(cpmWithDecimals.typingSpeed(12.5)).toEqual("62.50");
expect(cpmWithDecimals.typingSpeed(0)).toEqual("0.00");
});
it("should format with fallback", () => {
//default fallback
const format = getInstance();
expect(format.typingSpeed(null)).toEqual("-");
expect(format.typingSpeed(undefined)).toEqual("-");
//provided fallback
expect(format.typingSpeed(null, { fallback: "none" })).toEqual("none");
expect(format.typingSpeed(null, { fallback: "" })).toEqual("");
expect(format.typingSpeed(undefined, { fallback: "none" })).toEqual(
"none",
);
expect(format.typingSpeed(undefined, { fallback: "" })).toEqual("");
expect(format.typingSpeed(undefined, { fallback: undefined })).toEqual(
"",
);
});
it("should format with decimals", () => {
//force with decimals
const wpmNoDecimals = getInstance({
typingSpeedUnit: "wpm",
alwaysShowDecimalPlaces: false,
});
expect(
wpmNoDecimals.typingSpeed(100, { showDecimalPlaces: true }),
).toEqual("100.00");
//force without decimals
const wpmWithDecimals = getInstance({
typingSpeedUnit: "wpm",
alwaysShowDecimalPlaces: true,
});
expect(
wpmWithDecimals.typingSpeed(100, { showDecimalPlaces: false }),
).toEqual("100");
});
it("should format with suffix", () => {
const format = getInstance({
typingSpeedUnit: "wpm",
alwaysShowDecimalPlaces: false,
});
expect(format.typingSpeed(100, { suffix: " raw" })).toEqual("100 raw");
expect(format.typingSpeed(100, { suffix: undefined })).toEqual("100");
expect(format.typingSpeed(0, { suffix: " raw" })).toEqual("0 raw");
expect(format.typingSpeed(null, { suffix: " raw" })).toEqual("-");
expect(format.typingSpeed(undefined, { suffix: " raw" })).toEqual("-");
});
it("should format with rounding", () => {
const format = getInstance({ alwaysShowDecimalPlaces: false });
expect(format.typingSpeed(80.25)).toEqual("80");
expect(format.typingSpeed(80.25, { rounding: Math.ceil })).toEqual("81");
expect(format.typingSpeed(80.75, { rounding: Math.floor })).toEqual("80");
});
});
describe("percentage", () => {
it("should format with decimalPlaces from configuration", () => {
//no decimals
const noDecimals = getInstance({ alwaysShowDecimalPlaces: false });
expect(noDecimals.percentage(12.5)).toEqual("13%");
expect(noDecimals.percentage(0)).toEqual("0%");
//with decimals
const withDecimals = getInstance({ alwaysShowDecimalPlaces: true });
expect(withDecimals.percentage(12.5)).toEqual("12.50%");
expect(withDecimals.percentage(0)).toEqual("0.00%");
});
it("should format with fallback", () => {
//default fallback
const format = getInstance();
expect(format.percentage(null)).toEqual("-");
expect(format.percentage(undefined)).toEqual("-");
//provided fallback
expect(format.percentage(null, { fallback: "none" })).toEqual("none");
expect(format.percentage(null, { fallback: "" })).toEqual("");
expect(format.percentage(undefined, { fallback: "none" })).toEqual(
"none",
);
expect(format.percentage(undefined, { fallback: "" })).toEqual("");
expect(format.percentage(undefined, { fallback: undefined })).toEqual("");
});
it("should format with decimals", () => {
//force with decimals
const noDecimals = getInstance({ alwaysShowDecimalPlaces: false });
expect(noDecimals.percentage(100, { showDecimalPlaces: true })).toEqual(
"100.00%",
);
//force without decimals
const withDecimals = getInstance({ alwaysShowDecimalPlaces: true });
expect(
withDecimals.percentage(100, { showDecimalPlaces: false }),
).toEqual("100%");
});
it("should format with suffix", () => {
const format = getInstance({ alwaysShowDecimalPlaces: false });
expect(format.percentage(100, { suffix: " raw" })).toEqual("100% raw");
expect(format.percentage(100, { suffix: undefined })).toEqual("100%");
expect(format.percentage(0, { suffix: " raw" })).toEqual("0% raw");
expect(format.percentage(null, { suffix: " raw" })).toEqual("-");
expect(format.percentage(undefined, { suffix: " raw" })).toEqual("-");
});
it("should format with rounding", () => {
const format = getInstance({ alwaysShowDecimalPlaces: false });
expect(format.percentage(80.25)).toEqual("80%");
expect(format.percentage(80.25, { rounding: Math.ceil })).toEqual("81%");
expect(format.percentage(80.75, { rounding: Math.floor })).toEqual("80%");
});
});
describe("accuracy", () => {
it("should floor decimals by default", () => {
//no decimals
const noDecimals = getInstance({ alwaysShowDecimalPlaces: false });
expect(noDecimals.accuracy(12.75)).toEqual("12%");
//with decimals
const withDecimals = getInstance({ alwaysShowDecimalPlaces: true });
expect(withDecimals.accuracy(12.75)).toEqual("12.75%");
});
it("should format with rounding", () => {
const format = getInstance({ alwaysShowDecimalPlaces: false });
expect(format.accuracy(80.5)).toEqual("80%");
expect(format.accuracy(80.25, { rounding: Math.ceil })).toEqual("81%");
expect(format.accuracy(80.75, { rounding: Math.floor })).toEqual("80%");
});
});
describe("decimals", () => {
it("should format with decimalPlaces from configuration", () => {
//no decimals
const noDecimals = getInstance({ alwaysShowDecimalPlaces: false });
expect(noDecimals.decimals(12.5)).toEqual("13");
expect(noDecimals.decimals(0)).toEqual("0");
//with decimals
const withDecimals = getInstance({ alwaysShowDecimalPlaces: true });
expect(withDecimals.decimals(12.5)).toEqual("12.50");
expect(withDecimals.decimals(0)).toEqual("0.00");
});
it("should format with fallback", () => {
//default fallback
const format = getInstance();
expect(format.decimals(null)).toEqual("-");
expect(format.decimals(undefined)).toEqual("-");
//provided fallback
expect(format.decimals(null, { fallback: "none" })).toEqual("none");
expect(format.decimals(null, { fallback: "" })).toEqual("");
expect(format.decimals(undefined, { fallback: "none" })).toEqual("none");
expect(format.decimals(undefined, { fallback: "" })).toEqual("");
expect(format.decimals(undefined, { fallback: undefined })).toEqual("");
});
it("should format with decimals", () => {
//force with decimals
const noDecimals = getInstance({ alwaysShowDecimalPlaces: false });
expect(noDecimals.decimals(100, { showDecimalPlaces: true })).toEqual(
"100.00",
);
//force without decimals
const withDecimals = getInstance({ alwaysShowDecimalPlaces: true });
expect(withDecimals.decimals(100, { showDecimalPlaces: false })).toEqual(
"100",
);
});
it("should format with suffix", () => {
const format = getInstance({ alwaysShowDecimalPlaces: false });
expect(format.decimals(100, { suffix: " raw" })).toEqual("100 raw");
expect(format.decimals(100, { suffix: undefined })).toEqual("100");
expect(format.decimals(0, { suffix: " raw" })).toEqual("0 raw");
expect(format.decimals(null, { suffix: " raw" })).toEqual("-");
expect(format.decimals(undefined, { suffix: " raw" })).toEqual("-");
});
it("should format with rounding", () => {
const format = getInstance({ alwaysShowDecimalPlaces: false });
expect(format.decimals(80.25)).toEqual("80");
expect(format.decimals(80.25, { rounding: Math.ceil })).toEqual("81");
expect(format.decimals(80.75, { rounding: Math.floor })).toEqual("80");
});
});
describe("rank", () => {
it("should format with default fallback", () => {
const format = getInstance();
expect(format.rank(1)).toEqual("1st");
expect(format.rank(2)).toEqual("2nd");
expect(format.rank(3)).toEqual("3rd");
expect(format.rank(4)).toEqual("4th");
expect(format.rank(11)).toEqual("11th");
expect(format.rank(12)).toEqual("12th");
expect(format.rank(13)).toEqual("13th");
expect(format.rank(14)).toEqual("14th");
expect(format.rank(21)).toEqual("21st");
expect(format.rank(22)).toEqual("22nd");
expect(format.rank(23)).toEqual("23rd");
expect(format.rank(24)).toEqual("24th");
});
it("should format with fallback", () => {
const format = getInstance();
expect(format.rank(0)).toEqual("0th");
expect(format.rank(null)).toEqual("-");
expect(format.rank(undefined)).toEqual("-");
expect(format.rank(0, {})).toEqual("0th");
expect(format.rank(null, {})).toEqual("-");
expect(format.rank(undefined, {})).toEqual("-");
expect(format.rank(0, { fallback: "none" })).toEqual("0th");
expect(format.rank(null, { fallback: "none" })).toEqual("none");
expect(format.rank(undefined, { fallback: "none" })).toEqual("none");
expect(format.rank(0, { fallback: "" })).toEqual("0th");
expect(format.rank(null, { fallback: "" })).toEqual("");
expect(format.rank(undefined, { fallback: "" })).toEqual("");
});
});
});
function getInstance(config?: Partial<Config>): Formatting {
const target: Config = { ...getDefaultConfig(), ...config };
return new Formatting(target);
}

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import * as generate from "../../src/ts/utils/generate";
describe("hexadecimal", () => {
it("should generate a random hexadecimal string", () => {
const hex = generate.getHexadecimal();
expect(hex.length).toSatisfy(
(len: number) => len % 2 === 0,
"The length of the hexadecimal string should be even.",
);
expect(hex.length).toBeGreaterThanOrEqual(2);
expect(hex.length).toBeLessThanOrEqual(16);
expect(hex).toMatch(/^[0-9a-f]+$/);
});
});
describe("specials", () => {
it("should generate valid special character strings", () => {
let foundComma = false;
let foundPeriod = false;
const expectedSpecials = generate.__testing.specials;
// Generate 1000 special "words" and check each
for (let i = 0; i < 1000; i++) {
const specials = generate.getSpecials();
// Check min/max length (1-7 as per implementation)
expect(specials.length).toBeGreaterThanOrEqual(1);
expect(specials.length).toBeLessThanOrEqual(7);
// Check that every character is from the expected specials array
for (const char of specials) {
expect(expectedSpecials).toContain(char);
if (char === ",") foundComma = true;
if (char === ".") foundPeriod = true;
}
}
// Ensure comma and period were found during the test
expect(foundComma).toBe(true);
expect(foundPeriod).toBe(true);
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import * as IpAddresses from "../../src/ts/utils/ip-addresses";
const IP_GENERATE_COUNT = 50;
describe("IP Addresses", () => {
describe("Compressing IPv6", () => {
it("should compress ipv6 according to the official rules", () => {
const rawIps = [
"0000:0000:0000:0000:0001:0000:0000:0000",
"b70b:ad23:3d4b:23a9:8000:0000:0000:0000",
"ad69:0005:02a4:a8a9:5dae:55f4:d87a:0000",
"0000:0000:0000:0001:0002:0000:0000:0000",
"0000:0000:0000:0000:0000:0000:0000:0000",
"2001:db8:0:0:0:0:2:1",
"2001:db8:0000:1:1:1:1:1",
"9ffd:7895:b4ae:36f6:b50a:8300:0000:0000/88",
];
const compressedIps = [
"::1:0:0:0",
"b70b:ad23:3d4b:23a9:8000::",
"ad69:5:2a4:a8a9:5dae:55f4:d87a:0",
"::1:2:0:0:0",
"::",
"2001:db8::2:1",
"2001:db8:0:1:1:1:1:1",
"9ffd:7895:b4ae:36f6:b50a:8300::/88",
];
for (let i = 0; i < rawIps.length; i++) {
expect(IpAddresses.compressIpv6(rawIps[i] as string)).toEqual(
compressedIps[i],
);
}
});
});
describe("Generating IPv4", () => {
it("should generate valid IPv4 addresses", () => {
for (let i = 0; i < IP_GENERATE_COUNT; i++) {
const ipAddress = IpAddresses.getRandomIPv4address();
const parts = ipAddress.split(".");
expect(parts).toHaveLength(4);
for (const part of parts) {
const num = Number(part);
expect(num).toBeGreaterThanOrEqual(0);
expect(num).toBeLessThanOrEqual(255);
expect(Number.isInteger(num)).toBe(true);
}
}
});
});
describe("Generating IPv6", () => {
it("should generate valid IPv6 addresses", () => {
for (let i = 0; i < IP_GENERATE_COUNT; i++) {
const ipAddress = IpAddresses.getRandomIPv6address();
const parts = ipAddress.split(":");
expect(parts).toHaveLength(8);
for (const part of parts) {
expect(part.length).toBeGreaterThanOrEqual(1);
expect(part.length).toBeLessThanOrEqual(4);
const num = parseInt(part, 16);
expect(num).not.toBeNaN();
expect(num).toBeGreaterThanOrEqual(0);
expect(num).toBeLessThanOrEqual(0xffff);
}
}
});
});
describe("Address to CIDR", () => {
it("should convert an IPv4 address to CIDR notation", () => {
const ip = "192.168.1.1";
const cidr = IpAddresses.addressToCIDR(ip);
const ipParts = cidr.split("/");
expect(
ipParts.length,
"There should only be one '/' in the ip addresss",
).toEqual(2);
const maskSize = Number(ipParts[1]);
expect(maskSize).not.toBeNaN();
expect(maskSize).toBeGreaterThanOrEqual(0);
expect(maskSize).toBeLessThanOrEqual(32);
});
it("should convert an IPv6 address to CIDR notation", () => {
const ip = "b70b:ad23:3d4b:23a9:8000:0000:0000:0000";
const cidr = IpAddresses.addressToCIDR(ip);
const ipParts = cidr.split("/");
expect(
ipParts.length,
"There should only be one '/' in the ip addresss",
).toEqual(2);
console.log(cidr);
const maskSize = Number(ipParts[1]);
expect(maskSize).not.toBeNaN();
expect(maskSize).toBeGreaterThanOrEqual(1);
expect(maskSize).toBeLessThanOrEqual(128);
});
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { layoutKeyToKeycode } from "../../src/ts/utils/key-converter";
const isoDvorak = JSON.parse(
readFileSync(
import.meta.dirname + "/../../static/layouts/swedish_dvorak.json",
"utf-8",
),
);
const dvorak = JSON.parse(
readFileSync(
import.meta.dirname + "/../../static/layouts/dvorak.json",
"utf-8",
),
);
describe("key-converter", () => {
describe("layoutKeyToKeycode", () => {
it("handles unknown key", () => {
const keycode = layoutKeyToKeycode("🤷", isoDvorak);
expect(keycode).toBeUndefined();
});
it("handles iso backslash", () => {
const keycode = layoutKeyToKeycode("*", isoDvorak);
expect(keycode).toEqual("Backslash");
});
it("handles iso IntlBackslash", () => {
const keycode = layoutKeyToKeycode("<", isoDvorak);
expect(keycode).toEqual("IntlBackslash");
});
it("handles iso row4", () => {
const keycode = layoutKeyToKeycode("q", isoDvorak);
expect(keycode).toEqual("KeyX");
});
it("handles ansi", () => {
const keycode = layoutKeyToKeycode("q", dvorak);
expect(keycode).toEqual("KeyX");
});
});
});

View File

@@ -0,0 +1,302 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { z } from "zod";
import { LocalStorageWithSchema } from "../../src/ts/utils/local-storage-with-schema";
describe("local-storage-with-schema.ts", () => {
describe("LocalStorageWithSchema", () => {
const objectSchema = z.object({
punctuation: z.boolean(),
mode: z.enum(["words", "time"]),
fontSize: z.number(),
});
const defaultObject: z.infer<typeof objectSchema> = {
punctuation: true,
mode: "words",
fontSize: 16,
};
let ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
});
const getItemMock = vi.fn();
const setItemMock = vi.fn();
const removeItemMock = vi.fn();
vi.stubGlobal("localStorage", {
getItem: getItemMock,
setItem: setItemMock,
removeItem: removeItemMock,
});
afterEach(() => {
getItemMock.mockClear();
setItemMock.mockClear();
removeItemMock.mockClear();
});
beforeEach(() => {
ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
});
});
describe("set", () => {
it("should save to localStorage if schema is correct and return true", () => {
const res = ls.set(defaultObject);
expect(localStorage.setItem).toHaveBeenCalledWith(
"config",
JSON.stringify(defaultObject),
);
expect(res).toBe(true);
});
it("should fail to save to localStorage if schema is incorrect and return false", () => {
const obj = {
hi: "hello",
};
const res = ls.set(obj as any);
expect(localStorage.setItem).not.toHaveBeenCalled();
expect(res).toBe(false);
});
it("should update cache on set", () => {
ls.set(defaultObject);
expect(ls.get()).toStrictEqual(defaultObject);
const update = { ...defaultObject, fontSize: 5 };
ls.set(update);
getItemMock.mockReset();
expect(ls.get()).toStrictEqual(update);
expect(getItemMock).not.toHaveBeenCalled();
});
it("should get last valid value if schema is incorrect", () => {
ls.set(defaultObject);
getItemMock.mockReset();
ls.set({ hi: "hello" } as any);
expect(ls.get()).toEqual(defaultObject);
expect(setItemMock).toHaveBeenCalledOnce();
expect(getItemMock).not.toHaveBeenCalled();
});
it("should not set if value has not changed", () => {
ls.set(defaultObject);
setItemMock.mockReset();
ls.set(defaultObject);
expect(setItemMock).not.toHaveBeenCalled();
});
});
describe("get", () => {
it("should revert to the fallback value if localstorage is null", () => {
getItemMock.mockReturnValue(null);
const res = ls.get();
expect(getItemMock).toHaveBeenCalledWith("config");
expect(setItemMock).not.toHaveBeenCalled();
expect(res).toEqual(defaultObject);
//cache used
expect(ls.get()).toEqual(res);
expect(getItemMock).toHaveBeenCalledOnce();
});
it("should revert to the fallback value if localstorage json is malformed", () => {
getItemMock.mockReturnValue("badjson");
const res = ls.get();
expect(getItemMock).toHaveBeenCalledWith("config");
expect(setItemMock).toHaveBeenCalledWith(
"config",
JSON.stringify(defaultObject),
);
expect(res).toEqual(defaultObject);
//cache used
expect(ls.get()).toEqual(defaultObject);
expect(getItemMock).toHaveBeenCalledOnce();
});
it("should get from localStorage", () => {
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
const res = ls.get();
expect(getItemMock).toHaveBeenCalledWith("config");
expect(setItemMock).not.toHaveBeenCalled();
expect(res).toEqual(defaultObject);
//cache used
expect(ls.get()).toEqual(res);
expect(getItemMock).toHaveBeenCalledOnce();
});
it("should revert to fallback value if no migrate function and schema failed", () => {
getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" }));
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
});
const res = ls.get();
expect(getItemMock).toHaveBeenCalledWith("config");
expect(setItemMock).toHaveBeenCalledWith(
"config",
JSON.stringify(defaultObject),
);
expect(res).toEqual(defaultObject);
//cache used
expect(ls.get()).toEqual(defaultObject);
expect(getItemMock).toHaveBeenCalledOnce();
});
it("should migrate (when function is provided) if schema failed", () => {
const existingValue = { hi: "hello" };
getItemMock.mockReturnValue(JSON.stringify(existingValue));
const migrated = {
punctuation: false,
mode: "time",
fontSize: 1,
};
const migrateFnMock = vi.fn(() => migrated as any);
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
migrate: migrateFnMock,
});
const res = ls.get();
expect(getItemMock).toHaveBeenCalledWith("config");
expect(migrateFnMock).toHaveBeenCalledWith(
existingValue,
expect.any(Array),
);
expect(setItemMock).toHaveBeenCalledWith(
"config",
JSON.stringify(migrated),
);
expect(res).toEqual(migrated);
//cache used
expect(ls.get()).toEqual(migrated);
expect(getItemMock).toHaveBeenCalledOnce();
});
it("should return a clone so mutating the result does not affect the cache", () => {
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
const first = ls.get();
first.fontSize = 999;
first.mode = "time";
const second = ls.get();
expect(second).toEqual(defaultObject);
// only one call to getItem — second get() used cache
expect(getItemMock).toHaveBeenCalledOnce();
});
it("should return a clone of the fallback so mutating it does not affect the cache", () => {
getItemMock.mockReturnValue(null);
const first = ls.get();
first.punctuation = false;
first.fontSize = 0;
const second = ls.get();
expect(second).toEqual(defaultObject);
expect(getItemMock).toHaveBeenCalledOnce();
});
it("should return a new object reference on each get() call", () => {
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
const first = ls.get();
const second = ls.get();
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it("should not skip set() after caller mutates a previously returned value", () => {
ls.set(defaultObject);
setItemMock.mockReset();
// get a clone, mutate it, then set it back — should detect the change
const value = ls.get();
value.fontSize = 42;
ls.set(value);
expect(setItemMock).toHaveBeenCalledWith(
"config",
JSON.stringify({ ...defaultObject, fontSize: 42 }),
);
});
it("should revert to fallback if migration ran but schema still failed", () => {
const existingValue = { hi: "hello" };
getItemMock.mockReturnValue(JSON.stringify(existingValue));
const invalidMigrated = {
punctuation: 1,
mode: "time",
fontSize: 1,
};
const migrateFnMock = vi.fn(() => invalidMigrated as any);
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
migrate: migrateFnMock,
});
const res = ls.get();
expect(getItemMock).toHaveBeenCalledWith("config");
expect(migrateFnMock).toHaveBeenCalledWith(
existingValue,
expect.any(Array),
);
expect(setItemMock).toHaveBeenCalledWith(
"config",
JSON.stringify(defaultObject),
);
expect(res).toEqual(defaultObject);
//cache used
expect(ls.get()).toEqual(defaultObject);
expect(getItemMock).toHaveBeenCalledOnce();
});
});
});
});

View File

@@ -0,0 +1,453 @@
import { describe, it, expect, vi } from "vitest";
import {
isObject,
escapeHTML,
promiseWithResolvers,
} from "../../src/ts/utils/misc";
import {
getLanguageDisplayString,
removeLanguageSize,
} from "../../src/ts/utils/strings";
import { Language } from "@monkeytype/schemas/languages";
import { getErrorMessage } from "../../src/ts/utils/error";
describe("misc.ts", () => {
describe("getLanguageDisplayString", () => {
it("should return correctly formatted strings", () => {
const tests: {
input: Language;
noSizeString: boolean;
expected: string;
}[] = [
{
input: "english",
noSizeString: false,
expected: "english",
},
{
input: "english_1k",
noSizeString: false,
expected: "english 1k",
},
{
input: "english_1k",
noSizeString: true,
expected: "english",
},
{
input: "english_medical",
noSizeString: false,
expected: "english medical",
},
{
input: "arabic_egypt_1k",
noSizeString: false,
expected: "arabic egypt 1k",
},
{
input: "arabic_egypt_1k",
noSizeString: true,
expected: "arabic egypt",
},
];
tests.forEach((test) => {
const result = getLanguageDisplayString(test.input, test.noSizeString);
expect(result).toBe(test.expected);
});
});
});
describe("removeLanguageSize", () => {
it("should remove language size", () => {
const tests: { input: Language; expected: Language }[] = [
{
input: "english",
expected: "english",
},
{
input: "english_1k",
expected: "english",
},
{
input: "arabic_egypt",
expected: "arabic_egypt",
},
{
input: "arabic_egypt_1k",
expected: "arabic_egypt",
},
];
tests.forEach((test) => {
const result = removeLanguageSize(test.input);
expect(result).toBe(test.expected);
});
});
});
describe("isObject", () => {
it("should correctly identify objects", () => {
const tests = [
{
input: {},
expected: true,
},
{
input: { a: 1 },
expected: true,
},
{
input: [],
expected: false,
},
{
input: [1, 2, 3],
expected: false,
},
{
input: "string",
expected: false,
},
{
input: 1,
expected: false,
},
{
input: null,
expected: false,
},
{
input: undefined,
expected: false,
},
];
tests.forEach((test) => {
const result = isObject(test.input);
expect(result).toBe(test.expected);
});
});
});
describe("escapeHTML", () => {
it("should escape HTML characters correctly", () => {
const tests = [
{
input: "hello world",
expected: "hello world",
},
{
input: "<script>alert('xss')</script>",
expected: "&lt;script&gt;alert(&#39;xss&#39;)&lt;&#x2F;script&gt;",
},
{
input: 'Hello "world" & friends',
expected: "Hello &quot;world&quot; &amp; friends",
},
{
input: "Click `here` to continue",
expected: "Click &#x60;here&#x60; to continue",
},
{
input: null,
expected: null,
},
{
input: undefined,
expected: undefined,
},
{
input: "",
expected: "",
},
];
tests.forEach((test) => {
const result = escapeHTML(test.input);
expect(result).toBe(test.expected);
});
});
});
describe("getErrorMesssage", () => {
it("should correctly get the error message", () => {
const tests = [
{
input: null,
expected: undefined,
},
{
input: undefined,
expected: undefined,
},
{
input: "",
expected: undefined,
},
{
input: {},
expected: undefined,
},
{
input: "error message",
expected: "error message",
},
{
input: 1,
expected: "1",
},
{
input: { message: "error message" },
expected: "error message",
},
{
input: { message: 1 },
expected: "1",
},
{
input: { message: "" },
expected: undefined,
},
{
input: { message: {} },
expected: undefined,
},
{
input: new Error("error message"),
expected: "error message",
},
];
tests.forEach((test) => {
const result = getErrorMessage(test.input);
expect(result).toBe(test.expected);
});
});
});
describe("promiseWithResolvers", () => {
it("should resolve the promise from outside", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<number>();
//WHEN
resolve(42);
//THEN
await expect(promise).resolves.toBe(42);
});
it("should resolve new promise after reset using same promise reference", async () => {
const { promise, resolve, reset } = promiseWithResolvers<number>();
const firstPromise = promise;
reset();
resolve(10);
await expect(firstPromise).resolves.toBe(10);
expect(promise).toBe(firstPromise);
});
it("should reject the promise from outside", async () => {
//GIVEN
const { promise, reject } = promiseWithResolvers<number>();
const error = new Error("test error");
//WHEN
reject(error);
//THEN
await expect(promise).rejects.toThrow("test error");
});
it("should work with void type", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers();
//WHEN
resolve();
//THEN
await expect(promise).resolves.toBeUndefined();
});
it("should allow multiple resolves (only first takes effect)", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<number>();
//WHEN
resolve(42);
resolve(100); // This should have no effect
//THEN
await expect(promise).resolves.toBe(42);
});
it("should reset and create a new promise", async () => {
//GIVEN
const { promise, resolve, reset } = promiseWithResolvers<number>();
resolve(42);
//WHEN
reset();
resolve(100);
//THEN
await expect(promise).resolves.toBe(100);
});
it("should keep the same promise reference after reset", async () => {
//GIVEN
const wrapper = promiseWithResolvers<number>();
const firstPromise = wrapper.promise;
wrapper.resolve(42);
await expect(firstPromise).resolves.toBe(42);
//WHEN
wrapper.reset();
const secondPromise = wrapper.promise;
wrapper.resolve(100);
//THEN
expect(firstPromise).toBe(secondPromise); // Same reference
await expect(wrapper.promise).resolves.toBe(100);
});
it("should allow reject after reset", async () => {
//GIVEN
const wrapper = promiseWithResolvers<number>();
wrapper.resolve(42);
await wrapper.promise;
//WHEN
wrapper.reset();
const error = new Error("after reset");
wrapper.reject(error);
//THEN
await expect(wrapper.promise).rejects.toThrow("after reset");
});
it("should work with complex types", async () => {
//GIVEN
type ComplexType = { id: number; data: string[] };
const { promise, resolve } = promiseWithResolvers<ComplexType>();
const data: ComplexType = { id: 1, data: ["a", "b", "c"] };
//WHEN
resolve(data);
//THEN
await expect(promise).resolves.toEqual(data);
});
it("should handle rejection with non-Error values", async () => {
//GIVEN
const { promise, reject } = promiseWithResolvers<number>();
//WHEN
reject("string error");
//THEN
await expect(promise).rejects.toBe("string error");
});
it("should allow chaining with then/catch", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<number>();
const onFulfilled = vi.fn((value) => value * 2);
const chained = promise.then(onFulfilled);
//WHEN
resolve(21);
//THEN
await expect(chained).resolves.toBe(42);
expect(onFulfilled).toHaveBeenCalledWith(21);
});
it("should support async/await patterns", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<string>();
//WHEN
setTimeout(() => resolve("delayed"), 10);
//THEN
const result = await promise;
expect(result).toBe("delayed");
});
it("should resolve old promise reference after reset", async () => {
//GIVEN
const wrapper = promiseWithResolvers<number>();
const oldPromise = wrapper.promise;
//WHEN
wrapper.reset();
wrapper.resolve(42);
//THEN
// Old promise reference should still resolve with the same value
await expect(oldPromise).resolves.toBe(42);
expect(oldPromise).toBe(wrapper.promise);
});
it("should handle catch", async () => {
//GIVEN
const { promise, reject } = promiseWithResolvers<number>();
const error = new Error("test error");
//WHEN
const caught = promise.catch(() => "recovered");
reject(error);
//THEN
await expect(caught).resolves.toBe("recovered");
});
it("should call finally handler on resolution", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<number>();
const onFinally = vi.fn();
//WHEN
const final = promise.finally(onFinally);
resolve(42);
//THEN
await expect(final).resolves.toBe(42);
expect(onFinally).toHaveBeenCalledOnce();
});
it("should call finally handler on rejection", async () => {
//GIVEN
const { promise, reject } = promiseWithResolvers<number>();
const onFinally = vi.fn();
const error = new Error("test error");
//WHEN
const final = promise.finally(onFinally);
reject(error);
//THEN
await expect(final).rejects.toThrow("test error");
expect(onFinally).toHaveBeenCalledOnce();
});
it("should preserve rejection through finally", async () => {
//GIVEN
const { promise, reject } = promiseWithResolvers<number>();
const onFinally = vi.fn();
const error = new Error("preserved error");
//WHEN
const final = promise.finally(onFinally);
reject(error);
//THEN
await expect(final).rejects.toThrow("preserved error");
expect(onFinally).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import * as Numbers from "../../src/ts/utils/numbers";
describe("numbers", () => {
describe("abbreviateNumber", () => {
it("should round to one decimal by default", () => {
expect(Numbers.abbreviateNumber(1)).toEqual("1.0");
expect(Numbers.abbreviateNumber(1.5)).toEqual("1.5");
expect(Numbers.abbreviateNumber(1.55)).toEqual("1.6");
expect(Numbers.abbreviateNumber(1000)).toEqual("1.0k");
expect(Numbers.abbreviateNumber(1010)).toEqual("1.0k");
expect(Numbers.abbreviateNumber(1099)).toEqual("1.1k");
});
it("should round to full numbers", () => {
expect(Numbers.abbreviateNumber(1, 0)).toEqual("1");
expect(Numbers.abbreviateNumber(1.5, 0)).toEqual("2");
expect(Numbers.abbreviateNumber(1.55, 0)).toEqual("2");
expect(Numbers.abbreviateNumber(1000, 0)).toEqual("1k");
expect(Numbers.abbreviateNumber(1010, 0)).toEqual("1k");
expect(Numbers.abbreviateNumber(1099, 0)).toEqual("1k");
});
it("should round to two decimals", () => {
expect(Numbers.abbreviateNumber(1, 2)).toEqual("1.00");
expect(Numbers.abbreviateNumber(1.5, 2)).toEqual("1.50");
expect(Numbers.abbreviateNumber(1.55, 2)).toEqual("1.55");
expect(Numbers.abbreviateNumber(1000, 2)).toEqual("1.00k");
expect(Numbers.abbreviateNumber(1010, 2)).toEqual("1.01k");
expect(Numbers.abbreviateNumber(1099, 2)).toEqual("1.10k");
});
it("should use suffixes", () => {
let number = 1;
expect(Numbers.abbreviateNumber(number)).toEqual("1.0");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0k");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0m");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0b");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0t");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0q");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0Q");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0s");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0S");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0o");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0n");
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0d");
});
});
describe("parseIntOptional", () => {
it("should return a number when given a valid string", () => {
expect(Numbers.parseIntOptional("123")).toBe(123);
expect(Numbers.parseIntOptional("42")).toBe(42);
expect(Numbers.parseIntOptional("0")).toBe(0);
});
it("should return undefined when given null", () => {
expect(Numbers.parseIntOptional(null)).toBeUndefined();
});
it("should return undefined when given undefined", () => {
expect(Numbers.parseIntOptional(undefined)).toBeUndefined();
});
it("should handle non-numeric strings", () => {
expect(Numbers.parseIntOptional("abc")).toBeNaN();
expect(Numbers.parseIntOptional("12abc")).toBe(12); // parseInt stops at non-numeric chars
});
it("should handle leading and trailing spaces", () => {
expect(Numbers.parseIntOptional(" 42 ")).toBe(42);
});
it("should return a number when given a valid string and radix", () => {
expect(Numbers.parseIntOptional("1010", 2)).toBe(10);
expect(Numbers.parseIntOptional("CF", 16)).toBe(207);
expect(Numbers.parseIntOptional("C", 26)).toBe(12);
});
});
});

View File

@@ -0,0 +1,384 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { sanitize } from "../../src/ts/utils/sanitize";
describe("sanitize function", () => {
describe("arrays", () => {
const numberArraySchema = z.array(z.number());
const numbersArrayMin2Schema = numberArraySchema.min(2);
const testCases: {
input: number[];
expected: {
numbers: number[] | boolean;
numbersMin: number[] | boolean;
};
}[] = [
{ input: [], expected: { numbers: true, numbersMin: false } },
{ input: [1, 2], expected: { numbers: true, numbersMin: true } },
{
input: [1, "2" as any],
expected: { numbers: [1], numbersMin: false },
},
{
input: ["one", "two"] as any,
expected: { numbers: [], numbersMin: false },
},
];
it.for(testCases)("number array with $input", ({ input, expected }) => {
const sanitized = expect(
expected.numbers === false
? () => sanitize(numberArraySchema, input)
: sanitize(numberArraySchema, input),
);
if (expected.numbers === false) {
sanitized.toThrow();
} else if (expected.numbers === true) {
sanitized.toStrictEqual(input);
} else {
sanitized.toStrictEqual(expected.numbers);
}
});
it.for(testCases)(
"number array.min(2) with $input",
({ input, expected }) => {
const sanitized = expect(
expected.numbersMin === false
? () => sanitize(numbersArrayMin2Schema, input)
: sanitize(numbersArrayMin2Schema, input),
);
if (expected.numbersMin === false) {
sanitized.toThrow();
} else if (expected.numbersMin === true) {
sanitized.toStrictEqual(input);
} else {
sanitized.toStrictEqual(expected.numbersMin);
}
},
);
});
describe("objects", () => {
const objectSchema = z.object({
name: z.string(),
age: z.number().positive(),
tags: z.array(z.string()),
enumArray: z.array(z.enum(["one", "two"])).min(2),
});
const objectSchemaFullPartial = objectSchema.partial().strip();
const objectSchemaWithOptional = objectSchema.partial({
tags: true,
enumArray: true,
});
const testCases: {
input: z.infer<typeof objectSchemaFullPartial>;
expected: {
mandatory: z.infer<typeof objectSchema> | boolean;
partial: z.infer<typeof objectSchemaFullPartial> | boolean;
optional: z.infer<typeof objectSchemaWithOptional> | boolean;
};
}[] = [
{
input: {},
expected: { mandatory: false, partial: true, optional: false },
},
{
input: {
name: "Alice",
age: 23,
tags: ["one", "two"],
enumArray: ["one", "two"],
},
expected: { mandatory: true, partial: true, optional: true },
},
{
input: {
name: "Alice",
age: 23,
},
expected: { mandatory: false, partial: true, optional: true },
},
{
input: {
name: "Alice",
age: "sixty" as any,
},
expected: {
mandatory: false,
partial: { name: "Alice" },
optional: false,
},
},
{
input: {
name: "Alice",
age: 23,
tags: ["one", 2 as any],
enumArray: "one" as any,
},
expected: {
mandatory: false,
partial: { name: "Alice", age: 23, tags: ["one"] },
optional: { name: "Alice", age: 23, tags: ["one"] },
},
},
{
input: {
name: "Alice",
age: 23,
tags: [1, 2] as any,
enumArray: [1, 2] as any,
},
expected: {
mandatory: false,
partial: { name: "Alice", age: 23 },
optional: { name: "Alice", age: 23 },
},
},
{
input: {
name: "Alice",
age: 23,
extraArray: [],
extraObject: {},
extraString: "",
} as any,
expected: {
mandatory: false,
partial: { name: "Alice", age: 23 },
optional: { name: "Alice", age: 23 },
},
},
{
input: {
name: "Alice",
//results in two errors on the same path. array with invalid value and not enough items
enumArray: ["invalid" as any],
},
expected: {
mandatory: false,
partial: { name: "Alice" }, //enumArray is removed
optional: false,
},
},
];
it.for(testCases)("object mandatory with $input", ({ input, expected }) => {
const sanitized = expect(
expected.mandatory === false
? () => sanitize(objectSchema, input as any)
: sanitize(objectSchema, input as any),
);
if (expected.mandatory === false) {
sanitized.toThrow();
} else if (expected.mandatory === true) {
sanitized.toStrictEqual(input);
} else {
sanitized.toStrictEqual(expected.mandatory);
}
});
it.for(testCases)(
"object full partial with $input",
({ input, expected }) => {
const sanitized = expect(
expected.partial === false
? () => sanitize(objectSchemaFullPartial, input as any)
: sanitize(objectSchemaFullPartial, input as any),
);
if (expected.partial === false) {
sanitized.toThrow();
} else if (expected.partial === true) {
sanitized.toStrictEqual(input);
} else {
sanitized.toStrictEqual(expected.partial);
}
},
);
it.for(testCases)("object optional with $input", ({ input, expected }) => {
const sanitized = expect(
expected.optional === false
? () => sanitize(objectSchemaWithOptional, input as any)
: sanitize(objectSchemaWithOptional, input as any),
);
if (expected.optional === false) {
sanitized.toThrow();
} else if (expected.optional === true) {
sanitized.toStrictEqual(input);
} else {
sanitized.toStrictEqual(expected.optional);
}
});
});
describe("nested", () => {
const itemSchema = z.object({
name: z.string(),
enumArray: z.array(z.enum(["one", "two"])).min(2),
});
const nestedSchema = z.object({
nested: z.array(itemSchema),
});
const nestedSchemaFullPartial = z
.object({
nested: z.array(itemSchema.partial()),
})
.partial();
const nestedSchemaWithMin2Array = z.object({
nested: z.array(itemSchema).min(2),
});
const testCases: {
input: z.infer<typeof nestedSchema>;
expected: {
mandatory: z.infer<typeof nestedSchema> | boolean;
partial: z.infer<typeof nestedSchemaFullPartial> | boolean;
minArray: z.infer<typeof nestedSchemaWithMin2Array> | boolean;
};
}[] = [
{
input: {} as any,
expected: {
mandatory: false,
partial: true,
minArray: false,
},
},
{
input: {
nested: [
{ name: "Alice", enumArray: ["one", "two"] },
{ name: "Bob", enumArray: ["one", "two"] },
],
},
expected: {
mandatory: true,
partial: true,
minArray: true,
},
},
{
input: {
nested: [
{ name: "Alice", enumArray: ["one", "two"] },
{ name: "Bob" } as any,
],
},
expected: {
mandatory: {
nested: [{ name: "Alice", enumArray: ["one", "two"] }],
},
partial: true,
minArray: false,
},
},
{
input: {
nested: [
{ enumArray: ["one", "two"] } as any,
{ name: "Bob" } as any,
],
},
expected: {
mandatory: false,
partial: true,
minArray: false,
},
},
];
it.for(testCases)("nested mandatory with $input", ({ input, expected }) => {
const sanitized = expect(
expected.mandatory === false
? () => sanitize(nestedSchema, input as any)
: sanitize(nestedSchema, input as any),
);
if (expected.mandatory === false) {
sanitized.toThrow();
} else if (expected.mandatory === true) {
sanitized.toStrictEqual(input);
} else {
sanitized.toStrictEqual(expected.mandatory);
}
});
it.for(testCases)("nested partial with $input", ({ input, expected }) => {
const sanitized = expect(
expected.partial === false
? () => sanitize(nestedSchemaFullPartial, input as any)
: sanitize(nestedSchemaFullPartial, input as any),
);
if (expected.partial === false) {
sanitized.toThrow();
} else if (expected.partial === true) {
sanitized.toStrictEqual(input);
} else {
sanitized.toStrictEqual(expected.partial);
}
});
it.for(testCases)(
"nested array min(2) with $input",
({ input, expected }) => {
const sanitized = expect(
expected.minArray === false
? () => sanitize(nestedSchemaWithMin2Array, input as any)
: sanitize(nestedSchemaWithMin2Array, input as any),
);
if (expected.minArray === false) {
sanitized.toThrow();
} else if (expected.minArray === true) {
sanitized.toStrictEqual(input);
} else {
sanitized.toStrictEqual(expected.minArray);
}
},
);
});
const schema = z
.object({
name: z.string(),
age: z.number().positive(),
tags: z.array(z.string()),
enumArray: z.array(z.enum(["one", "two"])).min(2),
})
.partial()
.strip();
it("should strip extra keys", () => {
const obj = {
name: "bob",
age: 30,
tags: ["developer", "coder"],
powerLevel: 9001,
} as any;
const stripped = sanitize(schema.strip(), obj);
expect(stripped).not.toHaveProperty("powerLevel");
});
it("should strip extra keys on error", () => {
const obj = {
name: "bob",
age: 30,
powerLevel: 9001,
} as any;
const stripped = sanitize(schema.strip(), obj);
expect(stripped).not.toHaveProperty("powerLevel");
});
it("should provide a readable error message", () => {
const obj = {
arrayOneTwo: ["one", "nonexistent"],
} as any;
expect(() => {
sanitize(schema.required().strip(), obj);
}).toThrow(
"unable to sanitize: name: Required, age: Required, tags: Required, enumArray: Required",
);
});
});

View File

@@ -0,0 +1,590 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as Strings from "../../src/ts/utils/strings";
describe("string utils", () => {
describe("highlightMatches", () => {
const shouldHighlight = [
{
description: "word at the beginning",
text: "Start here.",
matches: ["Start"],
expected: '<span class="highlight">Start</span> here.',
},
{
description: "word at the end",
text: "reach the end",
matches: ["end"],
expected: 'reach the <span class="highlight">end</span>',
},
{
description: "mutliple matches",
text: "one two three",
matches: ["one", "three"],
expected:
'<span class="highlight">one</span> two <span class="highlight">three</span>',
},
{
description: "repeated matches",
text: "one two two",
matches: ["two"],
expected:
'one <span class="highlight">two</span> <span class="highlight">two</span>',
},
{
description: "longest possible match",
text: "abc ab",
matches: ["ab", "abc"],
expected:
'<span class="highlight">abc</span> <span class="highlight">ab</span>',
},
{
description: "if wrapped in parenthesis",
text: "(test)",
matches: ["test"],
expected: '(<span class="highlight">test</span>)',
},
{
description: "if wrapped in commas",
text: ",test,",
matches: ["test"],
expected: ',<span class="highlight">test</span>,',
},
{
description: "if wrapped in underscores",
text: "_test_",
matches: ["test"],
expected: '_<span class="highlight">test</span>_',
},
{
description: "words in russian",
text: "Привет, мир!",
matches: ["Привет", "мир"],
expected:
'<span class="highlight">Привет</span>, <span class="highlight">мир</span>!',
},
{
description: "words with chinese punctuation",
text: "你好,世界!",
matches: ["你好", "世界"],
expected:
'<span class="highlight">你好</span><span class="highlight">世界</span>',
},
{
description: "words with arabic punctuation",
text: "؟مرحبا، بكم؛",
matches: ["مرحبا", "بكم"],
expected:
'؟<span class="highlight">مرحبا</span>، <span class="highlight">بكم</span>؛',
},
{
description: "standalone numbers",
text: "My number is 1234.",
matches: ["1234"],
expected: 'My number is <span class="highlight">1234</span>.',
},
];
const shouldNotHighlight = [
{
description: "a match within a longer word",
text: "together",
matches: ["get"],
},
{
description: "a match with leading letters",
text: "welcome",
matches: ["come"],
},
{
description: "a match with trailing letters",
text: "comets",
matches: ["come"],
},
{
description: "japanese matches within longer words",
text: "こんにちは世界",
matches: ["こんにちは"],
},
{
description: "numbers within words",
text: "abc1234def",
matches: ["1234"],
},
];
const returnOriginal = [
{
description: "if matches is an empty array",
text: "Nothing to match.",
matches: [],
},
{
description: "if matches has an empty string only",
text: "Nothing to match.",
matches: [""],
},
{
description: "if no matches found in text",
text: "Hello world.",
matches: ["absent"],
},
{
description: "if text is empty",
text: "",
matches: ["anything"],
},
];
it.each(shouldHighlight)(
"should highlight $description",
({ text, matches, expected }) => {
expect(Strings.highlightMatches(text, matches)).toBe(expected);
},
);
it.each(shouldNotHighlight)(
"should not highlight $description",
({ text, matches }) => {
expect(Strings.highlightMatches(text, matches)).toBe(text);
},
);
it.each(returnOriginal)(
"should return original text $description",
({ text, matches }) => {
expect(Strings.highlightMatches(text, matches)).toBe(text);
},
);
});
describe("splitIntoCharacters", () => {
it("splits regular characters", () => {
expect(Strings.splitIntoCharacters("abc")).toEqual(["a", "b", "c"]);
});
it("splits characters outside of the bmp", () => {
expect(Strings.splitIntoCharacters("t𐑩e")).toEqual(["t", "𐑩", "e"]);
});
});
describe("replaceControlCharacters", () => {
it.each([
// Basic tab conversions
["\\t", "\t", "single tab"],
["\\t\\t\\t", "\t\t\t", "multiple tabs"],
["hello\\tworld", "hello\tworld", "tab between words"],
["\\tstart", "\tstart", "tab at start"],
["end\\t", "end\t", "tab at end"],
// Basic newline conversions
["\\n", " \n", "single newline with space prefix"],
["hello\\nworld", "hello \nworld", "newline between words with space"],
["\\nstart", " \nstart", "newline at start with space"],
["end\\n", "end \n", "newline at end with space"],
// Complex newline handling (after first two regexes)
["a\\n", "a \n", "single char followed by newline gets space prefix"],
["hello\\n", "hello \n", "word followed by newline gets space prefix"],
// Double-escaped sequences (should become single-escaped)
["\\\\t", "\\t", "double-escaped tab becomes single-escaped"],
[
"\\\\n",
"\\ \n",
"double-escaped newline becomes backslash + space + newline",
],
["\\\\t\\\\n", "\\t\\ \n", "multiple double-escaped sequences"],
// Mixed scenarios
[
"\\t\\n\\\\t",
"\t \n\\t",
"mix of tab, newline, and double-escaped tab",
],
[
"hello\\tworld\\ntest\\\\t",
"hello\tworld \ntest\\t",
"complex mixed scenario",
],
// Edge cases
["", "", "empty string"],
["no escapes", "no escapes", "string with no escape sequences"],
["\\", "\\", "single backslash"],
["\\x", "\\x", "backslash with non-control character"],
// Escaped backslashes that don't precede control chars
["\\\\", "\\\\", "double backslash not followed by control char"],
["\\\\x", "\\\\x", "double backslash followed by non-control char"],
])(
"should convert %s to %s (%s)",
(input: string, expected: string, _description: string) => {
expect(Strings.replaceControlCharacters(input)).toBe(expected);
},
);
});
describe("hasRTLCharacters", () => {
it.each([
// LTR characters should return false
[false, "hello", "basic Latin text"],
[false, "world123", "Latin text with numbers"],
[false, "test!", "Latin text with punctuation"],
[false, "ABC", "uppercase Latin text"],
[false, "", "empty string"],
[false, "123", "numbers only"],
[false, "!@#$%", "punctuation and symbols only"],
[false, " ", "whitespace only"],
// Common LTR scripts
[false, "Здравствуй", "Cyrillic text"],
[false, "Bonjour", "Latin with accents"],
[false, "Καλημέρα", "Greek text"],
[false, "こんにちは", "Japanese Hiragana"],
[false, "你好", "Chinese characters"],
[false, "안녕하세요", "Korean text"],
// RTL characters should return true - Arabic
[true, "مرحبا", "Arabic text"],
[true, "السلام", "Arabic phrase"],
[true, "العربية", "Arabic word"],
[true, "٠١٢٣٤٥٦٧٨٩", "Arabic-Indic digits"],
// RTL characters should return true - Hebrew
[true, "שלום", "Hebrew text"],
[true, "עברית", "Hebrew word"],
[true, "ברוך", "Hebrew name"],
// RTL characters should return true - Persian/Farsi
[true, "سلام", "Persian text"],
[true, "فارسی", "Persian word"],
// Mixed content (should return true if ANY RTL characters are present)
[true, "hello مرحبا", "mixed LTR and Arabic"],
[true, "123 שלום", "numbers and Hebrew"],
[true, "test سلام!", "Latin, Persian, and punctuation"],
[true, "مرحبا123", "Arabic with numbers"],
[true, "hello؟", "Latin with Arabic punctuation"],
// Edge cases with various Unicode ranges
[false, "𝕳𝖊𝖑𝖑𝖔", "mathematical bold text (LTR)"],
[false, "🌍🌎🌏", "emoji"],
] as const)(
"should return %s for word '%s' (%s)",
(expected: boolean, word: string, _description: string) => {
expect(Strings.__testing.hasRTLCharacters(word)[0]).toBe(expected);
},
);
});
describe("isWordRightToLeft", () => {
beforeEach(() => {
Strings.clearWordDirectionCache();
});
it.each([
// Basic functionality - should use hasRTLCharacters result when word has core content
[false, "hello", false, "LTR word in LTR language"],
[
false,
"hello",
true,
"LTR word in RTL language (word direction overrides language)",
],
[
true,
"مرحبا",
false,
"RTL word in LTR language (word direction overrides language)",
],
[true, "مرحبا", true, "RTL word in RTL language"],
// Punctuation stripping behavior
[false, "hello!", false, "LTR word with trailing punctuation"],
[false, "!hello", false, "LTR word with leading punctuation"],
[false, "!hello!", false, "LTR word with surrounding punctuation"],
[true, "مرحبا؟", false, "RTL word with trailing punctuation"],
[true, "؟مرحبا", false, "RTL word with leading punctuation"],
[true, "؟مرحبا؟", false, "RTL word with surrounding punctuation"],
// Fallback to language direction for empty/neutral content
[false, "", false, "empty string falls back to LTR language"],
[true, "", true, "empty string falls back to RTL language"],
[false, "!!!", false, "punctuation only falls back to LTR language"],
[true, "!!!", true, "punctuation only falls back to RTL language"],
[false, " ", false, "whitespace only falls back to LTR language"],
[true, " ", true, "whitespace only falls back to RTL language"],
// Numbers behavior (numbers are neutral, follow hasRTLCharacters detection)
[false, "123", false, "regular digits are not RTL"],
[false, "123", true, "regular digits are not RTL regardless of language"],
[true, "١٢٣", false, "Arabic-Indic digits are detected as RTL"],
[true, "١٢٣", true, "Arabic-Indic digits are detected as RTL"],
] as const)(
"should return %s for word '%s' with languageRTL=%s (%s)",
(
expected: boolean,
word: string,
languageRTL: boolean,
_description: string,
) => {
expect(Strings.isWordRightToLeft(word, languageRTL)[0]).toBe(expected);
},
);
it("should return languageRTL for undefined word", () => {
expect(Strings.isWordRightToLeft(undefined, false)[0]).toBe(false);
expect(Strings.isWordRightToLeft(undefined, true)[0]).toBe(true);
});
// testing reverseDirection
it("should return true for LTR word with reversed direction", () => {
expect(Strings.isWordRightToLeft("hello", false, true)[0]).toBe(true);
expect(Strings.isWordRightToLeft("hello", true, true)[0]).toBe(true);
});
it("should return false for RTL word with reversed direction", () => {
expect(Strings.isWordRightToLeft("مرحبا", true, true)[0]).toBe(false);
expect(Strings.isWordRightToLeft("مرحبا", false, true)[0]).toBe(false);
});
it("should return reverse of languageRTL for undefined word with reversed direction", () => {
expect(Strings.isWordRightToLeft(undefined, false, true)[0]).toBe(true);
expect(Strings.isWordRightToLeft(undefined, true, true)[0]).toBe(false);
});
describe("caching", () => {
let mapGetSpy: ReturnType<typeof vi.spyOn>;
let mapSetSpy: ReturnType<typeof vi.spyOn>;
let mapClearSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mapGetSpy = vi.spyOn(Map.prototype, "get");
mapSetSpy = vi.spyOn(Map.prototype, "set");
mapClearSpy = vi.spyOn(Map.prototype, "clear");
});
afterEach(() => {
mapGetSpy.mockRestore();
mapSetSpy.mockRestore();
mapClearSpy.mockRestore();
});
it("should use cache for repeated calls", () => {
// First call should cache the result (cache miss)
const result1 = Strings.isWordRightToLeft("hello", false);
expect(result1[0]).toBe(false);
expect(mapSetSpy).toHaveBeenCalledWith("hello", [false, 0]);
// Reset spies to check second call
mapGetSpy.mockClear();
mapSetSpy.mockClear();
// Second call should use cache (cache hit)
const result2 = Strings.isWordRightToLeft("hello", false);
expect(result2[0]).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again
// Cache should work regardless of language direction for same word
mapGetSpy.mockClear();
mapSetSpy.mockClear();
const result3 = Strings.isWordRightToLeft("hello", true);
expect(result3[0]).toBe(false); // Still false because "hello" is LTR regardless of language
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again
});
it("should cache based on core word without punctuation", () => {
// First call should cache the result for core "hello"
const result1 = Strings.isWordRightToLeft("hello", false);
expect(result1[0]).toBe(false);
expect(mapSetSpy).toHaveBeenCalledWith("hello", [false, 0]);
mapGetSpy.mockClear();
mapSetSpy.mockClear();
// These should all use the same cache entry since they have the same core
const result2 = Strings.isWordRightToLeft("hello!", false);
expect(result2[0]).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled();
mapGetSpy.mockClear();
mapSetSpy.mockClear();
const result3 = Strings.isWordRightToLeft("!hello", false);
expect(result3[0]).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled();
mapGetSpy.mockClear();
mapSetSpy.mockClear();
const result4 = Strings.isWordRightToLeft("!hello!", false);
expect(result4[0]).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled();
});
it("should handle cache clearing", () => {
// Cache a result
Strings.isWordRightToLeft("test", false);
expect(mapSetSpy).toHaveBeenCalledWith("test", [false, 0]);
// Clear cache
Strings.clearWordDirectionCache();
expect(mapClearSpy).toHaveBeenCalled();
mapGetSpy.mockClear();
mapSetSpy.mockClear();
mapClearSpy.mockClear();
// Should work normally after cache clear (cache miss again)
const result = Strings.isWordRightToLeft("test", false);
expect(result[0]).toBe(false);
expect(mapSetSpy).toHaveBeenCalledWith("test", [false, 0]);
});
it("should demonstrate cache miss vs cache hit behavior", () => {
// Test cache miss - first time seeing this word
const result1 = Strings.isWordRightToLeft("unique", false);
expect(result1[0]).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("unique");
expect(mapSetSpy).toHaveBeenCalledWith("unique", [false, 0]);
mapGetSpy.mockClear();
mapSetSpy.mockClear();
// Test cache hit - same word again
const result2 = Strings.isWordRightToLeft("unique", false);
expect(result2[0]).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("unique");
expect(mapSetSpy).not.toHaveBeenCalled(); // No cache set on hit
mapGetSpy.mockClear();
mapSetSpy.mockClear();
// Test cache miss - different word
const result3 = Strings.isWordRightToLeft("different", false);
expect(result3[0]).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("different");
expect(mapSetSpy).toHaveBeenCalledWith("different", [false, 0]);
});
});
});
describe("isSpace", () => {
it.each([
// Should return true for directly typable spaces
[" ", 0x0020, "regular space", true],
["\u2002", 0x2002, "en space", true],
["\u2003", 0x2003, "em space", true],
["\u2009", 0x2009, "thin space", true],
[" ", 0x3000, "ideographic space", true],
["\u00A0", 0x00a0, "non-breaking space", true],
["\u2007", 0x2007, "figure space", true],
["\u2008", 0x2008, "punctuation space", true],
["\u200A", 0x200a, "hair space", true],
["", 0x200b, "zero-width space", true],
// Should return false for other characters
["\t", 0x0009, "tab", false],
["a", 0x0061, "letter a", false],
["A", 0x0041, "letter A", false],
["1", 0x0031, "digit 1", false],
["!", 0x0021, "exclamation mark", false],
["\n", 0x000a, "newline", false],
["\r", 0x000d, "carriage return", false],
// Edge cases
["", null, "empty string", false],
[" ", null, "two spaces", false],
["ab", null, "two letters", false],
])(
"should return %s for %s (U+%s - %s)",
(
char: string,
expectedCodePoint: number | null,
description: string,
expected: boolean,
) => {
if (expectedCodePoint !== null && char.length === 1) {
expect(char.codePointAt(0)).toBe(expectedCodePoint);
}
expect(Strings.isSpace(char)).toBe(expected);
},
);
});
describe("areCharactersVisuallyEqual", () => {
it("should return true for identical characters", () => {
expect(Strings.areCharactersVisuallyEqual("a", "a")).toBe(true);
expect(Strings.areCharactersVisuallyEqual("!", "!")).toBe(true);
});
it("should return false for different characters", () => {
expect(Strings.areCharactersVisuallyEqual("a", "b")).toBe(false);
expect(Strings.areCharactersVisuallyEqual("!", "?")).toBe(false);
});
it("should return true for equivalent apostrophe variants", () => {
expect(Strings.areCharactersVisuallyEqual("'", "'")).toBe(true);
expect(Strings.areCharactersVisuallyEqual("'", "'")).toBe(true);
expect(Strings.areCharactersVisuallyEqual("'", "ʼ")).toBe(true);
});
it("should return true for equivalent quote variants", () => {
expect(Strings.areCharactersVisuallyEqual('"', '"')).toBe(true);
expect(Strings.areCharactersVisuallyEqual('"', '"')).toBe(true);
expect(Strings.areCharactersVisuallyEqual('"', "„")).toBe(true);
});
it("should return true for equivalent dash variants", () => {
expect(Strings.areCharactersVisuallyEqual("-", "")).toBe(true);
expect(Strings.areCharactersVisuallyEqual("-", "—")).toBe(true);
expect(Strings.areCharactersVisuallyEqual("", "—")).toBe(true);
});
it("should return true for equivalent comma variants", () => {
expect(Strings.areCharactersVisuallyEqual(",", "")).toBe(true);
});
it("should return false for characters from different equivalence groups", () => {
expect(Strings.areCharactersVisuallyEqual("'", '"')).toBe(false);
expect(Strings.areCharactersVisuallyEqual("-", "'")).toBe(false);
expect(Strings.areCharactersVisuallyEqual(",", '"')).toBe(false);
});
describe("should check russian specific equivalences", () => {
it.each([
{
desc: "е and ё are equivalent",
char1: "е",
char2: "ё",
expected: true,
},
{
desc: "e and ё are equivalent",
char1: "e",
char2: "ё",
expected: true,
},
{
desc: "е and e are equivalent",
char1: "е",
char2: "e",
expected: true,
},
{
desc: "non-equivalent characters return false",
char1: "а",
char2: "б",
expected: false,
},
{
desc: "non-equivalent characters return false (2)",
char1: "a",
char2: "б",
expected: false,
},
])("$desc", ({ char1, char2, expected }) => {
expect(
Strings.areCharactersVisuallyEqual(char1, char2, "russian"),
).toBe(expected);
});
});
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { buildTag } from "../../src/ts/utils/tag-builder";
describe("simple-modals", () => {
describe("buildTag", () => {
it("builds with mandatory", () => {
expect(buildTag({ tagname: "input" })).toBe("<input />");
});
it("builds with classes", () => {
expect(buildTag({ tagname: "input", classes: ["hidden", "bold"] })).toBe(
'<input class="hidden bold" />',
);
});
it("builds with attributes", () => {
expect(
buildTag({
tagname: "input",
attributes: {
id: "4711",
oninput: "console.log()",
required: true,
checked: true,
missing: undefined,
},
}),
).toBe('<input checked id="4711" oninput="console.log()" required />');
});
it("builds with innerHtml", () => {
expect(
buildTag({ tagname: "textarea", innerHTML: "<h1>Hello</h1>" }),
).toBe("<textarea><h1>Hello</h1></textarea>");
});
it("builds with everything", () => {
expect(
buildTag({
tagname: "textarea",
classes: ["hidden", "bold"],
attributes: {
id: "4711",
oninput: "console.log()",
readonly: true,
required: true,
},
innerHTML: "<h1>Hello</h1>",
}),
).toBe(
'<textarea class="hidden bold" id="4711" oninput="console.log()" readonly required><h1>Hello</h1></textarea>',
);
});
});
});