Skip to content
Merged
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
194 changes: 192 additions & 2 deletions src/components/AllocationInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("AllocationInput", () => {
render(
<AllocationInput assets={ASSETS} targets={{}} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Equalise" }));
fireEvent.click(screen.getByRole("button", { name: "Distribute Evenly" }));

const updated = onChange.mock.calls[0][0] as Record<string, number>;
const total = Object.values(updated).reduce((sum, n) => sum + n, 0);
Expand All @@ -54,7 +54,7 @@ describe("AllocationInput", () => {
const seven = Array.from({ length: 7 }, (_, i) => asset(`A${i}`, 100 / 7));
const onChange = vi.fn();
render(<AllocationInput assets={seven} targets={{}} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Equalise" }));
fireEvent.click(screen.getByRole("button", { name: "Distribute Evenly" }));

const updated = onChange.mock.calls[0][0] as Record<string, number>;
const total = Object.values(updated).reduce((sum, n) => sum + n, 0);
Expand Down Expand Up @@ -131,4 +131,194 @@ describe("AllocationInput", () => {
expect(typeof lastCall.XLM).toBe("number");
});
});
// ─── Issue #918 ─────────────────────────────────────────────────────────────
describe("invalid total highlighting (issue #918)", () => {
function inputs() {
return ASSETS.map((a) => screen.getByLabelText(`Target allocation for ${a.assetCode}`));
}

it("flags every row when the total is under 100%", () => {
render(
<AllocationInput assets={ASSETS} targets={{ XLM: 50, USDC: 20 }} onChange={vi.fn()} />,
);

const message = screen.getByText(/total allocation must equal 100%/i);
for (const input of inputs()) {
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAttribute("aria-describedby", message.id);
expect(input.className).toContain("border-red");
}
for (const a of ASSETS) {
expect(screen.getByTestId(`allocation-row-error-${a.assetCode}`)).toHaveTextContent("!");
}
});

it("flags every row when the total is over 100%", () => {
render(
<AllocationInput assets={ASSETS} targets={{ XLM: 80, USDC: 40 }} onChange={vi.fn()} />,
);

const alert = screen.getByRole("alert");
for (const input of inputs()) {
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAttribute("aria-describedby", alert.id);
}
});

it("clears the highlight once the total is exactly 100%", () => {
render(
<AllocationInput
assets={ASSETS}
targets={{ XLM: 60, USDC: 30, BTC: 10 }}
onChange={vi.fn()}
/>,
);

for (const input of inputs()) {
expect(input).not.toHaveAttribute("aria-invalid");
expect(input).not.toHaveAttribute("aria-describedby");
expect(input.className).not.toContain("border-red");
}
expect(screen.queryByText(/total allocation must equal 100%/i)).not.toBeInTheDocument();
for (const a of ASSETS) {
expect(screen.getByTestId(`allocation-row-error-${a.assetCode}`)).toBeEmptyDOMElement();
}
});

it("does not flag an untouched form", () => {
render(<AllocationInput assets={ASSETS} targets={{}} onChange={vi.fn()} />);
for (const input of inputs()) {
expect(input).not.toHaveAttribute("aria-invalid");
}
});
});

describe("arrow-key stepping (issue #918)", () => {
it("sets a 0.1 step on every input", () => {
render(<AllocationInput assets={ASSETS} targets={{}} onChange={vi.fn()} />);
for (const a of ASSETS) {
expect(screen.getByLabelText(`Target allocation for ${a.assetCode}`)).toHaveAttribute(
"step",
"0.1",
);
}
});

it("increments by 0.1% on ArrowUp", () => {
const onChange = vi.fn();
render(
<AllocationInput assets={ASSETS} targets={{ XLM: 50, USDC: 50 }} onChange={onChange} />,
);
fireEvent.keyDown(screen.getByLabelText("Target allocation for XLM"), { key: "ArrowUp" });

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toEqual({ XLM: 50.1, USDC: 50 });
});

it("decrements by 0.1% on ArrowDown", () => {
const onChange = vi.fn();
render(<AllocationInput assets={ASSETS} targets={{ XLM: 50 }} onChange={onChange} />);
fireEvent.keyDown(screen.getByLabelText("Target allocation for XLM"), { key: "ArrowDown" });

expect(onChange.mock.calls[0][0].XLM).toBe(49.9);
});

it("steps without floating-point drift", () => {
const onChange = vi.fn();
render(<AllocationInput assets={ASSETS} targets={{ XLM: 0.2 }} onChange={onChange} />);
fireEvent.keyDown(screen.getByLabelText("Target allocation for XLM"), { key: "ArrowUp" });

expect(onChange.mock.calls[0][0].XLM).toBe(0.3);
});

it("steps from the value currently being typed", () => {
const onChange = vi.fn();
render(<AllocationInput assets={ASSETS} targets={{ XLM: 10 }} onChange={onChange} />);
const input = screen.getByLabelText("Target allocation for XLM");
fireEvent.change(input, { target: { value: "12.5" } });
fireEvent.keyDown(input, { key: "ArrowUp" });

expect(onChange.mock.calls.at(-1)?.[0].XLM).toBe(12.6);
});

it("clamps to the 0–100 range", () => {
const onChange = vi.fn();
render(
<AllocationInput assets={ASSETS} targets={{ XLM: 100, USDC: 0 }} onChange={onChange} />,
);
fireEvent.keyDown(screen.getByLabelText("Target allocation for XLM"), { key: "ArrowUp" });
fireEvent.keyDown(screen.getByLabelText("Target allocation for USDC"), { key: "ArrowDown" });

expect(onChange.mock.calls[0][0].XLM).toBe(100);
expect(onChange.mock.calls[1][0].USDC).toBe(0);
});

it("ignores other keys", () => {
const onChange = vi.fn();
render(<AllocationInput assets={ASSETS} targets={{ XLM: 50 }} onChange={onChange} />);
fireEvent.keyDown(screen.getByLabelText("Target allocation for XLM"), { key: "ArrowLeft" });
fireEvent.keyDown(screen.getByLabelText("Target allocation for XLM"), { key: "Enter" });

expect(onChange).not.toHaveBeenCalled();
});
});

describe("Distribute Evenly (issue #918)", () => {
function distribute(assets: PortfolioAsset[]) {
const onChange = vi.fn();
render(<AllocationInput assets={assets} targets={{}} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Distribute Evenly" }));
expect(onChange).toHaveBeenCalledTimes(1);
return onChange.mock.calls[0][0] as Record<string, number>;
}

it("splits 100% evenly when it divides cleanly", () => {
expect(distribute([asset("XLM", 70), asset("USDC", 30)])).toEqual({ XLM: 50, USDC: 50 });
});

it("gives a single asset the full 100%", () => {
expect(distribute([asset("XLM", 100)])).toEqual({ XLM: 100 });
});

it("hands the remainder to the largest current holding", () => {
// 100 / 3 = 33.333…, so one asset takes the leftover cent.
expect(distribute(ASSETS)).toEqual({ XLM: 33.34, USDC: 33.33, BTC: 33.33 });
});

it("spreads a multi-cent remainder one cent at a time", () => {
const six = [
asset("A", 30),
asset("B", 25),
asset("C", 20),
asset("D", 15),
asset("E", 6),
asset("F", 4),
];
// 100 / 6 = 16.666…, floored to 16.66 → 4 cents left for A–D.
expect(distribute(six)).toEqual({
A: 16.67,
B: 16.67,
C: 16.67,
D: 16.67,
E: 16.66,
F: 16.66,
});
});

it("discards in-progress typing when distributing", () => {
const onChange = vi.fn();
render(<AllocationInput assets={ASSETS} targets={{}} onChange={onChange} />);
const input = screen.getByLabelText("Target allocation for XLM");
fireEvent.change(input, { target: { value: "7" } });
fireEvent.click(screen.getByRole("button", { name: "Distribute Evenly" }));

expect(input).toHaveValue(null);
expect(onChange.mock.calls.at(-1)?.[0]).toEqual({ XLM: 33.34, USDC: 33.33, BTC: 33.33 });
});

it("is disabled when the input is disabled", () => {
render(<AllocationInput assets={ASSETS} targets={{}} onChange={vi.fn()} disabled />);
expect(screen.getByRole("button", { name: "Distribute Evenly" })).toBeDisabled();
});
});
});
68 changes: 61 additions & 7 deletions src/components/AllocationInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
* Features:
* - Numeric input per asset clamped to 0–100
* - Live sum indicator with colour feedback (red when ≠ 100, green when exact)
* - "Equalise" button distributes 100% evenly across all assets
* - Every row is highlighted as invalid while the total is not exactly 100%
* - Arrow Up / Arrow Down nudge a target by 0.1%
* - "Distribute Evenly" button divides 100% evenly across all assets
* - "Reset" button restores the current-allocation percentages
* - Per-asset diff pill showing how much each target deviates from current
*/

import { useState } from "react";
import { useId, useState } from "react";

import { SLICE_COLORS } from "@/components/ui/PieChart";
import type { PortfolioAsset } from "@/lib/rebalancer";
Expand All @@ -33,6 +35,9 @@ function sumTargets(targets: Record<string, number>): number {
return Object.values(targets).reduce((s, v) => s + (isNaN(v) ? 0 : v), 0);
}

/** Arrow-key increment, in percentage points. */
const ARROW_STEP = 0.1;

function diffLabel(diff: number): string {
if (Math.abs(diff) < 0.01) return "";
return diff > 0 ? `+${diff.toFixed(1)}%` : `${diff.toFixed(1)}%`;
Expand All @@ -51,6 +56,11 @@ export function AllocationInput({
const remaining = parseFloat((100 - sum).toFixed(4));
const isExact = Math.abs(remaining) < 0.01;
const isOver = remaining < -0.01;
// Highlight every row once the user has entered something and the total is
// off, so it's clear the rows together — not one field — are invalid. An
// untouched (all-zero) form isn't flagged.
const isInvalid = !isExact && sum > 0;
const totalErrorId = useId();
// Issue #655: keep the raw text the user typed so clearing the field does not
// immediately collapse to 0. The parsed value is only committed on blur.
const [draft, setDraft] = useState<Record<string, string>>({});
Expand All @@ -77,6 +87,27 @@ export function AllocationInput({
onChange({ ...targets, [assetCode]: value });
}

function handleKeyDown(
e: React.KeyboardEvent<HTMLInputElement>,
assetCode: string,
) {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
// Step in tenths ourselves rather than relying on the native spinner, so
// repeated presses don't accumulate float drift (0.1 + 0.2 ≠ 0.3).
e.preventDefault();
const raw = draft[assetCode];
const parsed = raw !== undefined ? parseFloat(raw) : targets[assetCode] ?? 0;
const current = isNaN(parsed) ? 0 : parsed;
const delta = e.key === "ArrowUp" ? ARROW_STEP : -ARROW_STEP;
const value = Math.min(100, Math.max(0, Math.round((current + delta) * 10) / 10));
setDraft((prev) => {
const next = { ...prev };
delete next[assetCode];
return next;
});
onChange({ ...targets, [assetCode]: value });
}

function handleEqualise() {
setDraft({});
if (assets.length === 0) return;
Expand Down Expand Up @@ -122,7 +153,7 @@ export function AllocationInput({
onClick={handleEqualise}
className="text-[11px] text-brand hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
>
Equalise
Distribute Evenly
</button>
<span className="text-ink-4 text-[11px]">·</span>
<button
Expand Down Expand Up @@ -192,18 +223,23 @@ export function AllocationInput({
type="number"
min={0}
max={100}
step={0.1}
step={ARROW_STEP}
value={draft[asset.assetCode] ?? (target === 0 ? "" : target)}
placeholder="0"
disabled={disabled}
aria-label={`Target allocation for ${asset.assetCode}`}
aria-invalid={isInvalid || undefined}
aria-describedby={isInvalid ? totalErrorId : undefined}
onChange={(e) => handleChange(asset.assetCode, e.target.value)}
onBlur={() => handleBlur(asset.assetCode)}
onKeyDown={(e) => handleKeyDown(e, asset.assetCode)}
className={cn(
"h-8 w-20 rounded-lg border bg-surface-2 pl-3 pr-6",
"text-[12px] text-ink tabular-nums",
"outline-none transition-colors",
"border-line focus:border-line-2 focus:ring-1 focus:ring-brand-dim",
isInvalid
? "border-red focus:border-red focus:ring-1 focus:ring-error-dim"
: "border-line focus:border-line-2 focus:ring-1 focus:ring-brand-dim",
"disabled:opacity-40 disabled:cursor-not-allowed",
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
)}
Expand All @@ -215,6 +251,18 @@ export function AllocationInput({
%
</span>
</div>

{/* Per-row invalid-total indicator */}
<span
aria-hidden="true"
data-testid={`allocation-row-error-${asset.assetCode}`}
className={cn(
"w-3 text-center text-[11px] font-semibold text-red shrink-0",
!isInvalid && "invisible",
)}
>
{isInvalid ? "!" : ""}
</span>
</div>
);
})}
Expand Down Expand Up @@ -247,10 +295,16 @@ export function AllocationInput({
</span>
</div>

{isOver && (
<p role="alert" className="text-[11px] text-red">
{isOver ? (
<p id={totalErrorId} role="alert" className="text-[11px] text-red">
Total allocation exceeds 100%. Reduce one or more targets.
</p>
) : (
isInvalid && (
<p id={totalErrorId} className="text-[11px] text-red">
Total allocation must equal 100%. Increase one or more targets.
</p>
)
)}
</div>
);
Expand Down
Loading
Loading