Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 164 additions & 2 deletions src/components/AllocationWidget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<SVGCircleElement>("svg circle[stroke-dasharray]"));
}

describe("AllocationWidget – empty state", () => {
it("renders allocation-widget testid", () => {
it("renders allocation-widget testid", () => {
render(<AllocationWidget slices={[]} />);
expect(screen.getByTestId("allocation-widget")).toBeInTheDocument();
});
Expand Down Expand Up @@ -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(<AllocationWidget slices={makeSlices([50, 50])} />);
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(<AllocationWidget slices={makeSlices(pcts)} />);
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(<AllocationWidget slices={makeSlices(pcts)} />);
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(<AllocationWidget slices={unnormalised} />);

// 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(<AllocationWidget slices={valueOnlySlices} />);
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(<AllocationWidget slices={zeroSlices} />);
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(<AllocationWidget slices={SLICES} />);
Expand Down Expand Up @@ -138,4 +291,13 @@ describe("AllocationWidget – many holdings (overflow colours)", () => {
render(<AllocationWidget slices={many} />);
expect(screen.getByText("Fund 10")).toBeInTheDocument();
});

it("switches to bar view automatically during print mode", () => {
render(<AllocationWidget slices={SLICES} />);
act(() => {
window.dispatchEvent(new Event("beforeprint"));
});
expect(screen.getByTestId("allocation-widget")).toHaveAttribute("data-print-view", "bar");
expect(screen.getAllByRole("progressbar")).toHaveLength(SLICES.length);
});
});
43 changes: 33 additions & 10 deletions src/components/AllocationWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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)}%`}
Expand Down Expand Up @@ -166,6 +187,8 @@ export const AllocationWidget: React.FC<AllocationWidgetProps> = ({
);
}

const normalisedSlices = normaliseSlices(slices);

return (
<section
className="glass-card p-6 space-y-4"
Expand Down Expand Up @@ -200,11 +223,11 @@ export const AllocationWidget: React.FC<AllocationWidgetProps> = ({

{effectiveView === "donut" ? (
<div className="donut-view">
<DonutChart slices={slices} />
<DonutChart slices={normalisedSlices} />
</div>
) : (
<div className="bar-view">
<BarView slices={slices} />
<BarView slices={normalisedSlices} />
</div>
)}
</section>
Expand Down
Loading