This commit is contained in:
73
frontend/__tests__/__harness__/mock-dom.ts
Normal file
73
frontend/__tests__/__harness__/mock-dom.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { vi } from "vitest";
|
||||
import { ElementsWithUtils, ElementWithUtils } from "../../src/ts/utils/dom";
|
||||
|
||||
// Mock dom-utils to always return a mock element
|
||||
vi.mock("../../src/ts/utils/dom", async (importOriginal) => {
|
||||
const createMockElement = (): ElementWithUtils => {
|
||||
return {
|
||||
disable: vi.fn().mockReturnThis(),
|
||||
enable: vi.fn().mockReturnThis(),
|
||||
isDisabled: vi.fn().mockReturnValue(false),
|
||||
getAttribute: vi.fn(),
|
||||
hasAttribute: vi.fn().mockReturnValue(false),
|
||||
setAttribute: vi.fn().mockReturnThis(),
|
||||
removeAttribute: vi.fn().mockReturnThis(),
|
||||
isChecked: vi.fn().mockReturnValue(false),
|
||||
hide: vi.fn().mockReturnThis(),
|
||||
show: vi.fn().mockReturnThis(),
|
||||
addClass: vi.fn().mockReturnThis(),
|
||||
removeClass: vi.fn().mockReturnThis(),
|
||||
hasClass: vi.fn().mockReturnValue(false),
|
||||
toggleClass: vi.fn().mockReturnThis(),
|
||||
on: vi.fn().mockReturnThis(),
|
||||
onChild: vi.fn().mockReturnThis(),
|
||||
setHtml: vi.fn().mockReturnThis(),
|
||||
setText: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
setStyle: vi.fn().mockReturnThis(),
|
||||
getStyle: vi.fn().mockReturnValue({}),
|
||||
isFocused: vi.fn().mockReturnValue(false),
|
||||
qs: vi.fn().mockImplementation(() => createMockElement()),
|
||||
qsr: vi.fn().mockImplementation(() => createMockElement()),
|
||||
qsa: vi.fn().mockImplementation(() => new ElementsWithUtils()),
|
||||
empty: vi.fn().mockReturnThis(),
|
||||
appendHtml: vi.fn().mockReturnThis(),
|
||||
append: vi.fn().mockReturnThis(),
|
||||
prependHtml: vi.fn().mockReturnThis(),
|
||||
dispatch: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockReturnValue({ top: 0, left: 0 }),
|
||||
wrapWith: vi.fn().mockImplementation(() => createMockElement()),
|
||||
setValue: vi.fn().mockReturnThis(),
|
||||
getValue: vi.fn().mockReturnValue(""),
|
||||
getParent: vi.fn().mockImplementation(() => createMockElement()),
|
||||
replaceWith: vi.fn().mockReturnThis(),
|
||||
getOffsetWidth: vi.fn().mockReturnValue(0),
|
||||
getOffsetHeight: vi.fn().mockReturnValue(0),
|
||||
getOffsetTop: vi.fn().mockReturnValue(0),
|
||||
getOffsetLeft: vi.fn().mockReturnValue(0),
|
||||
animate: vi.fn().mockResolvedValue(null),
|
||||
promiseAnimate: vi.fn().mockResolvedValue(null),
|
||||
slideUp: vi.fn().mockResolvedValue(null),
|
||||
slideDown: vi.fn().mockResolvedValue(null),
|
||||
native: document.createElement("div"),
|
||||
// @ts-expect-error - mocking private method
|
||||
hasValue: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
};
|
||||
|
||||
const actual = await importOriginal();
|
||||
|
||||
return {
|
||||
//@ts-expect-error - mocking private method
|
||||
...actual,
|
||||
qsr: vi.fn().mockImplementation(() => createMockElement()),
|
||||
qs: vi.fn().mockImplementation(() => createMockElement()),
|
||||
qsa: vi.fn().mockImplementation(() => new ElementsWithUtils()),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock document.querySelector to return a div
|
||||
// oxlint-disable-next-line no-deprecated
|
||||
global.document.querySelector = vi
|
||||
.fn()
|
||||
.mockReturnValue(document.createElement("div"));
|
||||
7
frontend/__tests__/__harness__/mock-env-config.ts
Normal file
7
frontend/__tests__/__harness__/mock-env-config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { vi } from "vitest";
|
||||
vi.mock("../src/ts/constants/env-config", () => ({
|
||||
envConfig: {
|
||||
backendUrl: "invalid",
|
||||
isDevelopment: true,
|
||||
},
|
||||
}));
|
||||
5
frontend/__tests__/__harness__/mock-firebase.ts
Normal file
5
frontend/__tests__/__harness__/mock-firebase.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { vi } from "vitest";
|
||||
vi.mock("../../src/ts/firebase", () => ({
|
||||
app: undefined,
|
||||
Auth: undefined,
|
||||
}));
|
||||
2
frontend/__tests__/__harness__/setup-jsx.ts
Normal file
2
frontend/__tests__/__harness__/setup-jsx.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
//extend expect with dom matchers
|
||||
import "@testing-library/jest-dom";
|
||||
423
frontend/__tests__/commandline/util.spec.ts
Normal file
423
frontend/__tests__/commandline/util.spec.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
//import type { ConfigMetadata } from "../../src/ts/config/metadata";
|
||||
|
||||
import { describe, it, expect, afterAll, vi } from "vitest";
|
||||
import * as Util from "../../src/ts/commandline/util";
|
||||
|
||||
import type { CommandlineConfigMetadata } from "../../src/ts/commandline/commandline-metadata";
|
||||
import type { ConfigKey } from "@monkeytype/schemas/configs";
|
||||
import type { ConfigMetadata } from "../../src/ts/config/metadata";
|
||||
import { z, ZodSchema } from "zod";
|
||||
|
||||
const buildCommandForConfigKey = Util.__testing._buildCommandForConfigKey;
|
||||
|
||||
describe("CommandlineUtils", () => {
|
||||
vi.mock("../../src/ts/config/metadata", () => ({ configMetadata: [] }));
|
||||
vi.mock("../../src/ts/commandline/commandline-metadata", () => ({
|
||||
commandlineConfigMetadata: [],
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
describe("buildCommandWithSubgroup", () => {
|
||||
describe("type subgroup", () => {
|
||||
it("detects options for boolean schema", () => {
|
||||
//GIVEN
|
||||
const schema = z.boolean();
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: { subgroup: { options: "fromSchema" } },
|
||||
schema,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list.map((it) => it.id)).toEqual([
|
||||
"setFalse",
|
||||
"setTrue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects options for enum schema", () => {
|
||||
//GIVEN
|
||||
const schema = z.enum(["one", "two", "three"]);
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: { subgroup: { options: "fromSchema" } },
|
||||
schema,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list.map((it) => it.id)).toEqual([
|
||||
"setOne",
|
||||
"setTwo",
|
||||
"setThree",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects options for union schema of enum + literral", () => {
|
||||
//GIVEN
|
||||
const schema = z.literal("default").or(z.enum(["one", "two", "three"]));
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: { subgroup: { options: "fromSchema" } },
|
||||
schema,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list.map((it) => it.id)).toEqual([
|
||||
"setDefault",
|
||||
"setOne",
|
||||
"setTwo",
|
||||
"setThree",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses preset options over schema values", () => {
|
||||
//GIVEN
|
||||
const schema = z.enum(["one", "two", "three"]);
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: { subgroup: { options: ["one", "two"] } },
|
||||
schema,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list.map((it) => it.id)).toEqual([
|
||||
"setOne",
|
||||
"setTwo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses preset option for number schema", () => {
|
||||
//GIVEN
|
||||
const schema = z.number().int();
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: { subgroup: { options: [0.25, 0.75] } },
|
||||
schema,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list.map((it) => it.id)).toEqual([
|
||||
"set0.25",
|
||||
"set0.75",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets available", () => {
|
||||
//GIVEN
|
||||
const schema = z.boolean();
|
||||
const isAvailable = (val: any) => (val ? () => true : undefined);
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: {
|
||||
subgroup: {
|
||||
options: "fromSchema",
|
||||
isAvailable,
|
||||
},
|
||||
},
|
||||
schema,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list[0]?.available).toBeUndefined();
|
||||
expect(cmd.subgroup?.list[1]?.available?.()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("type subgroupWithInput", () => {
|
||||
it("uses commandValues for number schema", () => {
|
||||
//GIVEN
|
||||
const afterExec = () => "test";
|
||||
const schema = z.number().int();
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
key: "test" as any,
|
||||
cmdMeta: {
|
||||
subgroup: {
|
||||
options: [0.25, 0.75],
|
||||
},
|
||||
input: {
|
||||
display: "custom test...",
|
||||
inputValueConvert: Number,
|
||||
afterExec,
|
||||
alias: "alias",
|
||||
},
|
||||
},
|
||||
configMeta: {
|
||||
fa: {
|
||||
icon: "fa-keyboard",
|
||||
},
|
||||
},
|
||||
schema,
|
||||
});
|
||||
|
||||
const inputCmd = cmd.subgroup?.list[cmd.subgroup?.list.length - 1];
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list.map((it) => it.id)).toEqual([
|
||||
"setTest0.25",
|
||||
"setTest0.75",
|
||||
"setTestCustom",
|
||||
]);
|
||||
|
||||
expect(inputCmd).toEqual({
|
||||
id: "setTestCustom",
|
||||
display: "custom test...",
|
||||
defaultValue: expect.anything(),
|
||||
alias: "alias",
|
||||
input: true,
|
||||
icon: "fa-keyboard",
|
||||
exec: expect.anything(),
|
||||
hover: undefined,
|
||||
configValue: undefined,
|
||||
inputValueConvert: Number,
|
||||
validation: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("type input", () => {
|
||||
it("has basic properties", () => {
|
||||
//GIVEN
|
||||
const afterExec = () => "test";
|
||||
const schema = z.string();
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
key: "test" as any,
|
||||
cmdMeta: {
|
||||
input: {
|
||||
display: "custom test...",
|
||||
afterExec,
|
||||
alias: "alias",
|
||||
},
|
||||
},
|
||||
configMeta: {
|
||||
fa: {
|
||||
icon: "fa-keyboard",
|
||||
},
|
||||
},
|
||||
schema,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "setTestCustom",
|
||||
display: "custom test...",
|
||||
alias: "alias",
|
||||
input: true,
|
||||
icon: "fa-keyboard",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses displayString from config for display ", () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: { input: {} },
|
||||
configMeta: { displayString: "My Setting" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.display).toEqual("My Setting...");
|
||||
});
|
||||
|
||||
it("uses display string from command meta if provided", () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: {
|
||||
input: {
|
||||
display: "Input setting...",
|
||||
},
|
||||
},
|
||||
configMeta: { displayString: "My Setting" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.display).toEqual("Input setting...");
|
||||
});
|
||||
|
||||
it("display is custom... if part of subgroup (without display override)", () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: {
|
||||
input: {
|
||||
// display: "Input setting...",
|
||||
},
|
||||
subgroup: {
|
||||
options: [],
|
||||
},
|
||||
},
|
||||
configMeta: { displayString: "My Setting" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list[0]?.display).toEqual("custom...");
|
||||
});
|
||||
|
||||
it("display is is using display override if part of subgroup", () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
cmdMeta: {
|
||||
input: {
|
||||
display: "Input setting...",
|
||||
},
|
||||
subgroup: {
|
||||
options: [],
|
||||
},
|
||||
},
|
||||
configMeta: { displayString: "My Setting" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd.subgroup?.list[0]?.display).toEqual("Input setting...");
|
||||
});
|
||||
|
||||
it("uses inputValueConvert", () => {
|
||||
//GIVEN
|
||||
const schema = z.number().int();
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
key: "test" as any,
|
||||
cmdMeta: { input: { inputValueConvert: Number } },
|
||||
schema,
|
||||
});
|
||||
|
||||
//THEN
|
||||
expect(cmd).toEqual(
|
||||
expect.objectContaining({
|
||||
inputValueConvert: Number,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses validation from schema", () => {
|
||||
//GIVEN
|
||||
const schema = z.enum(["on", "off"]);
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
key: "test" as any,
|
||||
cmdMeta: { input: { validation: { schema: true } } },
|
||||
schema,
|
||||
});
|
||||
|
||||
expect(cmd).toEqual(
|
||||
expect.objectContaining({
|
||||
validation: { schema },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use validation if empty", () => {
|
||||
//GIVEN
|
||||
const schema = z.enum(["on", "off"]);
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
key: "test" as any,
|
||||
cmdMeta: { input: { validation: {} } },
|
||||
schema,
|
||||
});
|
||||
|
||||
expect(cmd).toHaveProperty("validation", {});
|
||||
});
|
||||
|
||||
it("uses validation by default", () => {
|
||||
//GIVEN
|
||||
const schema = z.enum(["on", "off"]);
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
key: "test" as any,
|
||||
cmdMeta: { input: {} },
|
||||
schema,
|
||||
});
|
||||
|
||||
expect(cmd).toEqual(
|
||||
expect.objectContaining({
|
||||
validation: { schema },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses validation with isValid", () => {
|
||||
//GIVEN
|
||||
const schema = z.enum(["on", "off"]);
|
||||
const isValid = (_val: any): Promise<boolean | string> =>
|
||||
Promise.resolve("error");
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
key: "test" as any,
|
||||
cmdMeta: { input: { validation: { isValid: isValid } } },
|
||||
schema,
|
||||
});
|
||||
|
||||
expect(cmd).toEqual(
|
||||
expect.objectContaining({
|
||||
validation: { isValid },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses secondKey", () => {
|
||||
//GIVEN
|
||||
const schema = z.enum(["on", "off"]);
|
||||
|
||||
//WHEN
|
||||
const cmd = buildCommand({
|
||||
key: "test" as any,
|
||||
cmdMeta: {
|
||||
input: { secondKey: "mySecondKey" },
|
||||
},
|
||||
schema,
|
||||
});
|
||||
|
||||
expect(cmd).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "setMySecondKeyCustom",
|
||||
display: "MySecondKey...",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildCommand<K extends ConfigKey>({
|
||||
cmdMeta,
|
||||
configMeta,
|
||||
schema,
|
||||
key,
|
||||
}: {
|
||||
cmdMeta: Partial<CommandlineConfigMetadata<any, any>>;
|
||||
configMeta?: Partial<ConfigMetadata<K>>;
|
||||
schema?: ZodSchema;
|
||||
key?: K;
|
||||
}) {
|
||||
return buildCommandForConfigKey(
|
||||
key ?? ("" as any),
|
||||
configMeta ?? ({} as any),
|
||||
cmdMeta as any,
|
||||
schema ?? z.string(),
|
||||
);
|
||||
}
|
||||
88
frontend/__tests__/components/common/AnimatedModal.spec.tsx
Normal file
88
frontend/__tests__/components/common/AnimatedModal.spec.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { render } from "@solidjs/testing-library";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { AnimatedModal } from "../../../src/ts/components/common/AnimatedModal";
|
||||
|
||||
describe("AnimatedModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock dialog methods that don't exist in jsdom
|
||||
HTMLDialogElement.prototype.showModal = vi.fn();
|
||||
HTMLDialogElement.prototype.show = vi.fn();
|
||||
HTMLDialogElement.prototype.close = vi.fn();
|
||||
});
|
||||
|
||||
function renderModal(props: {
|
||||
onEscape?: (e: KeyboardEvent) => void;
|
||||
onBackdropClick?: (e: MouseEvent) => void;
|
||||
wrapperClass?: string;
|
||||
beforeShow?: () => void | Promise<void>;
|
||||
afterShow?: () => void | Promise<void>;
|
||||
beforeHide?: () => void | Promise<void>;
|
||||
afterHide?: () => void | Promise<void>;
|
||||
animationMode?: "none" | "both" | "modalOnly";
|
||||
}): {
|
||||
container: HTMLElement;
|
||||
dialog: HTMLDialogElement;
|
||||
modalDiv: HTMLDivElement;
|
||||
} {
|
||||
const { container } = render(() => (
|
||||
<AnimatedModal id="Support" {...props}>
|
||||
<div data-testid="modal-content">Test Content</div>
|
||||
</AnimatedModal>
|
||||
));
|
||||
|
||||
return {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
container: container.children[0]! as HTMLElement,
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
dialog: container.querySelector("dialog")!,
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
modalDiv: container.querySelector(".modal")!,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders dialog with correct id and class", () => {
|
||||
const { dialog } = renderModal({});
|
||||
|
||||
expect(dialog).toHaveAttribute("id", "SupportModal");
|
||||
expect(dialog).toHaveClass("hidden");
|
||||
});
|
||||
|
||||
it("renders children inside modal div", () => {
|
||||
const { modalDiv } = renderModal({});
|
||||
|
||||
expect(
|
||||
modalDiv.querySelector("[data-testid='modal-content']"),
|
||||
).toHaveTextContent("Test Content");
|
||||
});
|
||||
|
||||
it("has escape handler attached", () => {
|
||||
const { dialog } = renderModal({});
|
||||
|
||||
expect(dialog.onkeydown).toBeDefined();
|
||||
});
|
||||
|
||||
it("has backdrop click handler attached", () => {
|
||||
const { dialog } = renderModal({});
|
||||
|
||||
expect(dialog.onmousedown).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies custom class to dialog", () => {
|
||||
const { dialog } = renderModal({
|
||||
wrapperClass: "customClass",
|
||||
});
|
||||
|
||||
expect(dialog).toHaveClass("customClass");
|
||||
});
|
||||
|
||||
it("renders with animationMode none", () => {
|
||||
const { dialog } = renderModal({
|
||||
animationMode: "none",
|
||||
});
|
||||
|
||||
expect(dialog).toHaveAttribute("id", "SupportModal");
|
||||
});
|
||||
});
|
||||
385
frontend/__tests__/components/common/AsyncContent.spec.tsx
Normal file
385
frontend/__tests__/components/common/AsyncContent.spec.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { render, screen, waitFor } from "@solidjs/testing-library";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
} from "@tanstack/solid-query";
|
||||
import { JSXElement, Show } from "solid-js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import AsyncContent, {
|
||||
Props,
|
||||
} from "../../../src/ts/components/common/AsyncContent";
|
||||
import * as Notifications from "../../../src/ts/states/notifications";
|
||||
|
||||
describe("AsyncContent", () => {
|
||||
const notifyErrorMock = vi.spyOn(Notifications, "showErrorNotification");
|
||||
|
||||
beforeEach(() => {
|
||||
notifyErrorMock.mockClear();
|
||||
});
|
||||
|
||||
describe("with single query", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
it("renders loading state while pending", () => {
|
||||
const { container } = renderWithQuery({ result: "data" });
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).toBeInTheDocument();
|
||||
expect(preloader?.querySelector("i")).toHaveClass(
|
||||
"fas",
|
||||
"fa-fw",
|
||||
"fa-spin",
|
||||
"fa-circle-notch",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders custom loader while pending", () => {
|
||||
const { container } = renderWithQuery(
|
||||
{ result: "data" },
|
||||
{ loader: <span class="preloader">Loading...</span> },
|
||||
);
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).toBeInTheDocument();
|
||||
expect(preloader).toHaveTextContent("Loading...");
|
||||
});
|
||||
|
||||
it("renders on resolve", async () => {
|
||||
const { container } = renderWithQuery({ result: "Test Data" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
|
||||
});
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders on resolve with object containing null", async () => {
|
||||
const { container } = renderWithQuery({
|
||||
result: { text: "Test Data", extra: null } as any,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toBeVisible();
|
||||
});
|
||||
expect(container.innerHTML).toContain("static content");
|
||||
});
|
||||
|
||||
it("renders default error message on fail", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery({ result: error });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("An error occurred", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders custom error message on fail", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery(
|
||||
{ result: error },
|
||||
{ errorMessage: "Custom error message" },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("Custom error message", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores error on fail if ignoreError is set", async () => {
|
||||
renderWithQuery(
|
||||
{ result: new Error("Test error") },
|
||||
{ ignoreError: true, alwaysShowContent: true },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no data/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders on pending if alwaysShowContent", async () => {
|
||||
const { container } = renderWithQuery({ result: "Test Data" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
|
||||
});
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders on resolve if alwaysShowContent", async () => {
|
||||
renderWithQuery({ result: "Test Data" }, { alwaysShowContent: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders on fail if alwaysShowContent", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery(
|
||||
{ result: error },
|
||||
{ errorMessage: "Custom error message" },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("Custom error message", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithQuery(
|
||||
query: {
|
||||
result: string | Error;
|
||||
},
|
||||
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
|
||||
): {
|
||||
container: HTMLElement;
|
||||
} {
|
||||
const wrapper = (): JSXElement => {
|
||||
const myQuery = useQuery(() => ({
|
||||
queryKey: ["test", Math.random() * 1000],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (query.result instanceof Error) {
|
||||
throw query.result;
|
||||
}
|
||||
return query.result;
|
||||
},
|
||||
retry: 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AsyncContent query={myQuery} {...(options as Props<string>)}>
|
||||
{(data: string | undefined) => (
|
||||
<>
|
||||
static content
|
||||
<Show when={data !== undefined} fallback={<div>no data</div>}>
|
||||
<div data-testid="content">{data}</div>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</AsyncContent>
|
||||
);
|
||||
};
|
||||
const { container } = render(() => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{wrapper()}
|
||||
</QueryClientProvider>
|
||||
));
|
||||
|
||||
return {
|
||||
container,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
describe("with multiple queries", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
it("renders loading state while pending", () => {
|
||||
const { container } = renderWithQuery({ first: "data", second: "data" });
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).toBeInTheDocument();
|
||||
expect(preloader?.querySelector("i")).toHaveClass(
|
||||
"fas",
|
||||
"fa-fw",
|
||||
"fa-spin",
|
||||
"fa-circle-notch",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders custom loader while pending", () => {
|
||||
const { container } = renderWithQuery(
|
||||
{ first: "data", second: "data" },
|
||||
{ loader: <span class="preloader">Loading...</span> },
|
||||
);
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).toBeInTheDocument();
|
||||
expect(preloader).toHaveTextContent("Loading...");
|
||||
});
|
||||
|
||||
it("renders on resolve", async () => {
|
||||
const { container } = renderWithQuery({
|
||||
first: "First Data",
|
||||
second: "Second Data",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("first")).toHaveTextContent("First Data");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("second")).toHaveTextContent("Second Data");
|
||||
});
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders default error message on fail", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery({ first: "data", second: error });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("An error occurred", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders custom error message on fail", async () => {
|
||||
const firstError = new Error("First error");
|
||||
renderWithQuery(
|
||||
{ first: firstError, second: new Error("Second error") },
|
||||
{ errorMessage: "Custom error message" },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("Custom error message", {
|
||||
error: firstError,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores error on fail if ignoreError is set", async () => {
|
||||
renderWithQuery(
|
||||
{ first: new Error("First error"), second: new Error("Second error") },
|
||||
{ ignoreError: true, alwaysShowContent: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no data/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(notifyErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders on pending if alwaysShowContent", async () => {
|
||||
const { container } = renderWithQuery(
|
||||
{
|
||||
first: undefined,
|
||||
second: undefined,
|
||||
},
|
||||
{ alwaysShowContent: true },
|
||||
);
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no data/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders on resolve if alwaysShowContent", async () => {
|
||||
renderWithQuery({
|
||||
first: "First Data",
|
||||
second: "Second Data",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("first")).toHaveTextContent("First Data");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("second")).toHaveTextContent("Second Data");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders on fail if alwaysShowContent", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery(
|
||||
{ first: "data", second: error },
|
||||
{ errorMessage: "Custom error message" },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("Custom error message", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithQuery(
|
||||
queries: {
|
||||
first: string | Error | undefined;
|
||||
second: string | Error | undefined;
|
||||
},
|
||||
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
|
||||
): {
|
||||
container: HTMLElement;
|
||||
} {
|
||||
const wrapper = (): JSXElement => {
|
||||
const firstQuery = useQuery(() => ({
|
||||
queryKey: ["first", Math.random() * 1000],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (queries.first instanceof Error) {
|
||||
throw queries.first;
|
||||
}
|
||||
return queries.first;
|
||||
},
|
||||
retry: 0,
|
||||
}));
|
||||
const secondQuery = useQuery(() => ({
|
||||
queryKey: ["second", Math.random() * 1000],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (queries.second instanceof Error) {
|
||||
throw queries.second;
|
||||
}
|
||||
return queries.second;
|
||||
},
|
||||
retry: 0,
|
||||
}));
|
||||
|
||||
type Q = { first: string | undefined; second: string | undefined };
|
||||
return (
|
||||
<AsyncContent
|
||||
queries={{ first: firstQuery, second: secondQuery }}
|
||||
{...(options as Props<Q>)}
|
||||
>
|
||||
{(results: {
|
||||
first: string | undefined;
|
||||
second: string | undefined;
|
||||
}) => (
|
||||
<>
|
||||
<Show
|
||||
when={
|
||||
results.first !== undefined && results.second !== undefined
|
||||
}
|
||||
fallback={<div>no data</div>}
|
||||
>
|
||||
<div data-testid="first">{results.first}</div>
|
||||
<div data-testid="second">{results.second}</div>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</AsyncContent>
|
||||
);
|
||||
};
|
||||
const { container } = render(() => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{wrapper()}
|
||||
</QueryClientProvider>
|
||||
));
|
||||
|
||||
return {
|
||||
container,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
273
frontend/__tests__/components/common/Button.spec.tsx
Normal file
273
frontend/__tests__/components/common/Button.spec.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { cleanup, render } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Button } from "../../../src/ts/components/common/Button";
|
||||
import { FaSolidIcon } from "../../../src/ts/types/font-awesome";
|
||||
|
||||
describe("Button component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders a button element when onClick is provided", () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
const { container } = render(() => (
|
||||
<Button onClick={onClick} text="Click me" />
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toBeTruthy();
|
||||
expect(button).toHaveTextContent("Click me");
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders an anchor element when href is provided", () => {
|
||||
const { container } = render(() => (
|
||||
<Button href="https://example.com" text="Go" />
|
||||
));
|
||||
|
||||
const anchor = container.querySelector("a");
|
||||
expect(anchor).toBeTruthy();
|
||||
expect(anchor).toHaveAttribute("href", "https://example.com");
|
||||
expect(anchor).toHaveAttribute("target", "_blank");
|
||||
expect(anchor).toHaveAttribute("rel", "noreferrer noopener");
|
||||
expect(anchor).not.toHaveAttribute("router-link");
|
||||
expect(anchor).not.toHaveAttribute("aria-label");
|
||||
expect(anchor).not.toHaveAttribute("data-balloon-pos");
|
||||
});
|
||||
|
||||
it("calls onClick when button is clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
const { container } = render(() => (
|
||||
<Button onClick={onClick} text="Click me" />
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
button?.click();
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders icon when icon prop is provided", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
fa={{
|
||||
icon: "fa-keyboard",
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
const icon = container.querySelector("i");
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon).toHaveClass("fas");
|
||||
expect(icon).toHaveClass("fa-keyboard");
|
||||
});
|
||||
|
||||
it("renders icon when icon prop has changed", () => {
|
||||
const [icon, setIcon] = createSignal<FaSolidIcon>("fa-keyboard");
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
fa={{
|
||||
icon: icon(),
|
||||
class: "test",
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
setIcon("fa-backward");
|
||||
|
||||
const i = container.querySelector("i");
|
||||
expect(i).toBeTruthy();
|
||||
expect(i).toHaveClass("fas");
|
||||
expect(i).toHaveClass("fa-backward");
|
||||
expect(i).toHaveClass("test");
|
||||
});
|
||||
|
||||
it("applies fa-fw class when fixedWidthIcon is true", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
fa={{
|
||||
fixedWidth: true,
|
||||
icon: "fa-keyboard",
|
||||
}}
|
||||
text="Hello"
|
||||
/>
|
||||
));
|
||||
|
||||
const icon = container.querySelector("i");
|
||||
expect(icon).toHaveClass("fa-fw");
|
||||
});
|
||||
|
||||
it("does not apply fa-fw when text is present and fixedWidthIcon is false", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
fa={{
|
||||
icon: "fa-keyboard",
|
||||
}}
|
||||
text="Hello"
|
||||
/>
|
||||
));
|
||||
|
||||
const icon = container.querySelector("i");
|
||||
expect(icon).not.toHaveClass("fa-fw");
|
||||
});
|
||||
|
||||
it("applies default button class", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toHaveClass("button");
|
||||
});
|
||||
|
||||
it("applies custom class when class prop is provided", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
class="custom-class"
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("renders children content", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
<span data-testid="child">Child</span>
|
||||
</Button>
|
||||
));
|
||||
|
||||
const child = container.querySelector('[data-testid="child"]');
|
||||
expect(child).toBeTruthy();
|
||||
expect(child).toHaveTextContent("Child");
|
||||
});
|
||||
|
||||
it("applies balloon to button with default position", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
balloon={{ text: "test" }}
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toHaveAttribute("aria-label", "test");
|
||||
expect(button).toHaveAttribute("data-balloon-pos", "up");
|
||||
});
|
||||
|
||||
it("applies balloon to button with custom position", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
balloon={{ text: "test", position: "down" }}
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toHaveAttribute("aria-label", "test");
|
||||
expect(button).toHaveAttribute("data-balloon-pos", "down");
|
||||
});
|
||||
|
||||
it("applies router-link to button", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
router-link
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toHaveAttribute("router-link", "");
|
||||
});
|
||||
|
||||
it("applies balloon to anchor with default position", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
href="http://example.com"
|
||||
text="Hello"
|
||||
balloon={{ text: "test" }}
|
||||
/>
|
||||
));
|
||||
|
||||
const anchor = container.querySelector("a");
|
||||
expect(anchor).toHaveAttribute("aria-label", "test");
|
||||
expect(anchor).toHaveAttribute("data-balloon-pos", "up");
|
||||
});
|
||||
|
||||
it("applies balloon to anchor with custom position", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
href="http://example.com"
|
||||
text="Hello"
|
||||
balloon={{ text: "test", position: "down" }}
|
||||
/>
|
||||
));
|
||||
|
||||
const anchor = container.querySelector("a");
|
||||
expect(anchor).toHaveAttribute("aria-label", "test");
|
||||
expect(anchor).toHaveAttribute("data-balloon-pos", "down");
|
||||
});
|
||||
|
||||
it("applies router-link to anchor", () => {
|
||||
const { container } = render(() => (
|
||||
<Button href="http://example.com" text="Hello" router-link />
|
||||
));
|
||||
|
||||
const anchor = container.querySelector("a");
|
||||
expect(anchor).toHaveAttribute("router-link", "");
|
||||
});
|
||||
|
||||
it("applies disabled to button", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
/** */
|
||||
}}
|
||||
text="Hello"
|
||||
disabled={true}
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
214
frontend/__tests__/components/common/Conditional.spec.tsx
Normal file
214
frontend/__tests__/components/common/Conditional.spec.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { Conditional } from "../../../src/ts/components/common/Conditional";
|
||||
|
||||
describe("Conditional", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("static rendering", () => {
|
||||
it("renders then when if is true", () => {
|
||||
render(() => <Conditional if={true} then={<div>then content</div>} />);
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders then when if is a truthy object", () => {
|
||||
render(() => (
|
||||
<Conditional if={{ value: 42 }} then={<div>then content</div>} />
|
||||
));
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders then when if is a truthy string", () => {
|
||||
render(() => <Conditional if="hello" then={<div>then content</div>} />);
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders else fallback when if is false", () => {
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={false}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders else fallback when if is null", () => {
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={null}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders else fallback when if is undefined", () => {
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={undefined}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders else fallback when if is 0", () => {
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={0}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when if is falsy and else is not provided", () => {
|
||||
const { container } = render(() => (
|
||||
<Conditional if={false} then={<div>then content</div>} />
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("then as function", () => {
|
||||
it("passes the truthy value to then function", () => {
|
||||
const obj: { label: string } | null = { label: "hello" };
|
||||
render(() => (
|
||||
<Conditional if={obj} then={(value) => <div>{value().label}</div>} />
|
||||
));
|
||||
|
||||
expect(screen.getByText("hello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call then function when if is falsy", () => {
|
||||
const obj: { label: string } | null = null;
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={obj}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactivity", () => {
|
||||
it("switches from else to then when if becomes truthy", async () => {
|
||||
const [condition, setCondition] = createSignal(false);
|
||||
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={condition()}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
|
||||
setCondition(true);
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
expect(screen.queryByText("else content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches from then to else when if becomes falsy", async () => {
|
||||
const [condition, setCondition] = createSignal(true);
|
||||
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={condition()}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
|
||||
setCondition(false);
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("then JSXElement updates reactively when inner signal changes", async () => {
|
||||
const [label, setLabel] = createSignal("initial");
|
||||
|
||||
render(() => <Conditional if={true} then={<div>{label()}</div>} />);
|
||||
|
||||
expect(screen.getByText("initial")).toBeInTheDocument();
|
||||
|
||||
setLabel("updated");
|
||||
|
||||
expect(screen.getByText("updated")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("then JSXElement updates reactively when if changes from a signal", async () => {
|
||||
const [data, setData] = createSignal<string | undefined>(undefined);
|
||||
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={data()}
|
||||
then={<div data-testid="content">{data()}</div>}
|
||||
else={<div>no data</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText("no data")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
|
||||
|
||||
setData("resolved");
|
||||
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("resolved");
|
||||
expect(screen.queryByText("no data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("then function value accessor tracks reactive if", () => {
|
||||
const [data, setData] = createSignal<{ name: string } | null>(null);
|
||||
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={data()}
|
||||
then={(value) => <div data-testid="content">{value().name}</div>}
|
||||
else={<div>no data</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText("no data")).toBeInTheDocument();
|
||||
|
||||
setData({ name: "Alice" });
|
||||
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Alice");
|
||||
|
||||
setData({ name: "Bob" });
|
||||
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Bob");
|
||||
});
|
||||
});
|
||||
});
|
||||
390
frontend/__tests__/components/common/anime/Anime.spec.tsx
Normal file
390
frontend/__tests__/components/common/anime/Anime.spec.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { cleanup, render } from "@solidjs/testing-library";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockReturnValue({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((_cb: unknown) => Promise.resolve()),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
// Mock applyReducedMotion
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { Anime } from "../../../../src/ts/components/common/anime/Anime";
|
||||
import {
|
||||
AnimeGroup,
|
||||
createStagger,
|
||||
} from "../../../../src/ts/components/common/anime/AnimeGroup";
|
||||
import { AnimePresence } from "../../../../src/ts/components/common/anime/AnimePresence";
|
||||
|
||||
describe("Anime", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a div wrapper by default", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime animation={{ opacity: 1 }}>
|
||||
<span>content</span>
|
||||
</Anime>
|
||||
));
|
||||
expect(container.querySelector("div")).toBeTruthy();
|
||||
expect(container.querySelector("span")).toHaveTextContent("content");
|
||||
});
|
||||
|
||||
it("renders with custom tag via `as` prop", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime animation={{ opacity: 1 }} as="section">
|
||||
<span>hi</span>
|
||||
</Anime>
|
||||
));
|
||||
expect(container.querySelector("section")).toBeTruthy();
|
||||
expect(container.querySelector("div")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies className and style props", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime
|
||||
animation={{ opacity: 1 }}
|
||||
class="my-class"
|
||||
style={{ color: "red" }}
|
||||
>
|
||||
<span />
|
||||
</Anime>
|
||||
));
|
||||
const el = container.querySelector(".my-class");
|
||||
expect(el).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls animejsAnimate on mount with animation prop", () => {
|
||||
render(() => (
|
||||
<Anime animation={{ opacity: 1, duration: 300 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies initial state with duration:0 then animates to animate prop", () => {
|
||||
render(() => (
|
||||
<Anime initial={{ opacity: 0 }} animate={{ opacity: 1, duration: 300 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
|
||||
// First call: initial state (duration: 0)
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, duration: 0 }),
|
||||
);
|
||||
// Second call: full animation
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-runs animation when reactive signal changes", () => {
|
||||
const [opacity, setOpacity] = createSignal(1);
|
||||
|
||||
render(() => (
|
||||
<Anime animation={{ opacity: opacity(), duration: 200 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
|
||||
const callsBefore = mockAnimate.mock.calls.length;
|
||||
setOpacity(0);
|
||||
|
||||
expect(mockAnimate.mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimePresence", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence>
|
||||
<div data-testid="child">hello</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
expect(container.querySelector("[data-testid='child']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders children in list mode", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence mode="list">
|
||||
<div data-testid="item-1">one</div>
|
||||
<div data-testid="item-2">two</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
expect(container.querySelector("[data-testid='item-1']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='item-2']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("list mode wraps children in a display:contents div", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence mode="list">
|
||||
<div>child</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
const wrapper = container.querySelector("div");
|
||||
expect(wrapper?.style.display).toBe("contents");
|
||||
});
|
||||
|
||||
it("mounts and unmounts Show child without errors", async () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimePresence>
|
||||
<Show when={show()}>
|
||||
<Anime animate={{ opacity: 1, duration: 0 }}>
|
||||
<div data-testid="toggled">toggled</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
</AnimePresence>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
});
|
||||
|
||||
it("exitBeforeEnter mode does not throw on child switch", () => {
|
||||
const [view, setView] = createSignal<"a" | "b">("a");
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimePresence exitBeforeEnter>
|
||||
<Show when={view() === "a"}>
|
||||
<Anime exit={{ opacity: 0, duration: 0 }}>
|
||||
<div>View A</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
<Show when={view() === "b"}>
|
||||
<Anime exit={{ opacity: 0, duration: 0 }}>
|
||||
<div>View B</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
</AnimePresence>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setView("b")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimeGroup", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a div wrapper by default", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }}>
|
||||
<div>a</div>
|
||||
<div>b</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector("div")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders with custom tag via `as` prop", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1 }} as="ul">
|
||||
<li>item</li>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector("ul")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("animates each child on mount", () => {
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={50}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// One call per child element
|
||||
const childCalls = mockAnimate.mock.calls.filter(
|
||||
([el]) => el instanceof HTMLElement,
|
||||
);
|
||||
expect(childCalls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("applies initial state before animating children", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
initial={{ opacity: 0 }}
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// Initial state calls (duration: 0) should precede animation calls
|
||||
const zeroDurationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 0,
|
||||
);
|
||||
expect(zeroDurationCalls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("applies stagger delays in forward direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={100}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// Calls with non-zero delay values reflecting stagger
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// forward stagger: delays should be 0, 100, 200
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(100);
|
||||
expect(delayCalls).toContain(200);
|
||||
});
|
||||
|
||||
it("reverses stagger direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
stagger={100}
|
||||
direction="reverse"
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// reverse: first child gets highest delay (200), last gets 0
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(200);
|
||||
});
|
||||
|
||||
it("applies center stagger direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
stagger={100}
|
||||
direction="center"
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// center: middle element (index 1) has 0 delay, outer elements have 100
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(100);
|
||||
});
|
||||
|
||||
it("accepts a function stagger", () => {
|
||||
const staggerFn = vi.fn((_i: number, _t: number) => 75);
|
||||
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={staggerFn}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
expect(staggerFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies class and style to wrapper", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1 }}
|
||||
class="group-class"
|
||||
style={{ gap: "8px" }}
|
||||
>
|
||||
<div>1</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector(".group-class")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStagger", () => {
|
||||
it("returns 0 for single element", () => {
|
||||
const fn = createStagger({ base: 100 });
|
||||
expect(fn(0, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("linear stagger from start: first=0, last=base*(total-1)", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "start" });
|
||||
expect(fn(0, 3)).toBeCloseTo(0);
|
||||
expect(fn(2, 3)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("linear stagger from end: first=base*(total-1), last=0", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "end" });
|
||||
expect(fn(0, 3)).toBeCloseTo(100);
|
||||
expect(fn(2, 3)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("center stagger: middle element has smallest value", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "center" });
|
||||
// For 5 items, center is index 2 → distance = 0
|
||||
expect(fn(2, 5)).toBeCloseTo(0);
|
||||
expect(fn(0, 5)).toBeGreaterThan(fn(2, 5));
|
||||
});
|
||||
|
||||
it("easeIn produces smaller values at start", () => {
|
||||
const linear = createStagger({ base: 100, ease: "linear" });
|
||||
const easeIn = createStagger({ base: 100, ease: "easeIn" });
|
||||
// At index 1 of 4, easeIn position is less progressed than linear
|
||||
expect(easeIn(1, 4)).toBeLessThan(linear(1, 4));
|
||||
});
|
||||
|
||||
it("easeOut produces larger values at start compared to easeIn", () => {
|
||||
const easeOut = createStagger({ base: 100, ease: "easeOut" });
|
||||
const easeIn = createStagger({ base: 100, ease: "easeIn" });
|
||||
expect(easeOut(1, 4)).toBeGreaterThan(easeIn(1, 4));
|
||||
});
|
||||
|
||||
it("easeInOut is symmetric", () => {
|
||||
const fn = createStagger({ base: 100, ease: "easeInOut", from: "start" });
|
||||
// At 50% position (index 1 of 3), easeInOut should equal linear
|
||||
// easeInOut at 0.5 = 0.5 → 100 * 0.5 * 2 = 100
|
||||
expect(fn(1, 3)).toBeCloseTo(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => ({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeConditional } from "../../../../src/ts/components/common/anime/AnimeConditional";
|
||||
|
||||
describe("AnimeConditional", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders `then` content when `if` is truthy", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("else-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders `else` content when `if` is falsy", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={false}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders `else` content when `if` is null", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={null}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches reactively from `then` to `else`", () => {
|
||||
const [condition, setCondition] = createSignal(true);
|
||||
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={condition()}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
|
||||
setCondition(false);
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches reactively from `else` to `then`", () => {
|
||||
const [condition, setCondition] = createSignal(false);
|
||||
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={condition()}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
|
||||
setCondition(true);
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("else-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports `then` as a function and passes the truthy value", () => {
|
||||
const obj = { label: "hello" };
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={obj}
|
||||
then={(value) => <div data-testid="fn-content">{value().label}</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("fn-content")).toHaveTextContent("hello");
|
||||
});
|
||||
|
||||
it("does not throw without `else` prop", () => {
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not throw on mount/unmount", () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={show()}
|
||||
then={<div>then</div>}
|
||||
else={<div>else</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
expect(() => setShow(true)).not.toThrow();
|
||||
});
|
||||
|
||||
describe("default animations (opacity fade)", () => {
|
||||
it("applies default opacity animate on `then` branch", () => {
|
||||
render(() => <AnimeConditional if={true} then={<div>then</div>} />);
|
||||
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 125 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies default opacity initial state on `then` branch", () => {
|
||||
render(() => <AnimeConditional if={true} then={<div>then</div>} />);
|
||||
|
||||
// Initial call: opacity:0 with duration:0
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, duration: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom animeProps", () => {
|
||||
it("uses custom animate params when animeProps provided", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div>then</div>}
|
||||
animeProps={{
|
||||
initial: { opacity: 0, translateY: -10 },
|
||||
animate: { opacity: 1, translateY: 0, duration: 400 },
|
||||
exit: { opacity: 0, translateY: -10, duration: 200 },
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, translateY: 0, duration: 400 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom initial state when animeProps provided", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div>then</div>}
|
||||
animeProps={{
|
||||
initial: { opacity: 0, translateY: -10 },
|
||||
animate: { opacity: 1, translateY: 0, duration: 400 },
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
// Initial state applied with duration:0
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, translateY: -10, duration: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("exitBeforeEnter prop does not throw on condition change", () => {
|
||||
const [cond, setCond] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
exitBeforeEnter
|
||||
if={cond()}
|
||||
then={<div>then</div>}
|
||||
else={<div>else</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setCond(false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
157
frontend/__tests__/components/common/anime/AnimeShow.spec.tsx
Normal file
157
frontend/__tests__/components/common/anime/AnimeShow.spec.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const animation = {
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
callbacks.push(cb);
|
||||
// Invoke immediately so exit animations complete synchronously in tests
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
return animation;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeShow } from "../../../../src/ts/components/common/anime/AnimeShow";
|
||||
|
||||
describe("AnimeShow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children when `when` is true", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={false}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows and hides reactively", () => {
|
||||
const [visible, setVisible] = createSignal(true);
|
||||
|
||||
render(() => (
|
||||
<AnimeShow when={visible()}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
setVisible(false);
|
||||
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
|
||||
setVisible(true);
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies class to the wrapper element when visible", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeShow when={true} class="my-class">
|
||||
<span>content</span>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(container.querySelector(".my-class")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not throw on mount/unmount", () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeShow when={show()}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
});
|
||||
|
||||
describe("slide mode", () => {
|
||||
it("renders children when `when` is true in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} slide>
|
||||
<div data-testid="slide-content">slide</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.getByTestId("slide-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={false} slide>
|
||||
<div data-testid="slide-content">slide</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.queryByTestId("slide-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("animates height in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} slide>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const heightCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.height !== undefined,
|
||||
);
|
||||
expect(heightCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duration prop", () => {
|
||||
it("uses the provided duration", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} duration={400}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const durationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 400,
|
||||
);
|
||||
expect(durationCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("defaults to 125ms when no duration is provided", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const defaultDurationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 125,
|
||||
);
|
||||
expect(defaultDurationCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
205
frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx
Normal file
205
frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => ({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeMatch } from "../../../../src/ts/components/common/anime/AnimeMatch";
|
||||
import { AnimeSwitch } from "../../../../src/ts/components/common/anime/AnimeSwitch";
|
||||
|
||||
describe("AnimeSwitch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the matched child", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={true}>
|
||||
<div data-testid="match-a">A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="match-b">B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("match-a")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("match-b")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the next matched child reactively", () => {
|
||||
const [tab, setTab] = createSignal<"a" | "b">("a");
|
||||
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={tab() === "a"}>
|
||||
<div data-testid="view-a">View A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={tab() === "b"}>
|
||||
<div data-testid="view-b">View B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("view-a")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-b")).not.toBeInTheDocument();
|
||||
|
||||
setTab("b");
|
||||
|
||||
expect(screen.queryByTestId("view-a")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("view-b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when no match", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="no-match">never</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("no-match")).not.toBeInTheDocument();
|
||||
// Only AnimePresence wrapper remains
|
||||
expect(container.querySelectorAll("[data-testid]").length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not throw when switching between children", () => {
|
||||
const [view, setView] = createSignal<"a" | "b">("a");
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeSwitch exitBeforeEnter>
|
||||
<AnimeMatch when={view() === "a"}>
|
||||
<div>View A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={view() === "b"}>
|
||||
<div>View B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setView("b")).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes animeProps down to all AnimeMatch children", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, duration: 300 },
|
||||
exit: { opacity: 0, duration: 300 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true}>
|
||||
<div>content</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// Expect animate call with the shared animeProps
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimeMatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children when `when` is true", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={true}>
|
||||
<div data-testid="match-content">match</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
expect(screen.getByTestId("match-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="hidden">hidden</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
expect(screen.queryByTestId("hidden")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("per-match animate overrides the shared animeProps", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
animate: { opacity: 1, duration: 200 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true} animate={{ opacity: 1, duration: 500 }}>
|
||||
<div>override</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// The per-match duration (500) should be used, not the shared one (200)
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 500 }),
|
||||
);
|
||||
const callsWithSharedDuration = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 200,
|
||||
);
|
||||
expect(callsWithSharedDuration.length).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to context animeProps when no per-match props provided", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, duration: 250 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true}>
|
||||
<div>content</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// Should use context duration 250
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 250 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
137
frontend/__tests__/components/core/Theme.spec.tsx
Normal file
137
frontend/__tests__/components/core/Theme.spec.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { render, fireEvent } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { Theme } from "../../../src/ts/components/core/Theme";
|
||||
import { ThemeWithName } from "../../../src/ts/constants/themes";
|
||||
import * as Loader from "../../../src/ts/states/loader-bar";
|
||||
import * as Notifications from "../../../src/ts/states/notifications";
|
||||
import * as ThemeSignal from "../../../src/ts/states/theme";
|
||||
|
||||
vi.mock("../../../src/ts/constants/themes", () => ({
|
||||
themes: {
|
||||
dark: { hasCss: true },
|
||||
light: {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./FavIcon", () => ({
|
||||
FavIcon: () => <div id="favicon" />,
|
||||
}));
|
||||
|
||||
describe("Theme component", () => {
|
||||
const [themeSignal, setThemeSignal] = createSignal<ThemeWithName>({} as any);
|
||||
const themeSignalMock = vi.spyOn(ThemeSignal, "getTheme");
|
||||
const loaderShowMock = vi.spyOn(Loader, "showLoaderBar");
|
||||
const loaderHideMock = vi.spyOn(Loader, "hideLoaderBar");
|
||||
const notificationAddMock = vi.spyOn(Notifications, "showNoticeNotification");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
loaderShowMock.mockClear();
|
||||
loaderHideMock.mockClear();
|
||||
notificationAddMock.mockClear();
|
||||
themeSignalMock.mockImplementation(() => themeSignal());
|
||||
setThemeSignal({
|
||||
name: "dark",
|
||||
bg: "#000",
|
||||
main: "#fff",
|
||||
caret: "#fff",
|
||||
sub: "#aaa",
|
||||
subAlt: "#888",
|
||||
text: "#fff",
|
||||
error: "#f00",
|
||||
errorExtra: "#c00",
|
||||
colorfulError: "#f55",
|
||||
colorfulErrorExtra: "#c55",
|
||||
});
|
||||
});
|
||||
|
||||
it("injects CSS variables based on theme", () => {
|
||||
const { style } = renderComponent();
|
||||
|
||||
expect(style.innerHTML).toEqual(`
|
||||
:root {
|
||||
--bg-color: #000;
|
||||
--main-color: #fff;
|
||||
--caret-color: #fff;
|
||||
--sub-color: #aaa;
|
||||
--sub-alt-color: #888;
|
||||
--text-color: #fff;
|
||||
--error-color: #f00;
|
||||
--error-extra-color: #c00;
|
||||
--colorful-error-color: #f55;
|
||||
--colorful-error-extra-color: #c55;
|
||||
}`);
|
||||
});
|
||||
|
||||
it("updates CSS variables based on signal", () => {
|
||||
setThemeSignal({ name: "light", bg: "#f00" } as any);
|
||||
const { style } = renderComponent();
|
||||
|
||||
expect(style.innerHTML).toContain("--bg-color: #f00;");
|
||||
});
|
||||
|
||||
it("loads CSS file and shows loader when theme has CSS", () => {
|
||||
const { css } = renderComponent();
|
||||
|
||||
expect(css.getAttribute("href")).toBe("/themes/dark.css");
|
||||
expect(loaderShowMock).toHaveBeenCalledOnce();
|
||||
fireEvent.load(css);
|
||||
expect(loaderHideMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("removes CSS when theme has no CSS", async () => {
|
||||
themeSignalMock.mockImplementation(() => ({ name: "light" }) as any);
|
||||
const { css } = renderComponent();
|
||||
expect(css.getAttribute("href")).toBe("");
|
||||
});
|
||||
|
||||
it("removes CSS when theme is custom", async () => {
|
||||
themeSignalMock.mockImplementation(() => ({ name: "custom" }) as any);
|
||||
const { css } = renderComponent();
|
||||
expect(css.getAttribute("href")).toBe("");
|
||||
});
|
||||
|
||||
it("handles CSS load error", () => {
|
||||
const { css } = renderComponent();
|
||||
expect(loaderShowMock).toHaveBeenCalledOnce();
|
||||
fireEvent.error(css);
|
||||
expect(loaderHideMock).toHaveBeenCalledOnce();
|
||||
expect(notificationAddMock).toHaveBeenCalledWith("Failed to load theme");
|
||||
});
|
||||
|
||||
it("renders favicon", () => {
|
||||
const { favIcon } = renderComponent();
|
||||
|
||||
expect(favIcon).toBeInTheDocument();
|
||||
expect(favIcon).toBeEmptyDOMElement(); //mocked
|
||||
});
|
||||
|
||||
function renderComponent(): {
|
||||
style: HTMLStyleElement;
|
||||
css: HTMLLinkElement;
|
||||
metaThemeColor: HTMLMetaElement;
|
||||
favIcon: HTMLElement;
|
||||
} {
|
||||
render(() => <Theme />);
|
||||
|
||||
//wait for debounce
|
||||
vi.runAllTimers();
|
||||
|
||||
//make sure content is rendered to the head, not the body
|
||||
const head = document.head;
|
||||
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
style: head.querySelector("style#theme")!,
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
css: head.querySelector("link#currentTheme")!,
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
metaThemeColor: head.querySelector("meta#metaThemeColor")!,
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
favIcon: head.querySelector("#favicon")!,
|
||||
};
|
||||
}
|
||||
});
|
||||
105
frontend/__tests__/components/layout/footer/ScrollToTop.spec.tsx
Normal file
105
frontend/__tests__/components/layout/footer/ScrollToTop.spec.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { render } from "@solidjs/testing-library";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { ScrollToTop } from "../../../../src/ts/components/layout/footer/ScrollToTop";
|
||||
import * as CoreSignals from "../../../../src/ts/states/core";
|
||||
|
||||
describe("ScrollToTop", () => {
|
||||
const getActivePageMock = vi.spyOn(CoreSignals, "getActivePage");
|
||||
beforeEach(() => {
|
||||
getActivePageMock.mockClear().mockReturnValue("account");
|
||||
Object.defineProperty(window, "scrollY", { value: 0, writable: true });
|
||||
});
|
||||
|
||||
function renderElement(): {
|
||||
container: HTMLElement;
|
||||
button: HTMLButtonElement;
|
||||
} {
|
||||
const { container } = render(() => <ScrollToTop />);
|
||||
|
||||
return {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
container: container.children[0]! as HTMLElement,
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
button: container.querySelector("button")!,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders with correct classes and structure", () => {
|
||||
const { container, button } = renderElement();
|
||||
|
||||
expect(container).toHaveClass("content-grid", "ScrollToTop");
|
||||
expect(button).toHaveClass("breakout");
|
||||
expect(button.querySelector("i")).toHaveClass("fas", "fa-angle-double-up");
|
||||
});
|
||||
|
||||
it("renders invisible when scrollY is 0", () => {
|
||||
const { button } = renderElement();
|
||||
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("becomes visible when scrollY > 100 on non-test pages", () => {
|
||||
const { button } = renderElement();
|
||||
scrollTo(150);
|
||||
|
||||
expect(button).not.toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("stays invisible on test page at scroll 0", () => {
|
||||
getActivePageMock.mockReturnValue("test");
|
||||
const { button } = renderElement();
|
||||
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("stays invisible on test page even with scroll > 100", () => {
|
||||
getActivePageMock.mockReturnValue("test");
|
||||
const { button } = renderElement();
|
||||
scrollTo(150);
|
||||
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("becomes invisible when scroll < 100 on non-test pages", () => {
|
||||
const { button } = renderElement();
|
||||
scrollTo(150);
|
||||
expect(button).not.toHaveClass("opacity-0");
|
||||
|
||||
scrollTo(50);
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("scrolls to top and hides button on click", async () => {
|
||||
const scrollToSpy = vi.fn();
|
||||
window.scrollTo = scrollToSpy;
|
||||
const { button } = renderElement();
|
||||
scrollTo(150);
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("cleans up scroll listener on unmount", () => {
|
||||
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
const { unmount } = render(() => <ScrollToTop />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"scroll",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
function scrollTo(value: number): void {
|
||||
Object.defineProperty(window, "scrollY", { value, writable: true });
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
}
|
||||
});
|
||||
91
frontend/__tests__/components/ui/form/Checkbox.spec.tsx
Normal file
91
frontend/__tests__/components/ui/form/Checkbox.spec.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { Checkbox } from "../../../../src/ts/components/ui/form/Checkbox";
|
||||
|
||||
function makeField(name: string, checked = false) {
|
||||
return {
|
||||
name,
|
||||
state: { value: checked },
|
||||
handleBlur: vi.fn(),
|
||||
handleChange: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("Checkbox", () => {
|
||||
it("renders with label text", () => {
|
||||
const field = makeField("agree");
|
||||
render(() => <Checkbox field={() => field} label="I agree" />);
|
||||
|
||||
expect(screen.getByText("I agree")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders checkbox with field name", () => {
|
||||
const field = makeField("terms");
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
expect(input).toHaveAttribute("id", "terms");
|
||||
expect(input).toHaveAttribute("name", "terms");
|
||||
});
|
||||
|
||||
it("reflects checked state", () => {
|
||||
const field = makeField("opt", true);
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
expect(input).toBeChecked();
|
||||
});
|
||||
|
||||
it("reflects unchecked state", () => {
|
||||
const field = makeField("opt", false);
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
expect(input).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("calls handleChange on change", async () => {
|
||||
const field = makeField("opt");
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
await fireEvent.change(input, { target: { checked: true } });
|
||||
expect(field.handleChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls handleBlur on blur", async () => {
|
||||
const field = makeField("opt");
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
await fireEvent.blur(input);
|
||||
expect(field.handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders disabled checkbox", () => {
|
||||
const field = makeField("opt");
|
||||
render(() => <Checkbox field={() => field} disabled />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows check icon styling when checked", () => {
|
||||
const field = makeField("opt", true);
|
||||
const { container } = render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const icon = container.querySelector(".fa-check");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass("text-main");
|
||||
});
|
||||
|
||||
it("shows transparent icon styling when unchecked", () => {
|
||||
const field = makeField("opt", false);
|
||||
const { container } = render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const icon = container.querySelector(".fa-check");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass("text-transparent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render } from "@solidjs/testing-library";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { FieldIndicator } from "../../../../src/ts/components/ui/form/FieldIndicator";
|
||||
|
||||
function makeField(overrides: {
|
||||
isValidating?: boolean;
|
||||
isTouched?: boolean;
|
||||
isValid?: boolean;
|
||||
isDefaultValue?: boolean;
|
||||
errors?: string[];
|
||||
hasWarning?: boolean;
|
||||
warnings?: string[];
|
||||
}) {
|
||||
return {
|
||||
state: {
|
||||
meta: {
|
||||
isValidating: overrides.isValidating ?? false,
|
||||
isTouched: overrides.isTouched ?? false,
|
||||
isValid: overrides.isValid ?? true,
|
||||
isDefaultValue: overrides.isDefaultValue ?? true,
|
||||
errors: overrides.errors ?? [],
|
||||
},
|
||||
},
|
||||
getMeta: () => ({
|
||||
hasWarning: overrides.hasWarning ?? false,
|
||||
warnings: overrides.warnings ?? [],
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("FieldIndicator", () => {
|
||||
it("shows loading spinner when validating", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator field={makeField({ isValidating: true })} />
|
||||
));
|
||||
expect(container.querySelector(".fa-circle-notch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error icon when touched and invalid", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator
|
||||
field={makeField({
|
||||
isTouched: true,
|
||||
isValid: false,
|
||||
errors: ["bad value"],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(container.querySelector(".fa-times")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning icon when has warning", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator
|
||||
field={makeField({
|
||||
hasWarning: true,
|
||||
warnings: ["weak"],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(
|
||||
container.querySelector(".fa-exclamation-triangle"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows success check when touched, valid, and not default", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator
|
||||
field={makeField({
|
||||
isTouched: true,
|
||||
isValid: true,
|
||||
isDefaultValue: false,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(container.querySelector(".fa-check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows nothing when untouched and not validating", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator field={makeField({})} />
|
||||
));
|
||||
expect(container.querySelector(".fa-times")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".fa-check")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
102
frontend/__tests__/components/ui/form/InputField.spec.tsx
Normal file
102
frontend/__tests__/components/ui/form/InputField.spec.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { InputField } from "../../../../src/ts/components/ui/form/InputField";
|
||||
|
||||
function makeField(name: string, value = "") {
|
||||
return {
|
||||
name,
|
||||
state: {
|
||||
value,
|
||||
meta: {
|
||||
isValidating: false,
|
||||
isTouched: false,
|
||||
isValid: true,
|
||||
isDefaultValue: true,
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
handleBlur: vi.fn(),
|
||||
handleChange: vi.fn(),
|
||||
getMeta: () => ({ hasWarning: false, warnings: [] }),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("InputField", () => {
|
||||
it("uses custom placeholder when provided", () => {
|
||||
const field = makeField("email");
|
||||
render(() => <InputField field={() => field} placeholder="Enter email" />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Enter email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to text type", () => {
|
||||
const field = makeField("name");
|
||||
render(() => <InputField field={() => field} />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("type", "text");
|
||||
});
|
||||
|
||||
it("uses custom type", () => {
|
||||
const field = makeField("password");
|
||||
const { container } = render(() => (
|
||||
<InputField field={() => field} type="password" />
|
||||
));
|
||||
|
||||
expect(container.querySelector("input")).toHaveAttribute(
|
||||
"type",
|
||||
"password",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls handleChange on input", async () => {
|
||||
const field = makeField("name");
|
||||
render(() => <InputField field={() => field} />);
|
||||
|
||||
await fireEvent.input(screen.getByRole("textbox"), {
|
||||
target: { value: "test" },
|
||||
});
|
||||
expect(field.handleChange).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("calls handleBlur on blur", async () => {
|
||||
const field = makeField("name");
|
||||
render(() => <InputField field={() => field} />);
|
||||
|
||||
await fireEvent.blur(screen.getByRole("textbox"));
|
||||
expect(field.handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onFocus callback", async () => {
|
||||
const field = makeField("name");
|
||||
const onFocus = vi.fn();
|
||||
render(() => <InputField field={() => field} onFocus={onFocus} />);
|
||||
|
||||
await fireEvent.focus(screen.getByRole("textbox"));
|
||||
expect(onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders disabled input", () => {
|
||||
const field = makeField("name");
|
||||
render(() => <InputField field={() => field} disabled />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows FieldIndicator when showIndicator is true", () => {
|
||||
const field = makeField("name");
|
||||
field.state.meta.isValidating = true;
|
||||
const { container } = render(() => (
|
||||
<InputField field={() => field} showIndicator />
|
||||
));
|
||||
|
||||
expect(container.querySelector(".fa-circle-notch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides FieldIndicator by default", () => {
|
||||
const field = makeField("name");
|
||||
const { container } = render(() => <InputField field={() => field} />);
|
||||
|
||||
expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
frontend/__tests__/components/ui/form/LabeledField.spec.tsx
Normal file
57
frontend/__tests__/components/ui/form/LabeledField.spec.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from "@solidjs/testing-library";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { LabeledField } from "../../../../src/ts/components/ui/form/LabeledField";
|
||||
|
||||
describe("LabeledField", () => {
|
||||
it("renders label text correctly", () => {
|
||||
render(() => (
|
||||
<LabeledField label="test label">
|
||||
<input />
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
expect(screen.getByText("test label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children correctly", () => {
|
||||
render(() => (
|
||||
<LabeledField label="test">
|
||||
<div data-testid="child">child content</div>
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders subtext when provided", () => {
|
||||
render(() => (
|
||||
<LabeledField label="test" subLabel="helper text">
|
||||
<input />
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
expect(screen.getByText("helper text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links label to input when id is provided", () => {
|
||||
const { container } = render(() => (
|
||||
<LabeledField label="test" id="test-id">
|
||||
<input id="test-id" />
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
const label = container.querySelector("label");
|
||||
expect(label).toHaveAttribute("for", "test-id");
|
||||
});
|
||||
|
||||
it("applies custom class to wrapper", () => {
|
||||
const { container } = render(() => (
|
||||
<LabeledField label="test" class="custom-wrapper-class">
|
||||
<input />
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
expect(container.firstChild).toHaveClass("custom-wrapper-class");
|
||||
});
|
||||
});
|
||||
74
frontend/__tests__/components/ui/form/SubmitButton.spec.tsx
Normal file
74
frontend/__tests__/components/ui/form/SubmitButton.spec.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from "@solidjs/testing-library";
|
||||
import { JSXElement } from "solid-js";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { SubmitButton } from "../../../../src/ts/components/ui/form/SubmitButton";
|
||||
|
||||
type FormState = {
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
isValid: boolean;
|
||||
isDirty: boolean;
|
||||
};
|
||||
|
||||
function makeForm(state: Partial<FormState> = {}) {
|
||||
const fullState: FormState = {
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
isDirty: true,
|
||||
...state,
|
||||
};
|
||||
|
||||
return {
|
||||
Subscribe: (props: {
|
||||
selector: (state: FormState) => FormState;
|
||||
children: (state: () => FormState) => JSXElement;
|
||||
}) => props.children(() => props.selector(fullState)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("SubmitButton", () => {
|
||||
it("renders enabled when form is dirty, valid, and can submit", () => {
|
||||
render(() => <SubmitButton form={makeForm()} text="Save" />);
|
||||
expect(screen.getByRole("button", { name: "Save" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders as submit type", () => {
|
||||
render(() => <SubmitButton form={makeForm()} text="Save" />);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("type", "submit");
|
||||
});
|
||||
|
||||
it("disables when form is not dirty", () => {
|
||||
render(() => (
|
||||
<SubmitButton form={makeForm({ isDirty: false })} text="Save" />
|
||||
));
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables when form cannot submit", () => {
|
||||
render(() => (
|
||||
<SubmitButton form={makeForm({ canSubmit: false })} text="Save" />
|
||||
));
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables when form is submitting", () => {
|
||||
render(() => (
|
||||
<SubmitButton form={makeForm({ isSubmitting: true })} text="Save" />
|
||||
));
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables when form is not valid", () => {
|
||||
render(() => (
|
||||
<SubmitButton form={makeForm({ isValid: false })} text="Save" />
|
||||
));
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables when disabled prop is true even if form is ready", () => {
|
||||
render(() => <SubmitButton form={makeForm()} text="Save" disabled />);
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
133
frontend/__tests__/components/ui/form/utils.spec.ts
Normal file
133
frontend/__tests__/components/ui/form/utils.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
fromSchema,
|
||||
handleResult,
|
||||
allFieldsMandatory,
|
||||
fieldMandatory,
|
||||
type ValidationResult,
|
||||
} from "../../../../src/ts/components/ui/form/utils";
|
||||
|
||||
describe("fromSchema", () => {
|
||||
const schema = z.string().min(3, "too short").max(10, "too long");
|
||||
const validate = fromSchema(schema);
|
||||
|
||||
it("returns undefined for valid value", () => {
|
||||
expect(validate({ value: "hello" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error messages for invalid value", () => {
|
||||
expect(validate({ value: "ab" })).toEqual(["too short"]);
|
||||
});
|
||||
|
||||
it("returns multiple error messages", () => {
|
||||
const numSchema = z.number().min(5, "too small").max(3, "too big");
|
||||
const v = fromSchema(numSchema);
|
||||
const result = v({ value: 4 });
|
||||
// 4 fails min(5) but passes max(3)? Actually 4 > 3, so both fail
|
||||
// number 4: min(5) fails, max(3) fails
|
||||
expect(result).toEqual(["too small", "too big"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleResult", () => {
|
||||
const mockSetMeta = vi.fn();
|
||||
|
||||
function makeField() {
|
||||
mockSetMeta.mockClear();
|
||||
return { setMeta: mockSetMeta } as any;
|
||||
}
|
||||
|
||||
it("returns undefined for undefined results", () => {
|
||||
expect(handleResult(makeField(), undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty results", () => {
|
||||
expect(handleResult(makeField(), [])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error messages and ignores warnings", () => {
|
||||
const results: ValidationResult[] = [
|
||||
{ type: "error", message: "bad email" },
|
||||
{ type: "error", message: "too short" },
|
||||
];
|
||||
expect(handleResult(makeField(), results)).toEqual([
|
||||
"bad email",
|
||||
"too short",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets warning meta on field", () => {
|
||||
const results: ValidationResult[] = [
|
||||
{ type: "warning", message: "weak password" },
|
||||
];
|
||||
const field = makeField();
|
||||
const result = handleResult(field, results);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockSetMeta).toHaveBeenCalledOnce();
|
||||
|
||||
const updater = mockSetMeta.mock.calls[0]![0];
|
||||
const newMeta = updater({ existing: true });
|
||||
expect(newMeta).toEqual({
|
||||
existing: true,
|
||||
hasWarning: true,
|
||||
warnings: ["weak password"],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles both errors and warnings", () => {
|
||||
const results: ValidationResult[] = [
|
||||
{ type: "warning", message: "not recommended" },
|
||||
{ type: "error", message: "invalid" },
|
||||
];
|
||||
const field = makeField();
|
||||
const result = handleResult(field, results);
|
||||
|
||||
expect(mockSetMeta).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual(["invalid"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allFieldsMandatory", () => {
|
||||
const validate = allFieldsMandatory<{ a: string; b: string }>();
|
||||
|
||||
it("returns undefined when all fields have values", () => {
|
||||
expect(validate({ value: { a: "x", b: "y" } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error when a field is empty string", () => {
|
||||
expect(validate({ value: { a: "x", b: "" } })).toBe(
|
||||
"all fields are mandatory",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns error when a field is undefined", () => {
|
||||
expect(validate({ value: { a: "x", b: undefined } as any })).toBe(
|
||||
"all fields are mandatory",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fieldMandatory", () => {
|
||||
it("returns undefined for non-empty value", () => {
|
||||
const validate = fieldMandatory();
|
||||
expect(validate({ value: "hello" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns default message for empty string", () => {
|
||||
const validate = fieldMandatory();
|
||||
expect(validate({ value: "" })).toBe("mandatory");
|
||||
});
|
||||
|
||||
it("returns default message for undefined", () => {
|
||||
const validate = fieldMandatory();
|
||||
expect(validate({ value: undefined })).toBe("mandatory");
|
||||
});
|
||||
|
||||
it("returns custom message", () => {
|
||||
const validate = fieldMandatory("required field");
|
||||
expect(validate({ value: "" })).toBe("required field");
|
||||
});
|
||||
});
|
||||
158
frontend/__tests__/components/ui/table/DataTable.spec.tsx
Normal file
158
frontend/__tests__/components/ui/table/DataTable.spec.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { DataTable } from "../../../../src/ts/components/ui/table/DataTable";
|
||||
|
||||
const [localStorage, setLocalStorage] = createSignal([]);
|
||||
vi.mock("../../../../src/ts/hooks/useLocalStorage", () => {
|
||||
return {
|
||||
useLocalStorage: () => {
|
||||
return [localStorage, setLocalStorage] as const;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const bpSignal = createSignal({
|
||||
xxs: true,
|
||||
sm: true,
|
||||
md: true,
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/ts/states/breakpoints", () => ({
|
||||
bp: () => bpSignal[0](),
|
||||
}));
|
||||
|
||||
type Person = {
|
||||
name: string;
|
||||
age: number;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: (info: any) => info.getValue(),
|
||||
meta: { maxBreakpoint: "sm" },
|
||||
},
|
||||
{
|
||||
id: "age",
|
||||
accessorKey: "age",
|
||||
header: "Age",
|
||||
cell: (info: any) => info.getValue(),
|
||||
meta: { breakpoint: "sm" },
|
||||
},
|
||||
];
|
||||
|
||||
const data: Person[] = [
|
||||
{ name: "Alice", age: 30 },
|
||||
{ name: "Bob", age: 20 },
|
||||
];
|
||||
|
||||
describe("DataTable", () => {
|
||||
beforeEach(() => {
|
||||
bpSignal[1]({
|
||||
xxs: true,
|
||||
sm: true,
|
||||
md: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders table headers and rows", () => {
|
||||
render(() => <DataTable id="people" columns={columns} data={data} />);
|
||||
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Age")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("30")).toBeInTheDocument();
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback when there is no data", () => {
|
||||
render(() => (
|
||||
<DataTable
|
||||
id="empty"
|
||||
columns={columns}
|
||||
data={[]}
|
||||
fallback={<div>No data</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText("No data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts rows when clicking a sortable header", async () => {
|
||||
render(() => <DataTable id="sorting" columns={columns} data={data} />);
|
||||
|
||||
const ageHeaderButton = screen.getByRole("button", { name: "Age" });
|
||||
const ageHeaderCell = ageHeaderButton.closest("th");
|
||||
|
||||
// Initial
|
||||
expect(ageHeaderCell).toHaveAttribute("aria-sort", "none");
|
||||
expect(ageHeaderCell?.querySelector("i")).toHaveClass("fa-fw");
|
||||
|
||||
// Descending
|
||||
await fireEvent.click(ageHeaderButton);
|
||||
expect(ageHeaderCell).toHaveAttribute("aria-sort", "descending");
|
||||
expect(ageHeaderCell?.querySelector("i")).toHaveClass(
|
||||
"fa-sort-down",
|
||||
"fas",
|
||||
"fa-fw",
|
||||
);
|
||||
expect(localStorage()).toEqual([
|
||||
{
|
||||
desc: true,
|
||||
id: "age",
|
||||
},
|
||||
]);
|
||||
|
||||
let rows = screen.getAllByRole("row");
|
||||
expect(rows[1]).toHaveTextContent("Alice"); // age 30
|
||||
expect(rows[2]).toHaveTextContent("Bob"); // age 20
|
||||
|
||||
// Ascending
|
||||
await fireEvent.click(ageHeaderButton);
|
||||
expect(ageHeaderCell).toHaveAttribute("aria-sort", "ascending");
|
||||
expect(ageHeaderCell?.querySelector("i")).toHaveClass(
|
||||
"fa-sort-up",
|
||||
"fas",
|
||||
"fa-fw",
|
||||
);
|
||||
expect(localStorage()).toEqual([
|
||||
{
|
||||
desc: false,
|
||||
id: "age",
|
||||
},
|
||||
]);
|
||||
|
||||
rows = screen.getAllByRole("row");
|
||||
expect(rows[1]).toHaveTextContent("Bob");
|
||||
expect(rows[2]).toHaveTextContent("Alice");
|
||||
|
||||
//back to initial
|
||||
await fireEvent.click(ageHeaderButton);
|
||||
expect(ageHeaderCell).toHaveAttribute("aria-sort", "none");
|
||||
expect(localStorage()).toEqual([]);
|
||||
});
|
||||
|
||||
it("hides columns based on breakpoint visibility", () => {
|
||||
bpSignal[1]({
|
||||
xxs: true,
|
||||
sm: false,
|
||||
md: false,
|
||||
});
|
||||
|
||||
render(() => <DataTable id="breakpoints" columns={columns} data={data} />);
|
||||
const nameHeader = screen.getByRole("button", {
|
||||
name: "Name",
|
||||
}).parentElement;
|
||||
const ageHeader = screen.getByRole("button", { name: "Age" }).parentElement;
|
||||
|
||||
expect(nameHeader).not.toHaveClass("hidden");
|
||||
expect(nameHeader).toHaveClass("sm:hidden");
|
||||
expect(ageHeader).toHaveClass("hidden sm:table-cell");
|
||||
});
|
||||
});
|
||||
173
frontend/__tests__/controllers/preset-controller.spec.ts
Normal file
173
frontend/__tests__/controllers/preset-controller.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import * as PresetController from "../../src/ts/controllers/preset-controller";
|
||||
import { Preset } from "@monkeytype/schemas/presets";
|
||||
import * as DB from "../../src/ts/db";
|
||||
import { setConfig } from "../../src/ts/config/setters";
|
||||
import { Config } from "../../src/ts/config/store";
|
||||
import * as Lifecycle from "../../src/ts/config/lifecycle";
|
||||
import * as ConfigUtils from "../../src/ts/config/utils";
|
||||
import * as Persistence from "../../src/ts/config/persistence";
|
||||
import * as Notifications from "../../src/ts/states/notifications";
|
||||
import * as TestLogic from "../../src/ts/test/test-logic";
|
||||
import * as TagController from "../../src/ts/controllers/tag-controller";
|
||||
|
||||
describe("PresetController", () => {
|
||||
describe("apply", () => {
|
||||
vi.mock("../../src/ts/test/test-logic", () => ({
|
||||
restart: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/ts/test/pace-caret", () => ({
|
||||
//
|
||||
}));
|
||||
const dbGetSnapshotMock = vi.spyOn(DB, "getSnapshot");
|
||||
const configApplyMock = vi.spyOn(Lifecycle, "applyConfig");
|
||||
const configSaveFullConfigMock = vi.spyOn(
|
||||
Persistence,
|
||||
"saveFullConfigToLocalStorage",
|
||||
);
|
||||
const configGetConfigChangesMock = vi.spyOn(
|
||||
ConfigUtils,
|
||||
"getConfigChanges",
|
||||
);
|
||||
const notificationAddMock = vi.spyOn(
|
||||
Notifications,
|
||||
"showSuccessNotification",
|
||||
);
|
||||
const testRestartMock = vi.spyOn(TestLogic, "restart");
|
||||
const tagControllerClearMock = vi.spyOn(TagController, "clear");
|
||||
const tagControllerSetMock = vi.spyOn(TagController, "set");
|
||||
const tagControllerSaveActiveMock = vi.spyOn(
|
||||
TagController,
|
||||
"saveActiveToLocalStorage",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
[
|
||||
dbGetSnapshotMock,
|
||||
configApplyMock,
|
||||
configSaveFullConfigMock,
|
||||
configGetConfigChangesMock,
|
||||
notificationAddMock,
|
||||
testRestartMock,
|
||||
tagControllerClearMock,
|
||||
tagControllerSetMock,
|
||||
tagControllerSaveActiveMock,
|
||||
].forEach((it) => it.mockClear());
|
||||
|
||||
configApplyMock.mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should apply for full preset", async () => {
|
||||
//GIVEN
|
||||
const preset = givenPreset({ config: { punctuation: true } });
|
||||
|
||||
//WHEN
|
||||
await PresetController.apply(preset._id);
|
||||
|
||||
//THEN
|
||||
expect(configApplyMock).toHaveBeenCalledWith(preset.config);
|
||||
expect(tagControllerClearMock).toHaveBeenCalled();
|
||||
expect(testRestartMock).toHaveBeenCalled();
|
||||
expect(notificationAddMock).toHaveBeenCalledWith("Preset applied", {
|
||||
durationMs: 2000,
|
||||
});
|
||||
expect(configSaveFullConfigMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should apply for full preset with tags", async () => {
|
||||
//GIVEN
|
||||
const preset = givenPreset({
|
||||
config: { tags: ["tagOne", "tagTwo"] },
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await PresetController.apply(preset._id);
|
||||
|
||||
//THEN
|
||||
expect(tagControllerClearMock).toHaveBeenCalled();
|
||||
expect(tagControllerSetMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tagOne",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(tagControllerSetMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tagTwo",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(tagControllerSaveActiveMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore unknown preset", async () => {
|
||||
//WHEN
|
||||
await PresetController.apply("unknown");
|
||||
//THEN
|
||||
expect(notificationAddMock).not.toHaveBeenCalled();
|
||||
expect(configApplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should apply for partial preset", async () => {
|
||||
//GIVEN
|
||||
const preset = givenPreset({
|
||||
config: { punctuation: true },
|
||||
settingGroups: ["test"],
|
||||
});
|
||||
|
||||
setConfig("numbers", true);
|
||||
const oldConfig = structuredClone(Config);
|
||||
|
||||
//WHEN
|
||||
await PresetController.apply(preset._id);
|
||||
|
||||
//THEN
|
||||
expect(configApplyMock).toHaveBeenCalledWith({
|
||||
...oldConfig,
|
||||
numbers: true,
|
||||
punctuation: true,
|
||||
});
|
||||
expect(testRestartMock).toHaveBeenCalled();
|
||||
expect(notificationAddMock).toHaveBeenCalledWith("Preset applied", {
|
||||
durationMs: 2000,
|
||||
});
|
||||
expect(configSaveFullConfigMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should apply for partial preset with tags", async () => {
|
||||
//GIVEN
|
||||
const preset = givenPreset({
|
||||
config: { tags: ["tagOne", "tagTwo"] },
|
||||
settingGroups: ["test", "behavior"],
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await PresetController.apply(preset._id);
|
||||
|
||||
//THEN
|
||||
expect(tagControllerClearMock).toHaveBeenCalled();
|
||||
expect(tagControllerSetMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tagOne",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(tagControllerSetMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tagTwo",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(tagControllerSaveActiveMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const givenPreset = (partialPreset: Partial<Preset>): Preset => {
|
||||
const preset: Preset = {
|
||||
_id: "1",
|
||||
...partialPreset,
|
||||
} as any;
|
||||
dbGetSnapshotMock.mockReturnValue({ presets: [preset] } as any);
|
||||
return preset;
|
||||
};
|
||||
});
|
||||
});
|
||||
299
frontend/__tests__/controllers/url-handler.spec.ts
Normal file
299
frontend/__tests__/controllers/url-handler.spec.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Difficulty, Mode, Mode2 } from "@monkeytype/schemas/shared";
|
||||
import { compressToURI } from "lz-ts";
|
||||
import * as UpdateConfig from "../../src/ts/config/setters";
|
||||
import * as Notifications from "../../src/ts/states/notifications";
|
||||
import * as TestLogic from "../../src/ts/test/test-logic";
|
||||
import * as TestState from "../../src/ts/test/test-state";
|
||||
import * as Misc from "../../src/ts/utils/misc";
|
||||
import { FunboxName } from "@monkeytype/schemas/configs";
|
||||
import { CustomTextSettings } from "@monkeytype/schemas/results";
|
||||
import { loadTestSettingsFromUrl } from "../../src/ts/controllers/url-handler";
|
||||
|
||||
//mock modules to avoid dependencies
|
||||
vi.mock("../../src/ts/test/test-logic", () => ({
|
||||
restart: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("url-handler", () => {
|
||||
describe("loadTestSettingsFromUrl", () => {
|
||||
const findGetParameterMock = vi.spyOn(Misc, "findGetParameter");
|
||||
|
||||
const setConfigMock = vi.spyOn(UpdateConfig, "setConfig");
|
||||
const setSelectedQuoteIdMock = vi.spyOn(TestState, "setSelectedQuoteId");
|
||||
const restartTestMock = vi.spyOn(TestLogic, "restart");
|
||||
const notifySuccessMock = vi.spyOn(
|
||||
Notifications,
|
||||
"showSuccessNotification",
|
||||
);
|
||||
const notifyMock = vi.spyOn(Notifications, "showNoticeNotification");
|
||||
|
||||
beforeEach(() => {
|
||||
[
|
||||
setConfigMock,
|
||||
findGetParameterMock,
|
||||
setSelectedQuoteIdMock,
|
||||
restartTestMock,
|
||||
notifySuccessMock,
|
||||
notifyMock,
|
||||
].forEach((it) => it.mockClear());
|
||||
|
||||
findGetParameterMock.mockImplementation((override) => override);
|
||||
});
|
||||
|
||||
it("handles null", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue("null");
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("handles mode2 as number", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(
|
||||
urlData({ mode: "time", mode2: 60 }),
|
||||
);
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith("mode", "time", {
|
||||
nosave: true,
|
||||
});
|
||||
expect(setConfigMock).toHaveBeenCalledWith("time", 60, {
|
||||
nosave: true,
|
||||
});
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets time", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(
|
||||
urlData({ mode: "time", mode2: "30" }),
|
||||
);
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith("mode", "time", {
|
||||
nosave: true,
|
||||
});
|
||||
expect(setConfigMock).toHaveBeenCalledWith("time", 30, {
|
||||
nosave: true,
|
||||
});
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets word count", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(
|
||||
urlData({ mode: "words", mode2: "50" }),
|
||||
);
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith("mode", "words", {
|
||||
nosave: true,
|
||||
});
|
||||
expect(setConfigMock).toHaveBeenCalledWith("words", 50, {
|
||||
nosave: true,
|
||||
});
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets quote length", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(
|
||||
urlData({ mode: "quote", mode2: "512" }),
|
||||
);
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith("mode", "quote", {
|
||||
nosave: true,
|
||||
});
|
||||
expect(setConfigMock).toHaveBeenCalledWith("quoteLength", [-2]);
|
||||
expect(setSelectedQuoteIdMock).toHaveBeenCalledWith(512);
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets punctuation", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(urlData({ punctuation: true }));
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith("punctuation", true, {
|
||||
nosave: true,
|
||||
});
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets numbers", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(urlData({ numbers: false }));
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith("numbers", false, {
|
||||
nosave: true,
|
||||
});
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets language", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(urlData({ language: "english" }));
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith("language", "english", {
|
||||
nosave: true,
|
||||
});
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets difficulty", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(urlData({ difficulty: "master" }));
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith("difficulty", "master", {
|
||||
nosave: true,
|
||||
});
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets funbox", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(
|
||||
urlData({ funbox: ["crt", "choo_choo"] }),
|
||||
);
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith(
|
||||
"funbox",
|
||||
["crt", "choo_choo"],
|
||||
{
|
||||
nosave: true,
|
||||
},
|
||||
);
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("sets funbox legacy", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(
|
||||
urlData({ funbox: "crt#choo_choo" }),
|
||||
);
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(setConfigMock).toHaveBeenCalledWith(
|
||||
"funbox",
|
||||
["crt", "choo_choo"],
|
||||
{
|
||||
nosave: true,
|
||||
},
|
||||
);
|
||||
expect(restartTestMock).toHaveBeenCalled();
|
||||
});
|
||||
it("adds notification", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(
|
||||
urlData({
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
customText: {
|
||||
text: ["abcabc"],
|
||||
limit: { value: 5, mode: "time" },
|
||||
mode: "random",
|
||||
pipeDelimiter: true,
|
||||
},
|
||||
punctuation: true,
|
||||
numbers: true,
|
||||
language: "english",
|
||||
difficulty: "master",
|
||||
funbox: ["ascii", "crt"],
|
||||
}),
|
||||
);
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(notifySuccessMock).toHaveBeenCalledWith(expect.anything(), {
|
||||
durationMs: 10000,
|
||||
useInnerHtml: true,
|
||||
});
|
||||
});
|
||||
it("rejects invalid values", () => {
|
||||
//GIVEN
|
||||
findGetParameterMock.mockReturnValue(
|
||||
urlData({
|
||||
mode: "invalidMode",
|
||||
mode2: "invalidMode2",
|
||||
customText: {
|
||||
text: "invalid",
|
||||
limit: "invalid",
|
||||
mode: "invalid",
|
||||
pipeDelimiter: "invalid",
|
||||
},
|
||||
punctuation: "invalid",
|
||||
numbers: "invalid",
|
||||
language: "invalid",
|
||||
difficulty: "invalid",
|
||||
funbox: ["invalid"],
|
||||
} as any),
|
||||
);
|
||||
|
||||
//WHEN
|
||||
loadTestSettingsFromUrl("");
|
||||
|
||||
//THEN
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
`Failed to load test settings from URL: JSON does not match schema: "0" invalid enum value. expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'invalidmode', "1" needs to be a number or a number represented as a string e.g. "10"., "2.mode" invalid enum value. expected 'repeat' | 'random' | 'shuffle', received 'invalid', "2.pipeDelimiter" expected boolean, received string, "2.limit" expected object, received string, "2.text" expected array, received string, "3" expected boolean, received string, "4" expected boolean, received string, "6" invalid enum value. expected 'normal' | 'expert' | 'master', received 'invalid', "7" invalid input`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const urlData = (
|
||||
data: Partial<{
|
||||
mode: Mode | undefined;
|
||||
mode2: Mode2<any> | number;
|
||||
customText: CustomTextSettings;
|
||||
punctuation: boolean;
|
||||
numbers: boolean;
|
||||
language: string;
|
||||
difficulty: Difficulty;
|
||||
funbox: FunboxName[] | string;
|
||||
}>,
|
||||
): string => {
|
||||
return compressToURI(
|
||||
JSON.stringify([
|
||||
data.mode ?? null,
|
||||
data.mode2 ?? null,
|
||||
data.customText ?? null,
|
||||
data.punctuation ?? null,
|
||||
data.numbers ?? null,
|
||||
data.language ?? null,
|
||||
data.difficulty ?? null,
|
||||
data.funbox ?? null,
|
||||
]),
|
||||
);
|
||||
};
|
||||
74
frontend/__tests__/elements/account/result-filters.spec.ts
Normal file
74
frontend/__tests__/elements/account/result-filters.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import defaultResultFilters from "../../../src/ts/constants/default-result-filters";
|
||||
import { mergeWithDefaultFilters } from "../../../src/ts/elements/account/result-filters";
|
||||
|
||||
describe("result-filters.ts", () => {
|
||||
describe("mergeWithDefaultFilters", () => {
|
||||
it("should merge with default filters correctly", () => {
|
||||
const tests = [
|
||||
{
|
||||
input: {
|
||||
pb: {
|
||||
no: false,
|
||||
yes: false,
|
||||
},
|
||||
},
|
||||
expected: () => {
|
||||
const expected = defaultResultFilters;
|
||||
expected.pb.no = false;
|
||||
expected.pb.yes = false;
|
||||
return expected;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
words: {
|
||||
"10": false,
|
||||
},
|
||||
},
|
||||
expected: () => {
|
||||
const expected = defaultResultFilters;
|
||||
expected.words["10"] = false;
|
||||
return expected;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
blah: true,
|
||||
},
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: null,
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: undefined,
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {},
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
];
|
||||
tests.forEach((test) => {
|
||||
const merged = mergeWithDefaultFilters(test.input as any);
|
||||
expect(merged).toEqual(test.expected());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1271
frontend/__tests__/elements/test-activity-calendar.spec.ts
Normal file
1271
frontend/__tests__/elements/test-activity-calendar.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
frontend/__tests__/global-setup.ts
Normal file
3
frontend/__tests__/global-setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const setup = (): void => {
|
||||
process.env.TZ = "UTC";
|
||||
};
|
||||
81
frontend/__tests__/hooks/createEvent.spec.ts
Normal file
81
frontend/__tests__/hooks/createEvent.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createRoot } from "solid-js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createEvent } from "../../src/ts/hooks/createEvent";
|
||||
|
||||
describe("createEvent", () => {
|
||||
it("dispatch notifies subscribers", () => {
|
||||
const event = createEvent<string>();
|
||||
const fn = vi.fn();
|
||||
event.subscribe(fn);
|
||||
event.dispatch("hello");
|
||||
expect(fn).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("dispatch notifies multiple subscribers", () => {
|
||||
const event = createEvent<number>();
|
||||
const fn1 = vi.fn();
|
||||
const fn2 = vi.fn();
|
||||
event.subscribe(fn1);
|
||||
event.subscribe(fn2);
|
||||
event.dispatch(42);
|
||||
expect(fn1).toHaveBeenCalledWith(42);
|
||||
expect(fn2).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it("dispatch with no type arg requires no arguments", () => {
|
||||
const event = createEvent();
|
||||
const fn = vi.fn();
|
||||
event.subscribe(fn);
|
||||
event.dispatch();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("subscribe returns an unsubscribe function", () => {
|
||||
const event = createEvent<string>();
|
||||
const fn = vi.fn();
|
||||
const unsub = event.subscribe(fn);
|
||||
event.dispatch("a");
|
||||
unsub();
|
||||
event.dispatch("b");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("two independent events do not share state", () => {
|
||||
const eventA = createEvent<string>();
|
||||
const eventB = createEvent<string>();
|
||||
const fnA = vi.fn();
|
||||
const fnB = vi.fn();
|
||||
eventA.subscribe(fnA);
|
||||
eventB.subscribe(fnB);
|
||||
eventA.dispatch("a");
|
||||
expect(fnA).toHaveBeenCalledWith("a");
|
||||
expect(fnB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("useListener auto-unsubscribes on dispose", () => {
|
||||
const event = createEvent<string>();
|
||||
const fn = vi.fn();
|
||||
createRoot((dispose) => {
|
||||
event.useListener(fn);
|
||||
event.dispatch("inside");
|
||||
dispose();
|
||||
});
|
||||
event.dispatch("outside");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("inside");
|
||||
});
|
||||
|
||||
it("subscriber errors do not prevent other subscribers from running", () => {
|
||||
const event = createEvent<string>();
|
||||
const fn1 = vi.fn(() => {
|
||||
throw new Error("oops");
|
||||
});
|
||||
const fn2 = vi.fn();
|
||||
event.subscribe(fn1);
|
||||
event.subscribe(fn2);
|
||||
event.dispatch("test");
|
||||
expect(fn1).toHaveBeenCalled();
|
||||
expect(fn2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
102
frontend/__tests__/hooks/createSignalWithSetters.spec.ts
Normal file
102
frontend/__tests__/hooks/createSignalWithSetters.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRoot } from "solid-js";
|
||||
import { createSignalWithSetters } from "../../src/ts/hooks/createSignalWithSetters";
|
||||
|
||||
describe("createSignalWithSetters", () => {
|
||||
it("returns default value from getter", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count] = createSignalWithSetters(42)({});
|
||||
expect(count()).toBe(42);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes raw set on the setters object", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count, { set }] = createSignalWithSetters(0)({});
|
||||
set(7);
|
||||
expect(count()).toBe(7);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("raw set accepts an updater function", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count, { set }] = createSignalWithSetters(3)({});
|
||||
set((prev) => prev * 2);
|
||||
expect(count()).toBe(6);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls a no-arg named setter", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count, { increment }] = createSignalWithSetters(0)({
|
||||
increment: (set) => set((n) => n + 1),
|
||||
});
|
||||
increment();
|
||||
expect(count()).toBe(1);
|
||||
increment();
|
||||
expect(count()).toBe(2);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls a named setter with custom args", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count, { addBy }] = createSignalWithSetters(0)({
|
||||
addBy: (set, amount: number) => set((n) => n + amount),
|
||||
});
|
||||
addBy(5);
|
||||
expect(count()).toBe(5);
|
||||
addBy(3);
|
||||
expect(count()).toBe(8);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("supports multiple named setters independently", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count, { increment, decrement, reset }] = createSignalWithSetters(
|
||||
10,
|
||||
)({
|
||||
increment: (set) => set((n) => n + 1),
|
||||
decrement: (set) => set((n) => n - 1),
|
||||
reset: (set) => set(0),
|
||||
});
|
||||
increment();
|
||||
expect(count()).toBe(11);
|
||||
decrement();
|
||||
decrement();
|
||||
expect(count()).toBe(9);
|
||||
reset();
|
||||
expect(count()).toBe(0);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("works with non-primitive default values", () => {
|
||||
createRoot((dispose) => {
|
||||
const [state, { setName }] = createSignalWithSetters({ name: "Alice" })({
|
||||
setName: (set, name: string) => set((prev) => ({ ...prev, name })),
|
||||
});
|
||||
setName("Bob");
|
||||
expect(state().name).toBe("Bob");
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("raw set and named setters share the same underlying signal", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count, { increment, set }] = createSignalWithSetters(0)({
|
||||
increment: (set) => set((n) => n + 1),
|
||||
});
|
||||
increment();
|
||||
set(100);
|
||||
expect(count()).toBe(100);
|
||||
increment();
|
||||
expect(count()).toBe(101);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
364
frontend/__tests__/input/helpers/fail-or-finish.spec.ts
Normal file
364
frontend/__tests__/input/helpers/fail-or-finish.spec.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
|
||||
import {
|
||||
checkIfFailedDueToMinBurst,
|
||||
checkIfFailedDueToDifficulty,
|
||||
checkIfFinished,
|
||||
} from "../../../src/ts/input/helpers/fail-or-finish";
|
||||
import { __testing } from "../../../src/ts/config/testing";
|
||||
import * as Misc from "../../../src/ts/utils/misc";
|
||||
import * as TestLogic from "../../../src/ts/test/test-logic";
|
||||
import * as Strings from "../../../src/ts/utils/strings";
|
||||
|
||||
const { replaceConfig } = __testing;
|
||||
|
||||
vi.mock("../../../src/ts/utils/misc", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../src/ts/utils/misc")>();
|
||||
return {
|
||||
...actual,
|
||||
whorf: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/ts/test/test-logic", () => ({
|
||||
areAllTestWordsGenerated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/ts/utils/strings", () => ({
|
||||
isSpace: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("checkIfFailedDueToMinBurst", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
replaceConfig({
|
||||
minBurst: "off",
|
||||
mode: "time",
|
||||
minBurstCustomSpeed: 100,
|
||||
});
|
||||
(Misc.whorf as any).mockReturnValue(0);
|
||||
(TestLogic.areAllTestWordsGenerated as any).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
replaceConfig({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
desc: "returns false if minBurst is off",
|
||||
config: { minBurst: "off" },
|
||||
lastBurst: 50,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "returns false if lastBurst is null",
|
||||
config: { minBurst: "fixed" },
|
||||
lastBurst: null,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "returns true if fixed burst is too slow",
|
||||
config: { minBurst: "fixed", minBurstCustomSpeed: 100 },
|
||||
lastBurst: 99,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "returns false if fixed burst is fast enough",
|
||||
config: { minBurst: "fixed", minBurstCustomSpeed: 100 },
|
||||
lastBurst: 100,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "returns true if flex burst is too slow",
|
||||
config: { minBurst: "flex", minBurstCustomSpeed: 100 },
|
||||
lastBurst: 49,
|
||||
whorfRet: 50,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "returns false if flex burst is fast enough",
|
||||
config: { minBurst: "flex", minBurstCustomSpeed: 100 },
|
||||
lastBurst: 50,
|
||||
whorfRet: 50,
|
||||
expected: false,
|
||||
},
|
||||
])("$desc", ({ config, lastBurst, whorfRet, expected }) => {
|
||||
replaceConfig(config as any);
|
||||
if (whorfRet !== undefined) {
|
||||
(Misc.whorf as any).mockReturnValue(whorfRet);
|
||||
}
|
||||
|
||||
const result = checkIfFailedDueToMinBurst({
|
||||
testInputWithData: "test",
|
||||
currentWord: "test",
|
||||
lastBurst,
|
||||
});
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses correct length for whorf calculation in zen mode", () => {
|
||||
replaceConfig({ minBurst: "flex", mode: "zen", minBurstCustomSpeed: 100 });
|
||||
checkIfFailedDueToMinBurst({
|
||||
testInputWithData: "zeninput",
|
||||
currentWord: "ignored",
|
||||
lastBurst: 50,
|
||||
});
|
||||
expect(Misc.whorf).toHaveBeenCalledWith(100, 8);
|
||||
});
|
||||
|
||||
it("uses correct length for whorf calculation in normal mode", () => {
|
||||
replaceConfig({ minBurst: "flex", mode: "time", minBurstCustomSpeed: 100 });
|
||||
checkIfFailedDueToMinBurst({
|
||||
testInputWithData: "input",
|
||||
currentWord: "target",
|
||||
lastBurst: 50,
|
||||
});
|
||||
expect(Misc.whorf).toHaveBeenCalledWith(100, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkIfFailedDueToDifficulty", () => {
|
||||
beforeEach(() => {
|
||||
replaceConfig({
|
||||
mode: "time",
|
||||
difficulty: "normal",
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
replaceConfig({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
desc: "zen mode, master - never fails",
|
||||
config: { mode: "zen", difficulty: "master" },
|
||||
correct: false,
|
||||
spaceOrNewline: true,
|
||||
input: "hello",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "zen mode - never fails",
|
||||
config: { mode: "zen", difficulty: "normal" },
|
||||
correct: false,
|
||||
spaceOrNewline: true,
|
||||
input: "hello",
|
||||
expected: false,
|
||||
},
|
||||
//
|
||||
{
|
||||
desc: "normal typing incorrect- never fails",
|
||||
config: { difficulty: "normal" },
|
||||
correct: false,
|
||||
spaceOrNewline: false,
|
||||
input: "hello",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "normal typing space incorrect - never fails",
|
||||
config: { difficulty: "normal" },
|
||||
correct: false,
|
||||
spaceOrNewline: true,
|
||||
input: "hello",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "normal typing correct - never fails",
|
||||
config: { difficulty: "normal" },
|
||||
correct: true,
|
||||
spaceOrNewline: false,
|
||||
input: "hello",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "normal typing space correct - never fails",
|
||||
config: { difficulty: "normal" },
|
||||
correct: true,
|
||||
spaceOrNewline: true,
|
||||
input: "hello",
|
||||
expected: false,
|
||||
},
|
||||
//
|
||||
{
|
||||
desc: "expert - fail if incorrect space",
|
||||
config: { difficulty: "expert" },
|
||||
correct: false,
|
||||
spaceOrNewline: true,
|
||||
input: "he",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "expert - dont fail if space is the first character",
|
||||
config: { difficulty: "expert" },
|
||||
correct: false,
|
||||
spaceOrNewline: true,
|
||||
input: " ",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "expert: - dont fail if just typing",
|
||||
config: { difficulty: "expert" },
|
||||
correct: false,
|
||||
spaceOrNewline: false,
|
||||
input: "h",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "expert: - dont fail if just typing",
|
||||
config: { difficulty: "expert" },
|
||||
correct: true,
|
||||
spaceOrNewline: false,
|
||||
input: "h",
|
||||
expected: false,
|
||||
},
|
||||
//
|
||||
{
|
||||
desc: "master - fail if incorrect char",
|
||||
config: { difficulty: "master" },
|
||||
correct: false,
|
||||
spaceOrNewline: false,
|
||||
input: "h",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "master - fail if incorrect first space",
|
||||
config: { difficulty: "master" },
|
||||
correct: true,
|
||||
spaceOrNewline: true,
|
||||
input: " ",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "master - dont fail if correct char",
|
||||
config: { difficulty: "master" },
|
||||
correct: true,
|
||||
spaceOrNewline: false,
|
||||
input: "a",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "master - dont fail if correct space",
|
||||
config: { difficulty: "master" },
|
||||
correct: true,
|
||||
spaceOrNewline: true,
|
||||
input: " ",
|
||||
expected: false,
|
||||
},
|
||||
])("$desc", ({ config, correct, spaceOrNewline, input, expected }) => {
|
||||
replaceConfig(config as any);
|
||||
const result = checkIfFailedDueToDifficulty({
|
||||
testInputWithData: input,
|
||||
correct,
|
||||
spaceOrNewline,
|
||||
});
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkIfFinished", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
replaceConfig({
|
||||
quickEnd: false,
|
||||
stopOnError: "off",
|
||||
});
|
||||
(Strings.isSpace as any).mockReturnValue(false);
|
||||
(TestLogic.areAllTestWordsGenerated as any).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
replaceConfig({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
desc: "false if not all words typed",
|
||||
allWordsTyped: false,
|
||||
testInputWithData: "word",
|
||||
currentWord: "word",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "false if not all words generated, but on the last word",
|
||||
allWordsGenerated: false,
|
||||
allWordsTyped: true,
|
||||
testInputWithData: "word",
|
||||
currentWord: "word",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "true if last word is correct",
|
||||
allWordsTyped: true,
|
||||
testInputWithData: "word",
|
||||
currentWord: "word",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "true if quickEnd enabled and lengths match",
|
||||
allWordsTyped: true,
|
||||
testInputWithData: "asdf",
|
||||
currentWord: "word",
|
||||
config: { quickEnd: true },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "false if quickEnd disabled and lengths match",
|
||||
allWordsTyped: true,
|
||||
testInputWithData: "asdf",
|
||||
currentWord: "word",
|
||||
config: { quickEnd: false },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "true if space on the last word",
|
||||
allWordsTyped: true,
|
||||
testInputWithData: "wo ",
|
||||
currentWord: "word",
|
||||
shouldGoToNextWord: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "false if still typing, quickend disabled",
|
||||
allWordsTyped: true,
|
||||
testInputWithData: "wordwordword",
|
||||
currentWord: "word",
|
||||
expected: false,
|
||||
},
|
||||
] as {
|
||||
desc: string;
|
||||
allWordsTyped: boolean;
|
||||
allWordsGenerated?: boolean;
|
||||
shouldGoToNextWord: boolean;
|
||||
testInputWithData: string;
|
||||
currentWord: string;
|
||||
config?: Record<string, any>;
|
||||
isSpace?: boolean;
|
||||
expected: boolean;
|
||||
}[])(
|
||||
"$desc",
|
||||
({
|
||||
allWordsTyped,
|
||||
allWordsGenerated,
|
||||
shouldGoToNextWord,
|
||||
testInputWithData,
|
||||
currentWord,
|
||||
config,
|
||||
expected,
|
||||
}) => {
|
||||
if (config) replaceConfig(config as any);
|
||||
|
||||
const result = checkIfFinished({
|
||||
shouldGoToNextWord,
|
||||
testInputWithData,
|
||||
currentWord,
|
||||
allWordsTyped,
|
||||
allWordsGenerated: allWordsGenerated ?? true,
|
||||
});
|
||||
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
277
frontend/__tests__/input/helpers/validation.spec.ts
Normal file
277
frontend/__tests__/input/helpers/validation.spec.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
|
||||
import {
|
||||
isCharCorrect,
|
||||
shouldInsertSpaceCharacter,
|
||||
} from "../../../src/ts/input/helpers/validation";
|
||||
import { __testing } from "../../../src/ts/config/testing";
|
||||
import * as FunboxList from "../../../src/ts/test/funbox/list";
|
||||
import * as Strings from "../../../src/ts/utils/strings";
|
||||
|
||||
const { replaceConfig } = __testing;
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../../src/ts/test/funbox/list", () => ({
|
||||
findSingleActiveFunboxWithFunction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/ts/utils/strings", async () => {
|
||||
const actual = await vi.importActual<typeof Strings>(
|
||||
"../../../src/ts/utils/strings",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
areCharactersVisuallyEqual: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("isCharCorrect", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset Config defaults
|
||||
replaceConfig({
|
||||
mode: "words",
|
||||
language: "english",
|
||||
stopOnError: "off",
|
||||
difficulty: "normal",
|
||||
strictSpace: false,
|
||||
});
|
||||
(FunboxList.findSingleActiveFunboxWithFunction as any).mockReturnValue(
|
||||
null,
|
||||
);
|
||||
(Strings.areCharactersVisuallyEqual as any).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
replaceConfig({});
|
||||
});
|
||||
|
||||
describe("Zen Mode", () => {
|
||||
it("always returns true", () => {
|
||||
replaceConfig({ mode: "zen" });
|
||||
expect(
|
||||
isCharCorrect({
|
||||
data: "a",
|
||||
inputValue: "test",
|
||||
targetWord: "word",
|
||||
correctShiftUsed: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Shift Key", () => {
|
||||
it("returns false if correct shift was not used", () => {
|
||||
expect(
|
||||
isCharCorrect({
|
||||
data: "A",
|
||||
inputValue: "test",
|
||||
targetWord: "testA",
|
||||
correctShiftUsed: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Space Handling", () => {
|
||||
it.each([
|
||||
["returns true at the end of a correct word", " ", "word", "word", true],
|
||||
[
|
||||
"returns false at the end of an incorrect word",
|
||||
" ",
|
||||
"worx",
|
||||
"word",
|
||||
false,
|
||||
],
|
||||
["returns false in the middle of a word", " ", "wor", "word", false],
|
||||
["returns false at the start of a word", " ", "", "word", false],
|
||||
[
|
||||
"returns false when longer than a word",
|
||||
" ",
|
||||
"wordwordword",
|
||||
"word",
|
||||
false,
|
||||
],
|
||||
])("%s", (_desc, char, input, word, expected) => {
|
||||
expect(
|
||||
isCharCorrect({
|
||||
data: char,
|
||||
inputValue: input,
|
||||
targetWord: word,
|
||||
correctShiftUsed: true,
|
||||
}),
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Standard Matching", () => {
|
||||
it.each([
|
||||
["a", "te", "tea", true],
|
||||
["b", "te", "tea", false],
|
||||
["x", "tea", "tea", false],
|
||||
])(
|
||||
"char '%s' for input '%s' (current word '%s') -> %s",
|
||||
(char, input, word, expected) => {
|
||||
expect(
|
||||
isCharCorrect({
|
||||
data: char,
|
||||
inputValue: input,
|
||||
targetWord: word,
|
||||
correctShiftUsed: true,
|
||||
}),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws error if data is undefined", () => {
|
||||
expect(() =>
|
||||
isCharCorrect({
|
||||
data: undefined as any,
|
||||
inputValue: "val",
|
||||
targetWord: "word",
|
||||
correctShiftUsed: true,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldInsertSpaceCharacter", () => {
|
||||
beforeEach(() => {
|
||||
replaceConfig({
|
||||
mode: "time",
|
||||
stopOnError: "off",
|
||||
strictSpace: false,
|
||||
difficulty: "normal",
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
replaceConfig({});
|
||||
});
|
||||
|
||||
it("returns null if data is not a space", () => {
|
||||
expect(
|
||||
shouldInsertSpaceCharacter({
|
||||
data: "a",
|
||||
inputValue: "test",
|
||||
targetWord: "test",
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns false in zen mode", () => {
|
||||
replaceConfig({ mode: "zen" });
|
||||
expect(
|
||||
shouldInsertSpaceCharacter({
|
||||
data: " ",
|
||||
inputValue: "test",
|
||||
targetWord: "test",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe("Logic Checks", () => {
|
||||
it.each([
|
||||
// Standard behavior (submit word)
|
||||
{
|
||||
desc: "submit correct word",
|
||||
inputValue: "hello",
|
||||
targetWord: "hello",
|
||||
config: {
|
||||
stopOnError: "off",
|
||||
strictSpace: false,
|
||||
difficulty: "normal",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "submit incorrect word (stopOnError off)",
|
||||
inputValue: "hel",
|
||||
targetWord: "hello",
|
||||
config: {
|
||||
stopOnError: "off",
|
||||
strictSpace: false,
|
||||
difficulty: "normal",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
// Stop on error
|
||||
{
|
||||
desc: "insert space if incorrect (stopOnError letter)",
|
||||
inputValue: "hel",
|
||||
targetWord: "hello",
|
||||
config: {
|
||||
stopOnError: "letter",
|
||||
strictSpace: false,
|
||||
difficulty: "normal",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "insert space if incorrect (stopOnError word)",
|
||||
inputValue: "hel",
|
||||
targetWord: "hello",
|
||||
config: {
|
||||
stopOnError: "word",
|
||||
strictSpace: false,
|
||||
difficulty: "normal",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "submit if correct (stopOnError letter)",
|
||||
inputValue: "hello",
|
||||
targetWord: "hello",
|
||||
config: {
|
||||
stopOnError: "letter",
|
||||
strictSpace: false,
|
||||
difficulty: "normal",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
// Strict space / Difficulty
|
||||
{
|
||||
desc: "insert space if empty input (strictSpace on)",
|
||||
inputValue: "",
|
||||
targetWord: "hello",
|
||||
config: {
|
||||
stopOnError: "off",
|
||||
strictSpace: true,
|
||||
difficulty: "normal",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "insert space if empty input (difficulty not normal - expert or master)",
|
||||
inputValue: "",
|
||||
targetWord: "hello",
|
||||
config: {
|
||||
stopOnError: "off",
|
||||
strictSpace: false,
|
||||
difficulty: "expert",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "submit if not empty input (strictSpace on)",
|
||||
inputValue: "h",
|
||||
targetWord: "hello",
|
||||
config: {
|
||||
stopOnError: "off",
|
||||
strictSpace: true,
|
||||
difficulty: "normal",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
])("$desc", ({ inputValue, targetWord, config, expected }) => {
|
||||
replaceConfig(config as any);
|
||||
expect(
|
||||
shouldInsertSpaceCharacter({
|
||||
data: " ",
|
||||
inputValue,
|
||||
targetWord,
|
||||
}),
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
394
frontend/__tests__/root/config-metadata.spec.ts
Normal file
394
frontend/__tests__/root/config-metadata.spec.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect, afterAll, vi } from "vitest";
|
||||
import { configMetadata } from "../../src/ts/config/metadata";
|
||||
import { __testing } from "../../src/ts/config/testing";
|
||||
import { setConfig } from "../../src/ts/config/setters";
|
||||
import { ConfigKey, Config as ConfigType } from "@monkeytype/schemas/configs";
|
||||
|
||||
const { replaceConfig, getConfig } = __testing;
|
||||
|
||||
type TestsByConfig<T> = Partial<{
|
||||
[K in keyof ConfigType]: (T & { value: ConfigType[K] })[];
|
||||
}>;
|
||||
|
||||
describe("ConfigMeta", () => {
|
||||
afterAll(() => {
|
||||
replaceConfig({});
|
||||
vi.resetModules();
|
||||
});
|
||||
it("should have changeRequiresRestart defined", () => {
|
||||
const configsRequiringRestarts = Object.entries(configMetadata)
|
||||
.filter(([_key, value]) => value.changeRequiresRestart)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
|
||||
expect(configsRequiringRestarts).toEqual(
|
||||
[
|
||||
"punctuation",
|
||||
"numbers",
|
||||
"words",
|
||||
"time",
|
||||
"mode",
|
||||
"quoteLength",
|
||||
"language",
|
||||
"difficulty",
|
||||
"minWpmCustomSpeed",
|
||||
"minWpm",
|
||||
"minAcc",
|
||||
"minAccCustom",
|
||||
"minBurst",
|
||||
"minBurstCustomSpeed",
|
||||
"britishEnglish",
|
||||
"funbox",
|
||||
"customLayoutfluid",
|
||||
"strictSpace",
|
||||
"stopOnError",
|
||||
"lazyMode",
|
||||
"layout",
|
||||
"codeUnindentOnBackspace",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should have triggerResize defined", () => {
|
||||
const configsWithTriggeResize = Object.entries(configMetadata)
|
||||
.filter(([_key, value]) => value.triggerResize === true)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
|
||||
expect(configsWithTriggeResize).toEqual(
|
||||
[
|
||||
"fontSize",
|
||||
"keymapSize",
|
||||
"maxLineWidth",
|
||||
"tapeMode",
|
||||
"tapeMargin",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
describe("overrideValue", () => {
|
||||
const testCases: TestsByConfig<{
|
||||
given?: Partial<ConfigType>;
|
||||
expected: Partial<ConfigType>;
|
||||
}> = {
|
||||
punctuation: [
|
||||
{ value: true, expected: { punctuation: true } },
|
||||
{
|
||||
value: true,
|
||||
given: { mode: "quote" },
|
||||
expected: { punctuation: false },
|
||||
},
|
||||
],
|
||||
numbers: [
|
||||
{ value: true, expected: { numbers: true } },
|
||||
{
|
||||
value: true,
|
||||
given: { mode: "quote" },
|
||||
expected: { numbers: false },
|
||||
},
|
||||
],
|
||||
customLayoutfluid: [
|
||||
{
|
||||
value: ["qwerty", "qwerty", "qwertz"],
|
||||
expected: { customLayoutfluid: ["qwerty", "qwertz"] },
|
||||
},
|
||||
],
|
||||
customPolyglot: [
|
||||
{
|
||||
value: ["english", "polish", "english"],
|
||||
expected: { customPolyglot: ["english", "polish"] },
|
||||
},
|
||||
],
|
||||
keymapSize: [
|
||||
{ value: 1, expected: { keymapSize: 1 } },
|
||||
{ value: 1.234, expected: { keymapSize: 1.2 } },
|
||||
{ value: 0.4, expected: { keymapSize: 0.5 } },
|
||||
{ value: 3.6, expected: { keymapSize: 3.5 } },
|
||||
],
|
||||
customBackground: [
|
||||
{
|
||||
value: " https://example.com/test.jpg ",
|
||||
expected: { customBackground: "https://example.com/test.jpg" },
|
||||
},
|
||||
],
|
||||
accountChart: [
|
||||
{
|
||||
value: ["on", "off", "off", "off"],
|
||||
expected: { accountChart: ["on", "off", "off", "off"] },
|
||||
},
|
||||
{
|
||||
value: ["off", "off", "off", "off"],
|
||||
given: { accountChart: ["on", "off", "off", "off"] },
|
||||
expected: { accountChart: ["off", "on", "off", "off"] },
|
||||
},
|
||||
{
|
||||
value: ["off", "off", "on", "on"],
|
||||
given: { accountChart: ["off", "on", "off", "off"] },
|
||||
expected: { accountChart: ["on", "off", "on", "on"] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it.for(
|
||||
Object.entries(testCases).flatMap(([key, value]) =>
|
||||
value.flatMap((it) => ({ key: key as ConfigKey, ...it })),
|
||||
),
|
||||
)(
|
||||
`$key value=$value given=$given expect=$expected`,
|
||||
({ key, value, given, expected }) => {
|
||||
//GIVEN
|
||||
replaceConfig(given ?? {});
|
||||
|
||||
//WHEN
|
||||
setConfig(key, value as any);
|
||||
|
||||
//THEN
|
||||
expect(getConfig()).toMatchObject(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
describe("isBlocked", () => {
|
||||
const testCases: TestsByConfig<{
|
||||
given?: Partial<ConfigType>;
|
||||
fail?: true;
|
||||
}> = {
|
||||
funbox: [
|
||||
{
|
||||
value: ["gibberish"],
|
||||
given: { mode: "quote" },
|
||||
fail: true,
|
||||
},
|
||||
],
|
||||
showAllLines: [
|
||||
{ value: true, given: { tapeMode: "off" } },
|
||||
{ value: false, given: { tapeMode: "word" } },
|
||||
{ value: true, given: { tapeMode: "word" }, fail: true },
|
||||
],
|
||||
monkey: [{ value: false, given: { liveSpeedStyle: "text" } }],
|
||||
liveSpeedStyle: [
|
||||
{ value: "mini", given: { monkey: true } },
|
||||
{ value: "text", given: { monkey: true } },
|
||||
],
|
||||
liveAccStyle: [
|
||||
{ value: "mini", given: { monkey: true } },
|
||||
{ value: "text", given: { monkey: true } },
|
||||
],
|
||||
};
|
||||
|
||||
it.for(
|
||||
Object.entries(testCases).flatMap(([key, value]) =>
|
||||
value.flatMap((it) => ({ key: key as ConfigKey, ...it })),
|
||||
),
|
||||
)(
|
||||
`$key value=$value given=$given fail=$fail`,
|
||||
({ key, value, given, fail }) => {
|
||||
//GIVEN
|
||||
replaceConfig(given ?? {});
|
||||
|
||||
//WHEN
|
||||
const applied = setConfig(key, value as any);
|
||||
|
||||
//THEN
|
||||
expect(applied).toEqual(!fail);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("overrideConfig", () => {
|
||||
const testCases: TestsByConfig<{
|
||||
given: Partial<ConfigType>;
|
||||
expected?: Partial<ConfigType>;
|
||||
}> = {
|
||||
mode: [
|
||||
{ value: "time", given: { numbers: true, punctuation: true } },
|
||||
{
|
||||
value: "custom",
|
||||
given: { numbers: true, punctuation: true },
|
||||
expected: { numbers: false, punctuation: false },
|
||||
},
|
||||
{
|
||||
value: "quote",
|
||||
given: { numbers: true, punctuation: true },
|
||||
expected: { numbers: false, punctuation: false },
|
||||
},
|
||||
{
|
||||
value: "zen",
|
||||
given: { numbers: true, punctuation: true },
|
||||
expected: { numbers: false, punctuation: false },
|
||||
},
|
||||
],
|
||||
numbers: [{ value: false, given: { mode: "quote" } }],
|
||||
freedomMode: [
|
||||
{
|
||||
value: false,
|
||||
given: { confidenceMode: "on" },
|
||||
expected: { confidenceMode: "on" },
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
given: { confidenceMode: "on" },
|
||||
expected: { confidenceMode: "off" },
|
||||
},
|
||||
],
|
||||
stopOnError: [
|
||||
{
|
||||
value: "off",
|
||||
given: { confidenceMode: "on" },
|
||||
expected: { confidenceMode: "on" },
|
||||
},
|
||||
{
|
||||
value: "word",
|
||||
given: { confidenceMode: "on" },
|
||||
expected: { confidenceMode: "off" },
|
||||
},
|
||||
],
|
||||
confidenceMode: [
|
||||
{
|
||||
value: "off",
|
||||
given: { freedomMode: true, stopOnError: "word" },
|
||||
expected: { freedomMode: true, stopOnError: "word" },
|
||||
},
|
||||
{
|
||||
value: "on",
|
||||
given: { freedomMode: true, stopOnError: "word" },
|
||||
expected: { freedomMode: false, stopOnError: "off" },
|
||||
},
|
||||
],
|
||||
monkey: [
|
||||
{
|
||||
value: false,
|
||||
given: { liveSpeedStyle: "text", liveAccStyle: "text" },
|
||||
expected: {
|
||||
liveSpeedStyle: "text",
|
||||
liveAccStyle: "text",
|
||||
},
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
given: { liveSpeedStyle: "text", liveAccStyle: "text" },
|
||||
expected: { liveSpeedStyle: "mini", liveAccStyle: "mini" },
|
||||
},
|
||||
],
|
||||
liveSpeedStyle: [
|
||||
{
|
||||
value: "mini",
|
||||
given: { monkey: true },
|
||||
expected: { monkey: true },
|
||||
},
|
||||
{
|
||||
value: "text",
|
||||
given: { monkey: true },
|
||||
expected: { monkey: false },
|
||||
},
|
||||
],
|
||||
liveAccStyle: [
|
||||
{
|
||||
value: "mini",
|
||||
given: { monkey: true },
|
||||
expected: { monkey: true },
|
||||
},
|
||||
{
|
||||
value: "text",
|
||||
given: { monkey: true },
|
||||
expected: { monkey: false },
|
||||
},
|
||||
],
|
||||
tapeMode: [
|
||||
{
|
||||
value: "off",
|
||||
given: { showAllLines: true },
|
||||
expected: { showAllLines: true },
|
||||
},
|
||||
{
|
||||
value: "letter",
|
||||
given: { showAllLines: true },
|
||||
expected: { showAllLines: false },
|
||||
},
|
||||
],
|
||||
theme: [
|
||||
{
|
||||
value: "8008",
|
||||
given: { customTheme: true },
|
||||
expected: { customTheme: false },
|
||||
},
|
||||
],
|
||||
keymapLayout: [
|
||||
{
|
||||
value: "3l",
|
||||
given: { keymapMode: "react" },
|
||||
expected: { keymapMode: "react" },
|
||||
},
|
||||
{
|
||||
value: "3l",
|
||||
given: { keymapMode: "off" },
|
||||
expected: { keymapMode: "static" },
|
||||
},
|
||||
],
|
||||
keymapStyle: [
|
||||
{
|
||||
value: "alice",
|
||||
given: { keymapMode: "react" },
|
||||
expected: { keymapMode: "react" },
|
||||
},
|
||||
{
|
||||
value: "alice",
|
||||
given: { keymapMode: "off" },
|
||||
expected: { keymapMode: "static" },
|
||||
},
|
||||
],
|
||||
keymapLegendStyle: [
|
||||
{
|
||||
value: "dynamic",
|
||||
given: { keymapMode: "react" },
|
||||
expected: { keymapMode: "react" },
|
||||
},
|
||||
{
|
||||
value: "dynamic",
|
||||
given: { keymapMode: "off" },
|
||||
expected: { keymapMode: "static" },
|
||||
},
|
||||
],
|
||||
keymapShowTopRow: [
|
||||
{
|
||||
value: "always",
|
||||
given: { keymapMode: "react" },
|
||||
expected: { keymapMode: "react" },
|
||||
},
|
||||
{
|
||||
value: "always",
|
||||
given: { keymapMode: "off" },
|
||||
expected: { keymapMode: "static" },
|
||||
},
|
||||
],
|
||||
keymapSize: [
|
||||
{
|
||||
value: 2,
|
||||
given: { keymapMode: "react" },
|
||||
expected: { keymapMode: "react" },
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
given: { keymapMode: "off" },
|
||||
expected: { keymapMode: "static" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it.for(
|
||||
Object.entries(testCases).flatMap(([key, value]) =>
|
||||
value.flatMap((it) => ({ key: key as ConfigKey, ...it })),
|
||||
),
|
||||
)(
|
||||
`$key value=$value given=$given expected=$expected`,
|
||||
({ key, value, given, expected }) => {
|
||||
//GIVEN
|
||||
replaceConfig(given);
|
||||
|
||||
//WHEN
|
||||
setConfig(key, value as any);
|
||||
|
||||
//THEN
|
||||
expect(getConfig()).toMatchObject(expected ?? {});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
471
frontend/__tests__/root/config.spec.ts
Normal file
471
frontend/__tests__/root/config.spec.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
||||
import * as Config from "../../src/ts/config/setters";
|
||||
import * as Lifecycle from "../../src/ts/config/lifecycle";
|
||||
import * as ConfigUtils from "../../src/ts/config/utils";
|
||||
import { __testing } from "../../src/ts/config/testing";
|
||||
import * as Misc from "../../src/ts/utils/misc";
|
||||
import * as Env from "../../src/ts/utils/env";
|
||||
import {
|
||||
ConfigKey,
|
||||
Config as ConfigType,
|
||||
CaretStyleSchema,
|
||||
} from "@monkeytype/schemas/configs";
|
||||
import * as FunboxValidation from "../../src/ts/config/funbox-validation";
|
||||
import * as ConfigValidation from "../../src/ts/config/validation";
|
||||
import { configEvent } from "../../src/ts/events/config";
|
||||
import * as ApeConfig from "../../src/ts/ape/config";
|
||||
import * as Notifications from "../../src/ts/states/notifications";
|
||||
const { replaceConfig, getConfig } = __testing;
|
||||
|
||||
describe("Config", () => {
|
||||
const isDevEnvironmentMock = vi.spyOn(Env, "isDevEnvironment");
|
||||
beforeEach(() => {
|
||||
isDevEnvironmentMock.mockClear();
|
||||
replaceConfig({});
|
||||
});
|
||||
|
||||
describe("test with mocks", () => {
|
||||
const canSetConfigWithCurrentFunboxesMock = vi.spyOn(
|
||||
FunboxValidation,
|
||||
"canSetConfigWithCurrentFunboxes",
|
||||
);
|
||||
const isConfigValueValidMock = vi.spyOn(
|
||||
ConfigValidation,
|
||||
"isConfigValueValid",
|
||||
);
|
||||
const dispatchConfigEventMock = vi.spyOn(configEvent, "dispatch");
|
||||
const dbSaveConfigMock = vi.spyOn(ApeConfig, "saveConfig");
|
||||
const notificationAddMock = vi.spyOn(
|
||||
Notifications,
|
||||
"showNoticeNotification",
|
||||
);
|
||||
const miscReloadAfterMock = vi.spyOn(Misc, "reloadAfter");
|
||||
const miscTriggerResizeMock = vi.spyOn(Misc, "triggerResize");
|
||||
|
||||
const mocks = [
|
||||
canSetConfigWithCurrentFunboxesMock,
|
||||
isConfigValueValidMock,
|
||||
dispatchConfigEventMock,
|
||||
dbSaveConfigMock,
|
||||
notificationAddMock,
|
||||
miscReloadAfterMock,
|
||||
miscTriggerResizeMock,
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.forEach((it) => it.mockClear());
|
||||
|
||||
vi.mock("../../src/ts/test/test-state", () => ({
|
||||
isActive: true,
|
||||
}));
|
||||
|
||||
isConfigValueValidMock.mockReturnValue(true);
|
||||
canSetConfigWithCurrentFunboxesMock.mockReturnValue(true);
|
||||
dbSaveConfigMock.mockResolvedValue();
|
||||
|
||||
replaceConfig({});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mocks.forEach((it) => it.mockRestore());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => isDevEnvironmentMock.mockClear());
|
||||
|
||||
it("should throw if config key in not found in metadata", () => {
|
||||
expect(() => {
|
||||
Config.setConfig("nonExistentKey" as ConfigKey, true);
|
||||
}).toThrow(`Config metadata for key "nonExistentKey" is not defined.`);
|
||||
});
|
||||
|
||||
it("fails if test is active and funbox no_quit", () => {
|
||||
//GIVEN
|
||||
replaceConfig({ funbox: ["no_quit"], numbers: false });
|
||||
|
||||
//WHEN
|
||||
expect(
|
||||
Config.setConfig("numbers", true, {
|
||||
nosave: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
//THEN
|
||||
expect(notificationAddMock).toHaveBeenCalledWith(
|
||||
"No quit funbox is active. Please finish the test.",
|
||||
{
|
||||
important: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
//TODO isBlocked
|
||||
it("should fail if config is blocked", () => {
|
||||
//GIVEN
|
||||
replaceConfig({ tapeMode: "letter" });
|
||||
|
||||
//WHEN / THEN
|
||||
expect(Config.setConfig("showAllLines", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("disables live text stats when enabling monkey", () => {
|
||||
//GIVEN
|
||||
replaceConfig({
|
||||
liveSpeedStyle: "text",
|
||||
liveAccStyle: "text",
|
||||
monkey: false,
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
expect(Config.setConfig("monkey", true)).toBe(true);
|
||||
expect(getConfig()).toMatchObject({
|
||||
monkey: true,
|
||||
liveSpeedStyle: "mini",
|
||||
liveAccStyle: "mini",
|
||||
});
|
||||
expect(notificationAddMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables monkey when enabling live speed text", () => {
|
||||
//GIVEN
|
||||
replaceConfig({ monkey: true, liveSpeedStyle: "off" });
|
||||
|
||||
//WHEN / THEN
|
||||
expect(Config.setConfig("liveSpeedStyle", "text")).toBe(true);
|
||||
expect(getConfig()).toMatchObject({
|
||||
monkey: false,
|
||||
liveSpeedStyle: "text",
|
||||
});
|
||||
expect(notificationAddMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables monkey when enabling live accuracy text", () => {
|
||||
//GIVEN
|
||||
replaceConfig({ monkey: true, liveAccStyle: "off" });
|
||||
|
||||
//WHEN / THEN
|
||||
expect(Config.setConfig("liveAccStyle", "text")).toBe(true);
|
||||
expect(getConfig()).toMatchObject({
|
||||
monkey: false,
|
||||
liveAccStyle: "text",
|
||||
});
|
||||
expect(notificationAddMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use overrideValue", () => {
|
||||
//WHEN
|
||||
Config.setConfig("customLayoutfluid", ["3l", "ABNT2", "3l"]);
|
||||
|
||||
//THEN
|
||||
expect(getConfig().customLayoutfluid).toEqual(["3l", "ABNT2"]);
|
||||
});
|
||||
|
||||
it("fails if config is invalid", () => {
|
||||
//GIVEN
|
||||
isConfigValueValidMock.mockReturnValue(false);
|
||||
|
||||
//WHEN / THEN
|
||||
expect(Config.setConfig("caretStyle", "banana" as any)).toBe(false);
|
||||
expect(isConfigValueValidMock).toHaveBeenCalledWith(
|
||||
"caret style",
|
||||
"banana",
|
||||
CaretStyleSchema,
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot set if funbox disallows", () => {
|
||||
//GIVEN
|
||||
canSetConfigWithCurrentFunboxesMock.mockReturnValue(false);
|
||||
|
||||
//WHEN / THEN
|
||||
expect(Config.setConfig("numbers", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("sets overrideConfigs", () => {
|
||||
//GIVEN
|
||||
replaceConfig({
|
||||
confidenceMode: "off",
|
||||
freedomMode: false, //already set correctly
|
||||
stopOnError: "letter", //should get updated
|
||||
});
|
||||
|
||||
//WHEN
|
||||
Config.setConfig("confidenceMode", "max");
|
||||
|
||||
//THEN
|
||||
expect(dispatchConfigEventMock).not.toHaveBeenCalledWith({
|
||||
key: "freedomMode",
|
||||
newValue: false,
|
||||
nosave: true,
|
||||
previousValue: true,
|
||||
});
|
||||
|
||||
expect(dispatchConfigEventMock).toHaveBeenCalledWith({
|
||||
key: "stopOnError",
|
||||
newValue: "off",
|
||||
nosave: false,
|
||||
previousValue: "letter",
|
||||
});
|
||||
|
||||
expect(dispatchConfigEventMock).toHaveBeenCalledWith({
|
||||
key: "confidenceMode",
|
||||
newValue: "max",
|
||||
nosave: false,
|
||||
previousValue: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("saves to localstorage if nosave=false", async () => {
|
||||
//GIVEN
|
||||
replaceConfig({ numbers: false });
|
||||
|
||||
//WHEN
|
||||
Config.setConfig("numbers", true);
|
||||
|
||||
//THEN
|
||||
//wait for debounce
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
//save
|
||||
expect(dbSaveConfigMock).toHaveBeenCalledWith({ numbers: true });
|
||||
});
|
||||
|
||||
it("saves configOverride values to localstorage if nosave=false", async () => {
|
||||
//GIVEN
|
||||
replaceConfig({});
|
||||
|
||||
//WHEN
|
||||
Config.setConfig("minWpmCustomSpeed", 120);
|
||||
|
||||
//THEN
|
||||
//wait for debounce
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
//save
|
||||
expect(dbSaveConfigMock).toHaveBeenCalledWith({
|
||||
minWpmCustomSpeed: 120,
|
||||
minWpm: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not save to localstorage if nosave=true", async () => {
|
||||
//GIVEN
|
||||
|
||||
replaceConfig({ numbers: false });
|
||||
|
||||
//WHEN
|
||||
Config.setConfig("numbers", true, {
|
||||
nosave: true,
|
||||
});
|
||||
|
||||
//THEN
|
||||
//wait for debounce
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
expect(dbSaveConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches event on set", () => {
|
||||
//GIVEN
|
||||
replaceConfig({ numbers: false });
|
||||
|
||||
//WHEN
|
||||
Config.setConfig("numbers", true, {
|
||||
nosave: true,
|
||||
});
|
||||
|
||||
//THEN
|
||||
|
||||
expect(dispatchConfigEventMock).toHaveBeenCalledWith({
|
||||
key: "numbers",
|
||||
newValue: true,
|
||||
nosave: true,
|
||||
previousValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers resize if property is set", () => {
|
||||
///WHEN
|
||||
Config.setConfig("maxLineWidth", 50);
|
||||
|
||||
expect(miscTriggerResizeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not triggers resize if property is not set", () => {
|
||||
///WHEN
|
||||
Config.setConfig("startGraphsAtZero", true);
|
||||
|
||||
expect(miscTriggerResizeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not triggers resize if property on nosave", () => {
|
||||
///WHEN
|
||||
Config.setConfig("maxLineWidth", 50, { nosave: true });
|
||||
|
||||
expect(miscTriggerResizeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls afterSet", () => {
|
||||
//GIVEN
|
||||
isDevEnvironmentMock.mockReturnValue(false);
|
||||
replaceConfig({ ads: "off" });
|
||||
|
||||
//WHEN
|
||||
Config.setConfig("ads", "sellout");
|
||||
|
||||
//THEN
|
||||
expect(notificationAddMock).toHaveBeenCalledWith(
|
||||
"Ad settings changed. Refreshing...",
|
||||
);
|
||||
expect(miscReloadAfterMock).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apply", () => {
|
||||
it("should fill missing values with defaults", async () => {
|
||||
//GIVEN
|
||||
replaceConfig({
|
||||
mode: "words",
|
||||
});
|
||||
await Lifecycle.applyConfig({
|
||||
numbers: true,
|
||||
punctuation: true,
|
||||
});
|
||||
const config = getConfig();
|
||||
expect(config.mode).toBe("time");
|
||||
expect(config.numbers).toBe(true);
|
||||
expect(config.punctuation).toBe(true);
|
||||
});
|
||||
|
||||
describe("should reset to default if setting failed", () => {
|
||||
const testCases: {
|
||||
display: string;
|
||||
value: Partial<ConfigType>;
|
||||
expected: Partial<ConfigType>;
|
||||
}[] = [
|
||||
{
|
||||
// invalid funbox
|
||||
display: "invalid funbox",
|
||||
value: { funbox: ["invalid_funbox"] as any },
|
||||
expected: { funbox: [] },
|
||||
},
|
||||
{
|
||||
display: "mode incompatible with funbox",
|
||||
value: { mode: "quote", funbox: ["58008"] },
|
||||
expected: { funbox: [] },
|
||||
},
|
||||
{
|
||||
display: "invalid combination of funboxes",
|
||||
value: { funbox: ["58008", "gibberish"] },
|
||||
expected: { funbox: [] },
|
||||
},
|
||||
{
|
||||
display: "sanitizes config, remove extra keys",
|
||||
value: { mode: "zen", unknownKey: true, unknownArray: [1, 2] } as any,
|
||||
expected: { mode: "zen" },
|
||||
},
|
||||
{
|
||||
display: "applies config migration",
|
||||
value: { mode: "zen", swapEscAndTab: true } as any,
|
||||
expected: { mode: "zen", quickRestart: "esc" },
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)("$display", async ({ value, expected }) => {
|
||||
await Lifecycle.applyConfig(value);
|
||||
|
||||
const config = getConfig();
|
||||
const applied = Object.fromEntries(
|
||||
Object.entries(config).filter(([key]) =>
|
||||
Object.keys(expected).includes(key),
|
||||
),
|
||||
);
|
||||
expect(applied).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should apply keys in an order to avoid overrides", () => {
|
||||
const testCases: {
|
||||
display: string;
|
||||
value: Partial<ConfigType>;
|
||||
expected: Partial<ConfigType>;
|
||||
}[] = [
|
||||
{
|
||||
display:
|
||||
"quote length shouldnt override mode, punctuation and numbers",
|
||||
value: {
|
||||
punctuation: true,
|
||||
numbers: true,
|
||||
quoteLength: [0],
|
||||
mode: "time",
|
||||
},
|
||||
expected: {
|
||||
punctuation: true,
|
||||
numbers: true,
|
||||
quoteLength: [0],
|
||||
mode: "time",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)("$display", async ({ value, expected }) => {
|
||||
await Lifecycle.applyConfig(value);
|
||||
const config = getConfig();
|
||||
const applied = Object.fromEntries(
|
||||
Object.entries(config).filter(([key]) =>
|
||||
Object.keys(expected).includes(key),
|
||||
),
|
||||
);
|
||||
expect(applied).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply a partial config but keep the rest unchanged", async () => {
|
||||
replaceConfig({
|
||||
numbers: true,
|
||||
});
|
||||
await Lifecycle.applyConfig({
|
||||
...ConfigUtils.getConfigChanges(),
|
||||
punctuation: true,
|
||||
});
|
||||
const config = getConfig();
|
||||
expect(config.numbers).toBe(true);
|
||||
});
|
||||
|
||||
it("should not enable minWpm if not provided", async () => {
|
||||
replaceConfig({
|
||||
minWpm: "off",
|
||||
});
|
||||
await Lifecycle.applyConfig({
|
||||
minWpmCustomSpeed: 100,
|
||||
});
|
||||
const config = getConfig();
|
||||
expect(config.minWpm).toBe("off");
|
||||
expect(config.minWpmCustomSpeed).toEqual(100);
|
||||
});
|
||||
|
||||
it("should apply minWpm if part of the full config", async () => {
|
||||
replaceConfig({
|
||||
minWpm: "off",
|
||||
});
|
||||
await Lifecycle.applyConfig({
|
||||
minWpm: "custom",
|
||||
minWpmCustomSpeed: 100,
|
||||
});
|
||||
const config = getConfig();
|
||||
expect(config.minWpm).toBe("custom");
|
||||
expect(config.minWpmCustomSpeed).toEqual(100);
|
||||
});
|
||||
|
||||
it("should keep the keymap off when applying keymapLayout", async () => {
|
||||
replaceConfig({});
|
||||
await Lifecycle.applyConfig({
|
||||
keymapLayout: "qwerty",
|
||||
});
|
||||
const config = getConfig();
|
||||
expect(config.keymapLayout).toEqual("qwerty");
|
||||
expect(config.keymapMode).toEqual("off");
|
||||
});
|
||||
});
|
||||
});
|
||||
322
frontend/__tests__/stores/notifications.spec.ts
Normal file
322
frontend/__tests__/stores/notifications.spec.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import {
|
||||
addNotificationWithLevel,
|
||||
showNoticeNotification,
|
||||
showSuccessNotification,
|
||||
showErrorNotification,
|
||||
removeNotification,
|
||||
clearAllNotifications,
|
||||
getNotifications,
|
||||
getNotificationHistory,
|
||||
__testing,
|
||||
AddNotificationOptions,
|
||||
} from "../../src/ts/states/notifications";
|
||||
|
||||
const { clearNotificationHistory } = __testing;
|
||||
|
||||
describe("notifications store", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
clearAllNotifications();
|
||||
clearNotificationHistory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("addNotificationWithLevel", () => {
|
||||
it("adds a notification to the store", () => {
|
||||
addNotificationWithLevel("test message", "notice");
|
||||
|
||||
const notifications = getNotifications();
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]!.message).toBe("test message");
|
||||
expect(notifications[0]!.level).toBe("notice");
|
||||
});
|
||||
|
||||
it("prepends new notifications", () => {
|
||||
addNotificationWithLevel("first", "notice");
|
||||
addNotificationWithLevel("second", "success");
|
||||
|
||||
const notifications = getNotifications();
|
||||
expect(notifications).toHaveLength(2);
|
||||
expect(notifications[0]!.message).toBe("second");
|
||||
expect(notifications[1]!.message).toBe("first");
|
||||
});
|
||||
|
||||
it("defaults error duration to 0 (sticky)", () => {
|
||||
addNotificationWithLevel("error msg", "error");
|
||||
|
||||
expect(getNotifications()[0]!.durationMs).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults non-error duration to 3000", () => {
|
||||
addNotificationWithLevel("notice msg", "notice");
|
||||
expect(getNotifications()[0]!.durationMs).toBe(3000);
|
||||
|
||||
addNotificationWithLevel("success msg", "success");
|
||||
expect(getNotifications()[0]!.durationMs).toBe(3000);
|
||||
});
|
||||
|
||||
it("respects custom durationMs", () => {
|
||||
addNotificationWithLevel("msg", "notice", { durationMs: 5000 });
|
||||
expect(getNotifications()[0]!.durationMs).toBe(5000);
|
||||
});
|
||||
|
||||
it("appends response body message", () => {
|
||||
const response = {
|
||||
status: 400,
|
||||
body: { message: "Bad request" },
|
||||
} as AddNotificationOptions["response"];
|
||||
|
||||
addNotificationWithLevel("Request failed", "error", { response });
|
||||
expect(getNotifications()[0]!.message).toBe(
|
||||
"Request failed: Bad request",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends error message via createErrorMessage", () => {
|
||||
addNotificationWithLevel("Something broke", "error", {
|
||||
error: new Error("underlying cause"),
|
||||
});
|
||||
expect(getNotifications()[0]!.message).toContain("underlying cause");
|
||||
});
|
||||
|
||||
it("sets useInnerHtml when specified", () => {
|
||||
addNotificationWithLevel("html <b>bold</b>", "notice", {
|
||||
useInnerHtml: true,
|
||||
});
|
||||
expect(getNotifications()[0]!.useInnerHtml).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults useInnerHtml to false", () => {
|
||||
addNotificationWithLevel("plain", "notice");
|
||||
expect(getNotifications()[0]!.useInnerHtml).toBe(false);
|
||||
});
|
||||
|
||||
it("sets customTitle and customIcon", () => {
|
||||
addNotificationWithLevel("msg", "notice", {
|
||||
customTitle: "Custom",
|
||||
customIcon: "gift",
|
||||
});
|
||||
const n = getNotifications()[0]!;
|
||||
expect(n.customTitle).toBe("Custom");
|
||||
expect(n.customIcon).toBe("gift");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto-remove timer", () => {
|
||||
it("removes notification after durationMs + 250ms", () => {
|
||||
addNotificationWithLevel("temp", "success", { durationMs: 1000 });
|
||||
expect(getNotifications()).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(1249);
|
||||
expect(getNotifications()).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(getNotifications()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not auto-remove sticky notifications (durationMs 0)", () => {
|
||||
addNotificationWithLevel("sticky", "error");
|
||||
expect(getNotifications()[0]!.durationMs).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(getNotifications()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls onDismiss with 'timeout' when auto-removed", () => {
|
||||
const onDismiss = vi.fn();
|
||||
addNotificationWithLevel("temp", "notice", {
|
||||
durationMs: 1000,
|
||||
onDismiss,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1250);
|
||||
expect(onDismiss).toHaveBeenCalledWith("timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeNotification", () => {
|
||||
it("removes a specific notification by id", () => {
|
||||
addNotificationWithLevel("first", "notice", { durationMs: 0 });
|
||||
addNotificationWithLevel("second", "notice", { durationMs: 0 });
|
||||
|
||||
const id = getNotifications()[1]!.id;
|
||||
removeNotification(id);
|
||||
|
||||
expect(getNotifications()).toHaveLength(1);
|
||||
expect(getNotifications()[0]!.message).toBe("second");
|
||||
});
|
||||
|
||||
it("calls onDismiss with 'click' by default", () => {
|
||||
const onDismiss = vi.fn();
|
||||
addNotificationWithLevel("msg", "notice", { durationMs: 0, onDismiss });
|
||||
|
||||
const id = getNotifications()[0]!.id;
|
||||
removeNotification(id);
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledWith("click");
|
||||
});
|
||||
|
||||
it("cancels auto-remove timer on manual removal", () => {
|
||||
const onDismiss = vi.fn();
|
||||
addNotificationWithLevel("msg", "notice", {
|
||||
durationMs: 5000,
|
||||
onDismiss,
|
||||
});
|
||||
|
||||
const id = getNotifications()[0]!.id;
|
||||
removeNotification(id);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
// onDismiss should only have been called once (the manual removal)
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(onDismiss).toHaveBeenCalledWith("click");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearAllNotifications", () => {
|
||||
it("removes all notifications", () => {
|
||||
addNotificationWithLevel("a", "notice", { durationMs: 0 });
|
||||
addNotificationWithLevel("b", "error");
|
||||
addNotificationWithLevel("c", "success", { durationMs: 0 });
|
||||
|
||||
clearAllNotifications();
|
||||
expect(getNotifications()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("calls onDismiss with 'clear' for each notification", () => {
|
||||
const onDismiss1 = vi.fn();
|
||||
const onDismiss2 = vi.fn();
|
||||
addNotificationWithLevel("a", "notice", {
|
||||
durationMs: 0,
|
||||
onDismiss: onDismiss1,
|
||||
});
|
||||
addNotificationWithLevel("b", "notice", {
|
||||
durationMs: 0,
|
||||
onDismiss: onDismiss2,
|
||||
});
|
||||
|
||||
clearAllNotifications();
|
||||
expect(onDismiss1).toHaveBeenCalledWith("clear");
|
||||
expect(onDismiss2).toHaveBeenCalledWith("clear");
|
||||
});
|
||||
|
||||
it("store is empty when onDismiss callbacks run", () => {
|
||||
let countDuringCallback = -1;
|
||||
addNotificationWithLevel("a", "notice", {
|
||||
durationMs: 0,
|
||||
onDismiss: () => {
|
||||
countDuringCallback = getNotifications().length;
|
||||
},
|
||||
});
|
||||
|
||||
clearAllNotifications();
|
||||
expect(countDuringCallback).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels all pending auto-remove timers", () => {
|
||||
const onDismiss = vi.fn();
|
||||
addNotificationWithLevel("a", "notice", {
|
||||
durationMs: 2000,
|
||||
onDismiss,
|
||||
});
|
||||
addNotificationWithLevel("b", "notice", {
|
||||
durationMs: 3000,
|
||||
onDismiss,
|
||||
});
|
||||
|
||||
clearAllNotifications();
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
// onDismiss called twice from clearAll, never from timers
|
||||
expect(onDismiss).toHaveBeenCalledTimes(2);
|
||||
expect(onDismiss).toHaveBeenCalledWith("clear");
|
||||
});
|
||||
|
||||
it("does not throw if onDismiss throws", () => {
|
||||
addNotificationWithLevel("a", "notice", {
|
||||
durationMs: 0,
|
||||
onDismiss: () => {
|
||||
throw new Error("callback error");
|
||||
},
|
||||
});
|
||||
addNotificationWithLevel("b", "notice", { durationMs: 0 });
|
||||
|
||||
expect(() => clearAllNotifications()).not.toThrow();
|
||||
expect(getNotifications()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convenience functions", () => {
|
||||
it("showNoticeNotification adds notice level", () => {
|
||||
showNoticeNotification("notice msg");
|
||||
expect(getNotifications()[0]!.level).toBe("notice");
|
||||
});
|
||||
|
||||
it("showSuccessNotification adds success level", () => {
|
||||
showSuccessNotification("success msg");
|
||||
expect(getNotifications()[0]!.level).toBe("success");
|
||||
});
|
||||
|
||||
it("showErrorNotification adds error level", () => {
|
||||
showErrorNotification("error msg");
|
||||
expect(getNotifications()[0]!.level).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification history", () => {
|
||||
it("adds entries to history", () => {
|
||||
addNotificationWithLevel("msg", "success");
|
||||
|
||||
const history = getNotificationHistory();
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]!.message).toBe("msg");
|
||||
expect(history[0]!.level).toBe("success");
|
||||
expect(history[0]!.title).toBe("Success");
|
||||
});
|
||||
|
||||
it("uses correct default titles", () => {
|
||||
addNotificationWithLevel("a", "success");
|
||||
addNotificationWithLevel("b", "error");
|
||||
addNotificationWithLevel("c", "notice");
|
||||
|
||||
const history = getNotificationHistory();
|
||||
expect(history[0]!.title).toBe("Success");
|
||||
expect(history[1]!.title).toBe("Error");
|
||||
expect(history[2]!.title).toBe("Notice");
|
||||
});
|
||||
|
||||
it("uses customTitle in history when provided", () => {
|
||||
addNotificationWithLevel("msg", "notice", { customTitle: "Reward" });
|
||||
expect(getNotificationHistory()[0]!.title).toBe("Reward");
|
||||
});
|
||||
|
||||
it("stores details in history", () => {
|
||||
addNotificationWithLevel("msg", "notice", {
|
||||
details: { foo: "bar" },
|
||||
});
|
||||
expect(getNotificationHistory()[0]!.details).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("caps history at 25 entries", () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
addNotificationWithLevel(`msg ${i}`, "notice");
|
||||
}
|
||||
|
||||
const history = getNotificationHistory();
|
||||
expect(history).toHaveLength(25);
|
||||
expect(history[0]!.message).toBe("msg 5");
|
||||
expect(history[24]!.message).toBe("msg 29");
|
||||
});
|
||||
|
||||
it("is not affected by clearAllNotifications", () => {
|
||||
addNotificationWithLevel("msg", "notice", { durationMs: 0 });
|
||||
clearAllNotifications();
|
||||
|
||||
expect(getNotificationHistory()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
frontend/__tests__/test/british-english.spec.ts
Normal file
72
frontend/__tests__/test/british-english.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { replace } from "../../src/ts/test/british-english";
|
||||
import { Config } from "../../src/ts/config/store";
|
||||
|
||||
describe("british-english", () => {
|
||||
describe("replace", () => {
|
||||
beforeEach(() => (Config.mode = "time"));
|
||||
|
||||
it("should not replace words with no rule", async () => {
|
||||
await expect(replace("test", "")).resolves.toEqual("test");
|
||||
await expect(replace("Test", "")).resolves.toEqual("Test");
|
||||
});
|
||||
|
||||
it("should replace words", async () => {
|
||||
await expect(replace("math", "")).resolves.toEqual("maths");
|
||||
await expect(replace("Math", "")).resolves.toEqual("Maths");
|
||||
});
|
||||
|
||||
it("should replace words with non-word characters around", async () => {
|
||||
await expect(replace(" :math-. ", "")).resolves.toEqual(" :maths-. ");
|
||||
await expect(replace(" :Math-. ", "")).resolves.toEqual(" :Maths-. ");
|
||||
});
|
||||
|
||||
it("should not replace in quote mode if previousWord matches excepted words", async () => {
|
||||
//GIVEN
|
||||
Config.mode = "quote";
|
||||
|
||||
//WHEN/THEN
|
||||
await expect(replace("tire", "will")).resolves.toEqual("tire");
|
||||
await expect(replace("tire", "")).resolves.toEqual("tyre");
|
||||
});
|
||||
|
||||
it("should replace hyphenated words", async () => {
|
||||
await expect(replace("cream-colored", "")).resolves.toEqual(
|
||||
"cream-coloured",
|
||||
);
|
||||
await expect(replace("armor-flavoring", "")).resolves.toEqual(
|
||||
"armour-flavouring",
|
||||
);
|
||||
});
|
||||
|
||||
it("should convert double quotes to single quotes", async () => {
|
||||
await expect(replace('"hello"', "")).resolves.toEqual("'hello'");
|
||||
await expect(replace('"test"', "")).resolves.toEqual("'test'");
|
||||
await expect(replace('"Hello World"', "")).resolves.toEqual(
|
||||
"'Hello World'",
|
||||
);
|
||||
});
|
||||
|
||||
it("should convert double quotes and replace words", async () => {
|
||||
await expect(replace('"color"', "")).resolves.toEqual("'colour'");
|
||||
await expect(replace('"math"', "")).resolves.toEqual("'maths'");
|
||||
await expect(replace('"Color"', "")).resolves.toEqual("'Colour'");
|
||||
});
|
||||
|
||||
it("should handle multiple double quotes in a word", async () => {
|
||||
await expect(
|
||||
replace('He said "hello" and "goodbye"', ""),
|
||||
).resolves.toEqual("He said 'hello' and 'goodbye'");
|
||||
});
|
||||
|
||||
it("should not affect words without double quotes", async () => {
|
||||
await expect(replace("'hello'", "")).resolves.toEqual("'hello'");
|
||||
await expect(replace("test", "")).resolves.toEqual("test");
|
||||
});
|
||||
|
||||
it("ignores prototype-related property names (e.g. constructor, __proto__)", async () => {
|
||||
await expect(replace("constructor", "")).resolves.toEqual("constructor");
|
||||
await expect(replace("__proto__", "")).resolves.toEqual("__proto__");
|
||||
});
|
||||
});
|
||||
});
|
||||
25
frontend/__tests__/test/funbox.spec.ts
Normal file
25
frontend/__tests__/test/funbox.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getAllFunboxes } from "../../src/ts/test/funbox/list";
|
||||
|
||||
describe("funbox", () => {
|
||||
describe("list", () => {
|
||||
it("should have every frontendFunctions function defined", () => {
|
||||
for (const funbox of getAllFunboxes()) {
|
||||
const packageFunctions = (funbox.frontendFunctions ?? []).sort();
|
||||
const implementations = Object.keys(funbox.functions ?? {}).sort();
|
||||
|
||||
let message = "has mismatched functions";
|
||||
|
||||
if (packageFunctions.length > implementations.length) {
|
||||
message = `missing function implementation in frontend`;
|
||||
} else if (implementations.length > packageFunctions.length) {
|
||||
message = `missing properties in frontendFunctions in the package`;
|
||||
}
|
||||
|
||||
expect(packageFunctions, `Funbox ${funbox.name} ${message}`).toEqual(
|
||||
implementations,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
74
frontend/__tests__/test/funbox/funbox-validation.spec.ts
Normal file
74
frontend/__tests__/test/funbox/funbox-validation.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { canSetConfigWithCurrentFunboxes } from "../../../src/ts/config/funbox-validation";
|
||||
|
||||
import { FunboxName } from "@monkeytype/schemas/configs";
|
||||
describe("funbox-validation", () => {
|
||||
describe("canSetConfigWithCurrentFunboxes", () => {
|
||||
const testCases = [
|
||||
//checks for frontendForcedConfig
|
||||
{
|
||||
key: "mode",
|
||||
value: "zen",
|
||||
funbox: ["memory"],
|
||||
expected: false,
|
||||
},
|
||||
{ key: "mode", value: "words", funbox: ["memory"], expected: true },
|
||||
|
||||
//checks for zen mode
|
||||
...[
|
||||
"58008", //getWord
|
||||
"wikipedia", //pullSection
|
||||
"morse", //alterText
|
||||
"polyglot", //withWords
|
||||
"rAnDoMcAsE", //changesCapitalisation
|
||||
"nospace", //nospace
|
||||
"plus_one", //toPush:
|
||||
"read_ahead_easy", //changesWordVisibility
|
||||
"tts", //speaks
|
||||
"layout_mirror", //changesLayout
|
||||
"zipf", //changesWordsFrequency
|
||||
].map((funbox) => ({
|
||||
key: "mode",
|
||||
value: "zen",
|
||||
funbox: [funbox],
|
||||
expected: false,
|
||||
})),
|
||||
{ key: "mode", value: "zen", funbox: ["mirror"], expected: true },
|
||||
{
|
||||
key: "mode",
|
||||
value: "zen",
|
||||
funbox: ["space_balls"],
|
||||
expected: true,
|
||||
},
|
||||
|
||||
//checks for words and custom
|
||||
...["quote", "custom"].flatMap((value) =>
|
||||
[
|
||||
"58008", //getWord
|
||||
"wikipedia", //pullSection
|
||||
"polyglot", //withWords
|
||||
"zipf", //changesWordsFrequency
|
||||
].map((funbox) => ({
|
||||
key: "mode",
|
||||
value,
|
||||
funbox: [funbox],
|
||||
expected: false,
|
||||
})),
|
||||
),
|
||||
{
|
||||
key: "mode",
|
||||
value: "quote",
|
||||
funbox: ["space_balls"],
|
||||
expected: true,
|
||||
},
|
||||
];
|
||||
it.for(testCases)(
|
||||
`check $funbox with $key=$value`,
|
||||
({ key, value, funbox, expected }) => {
|
||||
expect(
|
||||
canSetConfigWithCurrentFunboxes(key, value, funbox as FunboxName[]),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
86
frontend/__tests__/test/layout-emulator.spec.ts
Normal file
86
frontend/__tests__/test/layout-emulator.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
updateAltGrState,
|
||||
getIsAltGrPressed,
|
||||
} from "../../src/ts/test/layout-emulator";
|
||||
|
||||
describe("LayoutEmulator", () => {
|
||||
describe("updateAltGrState", () => {
|
||||
afterEach(() => {
|
||||
// Reset isAltGrPressed state after each test
|
||||
// Simulate keyup event to reset state
|
||||
const event = createEvent("AltRight", "keyup");
|
||||
updateAltGrState(event);
|
||||
});
|
||||
|
||||
const createEvent = (code: string, type: string): KeyboardEvent =>
|
||||
({
|
||||
code,
|
||||
type,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
it("should set isAltGrPressed to true on AltRight keydown", () => {
|
||||
const event = createEvent("AltRight", "keydown");
|
||||
updateAltGrState(event);
|
||||
expect(getIsAltGrPressed()).toBe(true);
|
||||
});
|
||||
|
||||
it("should set isAltGrPressed to false on AltRight keyup", () => {
|
||||
const event = createEvent("AltRight", "keyup");
|
||||
updateAltGrState(event);
|
||||
expect(getIsAltGrPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("should set isAltGrPressed to true on AltLeft keydown on Mac", () => {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Mac",
|
||||
configurable: true,
|
||||
});
|
||||
const event = createEvent("AltLeft", "keydown");
|
||||
updateAltGrState(event);
|
||||
expect(getIsAltGrPressed()).toBe(true);
|
||||
});
|
||||
|
||||
it("should set isAltGrPressed to false on AltLeft keyup on Mac", () => {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Mac",
|
||||
configurable: true,
|
||||
});
|
||||
const event = createEvent("AltLeft", "keyup");
|
||||
updateAltGrState(event);
|
||||
expect(getIsAltGrPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not change isAltGrPressed on AltLeft keydown on non-Mac", () => {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Windows",
|
||||
configurable: true,
|
||||
});
|
||||
const event = createEvent("AltLeft", "keydown");
|
||||
updateAltGrState(event);
|
||||
expect(getIsAltGrPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not change isAltGrPressed on AltLeft keyup on non-Mac", () => {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Windows",
|
||||
configurable: true,
|
||||
});
|
||||
const event = createEvent("AltLeft", "keyup");
|
||||
updateAltGrState(event);
|
||||
expect(getIsAltGrPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not change isAltGrPressed on keydown of other keys", () => {
|
||||
const event = createEvent("KeyA", "keydown");
|
||||
updateAltGrState(event);
|
||||
expect(getIsAltGrPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not change isAltGrPressed on keyup of other keys", () => {
|
||||
const event = createEvent("KeyA", "keyup");
|
||||
updateAltGrState(event);
|
||||
expect(getIsAltGrPressed()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
frontend/__tests__/test/lazy-mode.spec.ts
Normal file
42
frontend/__tests__/test/lazy-mode.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { replaceAccents } from "../../src/ts/test/lazy-mode";
|
||||
|
||||
let additionalAccents = [
|
||||
["abc", "1"],
|
||||
["def", "22"],
|
||||
["gh", "333"],
|
||||
] as [string, string][];
|
||||
|
||||
describe("lazy-mode", () => {
|
||||
describe("replaceAccents", () => {
|
||||
it("should replace common accents", () => {
|
||||
const result = replaceAccents("Héllö");
|
||||
expect(result).toBe("Hello");
|
||||
});
|
||||
it("should extend common accents with additional accents", () => {
|
||||
const result = replaceAccents("Héllö", [["ö", "oe"]]);
|
||||
expect(result).toBe("Helloe");
|
||||
});
|
||||
it("should remove accent if empty", () => {
|
||||
const result = replaceAccents("خصوصًا", [["ٌ", ""]]);
|
||||
expect(result).toBe("خصوصا");
|
||||
});
|
||||
it("should ignore empty word", () => {
|
||||
const result = replaceAccents("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
it("should correctly use additional accents", () => {
|
||||
const tests = [
|
||||
{ input: "abc", expected: "111" },
|
||||
{ input: "abcdef", expected: "111222222" },
|
||||
{ input: "gh", expected: "333333" },
|
||||
{ input: "abcdefgh", expected: "111222222333333" },
|
||||
{ input: "zzdzz", expected: "zz22zz" },
|
||||
];
|
||||
tests.forEach(({ input, expected }) => {
|
||||
const result = replaceAccents(input, additionalAccents);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
17
frontend/__tests__/tsconfig.json
Normal file
17
frontend/__tests__/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "ESNext",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js"
|
||||
},
|
||||
"files": ["vitest.d.ts"],
|
||||
"include": [
|
||||
"./**/*.spec.ts",
|
||||
"./**/*.spec.tsx",
|
||||
"./**/*.jsdom-spec.ts",
|
||||
"./setup-tests.ts"
|
||||
]
|
||||
}
|
||||
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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
24
frontend/__tests__/vitest.d.ts
vendored
Normal file
24
frontend/__tests__/vitest.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Assertion, AsymmetricMatchersContaining } from "vitest";
|
||||
import { TestActivityDay } from "../src/ts/elements/test-activity-calendar";
|
||||
|
||||
interface ActivityDayMatchers<R = TestActivityDay> {
|
||||
toBeDate: (date: string) => ActivityDayMatchers<R>;
|
||||
toHaveTests: (tests: number) => ActivityDayMatchers<R>;
|
||||
toHaveLevel: (level?: string | number) => ActivityDayMatchers<R>;
|
||||
toBeFiller: () => ActivityDayMatchers<R>;
|
||||
}
|
||||
|
||||
/// <reference types="vitest" />
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
declare module "vitest" {
|
||||
interface Assertion<T = any> extends ActivityDayMatchers<T> {}
|
||||
interface AsymmetricMatchersContaining extends ActivityDayMatchers {}
|
||||
}
|
||||
|
||||
interface MatcherResult {
|
||||
pass: boolean;
|
||||
message: () => string;
|
||||
actual?: unknown;
|
||||
expected?: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user