Coverage for tests/test_main.py: 100%
406 statements
« prev ^ index » next coverage.py v7.11.1, created at 2026-03-27 20:23 -0700
« prev ^ index » next coverage.py v7.11.1, created at 2026-03-27 20:23 -0700
1"""Integration test cases for the CLI."""
3import os
4import re
6import pytest
7from click.testing import CliRunner
9from countdown import __main__
12class FakeClock:
13 """Fake time.time() and time.sleep() that advance together.
15 Since run_countdown uses time() for loop control and sleep() for pacing,
16 both must be faked in sync to avoid tests running in real time.
17 """
19 def __init__(self, *, raises={}, drift_per_sleep=0.0): # noqa: B006
20 self.start = 1_000_000.0
21 self.current = self.start
22 self.slept = 0
23 self.raises = dict(raises)
24 self.drift_per_sleep = drift_per_sleep
26 @property
27 def elapsed(self):
28 """Total wall clock time elapsed (including any drift)."""
29 return self.current - self.start
31 def time(self):
32 return self.current
34 def sleep(self, seconds):
35 self.current += seconds + self.drift_per_sleep
36 self.slept += seconds
37 # Check for exception with floating point tolerance
38 for trigger_time, exception in self.raises.items():
39 if abs(self.slept - trigger_time) < 0.001:
40 raise exception
43def patch_clock(monkeypatch, clock):
44 """Monkeypatch both time and sleep to use the given FakeClock."""
45 monkeypatch.setattr("countdown.__main__.sleep", clock.sleep)
46 monkeypatch.setattr("countdown.__main__.time", clock.time)
49def fake_size(columns, lines):
50 def get_terminal_size(fallback=(columns, lines)):
51 return os.terminal_size(fallback)
53 return get_terminal_size
56def clean_main_output(output):
57 """Remove ANSI escape codes and whitespace at ends of lines."""
58 output = re.sub(r"\033\[(\?\d+[hl]|[HJ])", "", output)
59 output = re.sub(r" *\n", "\n", output)
60 return output
63@pytest.fixture
64def runner():
65 """Fixture for invoking command-line interfaces."""
66 return CliRunner()
69def test_main_with_no_arguments(runner):
70 """It shows help when run without arguments."""
71 result = runner.invoke(__main__.main)
72 # Should show help (not error)
73 assert result.exit_code == 0
74 assert "Usage:" in result.output
75 assert "DURATION" in result.output
76 assert "5m" in result.output # Should show examples
79def test_version_works(runner):
80 """It can print the version."""
81 result = runner.invoke(__main__.main, ["--version"])
82 assert ", version" in result.stdout
83 assert result.exit_code == 0
86def test_main_3_seconds(runner, monkeypatch):
87 # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h+2 <= 20)
88 monkeypatch.setattr(
89 "countdown.display.get_terminal_size",
90 fake_size(40, 20),
91 )
92 clock = FakeClock()
93 patch_clock(monkeypatch, clock)
94 result = runner.invoke(__main__.main, ["3s"])
95 assert result.exit_code == 0
96 assert clean_main_output(result.stdout) == (
97 "\n\n\n\n\n\n\n"
98 " ██████ ██████ ██████ ██████\n"
99 " ██ ██ ██ ██ ██ ██ ██ ██\n"
100 " ██ ██ ██ ██ ██ ██ █████\n"
101 " ██ ██ ██ ██ ██ ██ ██ ██\n"
102 " ██████ ██████ ██████ ██████\n"
103 "\n\n\n\n\n\n"
104 " ██████ ██████ ██████ ██████\n"
105 " ██ ██ ██ ██ ██ ██ ██ ██\n"
106 " ██ ██ ██ ██ ██ ██ ██████\n"
107 " ██ ██ ██ ██ ██ ██ ██ ██\n"
108 " ██████ ██████ ██████ ██████\n"
109 "\n\n\n\n\n\n"
110 " ██████ ██████ ██████ ██\n"
111 " ██ ██ ██ ██ ██ ██ ██ ███\n"
112 " ██ ██ ██ ██ ██ ██ ██\n"
113 " ██ ██ ██ ██ ██ ██ ██ ██\n"
114 " ██████ ██████ ██████ ██\n"
115 "\n\n\n\n\n\n"
116 " ██████ ██████ ██████ ██████\n"
117 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n"
118 " ██ ██ ██ ██ ██ ██ ██ ██\n"
119 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n"
120 " ██████ ██████ ██████ ██████ "
121 )
122 # 3 seconds + 1 to display 00:00, each sleeping ~1 second
123 assert clock.slept == pytest.approx(3 + 1, abs=0.01)
124 assert clock.elapsed == pytest.approx(3 + 1, abs=0.01)
127def test_main_1_minute(runner, monkeypatch):
128 # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10)
129 monkeypatch.setattr(
130 "countdown.display.get_terminal_size",
131 fake_size(40, 10),
132 )
134 # Raise exception after 11 seconds of fake sleep
135 clock = FakeClock(raises={11: SystemExit(0)})
136 patch_clock(monkeypatch, clock)
138 result = runner.invoke(__main__.main, ["1m"])
139 assert clean_main_output(result.stdout) == (
140 "\n\n"
141 " ██████ ██ ██████ ██████\n"
142 " ██ ██ ███ ██ ██ ██ ██ ██\n"
143 " ██ ██ ██ ██ ██ ██ ██\n"
144 " ██ ██ ██ ██ ██ ██ ██ ██\n"
145 " ██████ ██ ██████ ██████\n"
146 "\n"
147 " ██████ ██████ ██████ ██████\n"
148 " ██ ██ ██ ██ ██ ██ ██ ██\n"
149 " ██ ██ ██ ██ ██████ ██████\n"
150 " ██ ██ ██ ██ ██ ██ ██\n"
151 " ██████ ██████ ██████ █████\n"
152 "\n"
153 " ██████ ██████ ██████ ████\n"
154 " ██ ██ ██ ██ ██ ██ ██ ██\n"
155 " ██ ██ ██ ██ ██████ ████\n"
156 " ██ ██ ██ ██ ██ ██ ██ ██\n"
157 " ██████ ██████ ██████ ████\n"
158 "\n"
159 " ██████ ██████ ██████ ██████\n"
160 " ██ ██ ██ ██ ██ ██ ██\n"
161 " ██ ██ ██ ██ ██████ ██\n"
162 " ██ ██ ██ ██ ██ ██ ██\n"
163 " ██████ ██████ ██████ ██\n"
164 "\n"
165 " ██████ ██████ ██████ ██████\n"
166 " ██ ██ ██ ██ ██ ██ ██\n"
167 " ██ ██ ██ ██ ██████ ██████\n"
168 " ██ ██ ██ ██ ██ ██ ██ ██\n"
169 " ██████ ██████ ██████ ██████\n"
170 "\n"
171 " ██████ ██████ ██████ ██████\n"
172 " ██ ██ ██ ██ ██ ██ ██\n"
173 " ██ ██ ██ ██ ██████ ██████\n"
174 " ██ ██ ██ ██ ██ ██ ██\n"
175 " ██████ ██████ ██████ ██████\n"
176 "\n"
177 " ██████ ██████ ██████ ██ ██\n"
178 " ██ ██ ██ ██ ██ ██ ██ ██\n"
179 " ██ ██ ██ ██ ██████ ██████\n"
180 " ██ ██ ██ ██ ██ ██ ██\n"
181 " ██████ ██████ ██████ ██\n"
182 "\n"
183 " ██████ ██████ ██████ ██████\n"
184 " ██ ██ ██ ██ ██ ██ ██\n"
185 " ██ ██ ██ ██ ██████ █████\n"
186 " ██ ██ ██ ██ ██ ██ ██\n"
187 " ██████ ██████ ██████ ██████\n"
188 "\n"
189 " ██████ ██████ ██████ ██████\n"
190 " ██ ██ ██ ██ ██ ██ ██\n"
191 " ██ ██ ██ ██ ██████ ██████\n"
192 " ██ ██ ██ ██ ██ ██ ██\n"
193 " ██████ ██████ ██████ ██████\n"
194 "\n"
195 " ██████ ██████ ██████ ██\n"
196 " ██ ██ ██ ██ ██ ██ ███\n"
197 " ██ ██ ██ ██ ██████ ██\n"
198 " ██ ██ ██ ██ ██ ██ ██\n"
199 " ██████ ██████ ██████ ██\n"
200 "\n"
201 " ██████ ██████ ██████ ██████\n"
202 " ██ ██ ██ ██ ██ ██ ██ ██\n"
203 " ██ ██ ██ ██ ██████ ██ ██\n"
204 " ██ ██ ██ ██ ██ ██ ██ ██\n"
205 " ██████ ██████ ██████ ██████ "
206 )
209def test_main_10_minutes_has_600_clear_screens(runner, monkeypatch):
210 monkeypatch.setattr(
211 "countdown.display.get_terminal_size",
212 fake_size(32, 10),
213 )
214 clock = FakeClock()
215 patch_clock(monkeypatch, clock)
216 result = runner.invoke(__main__.main, ["10m"])
217 # 10 minutes = 600 seconds + 1 to display 00:00
218 assert clock.slept == pytest.approx(10 * 60 + 1, abs=0.1)
219 assert clock.elapsed == pytest.approx(10 * 60 + 1, abs=0.1)
220 assert result.stdout.count("\033[H\033[J") == 10 * 60 + 1
223def test_main_enables_alt_buffer_and_hides_cursor_at_beginning(
224 runner, monkeypatch
225):
226 monkeypatch.setattr(
227 "countdown.display.get_terminal_size",
228 fake_size(32, 10),
229 )
230 clock = FakeClock()
231 patch_clock(monkeypatch, clock)
232 result = runner.invoke(__main__.main, ["5m"])
233 assert result.stdout.startswith("\033[?1049h\033[?25l")
236def test_main_disable_alt_buffer_and_show_cursor_at_end(runner, monkeypatch):
237 monkeypatch.setattr(
238 "countdown.display.get_terminal_size",
239 fake_size(32, 10),
240 )
241 clock = FakeClock()
242 patch_clock(monkeypatch, clock)
243 result = runner.invoke(__main__.main, ["5m"])
244 assert result.stdout.endswith("\033[?25h\033[?1049l")
247def test_main_early_exit_still_shows_cursor_at_end(runner, monkeypatch):
248 # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10)
249 monkeypatch.setattr(
250 "countdown.display.get_terminal_size",
251 fake_size(40, 10),
252 )
254 # Hit Ctrl+C after 4 seconds total sleep time (chunked sleep)
255 clock = FakeClock(raises={4: KeyboardInterrupt()})
256 patch_clock(monkeypatch, clock)
258 result = runner.invoke(__main__.main, ["15m"])
259 # 4 seconds of sleep = 4 iterations, each printing 5 lines + 1 padding line
260 # except the last which has no trailing padding = 4*6-1 = 23 lines,
261 # plus 2 lines vertical padding for 10-line terminal = 25 lines
262 assert len(result.stdout.splitlines()) == 25
263 assert result.stdout.endswith("\033[?25h\033[?1049l")
266def test_pause_key_triggers_pause(runner, monkeypatch):
267 """Test that pressing a pause key triggers the pause logic."""
268 monkeypatch.setattr(
269 "countdown.display.get_terminal_size",
270 fake_size(40, 20),
271 )
273 # Exit after a short time
274 clock = FakeClock(raises={1: KeyboardInterrupt()})
275 patch_clock(monkeypatch, clock)
277 # Track whether pause key was detected
278 pause_key_detected = [False]
279 read_key_called = [False]
281 def fake_check_for_keypress():
282 # Return True once to simulate a keypress during first iteration
283 if not pause_key_detected[0]:
284 pause_key_detected[0] = True
285 return True
286 return False
288 def fake_read_key():
289 read_key_called[0] = True
290 return " " # Space bar (a pause key)
292 def fake_drain():
293 pass # No additional keys to drain
295 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
296 monkeypatch.setattr(__main__, "read_key", fake_read_key)
297 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
299 result = runner.invoke(__main__.main, ["5s"])
301 # The pause key should have been detected and read
302 assert pause_key_detected[0], "Pause key detection should have been called"
303 assert read_key_called[0], "read_key should have been called"
304 # Output should contain the paused color since we pressed a pause key
305 assert "\x1b[95m" in result.stdout, (
306 "Should show paused color when pause key pressed"
307 )
310def test_non_pause_key_ignored(runner, monkeypatch):
311 """Test that non-pause keys are ignored during countdown."""
312 monkeypatch.setattr(
313 "countdown.display.get_terminal_size",
314 fake_size(40, 20),
315 )
317 clock = FakeClock(raises={1: KeyboardInterrupt()})
318 patch_clock(monkeypatch, clock)
320 # Track keypresses
321 check_called = [False]
322 read_key_called = [False]
324 def fake_check_for_keypress():
325 if not check_called[0]:
326 check_called[0] = True
327 return True
328 return False
330 def fake_read_key():
331 read_key_called[0] = True
332 return "x" # Not a pause key
334 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
335 monkeypatch.setattr(__main__, "read_key", fake_read_key)
337 result = runner.invoke(__main__.main, ["5s"])
339 # The key should have been read
340 assert read_key_called[0], "read_key should have been called"
341 # Output should NOT contain paused color since 'x' is not a pause key
342 assert "\x1b[95m" not in result.stdout, (
343 "Should not show paused color for non-pause key"
344 )
345 assert result.exit_code == 0
348def test_sleep_exits_early_on_keypress(runner, monkeypatch):
349 """Test that sleep loop exits early when a key is pressed mid-sleep."""
350 monkeypatch.setattr(
351 "countdown.display.get_terminal_size",
352 fake_size(40, 20),
353 )
355 # Track sleep calls and use FakeClock for time control
356 clock = FakeClock()
357 patch_clock(monkeypatch, clock)
358 sleep_calls = []
359 original_sleep = clock.sleep
361 def tracking_sleep(seconds):
362 sleep_calls.append(seconds)
363 original_sleep(seconds)
364 if len(sleep_calls) >= 5:
365 raise KeyboardInterrupt()
367 monkeypatch.setattr("countdown.__main__.sleep", tracking_sleep)
369 # Simulate keypress after 3rd sleep call (during chunked 1-second sleep)
370 def fake_check_for_keypress():
371 # Return True on the 3rd sleep chunk to simulate keypress mid-sleep
372 return len(sleep_calls) == 3
374 def fake_read_key():
375 return " " # Pause key
377 def fake_drain():
378 pass
380 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
381 monkeypatch.setattr(__main__, "read_key", fake_read_key)
382 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
384 result = runner.invoke(__main__.main, ["10s"])
385 assert result.exit_code == 0, result.output
387 # Should have broken out of sleep loop early (not all 20 chunks)
388 assert len(sleep_calls) >= 3, "Should have at least 3 sleep calls"
389 first_iteration_sleeps = [s for s in sleep_calls[:3] if s == 0.05]
390 assert len(first_iteration_sleeps) == 3, (
391 "Should have 3 chunks of 0.05s before breaking"
392 )
395def test_resume_from_pause_exits_early(runner, monkeypatch):
396 """Test that when paused, pressing a key to resume exits the 0.05s sleep loop."""
397 monkeypatch.setattr(
398 "countdown.display.get_terminal_size",
399 fake_size(40, 20),
400 )
402 clock = FakeClock()
403 patch_clock(monkeypatch, clock)
404 sleep_calls = []
405 paused_state = [False]
406 original_sleep = clock.sleep
408 def tracking_sleep(seconds):
409 sleep_calls.append((seconds, paused_state[0]))
410 original_sleep(seconds)
411 if len(sleep_calls) >= 10:
412 raise KeyboardInterrupt()
414 monkeypatch.setattr("countdown.__main__.sleep", tracking_sleep)
416 # Simulate: pause immediately, then resume after a few paused sleeps
417 keypress_count = [0]
419 def fake_check_for_keypress():
420 keypress_count[0] += 1
421 # First keypress: pause immediately (keypress 1)
422 # Second keypress: resume after being paused (keypress 2)
423 return keypress_count[0] in [1, 5]
425 keys_to_return = [" ", " "] # Space to pause, space to resume
426 key_index = [0]
428 def fake_read_key():
429 key = keys_to_return[key_index[0]]
430 key_index[0] = min(key_index[0] + 1, len(keys_to_return) - 1)
431 return key
433 def fake_drain():
434 pass
436 # Track pause state transitions
437 original_print = __main__.print_full_screen
439 def tracking_print(lines, paused=False):
440 paused_state[0] = paused
441 return original_print(lines, paused=paused)
443 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
444 monkeypatch.setattr(__main__, "read_key", fake_read_key)
445 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
446 monkeypatch.setattr(__main__, "print_full_screen", tracking_print)
448 result = runner.invoke(__main__.main, ["10s"])
449 assert result.exit_code == 0, result.output
451 # Should have some paused sleeps (0.05) and some regular chunked sleeps (0.05)
452 paused_sleeps = [s for s, p in sleep_calls if p]
453 unpaused_sleeps = [s for s, p in sleep_calls if not p]
455 assert len(paused_sleeps) > 0, "Should have some paused sleep periods"
456 assert len(unpaused_sleeps) > 0, "Should have some unpaused sleep periods"
459def test_add_time_with_plus_key(runner, monkeypatch):
460 """Test that pressing + adds 30 seconds to the timer."""
461 monkeypatch.setattr(
462 "countdown.display.get_terminal_size",
463 fake_size(40, 20),
464 )
466 clock = FakeClock(raises={1: KeyboardInterrupt()})
467 patch_clock(monkeypatch, clock)
469 # Track the displayed times
470 displayed_times = []
471 original_get_number_lines = __main__.get_number_lines
473 def fake_get_number_lines(seconds):
474 displayed_times.append(seconds)
475 return original_get_number_lines(seconds)
477 def fake_check_for_keypress():
478 # Return True once to simulate a keypress
479 return len(displayed_times) == 1
481 def fake_read_key():
482 return "+" # Plus key to add time
484 def fake_drain():
485 pass
487 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines)
488 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
489 monkeypatch.setattr(__main__, "read_key", fake_read_key)
490 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
492 result = runner.invoke(__main__.main, ["1m"])
493 assert result.exit_code == 0, result.output
495 # Should have displayed 60s initially, then 90s after pressing +
496 assert 60 in displayed_times, "Should display initial time of 60s"
497 assert 90 in displayed_times, "Should display 90s after adding 30s"
500def test_subtract_time_with_minus_key(runner, monkeypatch):
501 """Test that pressing - subtracts 30 seconds from the timer."""
502 monkeypatch.setattr(
503 "countdown.display.get_terminal_size",
504 fake_size(40, 20),
505 )
507 clock = FakeClock(raises={1: KeyboardInterrupt()})
508 patch_clock(monkeypatch, clock)
510 # Track the displayed times
511 displayed_times = []
512 original_get_number_lines = __main__.get_number_lines
514 def fake_get_number_lines(seconds):
515 displayed_times.append(seconds)
516 return original_get_number_lines(seconds)
518 def fake_check_for_keypress():
519 # Return True once to simulate a keypress
520 return len(displayed_times) == 1
522 def fake_read_key():
523 return "-" # Minus key to subtract time
525 def fake_drain():
526 pass
528 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines)
529 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
530 monkeypatch.setattr(__main__, "read_key", fake_read_key)
531 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
533 result = runner.invoke(__main__.main, ["1m"])
534 assert result.exit_code == 0, result.output
536 # Should have displayed 60s initially, then 30s after pressing -
537 assert 60 in displayed_times, "Should display initial time of 60s"
538 assert 30 in displayed_times, "Should display 30s after subtracting 30s"
541def test_subtract_time_cannot_go_negative(runner, monkeypatch):
542 """Test that subtracting time stops at 0 (cannot go negative)."""
543 monkeypatch.setattr(
544 "countdown.display.get_terminal_size",
545 fake_size(40, 20),
546 )
548 clock = FakeClock(raises={1: KeyboardInterrupt()})
549 patch_clock(monkeypatch, clock)
551 # Track the displayed times
552 displayed_times = []
553 original_get_number_lines = __main__.get_number_lines
555 def fake_get_number_lines(seconds):
556 displayed_times.append(seconds)
557 return original_get_number_lines(seconds)
559 def fake_check_for_keypress():
560 # Return True once to simulate a keypress
561 return len(displayed_times) == 1
563 def fake_read_key():
564 return "-" # Minus key to subtract time
566 def fake_drain():
567 pass
569 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines)
570 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
571 monkeypatch.setattr(__main__, "read_key", fake_read_key)
572 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
574 result = runner.invoke(__main__.main, ["10s"])
575 assert result.exit_code == 0, result.output
577 # Should have displayed 10s initially, then 0s (not -20s) after pressing -
578 assert 10 in displayed_times, "Should display initial time of 10s"
579 assert 0 in displayed_times, (
580 "Should display 0s (not negative) after subtracting 30s"
581 )
582 assert all(t >= 0 for t in displayed_times), (
583 "All displayed times should be non-negative"
584 )
587def test_q_key_quits_timer(runner, monkeypatch):
588 """Test that pressing 'q' exits the timer."""
589 monkeypatch.setattr(
590 "countdown.display.get_terminal_size",
591 fake_size(40, 20),
592 )
594 clock = FakeClock()
595 patch_clock(monkeypatch, clock)
597 keypress_count = [0]
599 def fake_check_for_keypress():
600 keypress_count[0] += 1
601 # Return True on first check to simulate pressing q
602 return keypress_count[0] == 1
604 def fake_read_key():
605 return "q" # Press q to quit
607 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
608 monkeypatch.setattr(__main__, "read_key", fake_read_key)
610 result = runner.invoke(__main__.main, ["10m"])
612 # Should exit cleanly with code 0
613 assert result.exit_code == 0
614 # Should have shown cursor and disabled alt buffer on exit
615 assert result.stdout.endswith("\033[?25h\033[?1049l")
618def test_no_arguments_shows_help(runner):
619 """Test that running without arguments shows help message."""
620 result = runner.invoke(__main__.main, [])
622 # Should exit with code 0 (not an error)
623 assert result.exit_code == 0
624 # Should show usage information
625 assert "Usage:" in result.output
626 assert "DURATION" in result.output
627 # Should show examples
628 assert "5m" in result.output or "Examples" in result.output
631# --- Tests for drift-fix behavior ---
634def test_countdown_displays_each_second(runner, monkeypatch):
635 """Test that a 5-second countdown displays each second value."""
636 monkeypatch.setattr(
637 "countdown.display.get_terminal_size",
638 fake_size(40, 20),
639 )
640 clock = FakeClock()
641 patch_clock(monkeypatch, clock)
643 displayed_times = []
644 original_get_number_lines = __main__.get_number_lines
646 def tracking_get_number_lines(seconds):
647 displayed_times.append(seconds)
648 return original_get_number_lines(seconds)
650 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines)
651 result = runner.invoke(__main__.main, ["5s"])
652 assert result.exit_code == 0
653 assert displayed_times == [5, 4, 3, 2, 1, 0]
656def test_drift_correction_with_slow_sleeps(runner, monkeypatch):
657 """With drift, the timer still counts down the right number of seconds.
659 Each 0.05s sleep takes 0.06s (simulating OS scheduling overhead).
660 Without drift correction, this would make the countdown run 20% too long.
661 With drift correction, each second still advances based on wall clock time.
662 """
663 monkeypatch.setattr(
664 "countdown.display.get_terminal_size",
665 fake_size(40, 20),
666 )
667 clock = FakeClock(drift_per_sleep=0.01)
668 patch_clock(monkeypatch, clock)
670 displayed_times = []
671 original_get_number_lines = __main__.get_number_lines
673 def tracking_get_number_lines(seconds):
674 displayed_times.append(seconds)
675 return original_get_number_lines(seconds)
677 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines)
678 result = runner.invoke(__main__.main, ["5s"])
679 assert result.exit_code == 0
681 # Even with drift, we should still display 5 seconds counting down
682 assert displayed_times == [5, 4, 3, 2, 1, 0]
685def test_drift_correction_skips_seconds_when_very_slow(runner, monkeypatch):
686 """Simulate extreme drift.
688 With extreme drift, individual seconds may be skipped but total time
689 is still bounded by wall clock time, not by sleep iteration count.
690 """
691 monkeypatch.setattr(
692 "countdown.display.get_terminal_size",
693 fake_size(40, 20),
694 )
695 # Each 0.05s sleep takes 0.55s (extreme drift: 10x)
696 clock = FakeClock(drift_per_sleep=0.5)
697 patch_clock(monkeypatch, clock)
699 displayed_times = []
700 original_get_number_lines = __main__.get_number_lines
702 def tracking_get_number_lines(seconds):
703 displayed_times.append(seconds)
704 return original_get_number_lines(seconds)
706 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines)
707 result = runner.invoke(__main__.main, ["60m"])
708 assert result.exit_code == 0
710 # Timer should still start at 60m and count down
711 assert displayed_times[0] == 60 * 60
712 # Each second is displayed for fewer sleep iterations due to drift,
713 # but the total still counts down correctly (each displayed value
714 # is less than the one before)
715 for i in range(1, len(displayed_times)):
716 assert displayed_times[i] < displayed_times[i - 1]
719def test_pause_preserves_remaining_time(runner, monkeypatch):
720 """Pausing and resuming should not consume countdown time."""
721 monkeypatch.setattr(
722 "countdown.display.get_terminal_size",
723 fake_size(40, 20),
724 )
726 clock = FakeClock()
727 patch_clock(monkeypatch, clock)
729 displayed_times = []
730 original_get_number_lines = __main__.get_number_lines
732 def tracking_get_number_lines(seconds):
733 displayed_times.append(seconds)
734 return original_get_number_lines(seconds)
736 # Pause on first display, then resume after 3 checks while paused
737 keypress_count = [0]
739 def fake_check_for_keypress():
740 keypress_count[0] += 1
741 return keypress_count[0] in [1, 5] # pause, then resume
743 keys = iter([" ", " "]) # pause, resume
745 def fake_read_key():
746 return next(keys)
748 def fake_drain():
749 pass
751 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines)
752 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
753 monkeypatch.setattr(__main__, "read_key", fake_read_key)
754 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
756 result = runner.invoke(__main__.main, ["3s"])
757 assert result.exit_code == 0
759 # Despite pausing, all countdown seconds should still be displayed
760 assert 3 in displayed_times
761 assert 2 in displayed_times
762 assert 1 in displayed_times
765def test_add_time_extends_deadline(runner, monkeypatch):
766 """Pressing + should extend the countdown deadline by 30 seconds."""
767 monkeypatch.setattr(
768 "countdown.display.get_terminal_size",
769 fake_size(40, 20),
770 )
772 clock = FakeClock(raises={5: KeyboardInterrupt()})
773 patch_clock(monkeypatch, clock)
775 displayed_times = []
776 original_get_number_lines = __main__.get_number_lines
778 def tracking_get_number_lines(seconds):
779 displayed_times.append(seconds)
780 return original_get_number_lines(seconds)
782 # Press + on first display
783 def fake_check_for_keypress():
784 return len(displayed_times) == 1
786 def fake_read_key():
787 return "+"
789 def fake_drain():
790 pass
792 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines)
793 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
794 monkeypatch.setattr(__main__, "read_key", fake_read_key)
795 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
797 result = runner.invoke(__main__.main, ["10s"])
798 assert result.exit_code == 0
800 # After pressing + on display of 10, n jumps to 40 (10+30)
801 assert displayed_times[0] == 10
802 assert 40 in displayed_times
803 # Timer should count down from 40 (not restart from 10)
804 idx_40 = displayed_times.index(40)
805 assert displayed_times[idx_40 + 1] == 39
808def test_subtract_time_shortens_deadline(runner, monkeypatch):
809 """Pressing - should shorten the countdown deadline by 30 seconds."""
810 monkeypatch.setattr(
811 "countdown.display.get_terminal_size",
812 fake_size(40, 20),
813 )
815 clock = FakeClock()
816 patch_clock(monkeypatch, clock)
818 displayed_times = []
819 original_get_number_lines = __main__.get_number_lines
821 def tracking_get_number_lines(seconds):
822 displayed_times.append(seconds)
823 return original_get_number_lines(seconds)
825 # Press - on first display
826 def fake_check_for_keypress():
827 return len(displayed_times) == 1
829 def fake_read_key():
830 return "-"
832 def fake_drain():
833 pass
835 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines)
836 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
837 monkeypatch.setattr(__main__, "read_key", fake_read_key)
838 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
840 result = runner.invoke(__main__.main, ["1m"])
841 assert result.exit_code == 0
843 # After pressing - on display of 60, n drops to 30 (60-30)
844 assert displayed_times[0] == 60
845 assert 30 in displayed_times
846 # Timer should end at 0 after counting down ~30 seconds (not ~60)
847 assert displayed_times[-1] == 0
848 # Total seconds displayed should be ~31 (30 down to 0, not 60 down to 0)
849 assert len([t for t in displayed_times if t <= 30]) < 35