Files
budget2.0/src/ui/purchaseForm.test.ts
T
Lucas WintherandClaude Opus 5 5a11941d39 feat(ui): add purchase dialog with split editor
Validation lives in purchaseForm.ts as pure functions. Unbalanced splits
and over-budget purchases are deliberately not errors.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 20:48:51 +02:00

117 lines
3.4 KiB
TypeScript

import { test, expect, describe } from "bun:test";
import {
emptyDraft,
splitBalance,
validatePurchase,
toPurchase,
fromPurchase,
type PurchaseDraft,
} from "./purchaseForm.ts";
function draft(overrides: Partial<PurchaseDraft> = {}): PurchaseDraft {
return { ...emptyDraft("2026-09", "food"), amount: 200, description: "Dinner", ...overrides };
}
describe("emptyDraft", () => {
test("starts on the first of the given month with one full-weight split", () => {
const d = emptyDraft("2026-09", "food");
expect(d.date).toBe("2026-09-01");
expect(d.splitMode).toBe("percent");
expect(d.splits).toEqual([{ postId: "food", value: 100, absorbsRemainder: true }]);
expect(d.plan).toBeNull();
});
});
describe("splitBalance", () => {
test("is zero when percentages total 100", () => {
expect(splitBalance(draft())).toBe(0);
});
test("reports the missing percentage", () => {
const d = draft({
splits: [
{ postId: "food", value: 60, absorbsRemainder: true },
{ postId: "events", value: 30, absorbsRemainder: false },
],
});
expect(splitBalance(d)).toBe(10);
});
test("reports the missing amount in fixed mode", () => {
const d = draft({
splitMode: "fixed",
splits: [
{ postId: "food", value: 120, absorbsRemainder: true },
{ postId: "events", value: 50, absorbsRemainder: false },
],
});
expect(splitBalance(d)).toBe(30);
});
});
describe("validatePurchase", () => {
test("a well-formed draft has no errors", () => {
expect(validatePurchase(draft())).toEqual([]);
});
test("requires a description", () => {
expect(validatePurchase(draft({ description: " " }))).toContain(
"Give the purchase a description.",
);
});
test("requires a non-zero amount", () => {
expect(validatePurchase(draft({ amount: 0 }))).toContain(
"Enter an amount other than zero.",
);
});
test("requires at least one split", () => {
expect(validatePurchase(draft({ splits: [] }))).toContain(
"Add at least one post to split across.",
);
});
test("requires exactly one remainder absorber", () => {
const none = draft({
splits: [{ postId: "food", value: 100, absorbsRemainder: false }],
});
expect(validatePurchase(none)).toContain(
"Choose exactly one post to absorb rounding.",
);
});
test("rejects the same post twice", () => {
const dup = draft({
splits: [
{ postId: "food", value: 50, absorbsRemainder: true },
{ postId: "food", value: 50, absorbsRemainder: false },
],
});
expect(validatePurchase(dup)).toContain("Each post can only appear once.");
});
test("does NOT reject going over budget or an unbalanced split", () => {
// The remainder-absorbing split reconciles any imbalance, and over-budget
// is always permitted, so neither is an error.
const unbalanced = draft({
splits: [
{ postId: "food", value: 10, absorbsRemainder: true },
{ postId: "events", value: 10, absorbsRemainder: false },
],
});
expect(validatePurchase(unbalanced)).toEqual([]);
});
});
describe("toPurchase and fromPurchase", () => {
test("round-trips a simple purchase", () => {
const d = draft();
expect(fromPurchase({ ...toPurchase(d), id: "x" })).toEqual(d);
});
test("produces a schedule-free purchase when there is no plan", () => {
expect(toPurchase(draft()).schedule).toBeNull();
});
});