File size: 8,338 Bytes
f91bd05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
"""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