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>
164 lines
6 KiB
Python
164 lines
6 KiB
Python
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
|