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