src/features/reviews/computation-grid.helpers.test.ts
Linked repository file from this build.
import { expect, test } from "bun:test";
import { createSeed } from "@/programs/snap-negative/seed";
import {
alignIncomeRows,
appendAtmDigit,
applyFinalBlockers,
atmMoneyDisplay,
backspaceAtmCents,
calcScenario,
centsDiffer,
centsFromAtmDigits,
draftAfterSavedSync,
formatCentsDiffNote,
formatDecimal2FromCents,
hasAnyUnsavedDraft,
hasUnsavedDraft,
parseDollarsToCents,
retireDraftAfterSave,
scenarioForColumn,
scenariosEqual,
} from "./computation-grid.helpers";
test("only populated computation columns calculate, including populated zero-income cases", () => {
const review = createSeed();
for (const column of ["eligibility", "compI", "compII"] as const) {
const scenario = { ...review.facts.scenarios[column], income: [], populated: false };
expect(calcScenario(review, scenario, column).kind).toBe("empty");
scenario.populated = true;
const calculated = calcScenario(review, scenario, column);
expect(calculated.kind).toBe("ok");
if (calculated.kind === "ok") expect(calculated.result.grossCents).toBe(0);
scenario.householdSize = 0;
expect(calcScenario(review, scenario, column).kind).toBe("error");
}
});
test("parseDollarsToCents accepts non-negative amounts with up to two decimals", () => {
expect(parseDollarsToCents("0")).toBe(0);
expect(parseDollarsToCents("12.3")).toBe(1230);
expect(parseDollarsToCents("12.34")).toBe(1234);
expect(parseDollarsToCents(" 1000.00 ")).toBe(100000);
});
test("parseDollarsToCents rejects unsafe or malformed input", () => {
expect(parseDollarsToCents("")).toBeNull();
expect(parseDollarsToCents("-1")).toBeNull();
expect(parseDollarsToCents("12.345")).toBeNull();
expect(parseDollarsToCents("abc")).toBeNull();
});
test("ATM money digits shift from the cents and ignore punctuation", () => {
expect(appendAtmDigit(0, 1)).toBe(1);
expect(appendAtmDigit(1, 2)).toBe(12);
expect(appendAtmDigit(12, 5)).toBe(125);
expect(appendAtmDigit(125, 0)).toBe(1250);
expect(atmMoneyDisplay(0)).toBe("");
expect(atmMoneyDisplay(1250)).toBe("12.50");
expect(backspaceAtmCents(1250)).toBe(125);
expect(backspaceAtmCents(1)).toBe(0);
expect(centsFromAtmDigits("100.00")).toBe(10000);
expect(centsFromAtmDigits("20.00")).toBe(2000);
expect(centsFromAtmDigits("$12.34")).toBe(1234);
expect(centsFromAtmDigits("")).toBe(0);
expect(centsFromAtmDigits("1000000.00")).toBe(100_000_000);
expect(centsFromAtmDigits("1000000.01")).toBeNull();
expect(appendAtmDigit(100_000_000, 1)).toBeNull();
});
test("formatDecimal2FromCents and diff notes compare stored cents, not display strings", () => {
expect(formatDecimal2FromCents(1234)).toBe("12.34");
expect(centsDiffer(1234, 1234)).toBe(false);
expect(formatCentsDiffNote(100000, 123400)).toBe("+$234.00 vs Eligibility");
expect(formatCentsDiffNote(100000, 100000)).toBeNull();
});
test("scenariosEqual detects income and deduction changes", () => {
const review = createSeed();
const saved = review.facts.scenarios.compI;
const draft = structuredClone(saved);
expect(scenariosEqual(saved, draft)).toBe(true);
draft.rentCents += 100;
expect(scenariosEqual(saved, draft)).toBe(false);
});
test("draft tracking survives another column save and clears after matching save", () => {
const review = createSeed();
const compDraft = structuredClone(review.facts.scenarios.compI);
compDraft.rentCents += 500;
const drafts = { compI: compDraft };
expect(hasUnsavedDraft(drafts, review, "compI")).toBe(true);
expect(hasAnyUnsavedDraft(drafts, review)).toBe(true);
expect(draftAfterSavedSync(compDraft, review.facts.scenarios.compI)).toEqual(compDraft);
const savedAfter = structuredClone(compDraft);
expect(draftAfterSavedSync(compDraft, savedAfter)).toBeUndefined();
});
test("applyFinalBlockers requires saved drafts and an editable review", () => {
const review = createSeed();
const draft = structuredClone(review.facts.scenarios.compII);
draft.medicalDeductionCents = 5000;
const reasons = applyFinalBlockers(review, { compII: draft }, true);
expect(reasons.some((r) => r.includes("Save or reset"))).toBe(true);
expect(reasons.some((r) => r.includes("Eligibility"))).toBe(true);
review.facts.paCap = "yes";
expect(applyFinalBlockers(review, {}, true)).toEqual([]);
expect(applyFinalBlockers(review, {}, false).some((r) => r.includes("locked"))).toBe(true);
});
test("unsaved Eligibility draft blocks apply and persists in scenarioForColumn", () => {
const review = createSeed();
const eligibilityDraft = structuredClone(review.facts.scenarios.eligibility);
eligibilityDraft.populated = true;
eligibilityDraft.rentCents += 2500;
const drafts = { eligibility: eligibilityDraft };
expect(hasUnsavedDraft(drafts, review, "eligibility")).toBe(true);
expect(hasAnyUnsavedDraft(drafts, review)).toBe(true);
expect(applyFinalBlockers(review, drafts, true).some((r) => r.includes("Save or reset"))).toBe(
true,
);
expect(scenarioForColumn(review, "eligibility", drafts).rentCents).toBe(
review.facts.scenarios.eligibility.rentCents + 2500,
);
});
test("scenarioForColumn uses drafts for editable columns only", () => {
const review = createSeed();
const draft = structuredClone(review.facts.scenarios.compI);
draft.householdSize = 4;
expect(scenarioForColumn(review, "compI", { compI: draft }).householdSize).toBe(4);
expect(scenarioForColumn(review, "eligibility", { compI: draft }).householdSize).toBe(
review.facts.scenarios.eligibility.householdSize,
);
const eligibilityDraft = structuredClone(review.facts.scenarios.eligibility);
eligibilityDraft.householdSize = 2;
expect(
scenarioForColumn(review, "eligibility", { eligibility: eligibilityDraft }).householdSize,
).toBe(2);
});
test("retireDraftAfterSave clears only matching draft and preserves in-flight edits", () => {
const review = createSeed();
const savedDraft = structuredClone(review.facts.scenarios.compI);
savedDraft.rentCents += 500;
const inFlight = structuredClone(savedDraft);
inFlight.rentCents += 200;
const raw = { compI: savedDraft, compII: structuredClone(review.facts.scenarios.compII) };
raw.compII!.medicalDeductionCents = 999;
expect(retireDraftAfterSave(raw, "compI", savedDraft)).toEqual({ compII: raw.compII });
expect(retireDraftAfterSave({ compI: inFlight }, "compI", savedDraft)).toEqual({
compI: inFlight,
});
});
test("separately entered income aligns by member and source without changing saved IDs", () => {
const row = {
id: "eligibility-id",
memberId: "member-1",
typeCode: "WAGES",
category: "earned" as const,
amountCents: 10000,
expensesCents: 0,
description: "",
};
const other = { ...row, id: "comp-id", typeCode: " wages ", amountCents: 12000 };
const aligned = alignIncomeRows([[row], [other], [], []]);
expect(aligned).toHaveLength(1);
expect(aligned[0].rows[0]?.id).toBe("eligibility-id");
expect(aligned[0].rows[1]?.id).toBe("comp-id");
expect(aligned[0].rows[1]?.amountCents).toBe(12000);
expect(alignIncomeRows([[row, { ...row, id: "second" }], [other]])).toHaveLength(2);
expect(alignIncomeRows([[row], [{ ...other, memberId: "different" }]])).toHaveLength(2);
});
test("alignment keys distinguish reused IDs and repeated sources across all columns", () => {
const wages = { ...createSeed().facts.scenarios.eligibility.income[0]!, id: "income-2" };
const otherWages = { ...wages, id: "second-wages" };
const unearned = { ...wages, category: "unearned" as const };
const business = { ...wages, typeCode: "21", category: "self_employment" as const };
const lists = [[wages, otherWages], [unearned], [business], [business]];
const before = structuredClone(lists);
const aligned = alignIncomeRows(lists);
expect(aligned).toHaveLength(4);
expect(new Set(aligned.map((group) => group.key)).size).toBe(4);
expect(aligned.find((group) => group.rows[2] === business)?.rows[3]).toBe(business);
expect(lists).toEqual(before);
const updated = alignIncomeRows([
[{ ...wages, amountCents: wages.amountCents + 100 }, otherWages],
[unearned],
[business],
[business],
[wages, otherWages],
]);
expect(updated.map((group) => group.key)).toEqual(aligned.map((group) => group.key));
expect(updated[0].rows[4]).toBe(wages);
const withoutEligibility = alignIncomeRows([[], [], [business], [business]]);
expect(withoutEligibility[0].key).toBe(aligned.find((group) => group.rows[2] === business)!.key);
});