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