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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user