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) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 12:44:21 -06:00
parent 4d9b64d760
commit 0ba7a19972

164
tests/test_groups.py Normal file
View file

@ -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 <details class="group"> 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