|
5 | 5 | import heapq |
6 | 6 | from datetime import datetime |
7 | 7 |
|
| 8 | + |
| 9 | +def _fixed_datetime(hour: int, minute: int = 0): |
| 10 | + """Return a datetime class substitute that pins now() to a fixed hour.""" |
| 11 | + |
| 12 | + class _FakeDatetime(datetime): |
| 13 | + @classmethod |
| 14 | + def now(cls, tz=None): |
| 15 | + base = datetime(2026, 3, 22, hour, minute, 0) |
| 16 | + return base |
| 17 | + |
| 18 | + return _FakeDatetime |
| 19 | + |
| 20 | + |
8 | 21 | from ynab_tools.daemon.scheduler import ( |
9 | 22 | DaemonConfig, |
10 | 23 | Feature, |
@@ -56,17 +69,29 @@ class TestInWindow: |
56 | 69 | def test_empty_windows_always_true(self): |
57 | 70 | assert _in_window([]) is True |
58 | 71 |
|
59 | | - def test_in_window(self): |
60 | | - hour = datetime.now().hour |
61 | | - assert _in_window([(hour, hour + 1)]) is True |
| 72 | + def test_in_window(self, monkeypatch): |
| 73 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10)) |
| 74 | + assert _in_window([(10, 12)]) is True |
| 75 | + |
| 76 | + def test_outside_window(self, monkeypatch): |
| 77 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(14)) |
| 78 | + assert _in_window([(10, 12)]) is False |
| 79 | + |
| 80 | + def test_cross_midnight_inside_before_midnight(self, monkeypatch): |
| 81 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(23)) |
| 82 | + assert _in_window([(22, 2)]) is True |
| 83 | + |
| 84 | + def test_cross_midnight_inside_after_midnight(self, monkeypatch): |
| 85 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(1)) |
| 86 | + assert _in_window([(22, 2)]) is True |
| 87 | + |
| 88 | + def test_cross_midnight_outside(self, monkeypatch): |
| 89 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(15)) |
| 90 | + assert _in_window([(22, 2)]) is False |
62 | 91 |
|
63 | | - def test_outside_window(self): |
64 | | - hour = datetime.now().hour |
65 | | - # Use a window that doesn't contain the current hour |
66 | | - start = (hour + 2) % 24 |
67 | | - end = (hour + 3) % 24 |
68 | | - if start < end: |
69 | | - assert _in_window([(start, end)]) is False |
| 92 | + def test_cross_midnight_at_end_hour_excluded(self, monkeypatch): |
| 93 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(2)) |
| 94 | + assert _in_window([(22, 2)]) is False |
70 | 95 |
|
71 | 96 |
|
72 | 97 | class TestScheduleEntry: |
@@ -131,63 +156,65 @@ def test_both_exclusive_empty(self): |
131 | 156 |
|
132 | 157 |
|
133 | 158 | class TestNextWindowStart: |
134 | | - def test_next_window_today(self): |
135 | | - now = datetime.now() |
136 | | - # Window 2 hours from now |
137 | | - future_hour = (now.hour + 2) % 24 |
138 | | - if future_hour > now.hour: # only test if doesn't wrap past midnight |
139 | | - result = _next_window_start([(future_hour, future_hour + 1)]) |
140 | | - assert result.hour == future_hour |
141 | | - assert result.date() == now.date() |
142 | | - |
143 | | - def test_next_window_tomorrow(self): |
144 | | - now = datetime.now() |
145 | | - # Window 1 hour ago (already passed today) |
146 | | - past_hour = (now.hour - 1) % 24 |
147 | | - if past_hour < now.hour: # only test if doesn't wrap |
148 | | - result = _next_window_start([(past_hour, past_hour + 1)]) |
149 | | - assert result.hour == past_hour |
150 | | - assert result.date() > now.date() |
151 | | - |
152 | | - def test_picks_earliest_future_window(self): |
153 | | - now = datetime.now() |
154 | | - h1 = (now.hour + 2) % 24 |
155 | | - h2 = (now.hour + 5) % 24 |
156 | | - if h1 > now.hour and h2 > h1: |
157 | | - result = _next_window_start([(h2, h2 + 1), (h1, h1 + 1)]) |
158 | | - assert result.hour == h1 |
| 159 | + def test_next_window_today(self, monkeypatch): |
| 160 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10)) |
| 161 | + result = _next_window_start([(14, 16)]) |
| 162 | + assert result.hour == 14 |
| 163 | + assert result.date() == datetime(2026, 3, 22).date() |
| 164 | + |
| 165 | + def test_next_window_tomorrow(self, monkeypatch): |
| 166 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10)) |
| 167 | + result = _next_window_start([(8, 9)]) |
| 168 | + assert result.hour == 8 |
| 169 | + assert result.date() == datetime(2026, 3, 23).date() |
| 170 | + |
| 171 | + def test_picks_earliest_future_window(self, monkeypatch): |
| 172 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10)) |
| 173 | + result = _next_window_start([(18, 20), (14, 16)]) |
| 174 | + assert result.hour == 14 |
| 175 | + |
| 176 | + def test_cross_midnight_window_next_start(self, monkeypatch): |
| 177 | + # At 15:00, next opening for window 22-2 should be 22:00 today |
| 178 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(15)) |
| 179 | + result = _next_window_start([(22, 2)]) |
| 180 | + assert result.hour == 22 |
| 181 | + assert result.date() == datetime(2026, 3, 22).date() |
| 182 | + |
| 183 | + def test_cross_midnight_inside_no_false_next(self, monkeypatch): |
| 184 | + # At 23:00, inside window 22-2 — _next_window_start shouldn't be |
| 185 | + # called in this case, but if it is, it returns tomorrow's 22:00 |
| 186 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(23)) |
| 187 | + result = _next_window_start([(22, 2)]) |
| 188 | + assert result.hour == 22 |
| 189 | + assert result.date() == datetime(2026, 3, 23).date() |
159 | 190 |
|
160 | 191 |
|
161 | 192 | class TestExecuteEntry: |
162 | 193 | def test_monitor_returns_none(self, monkeypatch): |
163 | 194 | monkeypatch.setattr("ynab_tools.daemon.scheduler._run_monitor", lambda: None) |
164 | | - entry = ScheduleEntry(datetime.now(), Feature.MONITOR, 3600) |
| 195 | + entry = ScheduleEntry(datetime(2026, 3, 22, 10), Feature.MONITOR, 3600) |
165 | 196 | config = DaemonConfig(monitor_interval_seconds=3600) |
166 | 197 | assert _execute_entry(entry, config) is None |
167 | 198 |
|
168 | 199 | def test_amazon_outside_window_returns_next_open(self, monkeypatch): |
169 | | - now = datetime.now() |
170 | | - # Use a window that's NOT the current hour |
171 | | - future_hour = (now.hour + 3) % 24 |
172 | | - if future_hour <= now.hour: |
173 | | - return # skip if wraps (edge case) |
| 200 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10)) |
174 | 201 | config = DaemonConfig( |
175 | 202 | amazon_interval_seconds=86400, |
176 | | - amazon_windows=[(future_hour, future_hour + 1)], |
| 203 | + amazon_windows=[(14, 16)], |
177 | 204 | ) |
178 | | - entry = ScheduleEntry(now, Feature.AMAZON, 86400) |
| 205 | + entry = ScheduleEntry(datetime(2026, 3, 22, 10), Feature.AMAZON, 86400) |
179 | 206 | result = _execute_entry(entry, config) |
180 | 207 | assert result is not None |
181 | | - assert result.hour == future_hour |
| 208 | + assert result.hour == 14 |
182 | 209 |
|
183 | 210 | def test_amazon_inside_window_returns_none(self, monkeypatch): |
184 | 211 | monkeypatch.setattr("ynab_tools.daemon.scheduler._run_amazon", lambda: None) |
185 | | - now = datetime.now() |
| 212 | + monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10)) |
186 | 213 | config = DaemonConfig( |
187 | 214 | amazon_interval_seconds=86400, |
188 | | - amazon_windows=[(now.hour, now.hour + 1)], |
| 215 | + amazon_windows=[(10, 12)], |
189 | 216 | ) |
190 | | - entry = ScheduleEntry(now, Feature.AMAZON, 86400) |
| 217 | + entry = ScheduleEntry(datetime(2026, 3, 22, 10), Feature.AMAZON, 86400) |
191 | 218 | assert _execute_entry(entry, config) is None |
192 | 219 |
|
193 | 220 |
|
|
0 commit comments