test(web): add 291 tests for parsers, hooks, and UI components

Lib utilities: scopeImportParser (31), status-styles (58),
planningEntryIds (10), uuid (11).

Hooks: useFilters (28), useRowOrder (18), usePermissions (30),
useViewPrefs (24).

Components: AnimatedModal (14), DateInput (22), InfoTooltip (13),
ProgressRing (19).

Web test suite: 75 → 87 files, 553 → 844 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 17:14:11 +02:00
parent 98dca6126f
commit a3d75973ee
12 changed files with 2626 additions and 0 deletions
@@ -0,0 +1,148 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { AnimatedModal } from "./AnimatedModal.js";
// Mock framer-motion so animations resolve immediately in jsdom
vi.mock("framer-motion", () => ({
motion: {
div: ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => (
<div {...props}>{children}</div>
),
},
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// Mock useFocusTrap focus management is tested separately
vi.mock("~/hooks/useFocusTrap.js", () => ({
useFocusTrap: vi.fn(),
}));
const defaultProps = {
open: true,
onClose: vi.fn(),
children: <p>Modal content</p>,
};
describe("AnimatedModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("visibility", () => {
it("renders children when open is true", () => {
render(<AnimatedModal {...defaultProps} />);
expect(screen.getByText("Modal content")).toBeInTheDocument();
});
it("renders nothing when open is false", () => {
render(<AnimatedModal {...defaultProps} open={false} />);
expect(screen.queryByText("Modal content")).not.toBeInTheDocument();
});
});
describe("ARIA semantics", () => {
it("renders a dialog element with aria-modal", () => {
render(<AnimatedModal {...defaultProps} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute("aria-modal", "true");
});
});
describe("backdrop close", () => {
it("calls onClose when the overlay is clicked (default)", () => {
const onClose = vi.fn();
const { container } = render(<AnimatedModal {...defaultProps} onClose={onClose} />);
// The overlay is the first div inside the outer container (aria-hidden)
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(overlay).toBeTruthy();
fireEvent.click(overlay);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does NOT call onClose when disableBackdropClose is true", () => {
const onClose = vi.fn();
const { container } = render(
<AnimatedModal {...defaultProps} onClose={onClose} disableBackdropClose />,
);
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
fireEvent.click(overlay);
expect(onClose).not.toHaveBeenCalled();
});
});
describe("keyboard Escape", () => {
it("calls onClose when Escape is pressed while open", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AnimatedModal {...defaultProps} onClose={onClose} />);
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does NOT call onClose for other keys", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AnimatedModal {...defaultProps} onClose={onClose} />);
await user.keyboard("{Enter}");
expect(onClose).not.toHaveBeenCalled();
});
it("does NOT register the keydown listener when closed", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AnimatedModal {...defaultProps} open={false} onClose={onClose} />);
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
});
describe("className props", () => {
it("applies custom maxWidth class to the panel", () => {
render(<AnimatedModal {...defaultProps} maxWidth="max-w-2xl" />);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("max-w-2xl");
});
it("uses max-w-xl as the default maxWidth", () => {
render(<AnimatedModal {...defaultProps} />);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("max-w-xl");
});
it("applies additional className to the panel", () => {
render(<AnimatedModal {...defaultProps} className="my-custom-class" />);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("my-custom-class");
});
it("applies custom overlayClassName when provided", () => {
const { container } = render(
<AnimatedModal {...defaultProps} overlayClassName="custom-overlay" />,
);
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(overlay.className).toContain("custom-overlay");
});
it("falls back to default overlay classes when overlayClassName is omitted", () => {
const { container } = render(<AnimatedModal {...defaultProps} />);
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(overlay.className).toContain("bg-black/40");
});
});
describe("children", () => {
it("renders arbitrary children inside the dialog panel", () => {
render(
<AnimatedModal {...defaultProps}>
<button>Submit</button>
</AnimatedModal>,
);
expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();
});
});
});
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { DateInput } from "./DateInput.js";
function setup(props: Partial<React.ComponentProps<typeof DateInput>> = {}) {
const onChange = props.onChange ?? vi.fn();
const utils = render(<DateInput value={props.value ?? ""} onChange={onChange} {...props} />);
const textInput = screen.getByPlaceholderText("dd/mm/yyyy");
return { ...utils, textInput, onChange };
}
describe("DateInput", () => {
describe("initial display", () => {
it("renders an empty text input when value is empty", () => {
const { textInput } = setup({ value: "" });
expect(textInput).toHaveValue("");
});
it("converts an ISO value to dd/mm/yyyy display format", () => {
const { textInput } = setup({ value: "2024-03-15" });
expect(textInput).toHaveValue("15/03/2024");
});
it("renders the calendar button", () => {
setup();
expect(screen.getByRole("button", { name: /open date picker/i })).toBeInTheDocument();
});
});
describe("text input autoSlash formatting", () => {
it("formats digits as dd/mm/yyyy while typing", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "15032024");
expect(textInput).toHaveValue("15/03/2024");
});
it("inserts first slash after two digits", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "15");
expect(textInput).toHaveValue("15");
await user.type(textInput, "0");
expect(textInput).toHaveValue("15/0");
});
it("inserts second slash after four digits", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "1503");
expect(textInput).toHaveValue("15/03");
await user.type(textInput, "2");
expect(textInput).toHaveValue("15/03/2");
});
it("calls onChange with ISO string once all 8 digits are entered", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "15032024");
expect(onChange).toHaveBeenLastCalledWith("2024-03-15");
});
it("does NOT call onChange while input is still incomplete", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "1503");
// The partial value is not valid onChange should not have been called with an ISO string
expect(onChange).not.toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/));
});
});
describe("validation invalid dates are rejected", () => {
it("does not call onChange for a day > 31", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
// Day 32 is invalid
await user.type(textInput, "32032024");
expect(onChange).not.toHaveBeenCalled();
});
it("does not call onChange for month > 12", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "01132024");
expect(onChange).not.toHaveBeenCalled();
});
it("does not call onChange for year < 1900", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "01011899");
expect(onChange).not.toHaveBeenCalled();
});
it("does not call onChange for year > 2100", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "01012101");
expect(onChange).not.toHaveBeenCalled();
});
});
describe("blur normalisation", () => {
it("re-normalises the display to canonical dd/mm/yyyy on blur", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "15032024");
await user.tab(); // triggers blur
expect(textInput).toHaveValue("15/03/2024");
});
it("keeps the incomplete value on blur so the user can continue editing", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "1503");
await user.tab();
// Incomplete input should remain as-is
expect(textInput).toHaveValue("15/03");
});
it("clears display to empty on blur when the field is empty", async () => {
const user = userEvent.setup();
const { textInput } = setup({ value: "2024-03-15" });
await user.clear(textInput);
await user.tab();
expect(textInput).toHaveValue("");
});
});
describe("external value changes", () => {
it("updates display when the value prop changes", () => {
const { rerender, textInput } = setup({ value: "2024-01-01" });
expect(textInput).toHaveValue("01/01/2024");
rerender(<DateInput value="2025-12-31" onChange={vi.fn()} />);
expect(textInput).toHaveValue("31/12/2025");
});
it("clears display when value prop is reset to empty string", () => {
const { rerender, textInput } = setup({ value: "2024-01-01" });
rerender(<DateInput value="" onChange={vi.fn()} />);
expect(textInput).toHaveValue("");
});
});
describe("accessibility and props", () => {
it("forwards the id prop to the text input", () => {
setup({ id: "my-date" });
expect(screen.getByPlaceholderText("dd/mm/yyyy")).toHaveAttribute("id", "my-date");
});
it("sets the required attribute when required is true", () => {
const { textInput } = setup({ required: true });
expect(textInput).toBeRequired();
});
it("disables both the text input and the calendar button when disabled", () => {
setup({ disabled: true });
expect(screen.getByPlaceholderText("dd/mm/yyyy")).toBeDisabled();
expect(screen.getByRole("button", { name: /open date picker/i })).toBeDisabled();
});
it("hides the native date input from assistive technology", () => {
const { container } = setup();
const nativeDateInput = container.querySelector('input[type="date"]');
expect(nativeDateInput).toHaveAttribute("aria-hidden", "true");
});
});
});
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { InfoTooltip } from "./InfoTooltip.js";
// InfoTooltip uses createPortal jsdom supports document.body so portals work,
// but getBoundingClientRect returns all-zeros which is fine for logic tests.
describe("InfoTooltip", () => {
describe("trigger button", () => {
it("renders a button with accessible label", () => {
render(<InfoTooltip content="Help text" />);
const btn = screen.getByRole("button", { name: /more information/i });
expect(btn).toBeInTheDocument();
});
it("shows the letter i inside the button", () => {
render(<InfoTooltip content="Help text" />);
expect(screen.getByRole("button", { name: /more information/i })).toHaveTextContent("i");
});
});
describe("hover interaction", () => {
it("shows tooltip content on mouse enter", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Hover help" />);
const btn = screen.getByRole("button", { name: /more information/i });
await user.hover(btn);
expect(screen.getByText("Hover help")).toBeInTheDocument();
});
it("hides tooltip content on mouse leave", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Hover help" />);
const btn = screen.getByRole("button", { name: /more information/i });
await user.hover(btn);
expect(screen.getByText("Hover help")).toBeInTheDocument();
await user.unhover(btn);
expect(screen.queryByText("Hover help")).not.toBeInTheDocument();
});
});
describe("focus interaction", () => {
it("shows tooltip on focus", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Focus help" />);
const btn = screen.getByRole("button", { name: /more information/i });
await user.tab(); // move focus to the button
expect(screen.getByText("Focus help")).toBeInTheDocument();
});
it("hides tooltip on blur", async () => {
const user = userEvent.setup();
render(
<>
<InfoTooltip content="Focus help" />
<button>Other</button>
</>,
);
const btn = screen.getByRole("button", { name: /more information/i });
await user.tab(); // focus the info button
expect(screen.getByText("Focus help")).toBeInTheDocument();
await user.tab(); // move focus away → blur fires
expect(screen.queryByText("Focus help")).not.toBeInTheDocument();
});
});
describe("content", () => {
it("renders plain string content inside the tooltip", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Plain string" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
expect(screen.getByText("Plain string")).toBeInTheDocument();
});
it("renders JSX content inside the tooltip", async () => {
const user = userEvent.setup();
render(
<InfoTooltip
content={
<span data-testid="rich-content">
<strong>Bold</strong> text
</span>
}
/>,
);
await user.hover(screen.getByRole("button", { name: /more information/i }));
expect(screen.getByTestId("rich-content")).toBeInTheDocument();
expect(screen.getByText("Bold")).toBeInTheDocument();
});
});
describe("position prop", () => {
it("defaults to top position (no explicit position needed)", async () => {
const user = userEvent.setup();
const { container } = render(<InfoTooltip content="Top tip" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
// The tooltip div should be in the document (rendered via portal into body)
expect(document.body.textContent).toContain("Top tip");
// Arrow class for top position includes border-t-gray-900
// Look for the arrow span inside the tooltip
const arrows = document.querySelectorAll('[class*="border-t-gray-900"]');
expect(arrows.length).toBeGreaterThan(0);
});
it("renders with bottom position without throwing", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Bottom tip" position="bottom" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
expect(document.body.textContent).toContain("Bottom tip");
});
});
describe("width prop", () => {
it("applies default width class w-60 to the tooltip", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Width test" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
// Find the tooltip container div rendered in the portal
// It has the width class directly on the tooltip wrapper
const tooltipEl = document.body.querySelector(".w-60");
expect(tooltipEl).toBeTruthy();
});
it("applies custom width class when width prop is provided", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Wide tip" width="w-72" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
const tooltipEl = document.body.querySelector(".w-72");
expect(tooltipEl).toBeTruthy();
});
});
describe("portal rendering", () => {
it("renders tooltip into document.body (not inside the component root)", async () => {
const user = userEvent.setup();
const { container } = render(
<div data-testid="component-root">
<InfoTooltip content="Portal check" />
</div>,
);
await user.hover(screen.getByRole("button", { name: /more information/i }));
// Tooltip text should NOT be inside the component root
expect(container.querySelector('[data-testid="component-root"]')?.textContent).not.toContain(
"Portal check",
);
// But it IS present in document.body
expect(document.body.textContent).toContain("Portal check");
});
});
});
@@ -0,0 +1,197 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import { ProgressRing } from "./ProgressRing.js";
// Helper: grab the progress circle (second circle = the arc, not the track)
function getProgressCircle(container: HTMLElement): SVGCircleElement {
const circles = container.querySelectorAll("circle");
// First circle is the track, second is the progress arc
return circles[1] as SVGCircleElement;
}
function getTrackCircle(container: HTMLElement): SVGCircleElement {
const circles = container.querySelectorAll("circle");
return circles[0] as SVGCircleElement;
}
describe("ProgressRing", () => {
describe("SVG geometry", () => {
it("renders an SVG element with correct width and height", () => {
const { container } = render(<ProgressRing value={50} size={60} />);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("width", "60");
expect(svg).toHaveAttribute("height", "60");
});
it("uses default size of 40 when size is not provided", () => {
const { container } = render(<ProgressRing value={50} />);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("width", "40");
expect(svg).toHaveAttribute("height", "40");
});
it("sets the viewBox to match the size", () => {
const { container } = render(<ProgressRing value={25} size={80} />);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("viewBox", "0 0 80 80");
});
it("positions both circles at the center of the SVG", () => {
const { container } = render(<ProgressRing value={50} size={40} strokeWidth={4} />);
const track = getTrackCircle(container);
const progress = getProgressCircle(container);
expect(track).toHaveAttribute("cx", "20");
expect(track).toHaveAttribute("cy", "20");
expect(progress).toHaveAttribute("cx", "20");
expect(progress).toHaveAttribute("cy", "20");
});
it("calculates the correct radius from size and strokeWidth", () => {
// radius = (size - strokeWidth) / 2 = (40 - 4) / 2 = 18
const { container } = render(<ProgressRing value={50} size={40} strokeWidth={4} />);
const track = getTrackCircle(container);
expect(track).toHaveAttribute("r", "18");
});
});
describe("progress offset calculation (animated=false)", () => {
// With animated=false, mounted=true from the start so offset is applied immediately
it("sets strokeDashoffset to 0 for value=100 (full circle)", () => {
const { container } = render(
<ProgressRing value={100} size={40} strokeWidth={4} animated={false} />,
);
const progress = getProgressCircle(container);
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "1");
expect(offset).toBeCloseTo(0, 2);
expect(circumference).toBeGreaterThan(0);
});
it("sets strokeDashoffset equal to circumference for value=0 (empty circle)", () => {
const { container } = render(
<ProgressRing value={0} size={40} strokeWidth={4} animated={false} />,
);
const progress = getProgressCircle(container);
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "0");
expect(offset).toBeCloseTo(circumference, 2);
});
it("sets strokeDashoffset to half circumference for value=50", () => {
const { container } = render(
<ProgressRing value={50} size={40} strokeWidth={4} animated={false} />,
);
const progress = getProgressCircle(container);
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "0");
expect(offset).toBeCloseTo(circumference / 2, 1);
});
});
describe("value clamping", () => {
it("clamps values below 0 to 0", () => {
const { container } = render(<ProgressRing value={-10} animated={false} />);
const progress0 = getProgressCircle(container);
const { container: container0 } = render(<ProgressRing value={0} animated={false} />);
const progressAt0 = getProgressCircle(container0);
expect(progress0.getAttribute("stroke-dashoffset")).toBe(
progressAt0.getAttribute("stroke-dashoffset"),
);
});
it("clamps values above 100 to 100", () => {
const { container } = render(<ProgressRing value={150} animated={false} />);
const progressOver = getProgressCircle(container);
const { container: container100 } = render(<ProgressRing value={100} animated={false} />);
const progress100 = getProgressCircle(container100);
expect(progressOver.getAttribute("stroke-dashoffset")).toBe(
progress100.getAttribute("stroke-dashoffset"),
);
});
});
describe("colors", () => {
it("applies the default blue color to the progress arc", () => {
const { container } = render(<ProgressRing value={50} animated={false} />);
const progress = getProgressCircle(container);
expect(progress.getAttribute("stroke")).toContain("blue-500");
});
it("applies a custom color to the progress arc", () => {
const { container } = render(<ProgressRing value={50} color="#ff0000" animated={false} />);
const progress = getProgressCircle(container);
expect(progress).toHaveAttribute("stroke", "#ff0000");
});
it("applies the default gray trackColor to the track circle", () => {
const { container } = render(<ProgressRing value={50} animated={false} />);
const track = getTrackCircle(container);
expect(track.getAttribute("stroke")).toContain("gray-200");
});
it("applies a custom trackColor to the track circle", () => {
const { container } = render(
<ProgressRing value={50} trackColor="#cccccc" animated={false} />,
);
const track = getTrackCircle(container);
expect(track).toHaveAttribute("stroke", "#cccccc");
});
});
describe("children", () => {
it("renders children overlaid in the center when provided", () => {
render(
<ProgressRing value={75} animated={false}>
<span data-testid="label">75%</span>
</ProgressRing>,
);
expect(screen.getByTestId("label")).toBeInTheDocument();
expect(screen.getByText("75%")).toBeInTheDocument();
});
it("does not render the children overlay when children is not provided", () => {
const { container } = render(<ProgressRing value={75} animated={false} />);
// The absolute overlay div for children should be absent
const overlay = container.querySelector(".absolute.inset-0.flex");
expect(overlay).not.toBeInTheDocument();
});
});
describe("animation", () => {
it("sets strokeDashoffset to circumference initially when animated=true (before RAF fires)", () => {
// In jsdom, requestAnimationFrame is synchronous by default in vitest/jsdom,
// so mounted transitions to true immediately. We verify the component doesn't throw
// and renders a valid offset.
const { container } = render(<ProgressRing value={50} animated={true} />);
const progress = getProgressCircle(container);
const offset = progress.getAttribute("stroke-dashoffset");
expect(offset).not.toBeNull();
expect(isNaN(parseFloat(offset!))).toBe(false);
});
it("disables the CSS transition when animated=false", () => {
const { container } = render(<ProgressRing value={50} animated={false} />);
const progress = getProgressCircle(container);
expect(progress).toHaveStyle({ transition: "none" });
});
});
describe("className prop", () => {
it("applies additional className to the wrapper div", () => {
const { container } = render(
<ProgressRing value={50} animated={false} className="my-ring" />,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("my-ring");
});
});
describe("wrapper dimensions", () => {
it("sets inline style width and height on the wrapper to match size", () => {
const { container } = render(<ProgressRing value={50} size={64} animated={false} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveStyle({ width: "64px", height: "64px" });
});
});
});