Coverage for tests/test_main.py: 100%
199 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-06 17:54 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-06 17:54 -0800
1"""Test cases for the __main__ module."""
3import os
4import re
5from textwrap import dedent
7import pytest
8from click.testing import CliRunner
10from countdown import __main__
13class FakeSleep:
14 """Fake time.sleep."""
16 def __init__(self, *, raises={}): # noqa: B006
17 self.slept = 0
18 self.raises = dict(raises)
20 def __call__(self, seconds):
21 self.slept += seconds
22 if self.slept in self.raises:
23 raise self.raises[self.slept]
26def fake_size(
27 columns,
28 lines,
29):
30 def get_terminal_size(fallback=(columns, lines)):
31 return os.terminal_size(fallback)
33 return get_terminal_size
36def clean_main_output(output):
37 """Remove ANSI escape codes and whitespace at ends of lines."""
38 output = re.sub(r"\033\[(\?\d+[hl]|[HJ])", "", output)
39 output = re.sub(r" *\n", "\n", output)
40 return output
43def join_lines(lines):
44 """Given list of lines, return string of lines with whitespace stripped."""
45 return "\n".join(line.rstrip(" ") for line in lines)
48@pytest.fixture
49def runner():
50 """Fixture for invoking command-line interfaces."""
51 return CliRunner()
54def test_invalid_duration():
55 with pytest.raises(ValueError):
56 __main__.duration("10")
59def test_duration_10_seconds():
60 assert __main__.duration("10s") == 10
63def test_duration_60_seconds():
64 assert __main__.duration("60s") == 60
67def test_duration_1_minute():
68 assert __main__.duration("1m") == 60
71def test_duration_10_minutes():
72 assert __main__.duration("10m") == 600
75def test_duration_25_minutes():
76 assert __main__.duration("25m") == 1500
79def test_duration_3_minute_and_30_seconds():
80 assert __main__.duration("3m30s") == 210
83def test_duration_2_minutes_and_8_seconds():
84 assert __main__.duration("2m8s") == 128
87def test_get_number_lines_10_seconds(monkeypatch):
88 # Use 40x6 terminal to select size 5 digits (33w <= 40, 5h <= 6)
89 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6))
90 assert join_lines(__main__.get_number_lines(10)) == dedent(
91 """
92 ██████ ██████ ██ ██████
93 ██ ██ ██ ██ ██ ███ ██ ██
94 ██ ██ ██ ██ ██ ██ ██
95 ██ ██ ██ ██ ██ ██ ██ ██
96 ██████ ██████ ██ ██████
97 """
98 ).strip("\n")
101def test_get_number_lines_60_seconds(monkeypatch):
102 # Use 40x6 terminal to select size 5 digits
103 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6))
104 assert join_lines(__main__.get_number_lines(60)) == dedent(
105 """
106 ██████ ██ ██████ ██████
107 ██ ██ ███ ██ ██ ██ ██ ██
108 ██ ██ ██ ██ ██ ██ ██
109 ██ ██ ██ ██ ██ ██ ██ ██
110 ██████ ██ ██████ ██████
111 """
112 ).strip("\n")
115def test_get_number_lines_45_minutes(monkeypatch):
116 # Use 40x6 terminal to select size 5 digits
117 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6))
118 assert join_lines(__main__.get_number_lines(2700)) == dedent(
119 """
120 ██ ██ ██████ ██████ ██████
121 ██ ██ ██ ██ ██ ██ ██ ██
122 ██████ ██████ ██ ██ ██ ██
123 ██ ██ ██ ██ ██ ██ ██
124 ██ ██████ ██████ ██████
125 """
126 ).strip("\n")
129def test_get_number_lines_17_minutes_and_four_seconds(monkeypatch):
130 # Use 40x6 terminal to select size 5 digits
131 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6))
132 assert join_lines(__main__.get_number_lines(1024)) == (
133 " ██ ██████ ██████ ██ ██\n"
134 " ███ ██ ██ ██ ██ ██ ██\n"
135 " ██ ██ ██ ██ ██████\n"
136 " ██ ██ ██ ██ ██ ██\n"
137 " ██ ██ ██████ ██"
138 )
141def test_get_number_lines_8_minutes_and_6_seconds(monkeypatch):
142 # Use 40x6 terminal to select size 5 digits
143 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6))
144 assert join_lines(__main__.get_number_lines(486)) == dedent(
145 """
146 ██████ ████ ██████ ██████
147 ██ ██ ██ ██ ██ ██ ██ ██
148 ██ ██ ████ ██ ██ ██████
149 ██ ██ ██ ██ ██ ██ ██ ██ ██
150 ██████ ████ ██████ ██████
151 """
152 ).strip("\n")
155def test_get_number_lines_9_minutes(monkeypatch):
156 # Use 40x6 terminal to select size 5 digits
157 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6))
158 assert join_lines(__main__.get_number_lines(540)) == dedent(
159 """
160 ██████ ██████ ██████ ██████
161 ██ ██ ██ ██ ██ ██ ██ ██ ██
162 ██ ██ ██████ ██ ██ ██ ██
163 ██ ██ ██ ██ ██ ██ ██ ██
164 ██████ █████ ██████ ██████
165 """
166 ).strip("\n")
169def test_get_number_lines_3478(monkeypatch):
170 # Use 40x6 terminal to select size 5 digits
171 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6))
172 assert join_lines(__main__.get_number_lines(2118)) == dedent(
173 """
174 ██████ ██████ ██ ████
175 ██ ██ ██ ███ ██ ██
176 █████ ██████ ██ ████
177 ██ ██ ██ ██ ██ ██
178 ██████ ██████ ██ ████
179 """
180 ).strip("\n")
183def test_print_full_screen_tiny_terminal(
184 capsys,
185 monkeypatch,
186):
187 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10))
188 __main__.print_full_screen(["hello world"])
189 out, err = capsys.readouterr()
190 assert out[:6] == "\x1b[H\x1b[J"
191 assert out[6:] == "\n\n\n\n hello world"
194def test_print_full_screen_larger_terminal(
195 capsys,
196 monkeypatch,
197):
198 monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24))
199 __main__.print_full_screen(["hello world"])
200 out, err = capsys.readouterr()
201 assert out[:6] == "\x1b[H\x1b[J"
202 # 24 height - 1 line = 23, 23//2 = 11 newlines
203 # 80 width - 11 chars = 69, 69//2 = 34 spaces
204 assert out[6:] == "\n" * 11 + " " * 34 + "hello world"
207def test_print_full_screen_multiline_text(
208 capsys,
209 monkeypatch,
210):
211 monkeypatch.setattr("shutil.get_terminal_size", fake_size(100, 30))
212 __main__.print_full_screen(
213 dedent(
214 """\
215 ██████ ██████ ██ ████
216 ██ ██ ██ ███ ██ ██
217 █████ ██████ ██ ████
218 ██ ██ ██ ██ ██ ██
219 ██████ ██████ ██ ████
220 """
221 ).splitlines()
222 )
223 out, err = capsys.readouterr()
224 assert out[:6] == "\x1b[H\x1b[J"
225 assert out[6:] == (
226 "\n\n\n\n\n\n\n\n\n\n\n\n"
227 " ██████ ██████ ██ ████\n"
228 " ██ ██ ██ ███ ██ ██\n"
229 " █████ ██████ ██ ████\n"
230 " ██ ██ ██ ██ ██ ██\n"
231 " ██████ ██████ ██ ████"
232 )
235def test_main_with_no_arguments(runner):
236 """It exits with a status code of zero."""
237 result = runner.invoke(__main__.main)
238 assert result.output == dedent(
239 """\
240 Usage: main [OPTIONS] DURATION
241 Try 'main --help' for help.
243 Error: Missing argument 'DURATION'.
244 """
245 )
246 assert result.exit_code == 2
249def test_version_works(runner):
250 """It can print the version."""
251 result = runner.invoke(__main__.main, ["--version"])
252 assert ", version" in result.stdout
253 assert result.exit_code == 0
256def test_main_3_seconds_sleeps_4_times(
257 runner,
258 monkeypatch,
259):
260 # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h <= 20)
261 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20))
262 fake_sleep = FakeSleep()
263 monkeypatch.setattr("time.sleep", fake_sleep)
264 result = runner.invoke(__main__.main, ["3s"])
265 assert result.exit_code == 0
266 assert clean_main_output(result.stdout) == (
267 "\n\n\n\n\n\n\n"
268 " ██████ ██████ ██████ ██████\n"
269 " ██ ██ ██ ██ ██ ██ ██ ██\n"
270 " ██ ██ ██ ██ ██ ██ █████\n"
271 " ██ ██ ██ ██ ██ ██ ██ ██\n"
272 " ██████ ██████ ██████ ██████\n"
273 "\n\n\n\n\n\n"
274 " ██████ ██████ ██████ ██████\n"
275 " ██ ██ ██ ██ ██ ██ ██ ██\n"
276 " ██ ██ ██ ██ ██ ██ ██████\n"
277 " ██ ██ ██ ██ ██ ██ ██ ██\n"
278 " ██████ ██████ ██████ ██████\n"
279 "\n\n\n\n\n\n"
280 " ██████ ██████ ██████ ██\n"
281 " ██ ██ ██ ██ ██ ██ ██ ███\n"
282 " ██ ██ ██ ██ ██ ██ ██\n"
283 " ██ ██ ██ ██ ██ ██ ██ ██\n"
284 " ██████ ██████ ██████ ██\n"
285 "\n\n\n\n\n\n"
286 " ██████ ██████ ██████ ██████\n"
287 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n"
288 " ██ ██ ██ ██ ██ ██ ██ ██\n"
289 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n"
290 " ██████ ██████ ██████ ██████ "
291 )
292 assert fake_sleep.slept == 4 # 3 seconds = 4 sleeps
295def test_main_1_minute(
296 runner,
297 monkeypatch,
298):
299 # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h <= 10)
300 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10))
302 # Raise exception after 11 sleeps
303 fake_sleep = FakeSleep(raises={11: SystemExit(0)})
304 monkeypatch.setattr("time.sleep", fake_sleep)
306 result = runner.invoke(__main__.main, ["1m"])
307 assert clean_main_output(result.stdout) == (
308 "\n\n"
309 " ██████ ██ ██████ ██████\n"
310 " ██ ██ ███ ██ ██ ██ ██ ██\n"
311 " ██ ██ ██ ██ ██ ██ ██\n"
312 " ██ ██ ██ ██ ██ ██ ██ ██\n"
313 " ██████ ██ ██████ ██████\n"
314 "\n"
315 " ██████ ██████ ██████ ██████\n"
316 " ██ ██ ██ ██ ██ ██ ██ ██\n"
317 " ██ ██ ██ ██ ██████ ██████\n"
318 " ██ ██ ██ ██ ██ ██ ██\n"
319 " ██████ ██████ ██████ █████\n"
320 "\n"
321 " ██████ ██████ ██████ ████\n"
322 " ██ ██ ██ ██ ██ ██ ██ ██\n"
323 " ██ ██ ██ ██ ██████ ████\n"
324 " ██ ██ ██ ██ ██ ██ ██ ██\n"
325 " ██████ ██████ ██████ ████\n"
326 "\n"
327 " ██████ ██████ ██████ ██████\n"
328 " ██ ██ ██ ██ ██ ██ ██\n"
329 " ██ ██ ██ ██ ██████ ██\n"
330 " ██ ██ ██ ██ ██ ██ ██\n"
331 " ██████ ██████ ██████ ██\n"
332 "\n"
333 " ██████ ██████ ██████ ██████\n"
334 " ██ ██ ██ ██ ██ ██ ██\n"
335 " ██ ██ ██ ██ ██████ ██████\n"
336 " ██ ██ ██ ██ ██ ██ ██ ██\n"
337 " ██████ ██████ ██████ ██████\n"
338 "\n"
339 " ██████ ██████ ██████ ██████\n"
340 " ██ ██ ██ ██ ██ ██ ██\n"
341 " ██ ██ ██ ██ ██████ ██████\n"
342 " ██ ██ ██ ██ ██ ██ ██\n"
343 " ██████ ██████ ██████ ██████\n"
344 "\n"
345 " ██████ ██████ ██████ ██ ██\n"
346 " ██ ██ ██ ██ ██ ██ ██ ██\n"
347 " ██ ██ ██ ██ ██████ ██████\n"
348 " ██ ██ ██ ██ ██ ██ ██\n"
349 " ██████ ██████ ██████ ██\n"
350 "\n"
351 " ██████ ██████ ██████ ██████\n"
352 " ██ ██ ██ ██ ██ ██ ██\n"
353 " ██ ██ ██ ██ ██████ █████\n"
354 " ██ ██ ██ ██ ██ ██ ██\n"
355 " ██████ ██████ ██████ ██████\n"
356 "\n"
357 " ██████ ██████ ██████ ██████\n"
358 " ██ ██ ██ ██ ██ ██ ██\n"
359 " ██ ██ ██ ██ ██████ ██████\n"
360 " ██ ██ ██ ██ ██ ██ ██\n"
361 " ██████ ██████ ██████ ██████\n"
362 "\n"
363 " ██████ ██████ ██████ ██\n"
364 " ██ ██ ██ ██ ██ ██ ███\n"
365 " ██ ██ ██ ██ ██████ ██\n"
366 " ██ ██ ██ ██ ██ ██ ██\n"
367 " ██████ ██████ ██████ ██\n"
368 "\n"
369 " ██████ ██████ ██████ ██████\n"
370 " ██ ██ ██ ██ ██ ██ ██ ██\n"
371 " ██ ██ ██ ██ ██████ ██ ██\n"
372 " ██ ██ ██ ██ ██ ██ ██ ██\n"
373 " ██████ ██████ ██████ ██████ "
374 )
377def test_main_10_minutes_has_over_600_clear_screens(
378 runner,
379 monkeypatch,
380):
381 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
382 fake_sleep = FakeSleep()
383 monkeypatch.setattr("time.sleep", fake_sleep)
384 result = runner.invoke(__main__.main, ["10m"])
385 assert fake_sleep.slept == 601 # 10 minutes = 601 sleeps
386 assert result.stdout.count("\033[H\033[J") == 601
389def test_main_enables_alt_buffer_and_hides_cursor_at_beginning(
390 runner,
391 monkeypatch,
392):
393 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
394 fake_sleep = FakeSleep()
395 monkeypatch.setattr("time.sleep", fake_sleep)
396 result = runner.invoke(__main__.main, ["5m"])
397 assert result.stdout.startswith("\033[?1049h\033[?25l")
400def test_main_disable_alt_buffer_and_show_cursor_at_end(
401 runner,
402 monkeypatch,
403):
404 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
405 fake_sleep = FakeSleep()
406 monkeypatch.setattr("time.sleep", fake_sleep)
407 result = runner.invoke(__main__.main, ["5m"])
408 assert result.stdout.endswith("\033[?25h\033[?1049l")
411def test_main_early_exit_still_shows_cursor_at_end(
412 runner,
413 monkeypatch,
414):
415 # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h <= 10)
416 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10))
418 # Hit Ctrl+C after 4 seconds
419 fake_sleep = FakeSleep(raises={4: KeyboardInterrupt()})
420 monkeypatch.setattr("time.sleep", fake_sleep)
422 result = runner.invoke(__main__.main, ["15m"])
423 assert len(result.stdout.splitlines()) == 25, "4 seconds of lines printed"
424 assert result.stdout.endswith("\033[?25h\033[?1049l")
427def test_digit_sizes_available():
428 """Test that expected digit sizes are available."""
429 from countdown.digits import DIGIT_SIZES
431 assert 16 in DIGIT_SIZES, "Size 16 digits should be available"
432 assert 7 in DIGIT_SIZES, "Size 7 digits should be available"
433 assert 5 in DIGIT_SIZES, "Size 5 digits should be available"
434 assert 3 in DIGIT_SIZES, "Size 3 digits should be available"
435 assert 1 in DIGIT_SIZES, "Size 1 digits should be available"
438def test_all_characters_in_each_size():
439 """Test that all digit characters exist in each size."""
440 from countdown.digits import CHARS_BY_SIZE, DIGIT_SIZES
442 expected_chars = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":"}
443 for size in DIGIT_SIZES:
444 chars = CHARS_BY_SIZE[size]
445 assert set(chars.keys()) == expected_chars, (
446 f"Size {size} should have all characters"
447 )
450def test_char_heights_match_size():
451 """Test that character heights match the expected size."""
452 from countdown.digits import CHARS_BY_SIZE, DIGIT_SIZES
454 for size in DIGIT_SIZES:
455 chars = CHARS_BY_SIZE[size]
456 for char, text in chars.items():
457 height = len(text.splitlines())
458 assert height == size, (
459 f"Character '{char}' in size {size} should have height {size}, got {height}"
460 )
463def test_get_chars_for_terminal_selects_largest_that_fits(monkeypatch):
464 """Test that get_chars_for_terminal selects the largest size that fits both dimensions."""
465 # Size requirements: 16(93w), 7(57w), 5(33w), 3(20w), 1(10w)
467 # 80x24 terminal - size 7 fits (57w <= 80, 7h <= 24)
468 monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24))
469 chars = __main__.get_chars_for_terminal()
470 height = len(chars["0"].splitlines())
471 assert height == 7, "80x24 terminal should select size 7"
473 # 100x24 terminal - size 16 fits (93w <= 100, 16h <= 24)
474 monkeypatch.setattr("shutil.get_terminal_size", fake_size(100, 24))
475 chars = __main__.get_chars_for_terminal()
476 height = len(chars["0"].splitlines())
477 assert height == 16, "100x24 terminal should select size 16"
479 # 60x20 terminal - size 7 fits (57w <= 60, 7h <= 20)
480 monkeypatch.setattr("shutil.get_terminal_size", fake_size(60, 20))
481 chars = __main__.get_chars_for_terminal()
482 height = len(chars["0"].splitlines())
483 assert height == 7, "60x20 terminal should select size 7"
485 # 32x10 terminal - size 3 fits (20w <= 32, 3h <= 10)
486 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
487 chars = __main__.get_chars_for_terminal()
488 height = len(chars["0"].splitlines())
489 assert height == 3, "32x10 terminal should select size 3"
491 # 15x5 terminal - size 1 fits (10w <= 15, 1h <= 5)
492 monkeypatch.setattr("shutil.get_terminal_size", fake_size(15, 5))
493 chars = __main__.get_chars_for_terminal()
494 height = len(chars["0"].splitlines())
495 assert height == 1, "15x5 terminal should select size 1"
497 # Very small terminal - falls back to smallest
498 monkeypatch.setattr("shutil.get_terminal_size", fake_size(5, 1))
499 chars = __main__.get_chars_for_terminal()
500 height = len(chars["0"].splitlines())
501 assert height == 1, "5x1 terminal should fall back to size 1"
504def test_different_sizes_render_correctly(monkeypatch):
505 """Test that different sizes render correctly."""
506 # Test size 7 rendering (80x24 selects size 7)
507 monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24))
508 lines = __main__.get_number_lines(0) # 00:00
509 assert len(lines) == 7, "80x24 terminal should render 7 lines"
511 # Test size 3 rendering (32x10 selects size 3)
512 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
513 lines = __main__.get_number_lines(0) # 00:00
514 assert len(lines) == 3, "32x10 terminal should render 3 lines"
516 # Test size 1 rendering (15x5 selects size 1)
517 monkeypatch.setattr("shutil.get_terminal_size", fake_size(15, 5))
518 lines = __main__.get_number_lines(0) # 00:00
519 assert len(lines) == 1, "15x5 terminal should render 1 line"
522def test_width_constraints_force_smaller_size(monkeypatch):
523 """Test that narrow terminal widths force selection of smaller digit sizes."""
524 # Size 7 requires 57 width - a 56x20 terminal should select size 5 instead
525 monkeypatch.setattr("shutil.get_terminal_size", fake_size(56, 20))
526 chars = __main__.get_chars_for_terminal()
527 height = len(chars["0"].splitlines())
528 assert height == 5, (
529 "56x20 terminal too narrow for size 7, should select size 5"
530 )
532 # Size 5 requires 33 width - a 32x10 terminal should select size 3 instead
533 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10))
534 chars = __main__.get_chars_for_terminal()
535 height = len(chars["0"].splitlines())
536 assert height == 3, (
537 "32x10 terminal too narrow for size 5, should select size 3"
538 )
540 # Size 3 requires 20 width - a 19x5 terminal should select size 1 instead
541 monkeypatch.setattr("shutil.get_terminal_size", fake_size(19, 5))
542 chars = __main__.get_chars_for_terminal()
543 height = len(chars["0"].splitlines())
544 assert height == 1, (
545 "19x5 terminal too narrow for size 3, should select size 1"
546 )