Files
CapaKraken/apps/web/src/components/ui/ProgressRing.test.tsx
T
Hartmut a3d75973ee 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>
2026-04-10 17:14:11 +02:00

198 lines
8.4 KiB
TypeScript

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