From 0ba7a19972e5ca0854451207904e0f3a21ae3c7a Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:44:21 -0600 Subject: [PATCH] test: cover group mapping, subtotals, default state, and OOB swaps Every section maps to a group. Group order and defaults match the spec. Budget and month subtotal calculations check out across seeded entries. Pages render the expected details ids, income is open by default, committed is closed. Mutations return OOB group total spans. Sinking Funds section is visible on both pages. Refs #11 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_groups.py | 164 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/test_groups.py diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..d877f7a --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from decimal import Decimal + +from quartermaster import month_service, service +from quartermaster.groups import ( + GROUP_DEFAULT_OPEN, + GROUP_OF_SECTION, + Group, + group_order, + sections_in_group, +) +from quartermaster.models import Section + + +def test_every_section_maps_to_a_group(): + for s in Section: + assert s in GROUP_OF_SECTION, f"{s} is not assigned to a group" + + +def test_group_order_is_stable(): + assert group_order() == [ + Group.income, + Group.committed, + Group.savings, + Group.flexible, + ] + + +def test_sinking_fund_is_in_savings_group(): + assert GROUP_OF_SECTION[Section.sinking_fund] == Group.savings + assert Section.sinking_fund in sections_in_group(Group.savings) + + +def test_defaults_match_spec(): + assert GROUP_DEFAULT_OPEN[Group.income] is True + assert GROUP_DEFAULT_OPEN[Group.committed] is False + assert GROUP_DEFAULT_OPEN[Group.savings] is False + assert GROUP_DEFAULT_OPEN[Group.flexible] is True + + +def test_committed_group_contains_fixed_and_debt_minimum(): + committed_sections = sections_in_group(Group.committed) + assert Section.fixed_bill in committed_sections + assert Section.debt_minimum in committed_sections + # debt_target is a pointer, not a section; not part of the enum + assert len(committed_sections) == 2 + + +def test_budget_group_views_totals(db): + service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00")) + service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00")) + service.add_entry(db, Section.debt_minimum, "Card A", Decimal("40.00")) + service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00")) + service.add_entry(db, Section.food, "Groceries", Decimal("500.00")) + views = {v.group: v for v in service.budget_group_views(db)} + assert views[Group.income].total == Decimal("2500.00") + assert views[Group.committed].total == Decimal("1240.00") + assert views[Group.savings].total == Decimal("300.00") + assert views[Group.flexible].total == Decimal("500.00") + + +def test_month_group_views_planned_and_applied(db): + service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00")) + service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00")) + service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00")) + month = month_service.create_month(db, "2026-04") + # seed applied values + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.update_month_entry( + db, month, rent.id, applied=Decimal("1200.00") + ) + db.refresh(month) + views = {v.group: v for v in month_service.month_group_views(month)} + assert views[Group.income].total_planned == Decimal("2500.00") + assert views[Group.income].total_applied == Decimal("0.00") + assert views[Group.committed].total_planned == Decimal("1200.00") + assert views[Group.committed].total_applied == Decimal("1200.00") + assert views[Group.savings].total_planned == Decimal("300.00") + assert views[Group.savings].total_applied == Decimal("0.00") + assert views[Group.flexible].total_planned == Decimal("0.00") + + +def test_budget_page_renders_details_groups(client): + response = client.get("/") + assert response.status_code == 200 + # four
blocks + assert response.text.count('class="group"') >= 4 + # default open state: income and flexible open, committed and savings closed + assert 'id="group-income"' in response.text + assert 'id="group-committed"' in response.text + assert 'id="group-savings"' in response.text + assert 'id="group-flexible"' in response.text + # income has " open" in its details tag; committed does not + income_tag_start = response.text.index('id="group-income"') + income_chunk = response.text[income_tag_start - 80 : income_tag_start + 80] + assert " open" in income_chunk + committed_tag_start = response.text.index('id="group-committed"') + committed_chunk = response.text[ + committed_tag_start - 80 : committed_tag_start + 80 + ] + assert " open" not in committed_chunk + + +def test_add_entry_returns_group_total_oob(client): + response = client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "2500.00"}, + ) + assert response.status_code == 200 + assert 'id="group-total-income"' in response.text + assert 'hx-swap-oob="outerHTML"' in response.text + + +def test_sinking_fund_entry_ends_up_in_savings_group_total(client): + response = client.post( + "/sections/sinking_fund/entries", + data={"name": "Emergency", "amount": "500.00"}, + ) + assert response.status_code == 200 + # OOB response contains group-total-savings with 500 + assert 'id="group-total-savings"' in response.text + assert "$500.00" in response.text + + +def test_month_page_renders_group_headers_with_paired_totals(client): + client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "2500.00"}, + ) + client.post("/month/2026-04/create") + response = client.get("/month/2026-04") + assert response.status_code == 200 + assert 'id="group-committed"' in response.text + assert 'id="group-total-income"' in response.text + + +def test_month_mutation_returns_group_total_oob(client): + client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "2500.00"}, + ) + client.post("/month/2026-04/create") + response = client.post( + "/month/2026-04/entries/1", data={"applied": "2500.00"} + ) + assert response.status_code == 200 + assert 'id="group-total-income"' in response.text + + +def test_sinking_fund_section_shows_on_budget_page(client): + response = client.get("/") + assert "Sinking Funds" in response.text + + +def test_sinking_fund_section_shows_on_month_page(client): + client.post( + "/sections/sinking_fund/entries", + data={"name": "Emergency", "amount": "500.00"}, + ) + client.post("/month/2026-04/create") + response = client.get("/month/2026-04") + assert "Sinking Funds" in response.text + assert "Emergency" in response.text