Coverage for tests/test_main.py: 100%
263 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-07 20:45 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-07 20:45 -0800
1"""Integration test cases for the CLI."""
3import os
4import re
6import pytest
7from click.testing import CliRunner
9from countdown import __main__
12class FakeSleep:
13 """Fake time.sleep."""
15 def __init__(self, *, raises={}): # noqa: B006
16 self.slept = 0
17 self.raises = dict(raises)
19 def __call__(self, seconds):
20 self.slept += seconds
21 # Check for exception with floating point tolerance
22 for trigger_time, exception in self.raises.items():
23 if abs(self.slept - trigger_time) < 0.001:
24 raise exception
27def fake_size(columns, lines):
28 def get_terminal_size(fallback=(columns, lines)):
29 return os.terminal_size(fallback)
31 return get_terminal_size
34def clean_main_output(output):
35 """Remove ANSI escape codes and whitespace at ends of lines."""
36 output = re.sub(r"\033\[(\?\d+[hl]|[HJ])", "", output)
37 output = re.sub(r" *\n", "\n", output)
38 return output
41@pytest.fixture
42def runner():
43 """Fixture for invoking command-line interfaces."""
44 return CliRunner()
47def test_main_with_no_arguments(runner):
48 """It shows help when run without arguments."""
49 result = runner.invoke(__main__.main)
50 # Should show help (not error)
51 assert result.exit_code == 0
52 assert "Usage:" in result.output
53 assert "DURATION" in result.output
54 assert "5m" in result.output # Should show examples
57def test_version_works(runner):
58 """It can print the version."""
59 result = runner.invoke(__main__.main, ["--version"])
60 assert ", version" in result.stdout
61 assert result.exit_code == 0
64def test_main_3_seconds_sleeps_4_times(runner, monkeypatch):
65 # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h+2 <= 20)
66 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
67 fake_sleep = FakeSleep()
68 monkeypatch.setattr("time.sleep", fake_sleep)
69 result = runner.invoke(__main__.main, ["3s"])
70 assert result.exit_code == 0
71 assert clean_main_output(result.stdout) == (
72 "\n\n\n\n\n\n\n"
73 " ██████ ██████ ██████ ██████\n"
74 " ██ ██ ██ ██ ██ ██ ██ ██\n"
75 " ██ ██ ██ ██ ██ ██ █████\n"
76 " ██ ██ ██ ██ ██ ██ ██ ██\n"
77 " ██████ ██████ ██████ ██████\n"
78 "\n\n\n\n\n\n"
79 " ██████ ██████ ██████ ██████\n"
80 " ██ ██ ██ ██ ██ ██ ██ ██\n"
81 " ██ ██ ██ ██ ██ ██ ██████\n"
82 " ██ ██ ██ ██ ██ ██ ██ ██\n"
83 " ██████ ██████ ██████ ██████\n"
84 "\n\n\n\n\n\n"
85 " ██████ ██████ ██████ ██\n"
86 " ██ ██ ██ ██ ██ ██ ██ ███\n"
87 " ██ ██ ██ ██ ██ ██ ██\n"
88 " ██ ██ ██ ██ ██ ██ ██ ██\n"
89 " ██████ ██████ ██████ ██\n"
90 "\n\n\n\n\n\n"
91 " ██████ ██████ ██████ ██████\n"
92 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n"
93 " ██ ██ ██ ██ ██ ██ ██ ██\n"
94 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n"
95 " ██████ ██████ ██████ ██████ "
96 )
97 # 3 seconds countdown = 4 iterations (3,2,1,0), each sleeps 1 second = 4 seconds total
98 # Sleeping in chunks of 0.05, so total is ~4 seconds (floating point precision)
99 assert fake_sleep.slept == pytest.approx(4.0, abs=0.01)
102def test_main_1_minute(runner, monkeypatch):
103 # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10)
104 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10))
106 # Raise exception after 11 sleeps
107 fake_sleep = FakeSleep(raises={11: SystemExit(0)})
108 monkeypatch.setattr("time.sleep", fake_sleep)
110 result = runner.invoke(__main__.main, ["1m"])
111 assert clean_main_output(result.stdout) == (
112 "\n\n"
113 " ██████ ██ ██████ ██████\n"
114 " ██ ██ ███ ██ ██ ██ ██ ██\n"
115 " ██ ██ ██ ██ ██ ██ ██\n"
116 " ██ ██ ██ ██ ██ ██ ██ ██\n"
117 " ██████ ██ ██████ ██████\n"
118 "\n"
119 " ██████ ██████ ██████ ██████\n"
120 " ██ ██ ██ ██ ██ ██ ██ ██\n"
121 " ██ ██ ██ ██ ██████ ██████\n"
122 " ██ ██ ██ ██ ██ ██ ██\n"
123 " ██████ ██████ ██████ █████\n"
124 "\n"
125 " ██████ ██████ ██████ ████\n"
126 " ██ ██ ██ ██ ██ ██ ██ ██\n"
127 " ██ ██ ██ ██ ██████ ████\n"
128 " ██ ██ ██ ██ ██ ██ ██ ██\n"
129 " ██████ ██████ ██████ ████\n"
130 "\n"
131 " ██████ ██████ ██████ ██████\n"
132 " ██ ██ ██ ██ ██ ██ ██\n"
133 " ██ ██ ██ ██ ██████ ██\n"
134 " ██ ██ ██ ██ ██ ██ ██\n"
135 " ██████ ██████ ██████ ██\n"
136 "\n"
137 " ██████ ██████ ██████ ██████\n"
138 " ██ ██ ██ ██ ██ ██ ██\n"
139 " ██ ██ ██ ██ ██████ ██████\n"
140 " ██ ██ ██ ██ ██ ██ ██ ██\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 " ██████ ██████ ██████ ██████ "
178 )
181def test_main_10_minutes_has_over_600_clear_screens(runner, monkeypatch):
182 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
183 fake_sleep = FakeSleep()
184 monkeypatch.setattr("time.sleep", fake_sleep)
185 result = runner.invoke(__main__.main, ["10m"])
186 # 10 minutes = 601 iterations, each sleeps 1 second (via 20×0.05 chunks)
187 # Floating point precision: 601 × 20 × 0.05 ≈ 601.0
188 assert fake_sleep.slept == pytest.approx(601.0, abs=0.1)
189 assert result.stdout.count("\033[H\033[J") == 601
192def test_main_enables_alt_buffer_and_hides_cursor_at_beginning(runner, monkeypatch):
193 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
194 fake_sleep = FakeSleep()
195 monkeypatch.setattr("time.sleep", fake_sleep)
196 result = runner.invoke(__main__.main, ["5m"])
197 assert result.stdout.startswith("\033[?1049h\033[?25l")
200def test_main_disable_alt_buffer_and_show_cursor_at_end(runner, monkeypatch):
201 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
202 fake_sleep = FakeSleep()
203 monkeypatch.setattr("time.sleep", fake_sleep)
204 result = runner.invoke(__main__.main, ["5m"])
205 assert result.stdout.endswith("\033[?25h\033[?1049l")
208def test_main_early_exit_still_shows_cursor_at_end(runner, monkeypatch):
209 # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10)
210 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10))
212 # Hit Ctrl+C after 4 seconds total sleep time (chunked sleep)
213 fake_sleep = FakeSleep(raises={4: KeyboardInterrupt()})
214 monkeypatch.setattr("time.sleep", fake_sleep)
216 result = runner.invoke(__main__.main, ["15m"])
217 # After 4 seconds of sleep, we've completed 4 iterations, each prints lines
218 assert len(result.stdout.splitlines()) == 25, "4 seconds of lines printed"
219 assert result.stdout.endswith("\033[?25h\033[?1049l")
222def test_pause_key_triggers_pause(runner, monkeypatch):
223 """Test that pressing a pause key triggers the pause logic."""
224 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
226 # Exit after a short time
227 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()})
228 monkeypatch.setattr("time.sleep", fake_sleep)
230 # Track whether pause key was detected
231 pause_key_detected = [False]
232 read_key_called = [False]
234 def fake_check_for_keypress():
235 # Return True once to simulate a keypress during first iteration
236 if not pause_key_detected[0]:
237 pause_key_detected[0] = True
238 return True
239 return False
241 def fake_read_key():
242 read_key_called[0] = True
243 return " " # Space bar (a pause key)
245 def fake_drain():
246 pass # No additional keys to drain
248 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
249 monkeypatch.setattr(__main__, "read_key", fake_read_key)
250 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
252 result = runner.invoke(__main__.main, ["5s"])
254 # The pause key should have been detected and read
255 assert pause_key_detected[0], "Pause key detection should have been called"
256 assert read_key_called[0], "read_key should have been called"
257 # Output should contain the paused color since we pressed a pause key
258 assert "\x1b[95m" in result.stdout, "Should show paused color when pause key pressed"
261def test_non_pause_key_ignored(runner, monkeypatch):
262 """Test that non-pause keys are ignored during countdown."""
263 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
265 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()})
266 monkeypatch.setattr("time.sleep", fake_sleep)
268 # Track keypresses
269 check_called = [False]
270 read_key_called = [False]
272 def fake_check_for_keypress():
273 if not check_called[0]:
274 check_called[0] = True
275 return True
276 return False
278 def fake_read_key():
279 read_key_called[0] = True
280 return "x" # Not a pause key
282 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
283 monkeypatch.setattr(__main__, "read_key", fake_read_key)
285 result = runner.invoke(__main__.main, ["5s"])
287 # The key should have been read
288 assert read_key_called[0], "read_key should have been called"
289 # Output should NOT contain paused color since 'x' is not a pause key
290 assert (
291 "\x1b[95m" not in result.stdout
292 ), "Should not show paused color for non-pause key"
293 assert result.exit_code == 0
296def test_sleep_exits_early_on_keypress(runner, monkeypatch):
297 """Test that sleep loop exits early when a key is pressed mid-sleep."""
298 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
300 # Track sleep calls
301 sleep_calls = []
303 def fake_sleep(seconds):
304 sleep_calls.append(seconds)
305 # Exit after we've done a few sleep chunks
306 if len(sleep_calls) >= 5:
307 raise KeyboardInterrupt()
309 monkeypatch.setattr("time.sleep", fake_sleep)
311 # Simulate keypress after 3rd sleep call (during chunked 1-second sleep)
312 check_count = [0]
314 def fake_check_for_keypress():
315 check_count[0] += 1
316 # Return True on the 3rd sleep chunk to simulate keypress mid-sleep
317 return len(sleep_calls) == 3
319 def fake_read_key():
320 return " " # Pause key
322 def fake_drain():
323 pass
325 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
326 monkeypatch.setattr(__main__, "read_key", fake_read_key)
327 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
329 result = runner.invoke(__main__.main, ["10s"])
331 # Should have broken out of sleep loop early (not all 20 chunks)
332 # We expect: 3 chunks of first iteration, then breaks, then starts paused sleep
333 # The key point is we don't see all 20 chunks of 0.05 before breaking
334 assert len(sleep_calls) >= 3, "Should have at least 3 sleep calls"
335 # If it didn't exit early, we'd see many more 0.05 sleep calls
336 # The presence of the break means we don't complete all 20 chunks
337 first_iteration_sleeps = [s for s in sleep_calls[:3] if s == 0.05]
338 assert (
339 len(first_iteration_sleeps) == 3
340 ), "Should have 3 chunks of 0.05s before breaking"
343def test_resume_from_pause_exits_early(runner, monkeypatch):
344 """Test that when paused, pressing a key to resume exits the 0.05s sleep loop."""
345 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
347 sleep_calls = []
348 paused_state = [False]
350 def fake_sleep(seconds):
351 sleep_calls.append((seconds, paused_state[0]))
352 if len(sleep_calls) >= 10:
353 raise KeyboardInterrupt()
355 monkeypatch.setattr("time.sleep", fake_sleep)
357 # Simulate: pause immediately, then resume after a few paused sleeps
358 keypress_count = [0]
360 def fake_check_for_keypress():
361 keypress_count[0] += 1
362 # First keypress: pause immediately (keypress 1)
363 # Second keypress: resume after being paused (keypress 2)
364 return keypress_count[0] in [1, 5]
366 keys_to_return = [" ", " "] # Space to pause, space to resume
367 key_index = [0]
369 def fake_read_key():
370 key = keys_to_return[key_index[0]]
371 key_index[0] = min(key_index[0] + 1, len(keys_to_return) - 1)
372 return key
374 def fake_drain():
375 pass
377 # Track pause state transitions
378 original_print = __main__.print_full_screen
380 def tracking_print(lines, paused=False):
381 paused_state[0] = paused
382 return original_print(lines, paused=paused)
384 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
385 monkeypatch.setattr(__main__, "read_key", fake_read_key)
386 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
387 monkeypatch.setattr(__main__, "print_full_screen", tracking_print)
389 result = runner.invoke(__main__.main, ["10s"])
391 # Should have some paused sleeps (0.05) and some regular chunked sleeps (0.05)
392 paused_sleeps = [s for s, p in sleep_calls if p]
393 unpaused_sleeps = [s for s, p in sleep_calls if not p]
395 assert len(paused_sleeps) > 0, "Should have some paused sleep periods"
396 assert len(unpaused_sleeps) > 0, "Should have some unpaused sleep periods"
399def test_add_time_with_plus_key(runner, monkeypatch):
400 """Test that pressing + adds 30 seconds to the timer."""
401 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
403 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()})
404 monkeypatch.setattr("time.sleep", fake_sleep)
406 # Track the displayed times
407 displayed_times = []
408 original_get_number_lines = __main__.get_number_lines
410 def fake_get_number_lines(seconds):
411 displayed_times.append(seconds)
412 return original_get_number_lines(seconds)
414 def fake_check_for_keypress():
415 # Return True once to simulate a keypress
416 return len(displayed_times) == 1
418 def fake_read_key():
419 return "+" # Plus key to add time
421 def fake_drain():
422 pass
424 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines)
425 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
426 monkeypatch.setattr(__main__, "read_key", fake_read_key)
427 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
429 result = runner.invoke(__main__.main, ["1m"])
431 # Should have displayed 60s initially, then 90s after pressing +
432 assert 60 in displayed_times, "Should display initial time of 60s"
433 assert 90 in displayed_times, "Should display 90s after adding 30s"
436def test_subtract_time_with_minus_key(runner, monkeypatch):
437 """Test that pressing - subtracts 30 seconds from the timer."""
438 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
440 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()})
441 monkeypatch.setattr("time.sleep", fake_sleep)
443 # Track the displayed times
444 displayed_times = []
445 original_get_number_lines = __main__.get_number_lines
447 def fake_get_number_lines(seconds):
448 displayed_times.append(seconds)
449 return original_get_number_lines(seconds)
451 def fake_check_for_keypress():
452 # Return True once to simulate a keypress
453 return len(displayed_times) == 1
455 def fake_read_key():
456 return "-" # Minus key to subtract time
458 def fake_drain():
459 pass
461 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines)
462 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
463 monkeypatch.setattr(__main__, "read_key", fake_read_key)
464 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
466 result = runner.invoke(__main__.main, ["1m"])
468 # Should have displayed 60s initially, then 30s after pressing -
469 assert 60 in displayed_times, "Should display initial time of 60s"
470 assert 30 in displayed_times, "Should display 30s after subtracting 30s"
473def test_subtract_time_cannot_go_negative(runner, monkeypatch):
474 """Test that subtracting time stops at 0 (cannot go negative)."""
475 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
477 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()})
478 monkeypatch.setattr("time.sleep", fake_sleep)
480 # Track the displayed times
481 displayed_times = []
482 original_get_number_lines = __main__.get_number_lines
484 def fake_get_number_lines(seconds):
485 displayed_times.append(seconds)
486 return original_get_number_lines(seconds)
488 def fake_check_for_keypress():
489 # Return True once to simulate a keypress
490 return len(displayed_times) == 1
492 def fake_read_key():
493 return "-" # Minus key to subtract time
495 def fake_drain():
496 pass
498 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines)
499 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
500 monkeypatch.setattr(__main__, "read_key", fake_read_key)
501 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain)
503 result = runner.invoke(__main__.main, ["10s"])
505 # Should have displayed 10s initially, then 0s (not -20s) after pressing -
506 assert 10 in displayed_times, "Should display initial time of 10s"
507 assert 0 in displayed_times, "Should display 0s (not negative) after subtracting 30s"
508 assert all(
509 t >= 0 for t in displayed_times
510 ), "All displayed times should be non-negative"
513def test_q_key_quits_timer(runner, monkeypatch):
514 """Test that pressing 'q' exits the timer."""
515 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
517 fake_sleep = FakeSleep()
518 monkeypatch.setattr("time.sleep", fake_sleep)
520 keypress_count = [0]
522 def fake_check_for_keypress():
523 keypress_count[0] += 1
524 # Return True on first check to simulate pressing q
525 return keypress_count[0] == 1
527 def fake_read_key():
528 return "q" # Press q to quit
530 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress)
531 monkeypatch.setattr(__main__, "read_key", fake_read_key)
533 result = runner.invoke(__main__.main, ["10m"])
535 # Should exit cleanly with code 0
536 assert result.exit_code == 0
537 # Should have shown cursor and disabled alt buffer on exit
538 assert result.stdout.endswith("\033[?25h\033[?1049l")
541def test_no_arguments_shows_help(runner):
542 """Test that running without arguments shows help message."""
543 result = runner.invoke(__main__.main, [])
545 # Should exit with code 0 (not an error)
546 assert result.exit_code == 0
547 # Should show usage information
548 assert "Usage:" in result.output
549 assert "DURATION" in result.output
550 # Should show examples
551 assert "5m" in result.output or "Examples" in result.output