diff --git a/src/components/AllocationWidget.test.tsx b/src/components/AllocationWidget.test.tsx index 5455554..ce419fe 100644 --- a/src/components/AllocationWidget.test.tsx +++ b/src/components/AllocationWidget.test.tsx @@ -4,7 +4,7 @@ * Coverage target ≥95% on AllocationWidget.tsx */ -import { render, screen } from "@testing-library/react"; +import { act, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AllocationWidget, AllocationSlice } from "./AllocationWidget"; @@ -14,8 +14,26 @@ const SLICES: AllocationSlice[] = [ { id: "3", label: "Nexus Pay", value: 25000, percentage: 25 }, ]; +const RADIUS = 60; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; +const GAP_DEG = 2; +const GAP_LEN = (GAP_DEG / 360) * CIRCUMFERENCE; + +function makeSlices(percentages: number[]): AllocationSlice[] { + return percentages.map((percentage, idx) => ({ + id: String(idx + 1), + label: `Slice ${idx + 1}`, + value: percentage * 1000, + percentage, + })); +} + +function getSliceCircles(container: HTMLElement): SVGCircleElement[] { + return Array.from(container.querySelectorAll("svg circle[stroke-dasharray]")); +} + describe("AllocationWidget – empty state", () => { - it("renders allocation-widget testid", () => { + it("renders allocation-widget testid", () => { render(); expect(screen.getByTestId("allocation-widget")).toBeInTheDocument(); }); @@ -73,6 +91,141 @@ describe("AllocationWidget – donut view (default)", () => { }); }); +describe("AllocationWidget – donut SVG geometry & dasharray/dashoffset", () => { + it("computes correct strokeDasharray and strokeDashoffset for [50, 50] split (two half rings)", () => { + const { container } = render(); + const circles = getSliceCircles(container); + expect(circles).toHaveLength(2); + + const expectedLen = (50 / 100) * CIRCUMFERENCE - GAP_LEN; + const expectedGap = CIRCUMFERENCE - expectedLen; + + // Slice 0: starts at 0 and spans the first half ring + expect(circles[0].getAttribute("stroke-dasharray")).toBe(`${expectedLen} ${expectedGap}`); + expect(Number(circles[0].getAttribute("stroke-dashoffset"))).toBeCloseTo(0, 10); + + // Slice 1: starts at -0.5 * C and spans the second half ring + expect(circles[1].getAttribute("stroke-dasharray")).toBe(`${expectedLen} ${expectedGap}`); + expect(Number(circles[1].getAttribute("stroke-dashoffset"))).toBeCloseTo( + -0.5 * CIRCUMFERENCE, + 10 + ); + + // Regression check: dash period of each circle must equal C (not len + C) + for (const circle of circles) { + const [dash, gap] = circle + .getAttribute("stroke-dasharray")! + .split(" ") + .map(Number); + expect(dash).toBeGreaterThanOrEqual(0); + expect(gap).toBeGreaterThanOrEqual(0); + expect(dash + gap).toBeCloseTo(CIRCUMFERENCE, 10); + } + }); + + it("computes correct strokeDasharray and strokeDashoffset for [70, 20, 10]", () => { + const pcts = [70, 20, 10]; + const { container } = render(); + const circles = getSliceCircles(container); + expect(circles).toHaveLength(3); + + const expectedCumulativePct = [0, 70, 90]; + + pcts.forEach((pct, i) => { + const expectedLen = (pct / 100) * CIRCUMFERENCE - GAP_LEN; + const expectedGap = CIRCUMFERENCE - expectedLen; + const expectedOffset = -((expectedCumulativePct[i] / 100) * CIRCUMFERENCE); + + expect(circles[i].getAttribute("stroke-dasharray")).toBe(`${expectedLen} ${expectedGap}`); + expect(Number(circles[i].getAttribute("stroke-dashoffset"))).toBeCloseTo(expectedOffset, 10); + + const [dash, gap] = circles[i] + .getAttribute("stroke-dasharray")! + .split(" ") + .map(Number); + expect(dash).toBeGreaterThan(0); + expect(gap).toBeGreaterThan(0); + expect(dash + gap).toBeCloseTo(CIRCUMFERENCE, 10); + }); + }); + + it("clamps tiny slice dash length to >= 0 for [99.7, 0.3] with no negative dash values", () => { + const pcts = [99.7, 0.3]; + const { container } = render(); + const circles = getSliceCircles(container); + expect(circles).toHaveLength(2); + + const len0 = Math.max(0, (99.7 / 100) * CIRCUMFERENCE - GAP_LEN); + const len1 = Math.max(0, (0.3 / 100) * CIRCUMFERENCE - GAP_LEN); + expect(len1).toBe(0); + + expect(circles[0].getAttribute("stroke-dasharray")).toBe(`${len0} ${CIRCUMFERENCE - len0}`); + expect(Number(circles[0].getAttribute("stroke-dashoffset"))).toBeCloseTo(0, 10); + + expect(circles[1].getAttribute("stroke-dasharray")).toBe(`0 ${CIRCUMFERENCE}`); + expect(Number(circles[1].getAttribute("stroke-dashoffset"))).toBeCloseTo( + -((99.7 / 100) * CIRCUMFERENCE), + 10 + ); + + for (const circle of circles) { + const values = circle + .getAttribute("stroke-dasharray")! + .split(" ") + .map(Number); + values.forEach((v) => expect(v).toBeGreaterThanOrEqual(0)); + } + }); + + it("normalises slices whose percentages do not sum to 100 so legend and arcs match", () => { + const unnormalised: AllocationSlice[] = [ + { id: "1", label: "Alpha", value: 25000, percentage: 25 }, + { id: "2", label: "Beta", value: 25000, percentage: 25 }, + ]; + const { container } = render(); + + // Legend shows normalised 50.0% for each slice + const legend = screen.getByRole("list", { name: /Allocation legend/i }); + const pctLabels = within(legend).getAllByText("50.0%"); + expect(pctLabels).toHaveLength(2); + + // Drawn arcs match a [50, 50] split + const circles = getSliceCircles(container); + const expectedLen = (50 / 100) * CIRCUMFERENCE - GAP_LEN; + expect(circles[0].getAttribute("stroke-dasharray")).toBe( + `${expectedLen} ${CIRCUMFERENCE - expectedLen}` + ); + expect(Number(circles[0].getAttribute("stroke-dashoffset"))).toBeCloseTo(0, 10); + expect(circles[1].getAttribute("stroke-dasharray")).toBe( + `${expectedLen} ${CIRCUMFERENCE - expectedLen}` + ); + expect(Number(circles[1].getAttribute("stroke-dashoffset"))).toBeCloseTo( + -0.5 * CIRCUMFERENCE, + 10 + ); + }); + + it("derives normalised percentages from values when input percentages are 0", () => { + const valueOnlySlices: AllocationSlice[] = [ + { id: "1", label: "Alpha", value: 7000, percentage: 0 }, + { id: "2", label: "Beta", value: 3000, percentage: 0 }, + ]; + render(); + expect(screen.getByText("70.0%")).toBeInTheDocument(); + expect(screen.getByText("30.0%")).toBeInTheDocument(); + }); + + it("handles slices where both percentages and values are 0", () => { + const zeroSlices: AllocationSlice[] = [ + { id: "1", label: "Zero A", value: 0, percentage: 0 }, + ]; + const { container } = render(); + const circles = getSliceCircles(container); + expect(circles[0].getAttribute("stroke-dasharray")).toBe(`0 ${CIRCUMFERENCE}`); + expect(screen.getByText("0.0%")).toBeInTheDocument(); + }); +}); + describe("AllocationWidget – bar view toggle", () => { it("switches to bar view on click", async () => { render(); @@ -138,4 +291,13 @@ describe("AllocationWidget – many holdings (overflow colours)", () => { render(); expect(screen.getByText("Fund 10")).toBeInTheDocument(); }); + + it("switches to bar view automatically during print mode", () => { + render(); + act(() => { + window.dispatchEvent(new Event("beforeprint")); + }); + expect(screen.getByTestId("allocation-widget")).toHaveAttribute("data-print-view", "bar"); + expect(screen.getAllByRole("progressbar")).toHaveLength(SLICES.length); + }); }); diff --git a/src/components/AllocationWidget.tsx b/src/components/AllocationWidget.tsx index ef8d6fe..c4e6331 100644 --- a/src/components/AllocationWidget.tsx +++ b/src/components/AllocationWidget.tsx @@ -27,6 +27,24 @@ const SLICE_COLOURS = [ "var(--chart-cat-8, #f472b6)", // pink ]; +function normaliseSlices(slices: AllocationSlice[]): AllocationSlice[] { + const totalPct = slices.reduce((sum, s) => sum + Math.max(0, s.percentage), 0); + const totalValue = slices.reduce((sum, s) => sum + Math.max(0, s.value), 0); + + return slices.map((s) => { + const rawPct = Math.max(0, s.percentage); + const percentage = + totalPct === 100 + ? rawPct + : totalPct > 0 + ? (rawPct / totalPct) * 100 + : totalValue > 0 + ? (Math.max(0, s.value) / totalValue) * 100 + : 0; + return { ...s, percentage }; + }); +} + function DonutChart({ slices }: { slices: AllocationSlice[] }) { const size = 160; const r = 60; @@ -35,17 +53,20 @@ function DonutChart({ slices }: { slices: AllocationSlice[] }) { const circumference = 2 * Math.PI * r; const gap = 2; // degrees gap between slices - // Build strokes from percentages - let cumulativePct = 0; - const segments = slices.map((s, i) => { + const normalisedSlices = normaliseSlices(slices); + + // Build strokes from normalised percentages + let cumulativeLen = 0; + const segments = normalisedSlices.map((s, i) => { const pct = s.percentage; - const dashLen = (pct / 100) * circumference - (gap / 360) * circumference; - const offset = circumference - (cumulativePct / 100) * circumference; - cumulativePct += pct; + const sliceLen = (pct / 100) * circumference; + const dashLen = Math.max(0, sliceLen - (gap / 360) * circumference); + const offset = cumulativeLen === 0 ? 0 : -cumulativeLen; + cumulativeLen += sliceLen; return { ...s, dashLen, offset, colour: SLICE_COLOURS[i % SLICE_COLOURS.length] }; }); - const totalFormatted = slices.reduce((sum, s) => sum + s.value, 0).toLocaleString("en-US", { + const totalFormatted = normalisedSlices.reduce((sum, s) => sum + s.value, 0).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0, @@ -72,7 +93,7 @@ function DonutChart({ slices }: { slices: AllocationSlice[] }) { fill="none" stroke={seg.colour} strokeWidth={22} - strokeDasharray={`${seg.dashLen} ${circumference}`} + strokeDasharray={`${seg.dashLen} ${circumference - seg.dashLen}`} strokeDashoffset={seg.offset} transform={`rotate(-90 ${cx} ${cy})`} aria-label={`${seg.label}: ${seg.percentage.toFixed(1)}%`} @@ -166,6 +187,8 @@ export const AllocationWidget: React.FC = ({ ); } + const normalisedSlices = normaliseSlices(slices); + return (
= ({ {effectiveView === "donut" ? (
- +
) : (
- +
)}