FireTracker / test_fire_math.py
smlflg's picture
Initial public upload from Projekte/FireTracker
f91bd05 verified
Raw History Blame Contribute Delete
8.34 kB
"""Tests for fire_math.py β€” core FIRE calculation logic."""
import pytest
from fire_math import calculate_fire, calculate_years_to_fi, calculate_scenario, projection_timeline
# ---------------------------------------------------------------------------
# calculate_fire β€” Core Metrics
# ---------------------------------------------------------------------------
def test_basic_case():
result = calculate_fire(
monthly_income=4000,
monthly_expenses=2000,
total_portfolio=50000,
birth_year=1998,
)
assert result.fi_number == 600000.0
assert result.savings_rate == 50.0
assert result.monthly_savings == 2000.0
def test_already_fi():
# Portfolio exceeds FI number β€” years_to_fi must be 0.0, NOT None.
# This tests the line-68 bug fix: `if years_to_fi is not None` instead of `if years_to_fi`.
result = calculate_fire(
monthly_income=2000,
monthly_expenses=2000,
total_portfolio=700000,
birth_year=1998,
)
assert result.years_to_fi == 0.0, (
"years_to_fi must be 0.0 when already FI, not None. "
"Check the line-68 bug fix."
)
assert result.fi_date is not None
def test_zero_income():
result = calculate_fire(
monthly_income=0,
monthly_expenses=2000,
total_portfolio=50000,
birth_year=1998,
)
assert result.savings_rate == 0
def test_never_fi():
# Zero savings + zero portfolio β†’ compound growth on 0 stays 0 β†’ never reaches target.
result = calculate_fire(
monthly_income=2000,
monthly_expenses=2000,
total_portfolio=0,
birth_year=1998,
)
assert result.monthly_savings == 0.0
assert result.years_to_fi is None
assert result.fi_date is None
# ---------------------------------------------------------------------------
# calculate_years_to_fi β€” Iteration Logic
# ---------------------------------------------------------------------------
def test_already_at_target():
result = calculate_years_to_fi(
current_portfolio=600000,
annual_savings=24000,
target=600000,
annual_return=0.05,
)
assert result == 0.0
def test_normal_projection():
result = calculate_years_to_fi(
current_portfolio=50000,
annual_savings=24000,
target=600000,
annual_return=0.05,
)
assert result is not None
assert 13 <= result <= 20, f"Expected 13–20 years, got {result}"
def test_no_savings_no_return():
result = calculate_years_to_fi(
current_portfolio=50000,
annual_savings=0,
target=600000,
annual_return=0,
)
assert result is None
def test_max_years_cap():
# Savings so tiny the loop hits the 100-year cap.
result = calculate_years_to_fi(
current_portfolio=0,
annual_savings=12, # €1/month
target=10_000_000,
annual_return=0,
)
assert result is None
# ---------------------------------------------------------------------------
# projection_timeline β€” Chart Data
# ---------------------------------------------------------------------------
def test_timeline_length():
timeline = projection_timeline(
total_portfolio=50000,
monthly_savings=2000,
annual_return=0.05,
years=30,
)
assert len(timeline) == 31 # year 0 through year 30 inclusive
def test_first_entry():
starting = 75000.0
timeline = projection_timeline(
total_portfolio=starting,
monthly_savings=1500,
annual_return=0.05,
years=20,
)
assert timeline[0]["portfolio"] == starting
def test_growth():
timeline = projection_timeline(
total_portfolio=10000,
monthly_savings=500,
annual_return=0.05,
years=10,
)
for i in range(1, len(timeline)):
assert timeline[i]["portfolio"] > timeline[i - 1]["portfolio"]
# ---------------------------------------------------------------------------
# calculate_scenario β€” What-If
# ---------------------------------------------------------------------------
def test_extra_savings():
base = calculate_fire(
monthly_income=4000,
monthly_expenses=2000,
total_portfolio=50000,
birth_year=1990,
)
scenario = calculate_scenario(
base=base,
monthly_income=4000,
monthly_expenses=2000,
total_portfolio=50000,
birth_year=1990,
extra_monthly_savings=500,
)
assert scenario.years_to_fi is not None
assert base.years_to_fi is not None
assert scenario.years_to_fi < base.years_to_fi
def test_lower_expenses():
base = calculate_fire(
monthly_income=4000,
monthly_expenses=2000,
total_portfolio=50000,
birth_year=1990,
)
scenario = calculate_scenario(
base=base,
monthly_income=4000,
monthly_expenses=2000,
total_portfolio=50000,
birth_year=1990,
adjusted_expenses=1500,
)
assert scenario.fi_number < base.fi_number
# ---------------------------------------------------------------------------
# fi_date β€” Edge Cases for Month/Year Boundary Arithmetic
# ---------------------------------------------------------------------------
def test_fi_date_zero_years_to_fi():
"""When already FI (years_to_fi=0.0), fi_date must be first of current month."""
from datetime import date
result = calculate_fire(
monthly_income=2000,
monthly_expenses=2000,
total_portfolio=700000,
birth_year=1998,
)
assert result.fi_date is not None
assert result.fi_date.day == 1
assert result.fi_date.year == date.today().year
assert result.fi_date.month == date.today().month
def test_fi_date_whole_years():
"""Fractional part = 0.0 β€” fi_date must land in the current month of a future year."""
from datetime import date
# Force years_to_fi to be a whole number by giving a result we can verify.
# We use calculate_years_to_fi directly to avoid dependency on date.today() internals.
from fire_math import calculate_years_to_fi
result = calculate_years_to_fi(
current_portfolio=0,
annual_savings=60000,
target=600000,
annual_return=0.0, # No compounding β€” pure savings: exactly 10 years
)
assert result is not None
# 10 years of €60k/year = €600k exactly β€” should be ~10 years
assert 9.9 <= result <= 10.1
def test_fi_date_fractional_month_no_overflow():
"""Adding fractional months must not produce month > 12 for any start month."""
from datetime import date
from unittest.mock import patch
for start_month in range(1, 13):
fake_today = date(2026, start_month, 15)
with patch("fire_math.date") as mock_date:
mock_date.today.return_value = fake_today
mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
result = calculate_fire(
monthly_income=4000,
monthly_expenses=2000,
total_portfolio=50000,
birth_year=1998,
)
if result.fi_date is not None:
assert 1 <= result.fi_date.month <= 12, (
f"fi_date.month={result.fi_date.month} out of range for start_month={start_month}"
)
assert result.fi_date.year >= 2026
def test_fi_date_december_wrap():
"""December + 9 fractional months = September of next year. Must not add extra year."""
from datetime import date
from unittest.mock import patch
# Simulate running in December with 15.75 years to FI
# Dec (12) + 9 fractional months = 21 β†’ extra_years=1, final_month=9
# Expected: year=2026+15+1=2042, month=9
fake_today = date(2026, 12, 1)
with patch("fire_math.date") as mock_date:
mock_date.today.return_value = fake_today
mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
result = calculate_fire(
monthly_income=4000,
monthly_expenses=2000,
total_portfolio=50000,
birth_year=1998,
)
if result.fi_date is not None:
# fi_date must be a valid date
assert 1 <= result.fi_date.month <= 12
assert result.fi_date.year > 2026