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