"""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