2026-04-17 12:44:21 -06:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-04-17 17:34:53 -06:00
|
|
|
from datetime import date
|
2026-04-17 12:44:21 -06:00
|
|
|
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")
|
2026-04-17 17:34:53 -06:00
|
|
|
# seed applied values via a posting
|
2026-04-17 12:44:21 -06:00
|
|
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
2026-04-17 17:34:53 -06:00
|
|
|
month_service.add_posting(
|
|
|
|
|
db, month, rent.id, date.today(), Decimal("1200.00"), description="test"
|
2026-04-17 12:44:21 -06: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
|