This commit is contained in:
88
frontend/__tests__/components/common/AnimatedModal.spec.tsx
Normal file
88
frontend/__tests__/components/common/AnimatedModal.spec.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { render } from "@solidjs/testing-library";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { AnimatedModal } from "../../../src/ts/components/common/AnimatedModal";
|
||||
|
||||
describe("AnimatedModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock dialog methods that don't exist in jsdom
|
||||
HTMLDialogElement.prototype.showModal = vi.fn();
|
||||
HTMLDialogElement.prototype.show = vi.fn();
|
||||
HTMLDialogElement.prototype.close = vi.fn();
|
||||
});
|
||||
|
||||
function renderModal(props: {
|
||||
onEscape?: (e: KeyboardEvent) => void;
|
||||
onBackdropClick?: (e: MouseEvent) => void;
|
||||
wrapperClass?: string;
|
||||
beforeShow?: () => void | Promise<void>;
|
||||
afterShow?: () => void | Promise<void>;
|
||||
beforeHide?: () => void | Promise<void>;
|
||||
afterHide?: () => void | Promise<void>;
|
||||
animationMode?: "none" | "both" | "modalOnly";
|
||||
}): {
|
||||
container: HTMLElement;
|
||||
dialog: HTMLDialogElement;
|
||||
modalDiv: HTMLDivElement;
|
||||
} {
|
||||
const { container } = render(() => (
|
||||
<AnimatedModal id="Support" {...props}>
|
||||
<div data-testid="modal-content">Test Content</div>
|
||||
</AnimatedModal>
|
||||
));
|
||||
|
||||
return {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
container: container.children[0]! as HTMLElement,
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
dialog: container.querySelector("dialog")!,
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
modalDiv: container.querySelector(".modal")!,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders dialog with correct id and class", () => {
|
||||
const { dialog } = renderModal({});
|
||||
|
||||
expect(dialog).toHaveAttribute("id", "SupportModal");
|
||||
expect(dialog).toHaveClass("hidden");
|
||||
});
|
||||
|
||||
it("renders children inside modal div", () => {
|
||||
const { modalDiv } = renderModal({});
|
||||
|
||||
expect(
|
||||
modalDiv.querySelector("[data-testid='modal-content']"),
|
||||
).toHaveTextContent("Test Content");
|
||||
});
|
||||
|
||||
it("has escape handler attached", () => {
|
||||
const { dialog } = renderModal({});
|
||||
|
||||
expect(dialog.onkeydown).toBeDefined();
|
||||
});
|
||||
|
||||
it("has backdrop click handler attached", () => {
|
||||
const { dialog } = renderModal({});
|
||||
|
||||
expect(dialog.onmousedown).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies custom class to dialog", () => {
|
||||
const { dialog } = renderModal({
|
||||
wrapperClass: "customClass",
|
||||
});
|
||||
|
||||
expect(dialog).toHaveClass("customClass");
|
||||
});
|
||||
|
||||
it("renders with animationMode none", () => {
|
||||
const { dialog } = renderModal({
|
||||
animationMode: "none",
|
||||
});
|
||||
|
||||
expect(dialog).toHaveAttribute("id", "SupportModal");
|
||||
});
|
||||
});
|
||||
385
frontend/__tests__/components/common/AsyncContent.spec.tsx
Normal file
385
frontend/__tests__/components/common/AsyncContent.spec.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { render, screen, waitFor } from "@solidjs/testing-library";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
} from "@tanstack/solid-query";
|
||||
import { JSXElement, Show } from "solid-js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import AsyncContent, {
|
||||
Props,
|
||||
} from "../../../src/ts/components/common/AsyncContent";
|
||||
import * as Notifications from "../../../src/ts/states/notifications";
|
||||
|
||||
describe("AsyncContent", () => {
|
||||
const notifyErrorMock = vi.spyOn(Notifications, "showErrorNotification");
|
||||
|
||||
beforeEach(() => {
|
||||
notifyErrorMock.mockClear();
|
||||
});
|
||||
|
||||
describe("with single query", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
it("renders loading state while pending", () => {
|
||||
const { container } = renderWithQuery({ result: "data" });
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).toBeInTheDocument();
|
||||
expect(preloader?.querySelector("i")).toHaveClass(
|
||||
"fas",
|
||||
"fa-fw",
|
||||
"fa-spin",
|
||||
"fa-circle-notch",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders custom loader while pending", () => {
|
||||
const { container } = renderWithQuery(
|
||||
{ result: "data" },
|
||||
{ loader: <span class="preloader">Loading...</span> },
|
||||
);
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).toBeInTheDocument();
|
||||
expect(preloader).toHaveTextContent("Loading...");
|
||||
});
|
||||
|
||||
it("renders on resolve", async () => {
|
||||
const { container } = renderWithQuery({ result: "Test Data" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
|
||||
});
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders on resolve with object containing null", async () => {
|
||||
const { container } = renderWithQuery({
|
||||
result: { text: "Test Data", extra: null } as any,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toBeVisible();
|
||||
});
|
||||
expect(container.innerHTML).toContain("static content");
|
||||
});
|
||||
|
||||
it("renders default error message on fail", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery({ result: error });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("An error occurred", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders custom error message on fail", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery(
|
||||
{ result: error },
|
||||
{ errorMessage: "Custom error message" },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("Custom error message", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores error on fail if ignoreError is set", async () => {
|
||||
renderWithQuery(
|
||||
{ result: new Error("Test error") },
|
||||
{ ignoreError: true, alwaysShowContent: true },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no data/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders on pending if alwaysShowContent", async () => {
|
||||
const { container } = renderWithQuery({ result: "Test Data" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
|
||||
});
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders on resolve if alwaysShowContent", async () => {
|
||||
renderWithQuery({ result: "Test Data" }, { alwaysShowContent: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders on fail if alwaysShowContent", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery(
|
||||
{ result: error },
|
||||
{ errorMessage: "Custom error message" },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("Custom error message", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithQuery(
|
||||
query: {
|
||||
result: string | Error;
|
||||
},
|
||||
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
|
||||
): {
|
||||
container: HTMLElement;
|
||||
} {
|
||||
const wrapper = (): JSXElement => {
|
||||
const myQuery = useQuery(() => ({
|
||||
queryKey: ["test", Math.random() * 1000],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (query.result instanceof Error) {
|
||||
throw query.result;
|
||||
}
|
||||
return query.result;
|
||||
},
|
||||
retry: 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AsyncContent query={myQuery} {...(options as Props<string>)}>
|
||||
{(data: string | undefined) => (
|
||||
<>
|
||||
static content
|
||||
<Show when={data !== undefined} fallback={<div>no data</div>}>
|
||||
<div data-testid="content">{data}</div>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</AsyncContent>
|
||||
);
|
||||
};
|
||||
const { container } = render(() => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{wrapper()}
|
||||
</QueryClientProvider>
|
||||
));
|
||||
|
||||
return {
|
||||
container,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
describe("with multiple queries", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
it("renders loading state while pending", () => {
|
||||
const { container } = renderWithQuery({ first: "data", second: "data" });
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).toBeInTheDocument();
|
||||
expect(preloader?.querySelector("i")).toHaveClass(
|
||||
"fas",
|
||||
"fa-fw",
|
||||
"fa-spin",
|
||||
"fa-circle-notch",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders custom loader while pending", () => {
|
||||
const { container } = renderWithQuery(
|
||||
{ first: "data", second: "data" },
|
||||
{ loader: <span class="preloader">Loading...</span> },
|
||||
);
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).toBeInTheDocument();
|
||||
expect(preloader).toHaveTextContent("Loading...");
|
||||
});
|
||||
|
||||
it("renders on resolve", async () => {
|
||||
const { container } = renderWithQuery({
|
||||
first: "First Data",
|
||||
second: "Second Data",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("first")).toHaveTextContent("First Data");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("second")).toHaveTextContent("Second Data");
|
||||
});
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders default error message on fail", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery({ first: "data", second: error });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("An error occurred", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders custom error message on fail", async () => {
|
||||
const firstError = new Error("First error");
|
||||
renderWithQuery(
|
||||
{ first: firstError, second: new Error("Second error") },
|
||||
{ errorMessage: "Custom error message" },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("Custom error message", {
|
||||
error: firstError,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores error on fail if ignoreError is set", async () => {
|
||||
renderWithQuery(
|
||||
{ first: new Error("First error"), second: new Error("Second error") },
|
||||
{ ignoreError: true, alwaysShowContent: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no data/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(notifyErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders on pending if alwaysShowContent", async () => {
|
||||
const { container } = renderWithQuery(
|
||||
{
|
||||
first: undefined,
|
||||
second: undefined,
|
||||
},
|
||||
{ alwaysShowContent: true },
|
||||
);
|
||||
|
||||
const preloader = container.querySelector(".preloader");
|
||||
expect(preloader).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no data/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders on resolve if alwaysShowContent", async () => {
|
||||
renderWithQuery({
|
||||
first: "First Data",
|
||||
second: "Second Data",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("first")).toHaveTextContent("First Data");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("second")).toHaveTextContent("Second Data");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders on fail if alwaysShowContent", async () => {
|
||||
const error = new Error("Test error");
|
||||
renderWithQuery(
|
||||
{ first: "data", second: error },
|
||||
{ errorMessage: "Custom error message" },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
|
||||
});
|
||||
expect(notifyErrorMock).toHaveBeenCalledWith("Custom error message", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithQuery(
|
||||
queries: {
|
||||
first: string | Error | undefined;
|
||||
second: string | Error | undefined;
|
||||
},
|
||||
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
|
||||
): {
|
||||
container: HTMLElement;
|
||||
} {
|
||||
const wrapper = (): JSXElement => {
|
||||
const firstQuery = useQuery(() => ({
|
||||
queryKey: ["first", Math.random() * 1000],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (queries.first instanceof Error) {
|
||||
throw queries.first;
|
||||
}
|
||||
return queries.first;
|
||||
},
|
||||
retry: 0,
|
||||
}));
|
||||
const secondQuery = useQuery(() => ({
|
||||
queryKey: ["second", Math.random() * 1000],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (queries.second instanceof Error) {
|
||||
throw queries.second;
|
||||
}
|
||||
return queries.second;
|
||||
},
|
||||
retry: 0,
|
||||
}));
|
||||
|
||||
type Q = { first: string | undefined; second: string | undefined };
|
||||
return (
|
||||
<AsyncContent
|
||||
queries={{ first: firstQuery, second: secondQuery }}
|
||||
{...(options as Props<Q>)}
|
||||
>
|
||||
{(results: {
|
||||
first: string | undefined;
|
||||
second: string | undefined;
|
||||
}) => (
|
||||
<>
|
||||
<Show
|
||||
when={
|
||||
results.first !== undefined && results.second !== undefined
|
||||
}
|
||||
fallback={<div>no data</div>}
|
||||
>
|
||||
<div data-testid="first">{results.first}</div>
|
||||
<div data-testid="second">{results.second}</div>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</AsyncContent>
|
||||
);
|
||||
};
|
||||
const { container } = render(() => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{wrapper()}
|
||||
</QueryClientProvider>
|
||||
));
|
||||
|
||||
return {
|
||||
container,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
273
frontend/__tests__/components/common/Button.spec.tsx
Normal file
273
frontend/__tests__/components/common/Button.spec.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { cleanup, render } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Button } from "../../../src/ts/components/common/Button";
|
||||
import { FaSolidIcon } from "../../../src/ts/types/font-awesome";
|
||||
|
||||
describe("Button component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders a button element when onClick is provided", () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
const { container } = render(() => (
|
||||
<Button onClick={onClick} text="Click me" />
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toBeTruthy();
|
||||
expect(button).toHaveTextContent("Click me");
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders an anchor element when href is provided", () => {
|
||||
const { container } = render(() => (
|
||||
<Button href="https://example.com" text="Go" />
|
||||
));
|
||||
|
||||
const anchor = container.querySelector("a");
|
||||
expect(anchor).toBeTruthy();
|
||||
expect(anchor).toHaveAttribute("href", "https://example.com");
|
||||
expect(anchor).toHaveAttribute("target", "_blank");
|
||||
expect(anchor).toHaveAttribute("rel", "noreferrer noopener");
|
||||
expect(anchor).not.toHaveAttribute("router-link");
|
||||
expect(anchor).not.toHaveAttribute("aria-label");
|
||||
expect(anchor).not.toHaveAttribute("data-balloon-pos");
|
||||
});
|
||||
|
||||
it("calls onClick when button is clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
const { container } = render(() => (
|
||||
<Button onClick={onClick} text="Click me" />
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
button?.click();
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders icon when icon prop is provided", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
fa={{
|
||||
icon: "fa-keyboard",
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
const icon = container.querySelector("i");
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon).toHaveClass("fas");
|
||||
expect(icon).toHaveClass("fa-keyboard");
|
||||
});
|
||||
|
||||
it("renders icon when icon prop has changed", () => {
|
||||
const [icon, setIcon] = createSignal<FaSolidIcon>("fa-keyboard");
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
fa={{
|
||||
icon: icon(),
|
||||
class: "test",
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
setIcon("fa-backward");
|
||||
|
||||
const i = container.querySelector("i");
|
||||
expect(i).toBeTruthy();
|
||||
expect(i).toHaveClass("fas");
|
||||
expect(i).toHaveClass("fa-backward");
|
||||
expect(i).toHaveClass("test");
|
||||
});
|
||||
|
||||
it("applies fa-fw class when fixedWidthIcon is true", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
fa={{
|
||||
fixedWidth: true,
|
||||
icon: "fa-keyboard",
|
||||
}}
|
||||
text="Hello"
|
||||
/>
|
||||
));
|
||||
|
||||
const icon = container.querySelector("i");
|
||||
expect(icon).toHaveClass("fa-fw");
|
||||
});
|
||||
|
||||
it("does not apply fa-fw when text is present and fixedWidthIcon is false", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
fa={{
|
||||
icon: "fa-keyboard",
|
||||
}}
|
||||
text="Hello"
|
||||
/>
|
||||
));
|
||||
|
||||
const icon = container.querySelector("i");
|
||||
expect(icon).not.toHaveClass("fa-fw");
|
||||
});
|
||||
|
||||
it("applies default button class", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toHaveClass("button");
|
||||
});
|
||||
|
||||
it("applies custom class when class prop is provided", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
class="custom-class"
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("renders children content", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
<span data-testid="child">Child</span>
|
||||
</Button>
|
||||
));
|
||||
|
||||
const child = container.querySelector('[data-testid="child"]');
|
||||
expect(child).toBeTruthy();
|
||||
expect(child).toHaveTextContent("Child");
|
||||
});
|
||||
|
||||
it("applies balloon to button with default position", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
balloon={{ text: "test" }}
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toHaveAttribute("aria-label", "test");
|
||||
expect(button).toHaveAttribute("data-balloon-pos", "up");
|
||||
});
|
||||
|
||||
it("applies balloon to button with custom position", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
balloon={{ text: "test", position: "down" }}
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toHaveAttribute("aria-label", "test");
|
||||
expect(button).toHaveAttribute("data-balloon-pos", "down");
|
||||
});
|
||||
|
||||
it("applies router-link to button", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
text="Hello"
|
||||
router-link
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toHaveAttribute("router-link", "");
|
||||
});
|
||||
|
||||
it("applies balloon to anchor with default position", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
href="http://example.com"
|
||||
text="Hello"
|
||||
balloon={{ text: "test" }}
|
||||
/>
|
||||
));
|
||||
|
||||
const anchor = container.querySelector("a");
|
||||
expect(anchor).toHaveAttribute("aria-label", "test");
|
||||
expect(anchor).toHaveAttribute("data-balloon-pos", "up");
|
||||
});
|
||||
|
||||
it("applies balloon to anchor with custom position", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
href="http://example.com"
|
||||
text="Hello"
|
||||
balloon={{ text: "test", position: "down" }}
|
||||
/>
|
||||
));
|
||||
|
||||
const anchor = container.querySelector("a");
|
||||
expect(anchor).toHaveAttribute("aria-label", "test");
|
||||
expect(anchor).toHaveAttribute("data-balloon-pos", "down");
|
||||
});
|
||||
|
||||
it("applies router-link to anchor", () => {
|
||||
const { container } = render(() => (
|
||||
<Button href="http://example.com" text="Hello" router-link />
|
||||
));
|
||||
|
||||
const anchor = container.querySelector("a");
|
||||
expect(anchor).toHaveAttribute("router-link", "");
|
||||
});
|
||||
|
||||
it("applies disabled to button", () => {
|
||||
const { container } = render(() => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
/** */
|
||||
}}
|
||||
text="Hello"
|
||||
disabled={true}
|
||||
/>
|
||||
));
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
214
frontend/__tests__/components/common/Conditional.spec.tsx
Normal file
214
frontend/__tests__/components/common/Conditional.spec.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { Conditional } from "../../../src/ts/components/common/Conditional";
|
||||
|
||||
describe("Conditional", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("static rendering", () => {
|
||||
it("renders then when if is true", () => {
|
||||
render(() => <Conditional if={true} then={<div>then content</div>} />);
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders then when if is a truthy object", () => {
|
||||
render(() => (
|
||||
<Conditional if={{ value: 42 }} then={<div>then content</div>} />
|
||||
));
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders then when if is a truthy string", () => {
|
||||
render(() => <Conditional if="hello" then={<div>then content</div>} />);
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders else fallback when if is false", () => {
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={false}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders else fallback when if is null", () => {
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={null}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders else fallback when if is undefined", () => {
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={undefined}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders else fallback when if is 0", () => {
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={0}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when if is falsy and else is not provided", () => {
|
||||
const { container } = render(() => (
|
||||
<Conditional if={false} then={<div>then content</div>} />
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("then as function", () => {
|
||||
it("passes the truthy value to then function", () => {
|
||||
const obj: { label: string } | null = { label: "hello" };
|
||||
render(() => (
|
||||
<Conditional if={obj} then={(value) => <div>{value().label}</div>} />
|
||||
));
|
||||
|
||||
expect(screen.getByText("hello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call then function when if is falsy", () => {
|
||||
const obj: { label: string } | null = null;
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={obj}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactivity", () => {
|
||||
it("switches from else to then when if becomes truthy", async () => {
|
||||
const [condition, setCondition] = createSignal(false);
|
||||
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={condition()}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
|
||||
setCondition(true);
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
expect(screen.queryByText("else content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches from then to else when if becomes falsy", async () => {
|
||||
const [condition, setCondition] = createSignal(true);
|
||||
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={condition()}
|
||||
then={<div>then content</div>}
|
||||
else={<div>else content</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText("then content")).toBeInTheDocument();
|
||||
|
||||
setCondition(false);
|
||||
|
||||
expect(screen.queryByText("then content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("else content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("then JSXElement updates reactively when inner signal changes", async () => {
|
||||
const [label, setLabel] = createSignal("initial");
|
||||
|
||||
render(() => <Conditional if={true} then={<div>{label()}</div>} />);
|
||||
|
||||
expect(screen.getByText("initial")).toBeInTheDocument();
|
||||
|
||||
setLabel("updated");
|
||||
|
||||
expect(screen.getByText("updated")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("then JSXElement updates reactively when if changes from a signal", async () => {
|
||||
const [data, setData] = createSignal<string | undefined>(undefined);
|
||||
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={data()}
|
||||
then={<div data-testid="content">{data()}</div>}
|
||||
else={<div>no data</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText("no data")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
|
||||
|
||||
setData("resolved");
|
||||
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("resolved");
|
||||
expect(screen.queryByText("no data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("then function value accessor tracks reactive if", () => {
|
||||
const [data, setData] = createSignal<{ name: string } | null>(null);
|
||||
|
||||
render(() => (
|
||||
<Conditional
|
||||
if={data()}
|
||||
then={(value) => <div data-testid="content">{value().name}</div>}
|
||||
else={<div>no data</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText("no data")).toBeInTheDocument();
|
||||
|
||||
setData({ name: "Alice" });
|
||||
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Alice");
|
||||
|
||||
setData({ name: "Bob" });
|
||||
|
||||
expect(screen.getByTestId("content")).toHaveTextContent("Bob");
|
||||
});
|
||||
});
|
||||
});
|
||||
390
frontend/__tests__/components/common/anime/Anime.spec.tsx
Normal file
390
frontend/__tests__/components/common/anime/Anime.spec.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { cleanup, render } from "@solidjs/testing-library";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockReturnValue({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((_cb: unknown) => Promise.resolve()),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
// Mock applyReducedMotion
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { Anime } from "../../../../src/ts/components/common/anime/Anime";
|
||||
import {
|
||||
AnimeGroup,
|
||||
createStagger,
|
||||
} from "../../../../src/ts/components/common/anime/AnimeGroup";
|
||||
import { AnimePresence } from "../../../../src/ts/components/common/anime/AnimePresence";
|
||||
|
||||
describe("Anime", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a div wrapper by default", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime animation={{ opacity: 1 }}>
|
||||
<span>content</span>
|
||||
</Anime>
|
||||
));
|
||||
expect(container.querySelector("div")).toBeTruthy();
|
||||
expect(container.querySelector("span")).toHaveTextContent("content");
|
||||
});
|
||||
|
||||
it("renders with custom tag via `as` prop", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime animation={{ opacity: 1 }} as="section">
|
||||
<span>hi</span>
|
||||
</Anime>
|
||||
));
|
||||
expect(container.querySelector("section")).toBeTruthy();
|
||||
expect(container.querySelector("div")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies className and style props", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime
|
||||
animation={{ opacity: 1 }}
|
||||
class="my-class"
|
||||
style={{ color: "red" }}
|
||||
>
|
||||
<span />
|
||||
</Anime>
|
||||
));
|
||||
const el = container.querySelector(".my-class");
|
||||
expect(el).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls animejsAnimate on mount with animation prop", () => {
|
||||
render(() => (
|
||||
<Anime animation={{ opacity: 1, duration: 300 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies initial state with duration:0 then animates to animate prop", () => {
|
||||
render(() => (
|
||||
<Anime initial={{ opacity: 0 }} animate={{ opacity: 1, duration: 300 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
|
||||
// First call: initial state (duration: 0)
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, duration: 0 }),
|
||||
);
|
||||
// Second call: full animation
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-runs animation when reactive signal changes", () => {
|
||||
const [opacity, setOpacity] = createSignal(1);
|
||||
|
||||
render(() => (
|
||||
<Anime animation={{ opacity: opacity(), duration: 200 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
|
||||
const callsBefore = mockAnimate.mock.calls.length;
|
||||
setOpacity(0);
|
||||
|
||||
expect(mockAnimate.mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimePresence", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence>
|
||||
<div data-testid="child">hello</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
expect(container.querySelector("[data-testid='child']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders children in list mode", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence mode="list">
|
||||
<div data-testid="item-1">one</div>
|
||||
<div data-testid="item-2">two</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
expect(container.querySelector("[data-testid='item-1']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='item-2']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("list mode wraps children in a display:contents div", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence mode="list">
|
||||
<div>child</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
const wrapper = container.querySelector("div");
|
||||
expect(wrapper?.style.display).toBe("contents");
|
||||
});
|
||||
|
||||
it("mounts and unmounts Show child without errors", async () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimePresence>
|
||||
<Show when={show()}>
|
||||
<Anime animate={{ opacity: 1, duration: 0 }}>
|
||||
<div data-testid="toggled">toggled</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
</AnimePresence>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
});
|
||||
|
||||
it("exitBeforeEnter mode does not throw on child switch", () => {
|
||||
const [view, setView] = createSignal<"a" | "b">("a");
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimePresence exitBeforeEnter>
|
||||
<Show when={view() === "a"}>
|
||||
<Anime exit={{ opacity: 0, duration: 0 }}>
|
||||
<div>View A</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
<Show when={view() === "b"}>
|
||||
<Anime exit={{ opacity: 0, duration: 0 }}>
|
||||
<div>View B</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
</AnimePresence>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setView("b")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimeGroup", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a div wrapper by default", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }}>
|
||||
<div>a</div>
|
||||
<div>b</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector("div")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders with custom tag via `as` prop", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1 }} as="ul">
|
||||
<li>item</li>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector("ul")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("animates each child on mount", () => {
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={50}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// One call per child element
|
||||
const childCalls = mockAnimate.mock.calls.filter(
|
||||
([el]) => el instanceof HTMLElement,
|
||||
);
|
||||
expect(childCalls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("applies initial state before animating children", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
initial={{ opacity: 0 }}
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// Initial state calls (duration: 0) should precede animation calls
|
||||
const zeroDurationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 0,
|
||||
);
|
||||
expect(zeroDurationCalls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("applies stagger delays in forward direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={100}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// Calls with non-zero delay values reflecting stagger
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// forward stagger: delays should be 0, 100, 200
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(100);
|
||||
expect(delayCalls).toContain(200);
|
||||
});
|
||||
|
||||
it("reverses stagger direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
stagger={100}
|
||||
direction="reverse"
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// reverse: first child gets highest delay (200), last gets 0
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(200);
|
||||
});
|
||||
|
||||
it("applies center stagger direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
stagger={100}
|
||||
direction="center"
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// center: middle element (index 1) has 0 delay, outer elements have 100
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(100);
|
||||
});
|
||||
|
||||
it("accepts a function stagger", () => {
|
||||
const staggerFn = vi.fn((_i: number, _t: number) => 75);
|
||||
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={staggerFn}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
expect(staggerFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies class and style to wrapper", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1 }}
|
||||
class="group-class"
|
||||
style={{ gap: "8px" }}
|
||||
>
|
||||
<div>1</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector(".group-class")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStagger", () => {
|
||||
it("returns 0 for single element", () => {
|
||||
const fn = createStagger({ base: 100 });
|
||||
expect(fn(0, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("linear stagger from start: first=0, last=base*(total-1)", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "start" });
|
||||
expect(fn(0, 3)).toBeCloseTo(0);
|
||||
expect(fn(2, 3)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("linear stagger from end: first=base*(total-1), last=0", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "end" });
|
||||
expect(fn(0, 3)).toBeCloseTo(100);
|
||||
expect(fn(2, 3)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("center stagger: middle element has smallest value", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "center" });
|
||||
// For 5 items, center is index 2 → distance = 0
|
||||
expect(fn(2, 5)).toBeCloseTo(0);
|
||||
expect(fn(0, 5)).toBeGreaterThan(fn(2, 5));
|
||||
});
|
||||
|
||||
it("easeIn produces smaller values at start", () => {
|
||||
const linear = createStagger({ base: 100, ease: "linear" });
|
||||
const easeIn = createStagger({ base: 100, ease: "easeIn" });
|
||||
// At index 1 of 4, easeIn position is less progressed than linear
|
||||
expect(easeIn(1, 4)).toBeLessThan(linear(1, 4));
|
||||
});
|
||||
|
||||
it("easeOut produces larger values at start compared to easeIn", () => {
|
||||
const easeOut = createStagger({ base: 100, ease: "easeOut" });
|
||||
const easeIn = createStagger({ base: 100, ease: "easeIn" });
|
||||
expect(easeOut(1, 4)).toBeGreaterThan(easeIn(1, 4));
|
||||
});
|
||||
|
||||
it("easeInOut is symmetric", () => {
|
||||
const fn = createStagger({ base: 100, ease: "easeInOut", from: "start" });
|
||||
// At 50% position (index 1 of 3), easeInOut should equal linear
|
||||
// easeInOut at 0.5 = 0.5 → 100 * 0.5 * 2 = 100
|
||||
expect(fn(1, 3)).toBeCloseTo(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => ({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeConditional } from "../../../../src/ts/components/common/anime/AnimeConditional";
|
||||
|
||||
describe("AnimeConditional", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders `then` content when `if` is truthy", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("else-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders `else` content when `if` is falsy", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={false}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders `else` content when `if` is null", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={null}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches reactively from `then` to `else`", () => {
|
||||
const [condition, setCondition] = createSignal(true);
|
||||
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={condition()}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
|
||||
setCondition(false);
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches reactively from `else` to `then`", () => {
|
||||
const [condition, setCondition] = createSignal(false);
|
||||
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={condition()}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
|
||||
setCondition(true);
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("else-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports `then` as a function and passes the truthy value", () => {
|
||||
const obj = { label: "hello" };
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={obj}
|
||||
then={(value) => <div data-testid="fn-content">{value().label}</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("fn-content")).toHaveTextContent("hello");
|
||||
});
|
||||
|
||||
it("does not throw without `else` prop", () => {
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not throw on mount/unmount", () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={show()}
|
||||
then={<div>then</div>}
|
||||
else={<div>else</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
expect(() => setShow(true)).not.toThrow();
|
||||
});
|
||||
|
||||
describe("default animations (opacity fade)", () => {
|
||||
it("applies default opacity animate on `then` branch", () => {
|
||||
render(() => <AnimeConditional if={true} then={<div>then</div>} />);
|
||||
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 125 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies default opacity initial state on `then` branch", () => {
|
||||
render(() => <AnimeConditional if={true} then={<div>then</div>} />);
|
||||
|
||||
// Initial call: opacity:0 with duration:0
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, duration: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom animeProps", () => {
|
||||
it("uses custom animate params when animeProps provided", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div>then</div>}
|
||||
animeProps={{
|
||||
initial: { opacity: 0, translateY: -10 },
|
||||
animate: { opacity: 1, translateY: 0, duration: 400 },
|
||||
exit: { opacity: 0, translateY: -10, duration: 200 },
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, translateY: 0, duration: 400 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom initial state when animeProps provided", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div>then</div>}
|
||||
animeProps={{
|
||||
initial: { opacity: 0, translateY: -10 },
|
||||
animate: { opacity: 1, translateY: 0, duration: 400 },
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
// Initial state applied with duration:0
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, translateY: -10, duration: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("exitBeforeEnter prop does not throw on condition change", () => {
|
||||
const [cond, setCond] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
exitBeforeEnter
|
||||
if={cond()}
|
||||
then={<div>then</div>}
|
||||
else={<div>else</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setCond(false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
157
frontend/__tests__/components/common/anime/AnimeShow.spec.tsx
Normal file
157
frontend/__tests__/components/common/anime/AnimeShow.spec.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const animation = {
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
callbacks.push(cb);
|
||||
// Invoke immediately so exit animations complete synchronously in tests
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
return animation;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeShow } from "../../../../src/ts/components/common/anime/AnimeShow";
|
||||
|
||||
describe("AnimeShow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children when `when` is true", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={false}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows and hides reactively", () => {
|
||||
const [visible, setVisible] = createSignal(true);
|
||||
|
||||
render(() => (
|
||||
<AnimeShow when={visible()}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
setVisible(false);
|
||||
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
|
||||
setVisible(true);
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies class to the wrapper element when visible", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeShow when={true} class="my-class">
|
||||
<span>content</span>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(container.querySelector(".my-class")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not throw on mount/unmount", () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeShow when={show()}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
});
|
||||
|
||||
describe("slide mode", () => {
|
||||
it("renders children when `when` is true in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} slide>
|
||||
<div data-testid="slide-content">slide</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.getByTestId("slide-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={false} slide>
|
||||
<div data-testid="slide-content">slide</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.queryByTestId("slide-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("animates height in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} slide>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const heightCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.height !== undefined,
|
||||
);
|
||||
expect(heightCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duration prop", () => {
|
||||
it("uses the provided duration", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} duration={400}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const durationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 400,
|
||||
);
|
||||
expect(durationCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("defaults to 125ms when no duration is provided", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const defaultDurationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 125,
|
||||
);
|
||||
expect(defaultDurationCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
205
frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx
Normal file
205
frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => ({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeMatch } from "../../../../src/ts/components/common/anime/AnimeMatch";
|
||||
import { AnimeSwitch } from "../../../../src/ts/components/common/anime/AnimeSwitch";
|
||||
|
||||
describe("AnimeSwitch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the matched child", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={true}>
|
||||
<div data-testid="match-a">A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="match-b">B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("match-a")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("match-b")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the next matched child reactively", () => {
|
||||
const [tab, setTab] = createSignal<"a" | "b">("a");
|
||||
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={tab() === "a"}>
|
||||
<div data-testid="view-a">View A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={tab() === "b"}>
|
||||
<div data-testid="view-b">View B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("view-a")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-b")).not.toBeInTheDocument();
|
||||
|
||||
setTab("b");
|
||||
|
||||
expect(screen.queryByTestId("view-a")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("view-b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when no match", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="no-match">never</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("no-match")).not.toBeInTheDocument();
|
||||
// Only AnimePresence wrapper remains
|
||||
expect(container.querySelectorAll("[data-testid]").length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not throw when switching between children", () => {
|
||||
const [view, setView] = createSignal<"a" | "b">("a");
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeSwitch exitBeforeEnter>
|
||||
<AnimeMatch when={view() === "a"}>
|
||||
<div>View A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={view() === "b"}>
|
||||
<div>View B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setView("b")).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes animeProps down to all AnimeMatch children", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, duration: 300 },
|
||||
exit: { opacity: 0, duration: 300 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true}>
|
||||
<div>content</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// Expect animate call with the shared animeProps
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimeMatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children when `when` is true", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={true}>
|
||||
<div data-testid="match-content">match</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
expect(screen.getByTestId("match-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="hidden">hidden</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
expect(screen.queryByTestId("hidden")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("per-match animate overrides the shared animeProps", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
animate: { opacity: 1, duration: 200 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true} animate={{ opacity: 1, duration: 500 }}>
|
||||
<div>override</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// The per-match duration (500) should be used, not the shared one (200)
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 500 }),
|
||||
);
|
||||
const callsWithSharedDuration = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 200,
|
||||
);
|
||||
expect(callsWithSharedDuration.length).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to context animeProps when no per-match props provided", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, duration: 250 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true}>
|
||||
<div>content</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// Should use context duration 250
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 250 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
137
frontend/__tests__/components/core/Theme.spec.tsx
Normal file
137
frontend/__tests__/components/core/Theme.spec.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { render, fireEvent } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { Theme } from "../../../src/ts/components/core/Theme";
|
||||
import { ThemeWithName } from "../../../src/ts/constants/themes";
|
||||
import * as Loader from "../../../src/ts/states/loader-bar";
|
||||
import * as Notifications from "../../../src/ts/states/notifications";
|
||||
import * as ThemeSignal from "../../../src/ts/states/theme";
|
||||
|
||||
vi.mock("../../../src/ts/constants/themes", () => ({
|
||||
themes: {
|
||||
dark: { hasCss: true },
|
||||
light: {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./FavIcon", () => ({
|
||||
FavIcon: () => <div id="favicon" />,
|
||||
}));
|
||||
|
||||
describe("Theme component", () => {
|
||||
const [themeSignal, setThemeSignal] = createSignal<ThemeWithName>({} as any);
|
||||
const themeSignalMock = vi.spyOn(ThemeSignal, "getTheme");
|
||||
const loaderShowMock = vi.spyOn(Loader, "showLoaderBar");
|
||||
const loaderHideMock = vi.spyOn(Loader, "hideLoaderBar");
|
||||
const notificationAddMock = vi.spyOn(Notifications, "showNoticeNotification");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
loaderShowMock.mockClear();
|
||||
loaderHideMock.mockClear();
|
||||
notificationAddMock.mockClear();
|
||||
themeSignalMock.mockImplementation(() => themeSignal());
|
||||
setThemeSignal({
|
||||
name: "dark",
|
||||
bg: "#000",
|
||||
main: "#fff",
|
||||
caret: "#fff",
|
||||
sub: "#aaa",
|
||||
subAlt: "#888",
|
||||
text: "#fff",
|
||||
error: "#f00",
|
||||
errorExtra: "#c00",
|
||||
colorfulError: "#f55",
|
||||
colorfulErrorExtra: "#c55",
|
||||
});
|
||||
});
|
||||
|
||||
it("injects CSS variables based on theme", () => {
|
||||
const { style } = renderComponent();
|
||||
|
||||
expect(style.innerHTML).toEqual(`
|
||||
:root {
|
||||
--bg-color: #000;
|
||||
--main-color: #fff;
|
||||
--caret-color: #fff;
|
||||
--sub-color: #aaa;
|
||||
--sub-alt-color: #888;
|
||||
--text-color: #fff;
|
||||
--error-color: #f00;
|
||||
--error-extra-color: #c00;
|
||||
--colorful-error-color: #f55;
|
||||
--colorful-error-extra-color: #c55;
|
||||
}`);
|
||||
});
|
||||
|
||||
it("updates CSS variables based on signal", () => {
|
||||
setThemeSignal({ name: "light", bg: "#f00" } as any);
|
||||
const { style } = renderComponent();
|
||||
|
||||
expect(style.innerHTML).toContain("--bg-color: #f00;");
|
||||
});
|
||||
|
||||
it("loads CSS file and shows loader when theme has CSS", () => {
|
||||
const { css } = renderComponent();
|
||||
|
||||
expect(css.getAttribute("href")).toBe("/themes/dark.css");
|
||||
expect(loaderShowMock).toHaveBeenCalledOnce();
|
||||
fireEvent.load(css);
|
||||
expect(loaderHideMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("removes CSS when theme has no CSS", async () => {
|
||||
themeSignalMock.mockImplementation(() => ({ name: "light" }) as any);
|
||||
const { css } = renderComponent();
|
||||
expect(css.getAttribute("href")).toBe("");
|
||||
});
|
||||
|
||||
it("removes CSS when theme is custom", async () => {
|
||||
themeSignalMock.mockImplementation(() => ({ name: "custom" }) as any);
|
||||
const { css } = renderComponent();
|
||||
expect(css.getAttribute("href")).toBe("");
|
||||
});
|
||||
|
||||
it("handles CSS load error", () => {
|
||||
const { css } = renderComponent();
|
||||
expect(loaderShowMock).toHaveBeenCalledOnce();
|
||||
fireEvent.error(css);
|
||||
expect(loaderHideMock).toHaveBeenCalledOnce();
|
||||
expect(notificationAddMock).toHaveBeenCalledWith("Failed to load theme");
|
||||
});
|
||||
|
||||
it("renders favicon", () => {
|
||||
const { favIcon } = renderComponent();
|
||||
|
||||
expect(favIcon).toBeInTheDocument();
|
||||
expect(favIcon).toBeEmptyDOMElement(); //mocked
|
||||
});
|
||||
|
||||
function renderComponent(): {
|
||||
style: HTMLStyleElement;
|
||||
css: HTMLLinkElement;
|
||||
metaThemeColor: HTMLMetaElement;
|
||||
favIcon: HTMLElement;
|
||||
} {
|
||||
render(() => <Theme />);
|
||||
|
||||
//wait for debounce
|
||||
vi.runAllTimers();
|
||||
|
||||
//make sure content is rendered to the head, not the body
|
||||
const head = document.head;
|
||||
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
style: head.querySelector("style#theme")!,
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
css: head.querySelector("link#currentTheme")!,
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
metaThemeColor: head.querySelector("meta#metaThemeColor")!,
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
favIcon: head.querySelector("#favicon")!,
|
||||
};
|
||||
}
|
||||
});
|
||||
105
frontend/__tests__/components/layout/footer/ScrollToTop.spec.tsx
Normal file
105
frontend/__tests__/components/layout/footer/ScrollToTop.spec.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { render } from "@solidjs/testing-library";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { ScrollToTop } from "../../../../src/ts/components/layout/footer/ScrollToTop";
|
||||
import * as CoreSignals from "../../../../src/ts/states/core";
|
||||
|
||||
describe("ScrollToTop", () => {
|
||||
const getActivePageMock = vi.spyOn(CoreSignals, "getActivePage");
|
||||
beforeEach(() => {
|
||||
getActivePageMock.mockClear().mockReturnValue("account");
|
||||
Object.defineProperty(window, "scrollY", { value: 0, writable: true });
|
||||
});
|
||||
|
||||
function renderElement(): {
|
||||
container: HTMLElement;
|
||||
button: HTMLButtonElement;
|
||||
} {
|
||||
const { container } = render(() => <ScrollToTop />);
|
||||
|
||||
return {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
container: container.children[0]! as HTMLElement,
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
button: container.querySelector("button")!,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders with correct classes and structure", () => {
|
||||
const { container, button } = renderElement();
|
||||
|
||||
expect(container).toHaveClass("content-grid", "ScrollToTop");
|
||||
expect(button).toHaveClass("breakout");
|
||||
expect(button.querySelector("i")).toHaveClass("fas", "fa-angle-double-up");
|
||||
});
|
||||
|
||||
it("renders invisible when scrollY is 0", () => {
|
||||
const { button } = renderElement();
|
||||
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("becomes visible when scrollY > 100 on non-test pages", () => {
|
||||
const { button } = renderElement();
|
||||
scrollTo(150);
|
||||
|
||||
expect(button).not.toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("stays invisible on test page at scroll 0", () => {
|
||||
getActivePageMock.mockReturnValue("test");
|
||||
const { button } = renderElement();
|
||||
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("stays invisible on test page even with scroll > 100", () => {
|
||||
getActivePageMock.mockReturnValue("test");
|
||||
const { button } = renderElement();
|
||||
scrollTo(150);
|
||||
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("becomes invisible when scroll < 100 on non-test pages", () => {
|
||||
const { button } = renderElement();
|
||||
scrollTo(150);
|
||||
expect(button).not.toHaveClass("opacity-0");
|
||||
|
||||
scrollTo(50);
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("scrolls to top and hides button on click", async () => {
|
||||
const scrollToSpy = vi.fn();
|
||||
window.scrollTo = scrollToSpy;
|
||||
const { button } = renderElement();
|
||||
scrollTo(150);
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
expect(button).toHaveClass("opacity-0");
|
||||
});
|
||||
|
||||
it("cleans up scroll listener on unmount", () => {
|
||||
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
const { unmount } = render(() => <ScrollToTop />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"scroll",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
function scrollTo(value: number): void {
|
||||
Object.defineProperty(window, "scrollY", { value, writable: true });
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
}
|
||||
});
|
||||
91
frontend/__tests__/components/ui/form/Checkbox.spec.tsx
Normal file
91
frontend/__tests__/components/ui/form/Checkbox.spec.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { Checkbox } from "../../../../src/ts/components/ui/form/Checkbox";
|
||||
|
||||
function makeField(name: string, checked = false) {
|
||||
return {
|
||||
name,
|
||||
state: { value: checked },
|
||||
handleBlur: vi.fn(),
|
||||
handleChange: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("Checkbox", () => {
|
||||
it("renders with label text", () => {
|
||||
const field = makeField("agree");
|
||||
render(() => <Checkbox field={() => field} label="I agree" />);
|
||||
|
||||
expect(screen.getByText("I agree")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders checkbox with field name", () => {
|
||||
const field = makeField("terms");
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
expect(input).toHaveAttribute("id", "terms");
|
||||
expect(input).toHaveAttribute("name", "terms");
|
||||
});
|
||||
|
||||
it("reflects checked state", () => {
|
||||
const field = makeField("opt", true);
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
expect(input).toBeChecked();
|
||||
});
|
||||
|
||||
it("reflects unchecked state", () => {
|
||||
const field = makeField("opt", false);
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
expect(input).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("calls handleChange on change", async () => {
|
||||
const field = makeField("opt");
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
await fireEvent.change(input, { target: { checked: true } });
|
||||
expect(field.handleChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls handleBlur on blur", async () => {
|
||||
const field = makeField("opt");
|
||||
render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
await fireEvent.blur(input);
|
||||
expect(field.handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders disabled checkbox", () => {
|
||||
const field = makeField("opt");
|
||||
render(() => <Checkbox field={() => field} disabled />);
|
||||
|
||||
const input = screen.getByRole("checkbox", { hidden: true });
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows check icon styling when checked", () => {
|
||||
const field = makeField("opt", true);
|
||||
const { container } = render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const icon = container.querySelector(".fa-check");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass("text-main");
|
||||
});
|
||||
|
||||
it("shows transparent icon styling when unchecked", () => {
|
||||
const field = makeField("opt", false);
|
||||
const { container } = render(() => <Checkbox field={() => field} />);
|
||||
|
||||
const icon = container.querySelector(".fa-check");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass("text-transparent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render } from "@solidjs/testing-library";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { FieldIndicator } from "../../../../src/ts/components/ui/form/FieldIndicator";
|
||||
|
||||
function makeField(overrides: {
|
||||
isValidating?: boolean;
|
||||
isTouched?: boolean;
|
||||
isValid?: boolean;
|
||||
isDefaultValue?: boolean;
|
||||
errors?: string[];
|
||||
hasWarning?: boolean;
|
||||
warnings?: string[];
|
||||
}) {
|
||||
return {
|
||||
state: {
|
||||
meta: {
|
||||
isValidating: overrides.isValidating ?? false,
|
||||
isTouched: overrides.isTouched ?? false,
|
||||
isValid: overrides.isValid ?? true,
|
||||
isDefaultValue: overrides.isDefaultValue ?? true,
|
||||
errors: overrides.errors ?? [],
|
||||
},
|
||||
},
|
||||
getMeta: () => ({
|
||||
hasWarning: overrides.hasWarning ?? false,
|
||||
warnings: overrides.warnings ?? [],
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("FieldIndicator", () => {
|
||||
it("shows loading spinner when validating", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator field={makeField({ isValidating: true })} />
|
||||
));
|
||||
expect(container.querySelector(".fa-circle-notch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error icon when touched and invalid", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator
|
||||
field={makeField({
|
||||
isTouched: true,
|
||||
isValid: false,
|
||||
errors: ["bad value"],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(container.querySelector(".fa-times")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning icon when has warning", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator
|
||||
field={makeField({
|
||||
hasWarning: true,
|
||||
warnings: ["weak"],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(
|
||||
container.querySelector(".fa-exclamation-triangle"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows success check when touched, valid, and not default", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator
|
||||
field={makeField({
|
||||
isTouched: true,
|
||||
isValid: true,
|
||||
isDefaultValue: false,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(container.querySelector(".fa-check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows nothing when untouched and not validating", () => {
|
||||
const { container } = render(() => (
|
||||
<FieldIndicator field={makeField({})} />
|
||||
));
|
||||
expect(container.querySelector(".fa-times")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".fa-check")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
102
frontend/__tests__/components/ui/form/InputField.spec.tsx
Normal file
102
frontend/__tests__/components/ui/form/InputField.spec.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { InputField } from "../../../../src/ts/components/ui/form/InputField";
|
||||
|
||||
function makeField(name: string, value = "") {
|
||||
return {
|
||||
name,
|
||||
state: {
|
||||
value,
|
||||
meta: {
|
||||
isValidating: false,
|
||||
isTouched: false,
|
||||
isValid: true,
|
||||
isDefaultValue: true,
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
handleBlur: vi.fn(),
|
||||
handleChange: vi.fn(),
|
||||
getMeta: () => ({ hasWarning: false, warnings: [] }),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("InputField", () => {
|
||||
it("uses custom placeholder when provided", () => {
|
||||
const field = makeField("email");
|
||||
render(() => <InputField field={() => field} placeholder="Enter email" />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Enter email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to text type", () => {
|
||||
const field = makeField("name");
|
||||
render(() => <InputField field={() => field} />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("type", "text");
|
||||
});
|
||||
|
||||
it("uses custom type", () => {
|
||||
const field = makeField("password");
|
||||
const { container } = render(() => (
|
||||
<InputField field={() => field} type="password" />
|
||||
));
|
||||
|
||||
expect(container.querySelector("input")).toHaveAttribute(
|
||||
"type",
|
||||
"password",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls handleChange on input", async () => {
|
||||
const field = makeField("name");
|
||||
render(() => <InputField field={() => field} />);
|
||||
|
||||
await fireEvent.input(screen.getByRole("textbox"), {
|
||||
target: { value: "test" },
|
||||
});
|
||||
expect(field.handleChange).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("calls handleBlur on blur", async () => {
|
||||
const field = makeField("name");
|
||||
render(() => <InputField field={() => field} />);
|
||||
|
||||
await fireEvent.blur(screen.getByRole("textbox"));
|
||||
expect(field.handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onFocus callback", async () => {
|
||||
const field = makeField("name");
|
||||
const onFocus = vi.fn();
|
||||
render(() => <InputField field={() => field} onFocus={onFocus} />);
|
||||
|
||||
await fireEvent.focus(screen.getByRole("textbox"));
|
||||
expect(onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders disabled input", () => {
|
||||
const field = makeField("name");
|
||||
render(() => <InputField field={() => field} disabled />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows FieldIndicator when showIndicator is true", () => {
|
||||
const field = makeField("name");
|
||||
field.state.meta.isValidating = true;
|
||||
const { container } = render(() => (
|
||||
<InputField field={() => field} showIndicator />
|
||||
));
|
||||
|
||||
expect(container.querySelector(".fa-circle-notch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides FieldIndicator by default", () => {
|
||||
const field = makeField("name");
|
||||
const { container } = render(() => <InputField field={() => field} />);
|
||||
|
||||
expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
frontend/__tests__/components/ui/form/LabeledField.spec.tsx
Normal file
57
frontend/__tests__/components/ui/form/LabeledField.spec.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from "@solidjs/testing-library";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { LabeledField } from "../../../../src/ts/components/ui/form/LabeledField";
|
||||
|
||||
describe("LabeledField", () => {
|
||||
it("renders label text correctly", () => {
|
||||
render(() => (
|
||||
<LabeledField label="test label">
|
||||
<input />
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
expect(screen.getByText("test label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children correctly", () => {
|
||||
render(() => (
|
||||
<LabeledField label="test">
|
||||
<div data-testid="child">child content</div>
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders subtext when provided", () => {
|
||||
render(() => (
|
||||
<LabeledField label="test" subLabel="helper text">
|
||||
<input />
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
expect(screen.getByText("helper text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links label to input when id is provided", () => {
|
||||
const { container } = render(() => (
|
||||
<LabeledField label="test" id="test-id">
|
||||
<input id="test-id" />
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
const label = container.querySelector("label");
|
||||
expect(label).toHaveAttribute("for", "test-id");
|
||||
});
|
||||
|
||||
it("applies custom class to wrapper", () => {
|
||||
const { container } = render(() => (
|
||||
<LabeledField label="test" class="custom-wrapper-class">
|
||||
<input />
|
||||
</LabeledField>
|
||||
));
|
||||
|
||||
expect(container.firstChild).toHaveClass("custom-wrapper-class");
|
||||
});
|
||||
});
|
||||
74
frontend/__tests__/components/ui/form/SubmitButton.spec.tsx
Normal file
74
frontend/__tests__/components/ui/form/SubmitButton.spec.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from "@solidjs/testing-library";
|
||||
import { JSXElement } from "solid-js";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { SubmitButton } from "../../../../src/ts/components/ui/form/SubmitButton";
|
||||
|
||||
type FormState = {
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
isValid: boolean;
|
||||
isDirty: boolean;
|
||||
};
|
||||
|
||||
function makeForm(state: Partial<FormState> = {}) {
|
||||
const fullState: FormState = {
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
isDirty: true,
|
||||
...state,
|
||||
};
|
||||
|
||||
return {
|
||||
Subscribe: (props: {
|
||||
selector: (state: FormState) => FormState;
|
||||
children: (state: () => FormState) => JSXElement;
|
||||
}) => props.children(() => props.selector(fullState)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("SubmitButton", () => {
|
||||
it("renders enabled when form is dirty, valid, and can submit", () => {
|
||||
render(() => <SubmitButton form={makeForm()} text="Save" />);
|
||||
expect(screen.getByRole("button", { name: "Save" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders as submit type", () => {
|
||||
render(() => <SubmitButton form={makeForm()} text="Save" />);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("type", "submit");
|
||||
});
|
||||
|
||||
it("disables when form is not dirty", () => {
|
||||
render(() => (
|
||||
<SubmitButton form={makeForm({ isDirty: false })} text="Save" />
|
||||
));
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables when form cannot submit", () => {
|
||||
render(() => (
|
||||
<SubmitButton form={makeForm({ canSubmit: false })} text="Save" />
|
||||
));
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables when form is submitting", () => {
|
||||
render(() => (
|
||||
<SubmitButton form={makeForm({ isSubmitting: true })} text="Save" />
|
||||
));
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables when form is not valid", () => {
|
||||
render(() => (
|
||||
<SubmitButton form={makeForm({ isValid: false })} text="Save" />
|
||||
));
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables when disabled prop is true even if form is ready", () => {
|
||||
render(() => <SubmitButton form={makeForm()} text="Save" disabled />);
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
133
frontend/__tests__/components/ui/form/utils.spec.ts
Normal file
133
frontend/__tests__/components/ui/form/utils.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
fromSchema,
|
||||
handleResult,
|
||||
allFieldsMandatory,
|
||||
fieldMandatory,
|
||||
type ValidationResult,
|
||||
} from "../../../../src/ts/components/ui/form/utils";
|
||||
|
||||
describe("fromSchema", () => {
|
||||
const schema = z.string().min(3, "too short").max(10, "too long");
|
||||
const validate = fromSchema(schema);
|
||||
|
||||
it("returns undefined for valid value", () => {
|
||||
expect(validate({ value: "hello" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error messages for invalid value", () => {
|
||||
expect(validate({ value: "ab" })).toEqual(["too short"]);
|
||||
});
|
||||
|
||||
it("returns multiple error messages", () => {
|
||||
const numSchema = z.number().min(5, "too small").max(3, "too big");
|
||||
const v = fromSchema(numSchema);
|
||||
const result = v({ value: 4 });
|
||||
// 4 fails min(5) but passes max(3)? Actually 4 > 3, so both fail
|
||||
// number 4: min(5) fails, max(3) fails
|
||||
expect(result).toEqual(["too small", "too big"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleResult", () => {
|
||||
const mockSetMeta = vi.fn();
|
||||
|
||||
function makeField() {
|
||||
mockSetMeta.mockClear();
|
||||
return { setMeta: mockSetMeta } as any;
|
||||
}
|
||||
|
||||
it("returns undefined for undefined results", () => {
|
||||
expect(handleResult(makeField(), undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty results", () => {
|
||||
expect(handleResult(makeField(), [])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error messages and ignores warnings", () => {
|
||||
const results: ValidationResult[] = [
|
||||
{ type: "error", message: "bad email" },
|
||||
{ type: "error", message: "too short" },
|
||||
];
|
||||
expect(handleResult(makeField(), results)).toEqual([
|
||||
"bad email",
|
||||
"too short",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets warning meta on field", () => {
|
||||
const results: ValidationResult[] = [
|
||||
{ type: "warning", message: "weak password" },
|
||||
];
|
||||
const field = makeField();
|
||||
const result = handleResult(field, results);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockSetMeta).toHaveBeenCalledOnce();
|
||||
|
||||
const updater = mockSetMeta.mock.calls[0]![0];
|
||||
const newMeta = updater({ existing: true });
|
||||
expect(newMeta).toEqual({
|
||||
existing: true,
|
||||
hasWarning: true,
|
||||
warnings: ["weak password"],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles both errors and warnings", () => {
|
||||
const results: ValidationResult[] = [
|
||||
{ type: "warning", message: "not recommended" },
|
||||
{ type: "error", message: "invalid" },
|
||||
];
|
||||
const field = makeField();
|
||||
const result = handleResult(field, results);
|
||||
|
||||
expect(mockSetMeta).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual(["invalid"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allFieldsMandatory", () => {
|
||||
const validate = allFieldsMandatory<{ a: string; b: string }>();
|
||||
|
||||
it("returns undefined when all fields have values", () => {
|
||||
expect(validate({ value: { a: "x", b: "y" } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error when a field is empty string", () => {
|
||||
expect(validate({ value: { a: "x", b: "" } })).toBe(
|
||||
"all fields are mandatory",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns error when a field is undefined", () => {
|
||||
expect(validate({ value: { a: "x", b: undefined } as any })).toBe(
|
||||
"all fields are mandatory",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fieldMandatory", () => {
|
||||
it("returns undefined for non-empty value", () => {
|
||||
const validate = fieldMandatory();
|
||||
expect(validate({ value: "hello" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns default message for empty string", () => {
|
||||
const validate = fieldMandatory();
|
||||
expect(validate({ value: "" })).toBe("mandatory");
|
||||
});
|
||||
|
||||
it("returns default message for undefined", () => {
|
||||
const validate = fieldMandatory();
|
||||
expect(validate({ value: undefined })).toBe("mandatory");
|
||||
});
|
||||
|
||||
it("returns custom message", () => {
|
||||
const validate = fieldMandatory("required field");
|
||||
expect(validate({ value: "" })).toBe("required field");
|
||||
});
|
||||
});
|
||||
158
frontend/__tests__/components/ui/table/DataTable.spec.tsx
Normal file
158
frontend/__tests__/components/ui/table/DataTable.spec.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { DataTable } from "../../../../src/ts/components/ui/table/DataTable";
|
||||
|
||||
const [localStorage, setLocalStorage] = createSignal([]);
|
||||
vi.mock("../../../../src/ts/hooks/useLocalStorage", () => {
|
||||
return {
|
||||
useLocalStorage: () => {
|
||||
return [localStorage, setLocalStorage] as const;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const bpSignal = createSignal({
|
||||
xxs: true,
|
||||
sm: true,
|
||||
md: true,
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/ts/states/breakpoints", () => ({
|
||||
bp: () => bpSignal[0](),
|
||||
}));
|
||||
|
||||
type Person = {
|
||||
name: string;
|
||||
age: number;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: (info: any) => info.getValue(),
|
||||
meta: { maxBreakpoint: "sm" },
|
||||
},
|
||||
{
|
||||
id: "age",
|
||||
accessorKey: "age",
|
||||
header: "Age",
|
||||
cell: (info: any) => info.getValue(),
|
||||
meta: { breakpoint: "sm" },
|
||||
},
|
||||
];
|
||||
|
||||
const data: Person[] = [
|
||||
{ name: "Alice", age: 30 },
|
||||
{ name: "Bob", age: 20 },
|
||||
];
|
||||
|
||||
describe("DataTable", () => {
|
||||
beforeEach(() => {
|
||||
bpSignal[1]({
|
||||
xxs: true,
|
||||
sm: true,
|
||||
md: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders table headers and rows", () => {
|
||||
render(() => <DataTable id="people" columns={columns} data={data} />);
|
||||
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Age")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("30")).toBeInTheDocument();
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback when there is no data", () => {
|
||||
render(() => (
|
||||
<DataTable
|
||||
id="empty"
|
||||
columns={columns}
|
||||
data={[]}
|
||||
fallback={<div>No data</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText("No data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts rows when clicking a sortable header", async () => {
|
||||
render(() => <DataTable id="sorting" columns={columns} data={data} />);
|
||||
|
||||
const ageHeaderButton = screen.getByRole("button", { name: "Age" });
|
||||
const ageHeaderCell = ageHeaderButton.closest("th");
|
||||
|
||||
// Initial
|
||||
expect(ageHeaderCell).toHaveAttribute("aria-sort", "none");
|
||||
expect(ageHeaderCell?.querySelector("i")).toHaveClass("fa-fw");
|
||||
|
||||
// Descending
|
||||
await fireEvent.click(ageHeaderButton);
|
||||
expect(ageHeaderCell).toHaveAttribute("aria-sort", "descending");
|
||||
expect(ageHeaderCell?.querySelector("i")).toHaveClass(
|
||||
"fa-sort-down",
|
||||
"fas",
|
||||
"fa-fw",
|
||||
);
|
||||
expect(localStorage()).toEqual([
|
||||
{
|
||||
desc: true,
|
||||
id: "age",
|
||||
},
|
||||
]);
|
||||
|
||||
let rows = screen.getAllByRole("row");
|
||||
expect(rows[1]).toHaveTextContent("Alice"); // age 30
|
||||
expect(rows[2]).toHaveTextContent("Bob"); // age 20
|
||||
|
||||
// Ascending
|
||||
await fireEvent.click(ageHeaderButton);
|
||||
expect(ageHeaderCell).toHaveAttribute("aria-sort", "ascending");
|
||||
expect(ageHeaderCell?.querySelector("i")).toHaveClass(
|
||||
"fa-sort-up",
|
||||
"fas",
|
||||
"fa-fw",
|
||||
);
|
||||
expect(localStorage()).toEqual([
|
||||
{
|
||||
desc: false,
|
||||
id: "age",
|
||||
},
|
||||
]);
|
||||
|
||||
rows = screen.getAllByRole("row");
|
||||
expect(rows[1]).toHaveTextContent("Bob");
|
||||
expect(rows[2]).toHaveTextContent("Alice");
|
||||
|
||||
//back to initial
|
||||
await fireEvent.click(ageHeaderButton);
|
||||
expect(ageHeaderCell).toHaveAttribute("aria-sort", "none");
|
||||
expect(localStorage()).toEqual([]);
|
||||
});
|
||||
|
||||
it("hides columns based on breakpoint visibility", () => {
|
||||
bpSignal[1]({
|
||||
xxs: true,
|
||||
sm: false,
|
||||
md: false,
|
||||
});
|
||||
|
||||
render(() => <DataTable id="breakpoints" columns={columns} data={data} />);
|
||||
const nameHeader = screen.getByRole("button", {
|
||||
name: "Name",
|
||||
}).parentElement;
|
||||
const ageHeader = screen.getByRole("button", { name: "Age" }).parentElement;
|
||||
|
||||
expect(nameHeader).not.toHaveClass("hidden");
|
||||
expect(nameHeader).toHaveClass("sm:hidden");
|
||||
expect(ageHeader).toHaveClass("hidden sm:table-cell");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user