All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Three follow-ups to the actualbudget integration: **Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until the user types into either field, every refetch (mount, window focus) updates the form value. Once they edit, we leave it alone. A small ↻ button next to each anchor input flips the edited flag back off so the user can re-snap to live data on demand. React Query configured with staleTime=0 + refetchOnMount='always' + refetchOnWindowFocus=true so the cache never serves stale numbers. NW provenance shows the snapshot date. **Inflation-adjusted spending.** Backend now revalues each trailing month's nominal pence forward to today's £ using monthly compounding of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline `total_gbp` is the real-£ figure — matches the simulator's real-GBP convention. Response also includes `nominal_total_gbp` and `inflation_pct` for transparency. New /spending/annual?inflation_pct= override param. 10/10 actualbudget tests pass. **FanChart legend.** The bottom-anchored legend was overlapping the x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28 so nothing collides. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
166 lines
6.1 KiB
Python
166 lines
6.1 KiB
Python
"""Unit tests for the actualbudget client + trailing-spending helper.
|
|
|
|
We mock the upstream HTTP API with respx so the tests don't need a
|
|
live cluster connection. The interesting axes are:
|
|
|
|
- _trailing_months walks back the right complete-month window
|
|
- fetch_month flips sign and skips income groups
|
|
- fetch_trailing_spending applies exclusions correctly
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from fire_planner.actualbudget import (
|
|
ActualBudgetClient,
|
|
_trailing_months,
|
|
fetch_trailing_spending,
|
|
)
|
|
|
|
API_URL = "http://budget-http-api.test"
|
|
SYNC_ID = "sync-1"
|
|
|
|
|
|
def _client() -> ActualBudgetClient:
|
|
return ActualBudgetClient(api_url=API_URL, api_key="key", sync_id=SYNC_ID)
|
|
|
|
|
|
def test_trailing_months_skips_current_and_walks_back() -> None:
|
|
# 2025-04 mid-month → trailing 3 months = 2025-01, 2025-02, 2025-03
|
|
result = _trailing_months(date(2025, 4, 15), 3)
|
|
assert result == ["2025-01", "2025-02", "2025-03"]
|
|
|
|
|
|
def test_trailing_months_crosses_year_boundary() -> None:
|
|
result = _trailing_months(date(2025, 2, 1), 4)
|
|
assert result == ["2024-10", "2024-11", "2024-12", "2025-01"]
|
|
|
|
|
|
def test_trailing_months_zero_returns_empty() -> None:
|
|
assert _trailing_months(date(2025, 5, 1), 0) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_month_flips_sign_and_drops_income(respx_mock: respx.MockRouter) -> None:
|
|
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2025-03").mock(
|
|
return_value=httpx.Response(200, json={
|
|
"data": {
|
|
"categoryGroups": [
|
|
{
|
|
"name": "Usual Expenses",
|
|
"is_income": False,
|
|
"categories": [
|
|
{"spent": -1500}, # £15.00 spent
|
|
{"spent": -2000}, # £20.00 spent
|
|
],
|
|
},
|
|
{
|
|
"name": "Salary",
|
|
"is_income": True,
|
|
"categories": [{"spent": 0}],
|
|
},
|
|
{
|
|
"name": "Empty Group",
|
|
"is_income": False,
|
|
"categories": [],
|
|
},
|
|
],
|
|
}
|
|
}))
|
|
cli = _client()
|
|
async with httpx.AsyncClient() as h:
|
|
result = await cli.fetch_month(h, "2025-03")
|
|
# Income skipped; outflows positive
|
|
assert result.by_group == {"Usual Expenses": 3500, "Empty Group": 0}
|
|
assert result.total_pence == 3500
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_trailing_spending_excludes_groups(respx_mock: respx.MockRouter) -> None:
|
|
# Two months, both with Usual + Investments; we exclude Investments.
|
|
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock(
|
|
return_value=httpx.Response(200, json={
|
|
"data": ["2025-01", "2025-02"]
|
|
}))
|
|
for m, usual, invest in [("2025-01", -1000, -50000), ("2025-02", -2000, -10000)]:
|
|
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/{m}").mock(
|
|
return_value=httpx.Response(200, json={
|
|
"data": {
|
|
"categoryGroups": [
|
|
{"name": "Usual Expenses", "is_income": False,
|
|
"categories": [{"spent": usual}]},
|
|
{"name": "Investments and Savings", "is_income": False,
|
|
"categories": [{"spent": invest}]},
|
|
],
|
|
}
|
|
}))
|
|
|
|
spends, nominal_gbp, real_gbp = await fetch_trailing_spending(
|
|
months=2,
|
|
exclude_groups=frozenset(["Investments and Savings"]),
|
|
annual_inflation_pct=0.0, # no adjustment for the simple assertion
|
|
client_factory=_client(),
|
|
today=date(2025, 3, 15),
|
|
)
|
|
assert [ms.month for ms in spends] == ["2025-01", "2025-02"]
|
|
# Usual: 1000 + 2000 = 3000 pence = £30
|
|
assert nominal_gbp == Decimal("30.00")
|
|
assert real_gbp == Decimal("30.00") # 0% inflation → same number
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_trailing_spending_inflation_adjusts(respx_mock: respx.MockRouter) -> None:
|
|
"""At 12% annual inflation, a £10 expense from 12 months ago is
|
|
worth £11.20 in today's £. Sanity-check the compounding via the
|
|
oldest month of a 12-month window (today=2025-04 → 2024-04 is 12mo
|
|
old)."""
|
|
# Only one month available in the window.
|
|
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock(
|
|
return_value=httpx.Response(200, json={"data": ["2024-04"]}))
|
|
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2024-04").mock(
|
|
return_value=httpx.Response(200, json={
|
|
"data": {"categoryGroups": [
|
|
{"name": "Usual Expenses", "is_income": False,
|
|
"categories": [{"spent": -1000}]}, # £10
|
|
]}
|
|
}))
|
|
_, nominal_gbp, real_gbp = await fetch_trailing_spending(
|
|
months=12,
|
|
exclude_groups=frozenset(),
|
|
annual_inflation_pct=0.12,
|
|
client_factory=_client(),
|
|
today=date(2025, 4, 15),
|
|
)
|
|
assert nominal_gbp == Decimal("10.00")
|
|
assert real_gbp == Decimal("11.20")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_trailing_spending_drops_unavailable_months(
|
|
respx_mock: respx.MockRouter,
|
|
) -> None:
|
|
"""If the API surfaces fewer months than asked, missing months are
|
|
skipped instead of erroring."""
|
|
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock(
|
|
return_value=httpx.Response(200, json={"data": ["2025-02"]}))
|
|
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2025-02").mock(
|
|
return_value=httpx.Response(200, json={
|
|
"data": {"categoryGroups": [
|
|
{"name": "Usual Expenses", "is_income": False,
|
|
"categories": [{"spent": -5000}]},
|
|
]}
|
|
}))
|
|
spends, nominal_gbp, _real_gbp = await fetch_trailing_spending(
|
|
months=3,
|
|
exclude_groups=frozenset(),
|
|
annual_inflation_pct=0.0,
|
|
client_factory=_client(),
|
|
today=date(2025, 3, 15),
|
|
)
|
|
assert [ms.month for ms in spends] == ["2025-02"]
|
|
assert nominal_gbp == Decimal("50.00")
|