This commit is contained in:
165
frontend/__tests__/utils/colors.spec.ts
Normal file
165
frontend/__tests__/utils/colors.spec.ts
Normal 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);
|
||||
// });
|
||||
});
|
||||
});
|
||||
252
frontend/__tests__/utils/config.spec.ts
Normal file
252
frontend/__tests__/utils/config.spec.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
88
frontend/__tests__/utils/date-and-time.spec.ts
Normal file
88
frontend/__tests__/utils/date-and-time.spec.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
277
frontend/__tests__/utils/dom.jsdom-spec.ts
Normal file
277
frontend/__tests__/utils/dom.jsdom-spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
280
frontend/__tests__/utils/format.spec.ts
Normal file
280
frontend/__tests__/utils/format.spec.ts
Normal 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);
|
||||
}
|
||||
44
frontend/__tests__/utils/generate.spec.ts
Normal file
44
frontend/__tests__/utils/generate.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
107
frontend/__tests__/utils/ip-addresses.spec.ts
Normal file
107
frontend/__tests__/utils/ip-addresses.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
frontend/__tests__/utils/key-converter.spec.ts
Normal file
46
frontend/__tests__/utils/key-converter.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
302
frontend/__tests__/utils/local-storage-with-schema.spec.ts
Normal file
302
frontend/__tests__/utils/local-storage-with-schema.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
453
frontend/__tests__/utils/misc.spec.ts
Normal file
453
frontend/__tests__/utils/misc.spec.ts
Normal 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: "<script>alert('xss')</script>",
|
||||
},
|
||||
{
|
||||
input: 'Hello "world" & friends',
|
||||
expected: "Hello "world" & friends",
|
||||
},
|
||||
{
|
||||
input: "Click `here` to continue",
|
||||
expected: "Click `here` 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
79
frontend/__tests__/utils/numbers.spec.ts
Normal file
79
frontend/__tests__/utils/numbers.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
384
frontend/__tests__/utils/sanitize.spec.ts
Normal file
384
frontend/__tests__/utils/sanitize.spec.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
590
frontend/__tests__/utils/strings.spec.ts
Normal file
590
frontend/__tests__/utils/strings.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
52
frontend/__tests__/utils/tag-builder.spec.ts
Normal file
52
frontend/__tests__/utils/tag-builder.spec.ts
Normal 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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user