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

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

View File

@@ -0,0 +1,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"));

View File

@@ -0,0 +1,7 @@
import { vi } from "vitest";
vi.mock("../src/ts/constants/env-config", () => ({
envConfig: {
backendUrl: "invalid",
isDevelopment: true,
},
}));

View File

@@ -0,0 +1,5 @@
import { vi } from "vitest";
vi.mock("../../src/ts/firebase", () => ({
app: undefined,
Auth: undefined,
}));

View File

@@ -0,0 +1,2 @@
//extend expect with dom matchers
import "@testing-library/jest-dom";

View 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(),
);
}

View 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");
});
});

View 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,
};
}
});
});

View 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();
});
});

View 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");
});
});
});

View 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);
});
});

View File

@@ -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();
});
});

View 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);
});
});
});

View 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 }),
);
});
});

View 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")!,
};
}
});

View 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"));
}
});

View 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");
});
});

View File

@@ -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();
});
});

View 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();
});
});

View 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");
});
});

View 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();
});
});

View 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");
});
});

View 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");
});
});

View 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;
};
});
});

View 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,
]),
);
};

View 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());
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
export const setup = (): void => {
process.env.TZ = "UTC";
};

View 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();
});
});

View 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();
});
});
});

View 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);
},
);
});

View 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);
});
});
});

View 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 ?? {});
},
);
});
});

View 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");
});
});
});

View 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);
});
});
});

View 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__");
});
});
});

View 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,
);
}
});
});
});

View 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);
},
);
});
});

View 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);
});
});
});

View 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);
});
});
});
});

View 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"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

24
frontend/__tests__/vitest.d.ts vendored Normal file
View 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;
}