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();
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();
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();
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();
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();
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(
,
);
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(
,
);
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(
,
);
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();
const progress0 = getProgressCircle(container);
const { container: container0 } = render();
const progressAt0 = getProgressCircle(container0);
expect(progress0.getAttribute("stroke-dashoffset")).toBe(
progressAt0.getAttribute("stroke-dashoffset"),
);
});
it("clamps values above 100 to 100", () => {
const { container } = render();
const progressOver = getProgressCircle(container);
const { container: container100 } = render();
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();
const progress = getProgressCircle(container);
expect(progress.getAttribute("stroke")).toContain("blue-500");
});
it("applies a custom color to the progress arc", () => {
const { container } = render();
const progress = getProgressCircle(container);
expect(progress).toHaveAttribute("stroke", "#ff0000");
});
it("applies the default gray trackColor to the track circle", () => {
const { container } = render();
const track = getTrackCircle(container);
expect(track.getAttribute("stroke")).toContain("gray-200");
});
it("applies a custom trackColor to the track circle", () => {
const { container } = render(
,
);
const track = getTrackCircle(container);
expect(track).toHaveAttribute("stroke", "#cccccc");
});
});
describe("children", () => {
it("renders children overlaid in the center when provided", () => {
render(
75%
,
);
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();
// 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();
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();
const progress = getProgressCircle(container);
expect(progress).toHaveStyle({ transition: "none" });
});
});
describe("className prop", () => {
it("applies additional className to the wrapper div", () => {
const { container } = render(
,
);
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();
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveStyle({ width: "64px", height: "64px" });
});
});
});