src/programs/snap-negative/calculation.test.ts
Linked repository file from this build.
import { describe, expect, test } from "bun:test";
import { calculate, combineIncome, selectFinal, prorateBenefit } from "./calculation";
import { createSeed } from "./seed";
import {
EDUCATION_CALC_VERSION,
LEGACY_CALC_VERSION,
PREVIOUS_CALC_VERSION,
} from "./rule-versions";
describe("ordinary monthly workbook snapshot", () => {
test("independently worked April case: 608 versus 488, compared with a zero CAO allotment", () => {
const review = createSeed();
const first = calculate(review.facts.scenarios.compI, review.sampleMonth);
// $2,000 gross - $360 earned - $209 standard - $100 care = $1,331.
// $1,657 shelter - round($1,331 / 2) = $991; cap $744; net $587.
// $785 allotment - ceil($587 * .30) = $608.
expect(first).toMatchObject({
grossCents: 200000,
earnedDeductionCents: 36000,
adjustedIncomeCents: 133100,
shelterDeductionCents: 74400,
netCents: 58700,
benefitCents: 60800,
});
expect(calculate(review.facts.scenarios.eligibility, review.sampleMonth).benefitCents).toBe(0);
const final = selectFinal(review);
expect(final.scenario).toBe("compII");
expect(final.result.benefitCents).toBe(48800);
expect(final.errorCents).toBe(-48800);
});
test("tie chooses Comp I and a sole Comp II is allowed", () => {
const review = createSeed();
review.facts.scenarios.compII = structuredClone(review.facts.scenarios.compI);
expect(selectFinal(review).scenario).toBe("compI");
expect(selectFinal(review).reason).toContain("tie");
review.facts.scenarios.compI.populated = false;
expect(selectFinal(review).scenario).toBe("compII");
review.facts.scenarios.compII.populated = false;
expect(() => selectFinal(review)).toThrow("at least one");
});
test("leftover PA CAP values do not change ordinary allotment selection", () => {
const baseline = selectFinal(createSeed());
for (const paCap of ["yes", "unknown"] as const) {
const review = createSeed();
review.facts.paCap = paCap;
expect(selectFinal(review)).toEqual(baseline);
}
const missing = createSeed();
delete missing.facts.paCap;
expect(selectFinal(missing)).toEqual(baseline);
});
test("policy dates, missing income links and invalid money fail; old rules still reject losses", () => {
const review = createSeed();
expect(() => calculate(review.facts.scenarios.compI, "2025-09")).toThrow("policy");
expect(() =>
calculate({ ...review.facts.scenarios.compI, householdSize: 0 }, review.sampleMonth),
).toThrow("Household size");
review.facts.scenarios.compI.income[0].memberId = "missing";
expect(() => selectFinal(review)).toThrow("household member");
const s = createSeed().facts.scenarios.compI;
s.income[0].amountCents = NaN;
expect(() => calculate(s, "2026-04")).toThrow("money");
s.income[0] = {
...s.income[0],
category: "self_employment",
amountCents: 10000,
expensesCents: 20000,
};
expect(() => calculate(s, "2026-04", EDUCATION_CALC_VERSION)).toThrow("losses");
expect(calculate(s, "2026-04")).toMatchObject({
earnedCents: 0,
selfEmploymentLossCents: 10000,
grossCents: 10000,
});
});
test("self-employment profit and member/type aggregation retain source associations", () => {
const s = createSeed().facts.scenarios.compI;
s.income[0] = {
...s.income[0],
category: "self_employment",
amountCents: 200000,
expensesCents: 20000,
};
expect(calculate(s, "2026-04").earnedCents).toBe(180000);
const combined = combineIncome([
...s.income,
{ ...s.income[0], id: "another", amountCents: 10000, expensesCents: 1000 },
]);
expect(combined).toHaveLength(2);
expect(combined[0]).toMatchObject({
memberId: "member-1",
typeCode: "11",
amountCents: 210000,
expensesCents: 21000,
});
});
test("minimum benefit and uncapped shelter depend on explicit scenario facts", () => {
const s = createSeed().facts.scenarios.compI;
s.householdSize = 1;
s.categoricalEligibility = true;
s.income[0].amountCents = 300000;
expect(calculate(s, "2026-04").benefitCents).toBe(2400);
const ordinary = createSeed().facts.scenarios.compI;
ordinary.elderlyDisabled = true;
expect(calculate(ordinary, "2026-04").shelterDeductionCents).toBe(99100);
});
});
test("Final SAQC retains the selected inputs when the examiner later changes a column", () => {
const review = createSeed();
const final = selectFinal(review);
const selectedRent = final.inputs!.rentCents;
review.facts.scenarios[final.scenario].rentCents += 100;
expect(final.inputs!.rentCents).toBe(selectedRent);
expect(final.inputs).not.toBe(review.facts.scenarios[final.scenario]);
});
test("all twenty workbook household rows calculate with current rules", () => {
const gross = [
2610, 3526, 4442, 5360, 6276, 7192, 8110, 9026, 9944, 10862, 11780, 12698, 13616, 14534, 15452,
16370, 17288, 18206, 19124, 20042,
];
const net = [
1305, 1763, 2221, 2680, 3138, 3596, 4055, 4513, 4972, 5434, 5893, 6352, 6811, 7270, 7729, 8188,
8647, 9106, 9565, 10024,
];
const allotments = [
298, 546, 785, 994, 1183, 1421, 1571, 1789, 2007, 2225, 2443, 2661, 2879, 3097, 3315, 3533,
3751, 3969, 4187, 4405,
];
for (let size = 1; size <= 20; size++) {
const scenario = { ...createSeed().facts.scenarios.compI, householdSize: size, income: [] };
const result = calculate(scenario, "2026-09");
expect(result.grossLimitCents).toBe(gross[size - 1] * 100);
expect(result.netLimitCents).toBe(net[size - 1] * 100);
expect(result.benefitCents).toBe(allotments[size - 1] * 100);
expect(result.standardDeductionCents).toBe(
(size <= 3 ? 209 : size === 4 ? 223 : size === 5 ? 261 : 299) * 100,
);
}
for (const householdSize of [0, 21, 1.5]) {
expect(() =>
calculate({ ...createSeed().facts.scenarios.compI, householdSize }, "2026-09"),
).toThrow("Household size");
}
});
describe("v3 education and application-day proration", () => {
test("education net joins gross income without increasing the earned deduction", () => {
const review = createSeed();
const scenario = {
...review.facts.scenarios.compI,
educationGrantCents: 10000,
educationExpensesCents: 2000,
};
expect(calculate(scenario, review.sampleMonth)).toMatchObject({
earnedCents: 180000,
unearnedCents: 20000,
educationNetCents: 8000,
earnedAndEducationCents: 188000,
grossCents: 208000,
earnedDeductionCents: 36000,
netCents: 66700,
monthlyBenefitCents: 58400,
benefitCents: 58400,
});
expect(
calculate({ ...scenario, educationExpensesCents: 20000 }, review.sampleMonth).benefitCents,
).toBe(60800);
});
test("proration uses 30 days, rounds down to dollars, and preserves blank-day monthly benefit", () => {
const review = createSeed();
const scenario = review.facts.scenarios.compII;
for (const [day, cents] of [
[null, 48800],
[1, 48800],
[16, 24400],
[30, 1600],
[31, 0],
] as const) {
const result = calculate({ ...scenario, applicationDay: day }, review.sampleMonth);
expect(result.monthlyBenefitCents).toBe(48800);
expect(result.benefitCents).toBe(cents);
}
expect(prorateBenefit(1000, 1)).toBe(0);
expect(prorateBenefit(900, 16)).toBe(0);
expect(prorateBenefit(1100, 1)).toBe(1100);
expect(prorateBenefit(1000, null)).toBe(1000);
for (const applicationDay of [0, 32, 1.5, NaN])
expect(() => calculate({ ...scenario, applicationDay }, review.sampleMonth)).toThrow(
"Application day",
);
});
test("old bindings retain their original output and require rebind for new inputs", () => {
const review = createSeed();
for (const version of [LEGACY_CALC_VERSION, PREVIOUS_CALC_VERSION]) {
const result = calculate(review.facts.scenarios.compII, review.sampleMonth, version);
expect(result.benefitCents).toBe(48800);
expect(result).not.toHaveProperty("educationNetCents");
expect(() =>
calculate(
{ ...review.facts.scenarios.compII, educationGrantCents: 100 },
review.sampleMonth,
version,
),
).toThrow("require v3");
expect(() =>
calculate(
{ ...review.facts.scenarios.compII, applicationDay: 1 },
review.sampleMonth,
version,
),
).toThrow("require v3");
}
});
test("Final SAQC snapshots education and proration inputs", () => {
const review = createSeed();
review.facts.scenarios.compI.populated = false;
review.facts.scenarios.compII.applicationDay = 16;
review.facts.scenarios.compII.educationGrantCents = 10000;
const final = selectFinal(review);
expect(final.inputs?.applicationDay).toBe(16);
expect(final.inputs?.educationGrantCents).toBe(10000);
review.facts.scenarios.compII.educationGrantCents = 0;
expect(final.inputs?.educationGrantCents).toBe(10000);
});
});