From 352cc5c69e44195ba5924dd3b398a00cbfd03d2a Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 20:27:49 -0800 Subject: [PATCH 1/3] Show uv in install instructions --- README.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 4ed8ed5..3591d7b 100644 --- a/README.rst +++ b/README.rst @@ -75,11 +75,11 @@ Requirements Installation ------------ -You can install *countdown-cli* via pip_ from PyPI_: +You can install *countdown-cli* via uv_ from PyPI_: .. code:: console - $ python3 -m pip install countdown-cli + $ uv tool install countdown-cli Contributing @@ -104,10 +104,9 @@ please `file an issue`_ along with a detailed description. .. _Python Morsels: https://www.pythonmorsels.com -.. _Cookiecutter: https://github.com/audreyr/cookiecutter .. _MIT license: https://opensource.org/licenses/MIT .. _PyPI: https://pypi.org/project/countdown-cli/ .. _file an issue: https://github.com/treyhunner/countdown-cli/issues -.. _pip: https://pip.pypa.io/ +.. _uv: https://docs.astral.sh/uv/concepts/tools/ .. github-only .. _Contributor Guide: CONTRIBUTING.rst From 743f222c50e48113c8477087afb75da349ad8354 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 20:30:44 -0800 Subject: [PATCH 2/3] Improve feature listing --- README.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 3591d7b..a05d15d 100644 --- a/README.rst +++ b/README.rst @@ -50,8 +50,11 @@ Instead, try implementing this package with the `Python Morsels "countdown" exer Features -------- -* Full-screen countdown timer, centered in the terminal window -* Command-line interface for Linux/Mac/Windows +- Full-screen countdown timer, centered in the terminal window +- Responsive timer size based on terminal window width/height +- Command-line interface for Linux/Mac/Windows +- Pause/resume with ``p``, ``k``, ``Space``, or ``Enter`` +- Add / remove time with ``+`` or ``-`` |32:53| @@ -69,13 +72,13 @@ Features Requirements ------------ -* Python 3.10+ +- Python 3.10+ Installation ------------ -You can install *countdown-cli* via uv_ from PyPI_: +You can install **countdown-cli** via uv_ from PyPI_: .. code:: console @@ -93,7 +96,7 @@ License ------- Distributed under the terms of the `MIT license`_, -*countdown-cli* is free and open source software. +**countdown-cli** is free and open source software. Issues From 6e2e417fd0f7c95ac48fabeb6b18fa8ab8e68489 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 20:47:16 -0800 Subject: [PATCH 3/3] Refactor everything --- src/countdown/__init__.py | 3 +- src/countdown/__main__.py | 265 ++++---------------- src/countdown/display.py | 98 ++++++++ src/countdown/keys.py | 20 ++ src/countdown/terminal.py | 60 +++++ src/countdown/timer.py | 49 ++++ tests/test_display.py | 221 +++++++++++++++++ tests/test_keys.py | 34 +++ tests/test_main.py | 510 +++----------------------------------- tests/test_terminal.py | 18 ++ tests/test_timer.py | 142 +++++++++++ 11 files changed, 723 insertions(+), 697 deletions(-) create mode 100644 src/countdown/display.py create mode 100644 src/countdown/keys.py create mode 100644 src/countdown/terminal.py create mode 100644 src/countdown/timer.py create mode 100644 tests/test_display.py create mode 100644 tests/test_keys.py create mode 100644 tests/test_terminal.py create mode 100644 tests/test_timer.py diff --git a/src/countdown/__init__.py b/src/countdown/__init__.py index 412418e..22f74a2 100644 --- a/src/countdown/__init__.py +++ b/src/countdown/__init__.py @@ -1,6 +1,5 @@ """countdown timer for counting down seconds. Command-line program for counting down from given minutes/seconds, printing -out large MM:SS glyphs every second which are centered in the middle of the -terminal window. +out large MM:SS glyphs every second centered in the middle of the terminal. """ diff --git a/src/countdown/__main__.py b/src/countdown/__main__.py index 110b7c7..db73d12 100644 --- a/src/countdown/__main__.py +++ b/src/countdown/__main__.py @@ -1,176 +1,46 @@ """Command-line interface.""" -import re -import shutil -import sys import time import click -from .digits import CHARS_BY_SIZE, DIGIT_SIZES - -if sys.platform == "win32": # pragma: no cover - import msvcrt -else: # pragma: no cover - import select - import termios - import tty - -ENABLE_ALT_BUFFER = "\033[?1049h" -DISABLE_ALT_BUFFER = "\033[?1049l" -HIDE_CURSOR = "\033[?25l" -SHOW_CURSOR = "\033[?25h" - -DURATION_RE = re.compile( - r""" - ^ - (?: # Optional minutes - ( \d{1,2} ) # D or DD - m # "m" - )? - (?: # Optional seconds - ( \d{1,2} ) # D or DD - s # "s" - )? - $ -""", - re.VERBOSE, +from . import timer +from .display import ( + DISABLE_ALT_BUFFER, + ENABLE_ALT_BUFFER, + HIDE_CURSOR, + SHOW_CURSOR, + enable_ansi_escape_codes, + get_chars_for_terminal, + print_full_screen, +) +from .keys import get_time_adjustment, is_pause_key, is_time_adjust_key +from .terminal import ( + check_for_keypress, + drain_keypresses, + read_key, + restore_terminal, + setup_terminal, ) -CLEAR = "\033[H\033[J" -INTENSE_MAGENTA = "\x1b[95m" -RESET = "\033[0m" - - -def check_for_keypress(): # pragma: no cover - """Check if a key has been pressed (non-blocking).""" - if not sys.stdin.isatty(): - return False - if sys.platform == "win32": - return msvcrt.kbhit() - else: - return select.select([sys.stdin], [], [], 0)[0] - - -def read_key(): # pragma: no cover - """Read a single keypress.""" - if sys.platform == "win32": - return msvcrt.getch().decode("utf-8", errors="ignore") - else: - return sys.stdin.read(1) - - -def drain_keypresses(): # pragma: no cover - """Consume all pending keypresses from the input buffer.""" - while check_for_keypress(): - read_key() - - -def is_pause_key(key): - """Check if the given key is a pause/resume key (Space, p, k, Enter).""" - return key in (" ", "p", "k", "\r", "\n") - - -def is_time_adjust_key(key): - """Check if the given key is a time adjustment key (+, =, -).""" - return key in ("+", "=", "-") - - -def get_time_adjustment(key): - """Return the time adjustment in seconds for the given key.""" - if key in ("+", "="): - return 30 # Add 30 seconds - elif key == "-": - return -30 # Subtract 30 seconds - return 0 - - -def setup_terminal(): # pragma: no cover - """Setup terminal for non-blocking input (Unix only).""" - if sys.platform != "win32" and sys.stdin.isatty(): - try: - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - tty.setcbreak(fd) - return old_settings - except (termios.error, OSError): - pass - return None - - -def restore_terminal(old_settings): # pragma: no cover - """Restore terminal settings (Unix only).""" - if sys.platform != "win32" and old_settings: - fd = sys.stdin.fileno() - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - - -def get_required_width(chars): - """Calculate the minimum width required to display MM:SS format.""" - # MM:SS format has 4 digits, 1 colon, and 1 space after each char - digit_width = max(len(line) for line in chars["0"].splitlines()) - colon_width = max(len(line) for line in chars[":"].splitlines()) - # Total: 4 digits + 1 colon + 5 spaces (after each character) - return digit_width * 4 + colon_width + 5 - - -def get_chars_for_terminal(): - """Return the largest CHARS dictionary that fits in the current terminal.""" - width, height = shutil.get_terminal_size() - for size in DIGIT_SIZES: - chars = CHARS_BY_SIZE[size] - required_width = get_required_width(chars) - # For size 3 (smallest multi-line), allow it without padding - # For larger sizes, require 1 line of padding on top and bottom (2 total) - padding_needed = 0 if size == 3 else 2 - if size + padding_needed <= height and required_width <= width: - return chars - # If terminal is too small, return the smallest available - return CHARS_BY_SIZE[min(DIGIT_SIZES)] - - -def duration(string): - """Convert given XmXs string to seconds (as an integer).""" - match = DURATION_RE.search(string) - if not match: - raise ValueError(f"Invalid duration: {string}") - minutes, seconds = match.groups() - return int(minutes or 0) * 60 + int(seconds or 0) - - -@click.command() -@click.version_option(package_name="countdown-cli") -@click.argument("duration", type=duration, required=False) -@click.pass_context -def main(ctx, duration): - """Countdown from the given duration to 0. - - DURATION should be a number followed by m or s for minutes or seconds. - - Examples of DURATION: - - \b - - 5m (5 minutes) - - 45s (45 seconds) - - 2m30s (2 minutes and 30 seconds) - Press Space, p, k, or Enter to pause/resume the countdown. +def get_number_lines(seconds): + """Return list of lines which make large MM:SS glyphs for given seconds.""" + return timer.get_number_lines(seconds, get_chars_for_terminal()) - Press +/= to add 30 seconds, - to subtract 30 seconds. - Press q to quit. - """ # noqa: D301 - # Show help if no duration provided - if duration is None: - click.echo(ctx.get_help()) - return +def run_countdown(total_seconds): + """Run the countdown timer for the specified duration. + Args: + total_seconds: Duration in seconds to count down from + """ enable_ansi_escape_codes() old_settings = setup_terminal() print(ENABLE_ALT_BUFFER + HIDE_CURSOR, end="") try: paused = False - n = duration + n = total_seconds while n >= 0: lines = get_number_lines(n) print_full_screen(lines, paused=paused) @@ -213,75 +83,34 @@ def main(ctx, duration): print(SHOW_CURSOR + DISABLE_ALT_BUFFER, end="") -def enable_ansi_escape_codes(): # pragma: no cover - """If running on Windows, enable ANSI escape codes.""" - if sys.platform == "win32": - from ctypes import windll - - k = windll.kernel32 - stdout = -11 - enable_processed_output = 0x0001 - enable_wrap_at_eol_output = 0x0002 - enable_virtual_terminal_processing = 0x0004 - k.SetConsoleMode( - k.GetStdHandle(stdout), - enable_processed_output - | enable_wrap_at_eol_output - | enable_virtual_terminal_processing, - ) - - -def print_full_screen(lines, paused=False): - """Print the given lines centered in the middle of the terminal window.""" - term_width, term_height = shutil.get_terminal_size() - - # Calculate total content height - content_height = len(lines) - show_pause_text = False - if paused and content_height + 2 <= term_height: - # Only show PAUSED text if there's room - content_height += 2 # Blank line + PAUSED text - show_pause_text = True - - # Calculate vertical padding (ensure it doesn't go negative) - vertical_padding = max(0, (term_height - content_height) // 2) +@click.command() +@click.version_option(package_name="countdown-cli") +@click.argument("duration", type=timer.duration, required=False) +@click.pass_context +def main(ctx, duration): + """Countdown from the given duration to 0. - # Calculate horizontal padding for timer - max_line_width = max(len(line) for line in lines) - horizontal_padding = max(0, (term_width - max_line_width) // 2) + DURATION should be a number followed by m or s for minutes or seconds. - # Apply red color to timer if paused - if paused: - colored_lines = [INTENSE_MAGENTA + line + RESET for line in lines] - else: - colored_lines = lines + Examples of DURATION: - # Build the output - vertical_pad = "\n" * vertical_padding - padded_text = "\n".join( - " " * horizontal_padding + line for line in colored_lines - ) + \\b + - 5m (5 minutes) + - 45s (45 seconds) + - 2m30s (2 minutes and 30 seconds) - if show_pause_text: - pause_text = "PAUSED - Press any key to resume" - pause_padding = " " * max(0, (term_width - len(pause_text)) // 2) - padded_text += "\n\n" + pause_padding + pause_text + Press Space, p, k, or Enter to pause/resume the countdown. - print(CLEAR + vertical_pad + padded_text, flush=True, end="") + Press +/= to add 30 seconds, - to subtract 30 seconds. + Press q to quit. + """ # noqa: D301 + # Show help if no duration provided + if duration is None: + click.echo(ctx.get_help()) + return -def get_number_lines(seconds): - """Return list of lines which make large MM:SS glyphs for given seconds.""" - chars = get_chars_for_terminal() - digit_height = len(next(iter(chars.values())).splitlines()) - lines = [""] * digit_height - minutes, seconds = divmod(seconds, 60) - time = f"{minutes:02d}:{seconds:02d}" - for char in time: - char_lines = chars[char].splitlines() - for i, line in enumerate(char_lines): - lines[i] += line + " " - return lines + run_countdown(duration) if __name__ == "__main__": diff --git a/src/countdown/display.py b/src/countdown/display.py new file mode 100644 index 0000000..bb32a4b --- /dev/null +++ b/src/countdown/display.py @@ -0,0 +1,98 @@ +"""Visual rendering and ANSI terminal control.""" + +import shutil +import sys + +from .digits import CHARS_BY_SIZE, DIGIT_SIZES + +# ANSI escape codes for terminal control +ENABLE_ALT_BUFFER = "\033[?1049h" +DISABLE_ALT_BUFFER = "\033[?1049l" +HIDE_CURSOR = "\033[?25l" +SHOW_CURSOR = "\033[?25h" +CLEAR = "\033[H\033[J" + +# ANSI color codes +INTENSE_MAGENTA = "\x1b[95m" +RESET = "\033[0m" + + +def enable_ansi_escape_codes(): # pragma: no cover + """If running on Windows, enable ANSI escape codes.""" + if sys.platform == "win32": + from ctypes import windll + + k = windll.kernel32 + stdout = -11 + enable_processed_output = 0x0001 + enable_wrap_at_eol_output = 0x0002 + enable_virtual_terminal_processing = 0x0004 + k.SetConsoleMode( + k.GetStdHandle(stdout), + enable_processed_output + | enable_wrap_at_eol_output + | enable_virtual_terminal_processing, + ) + + +def get_required_width(chars): + """Calculate the minimum width required to display MM:SS format.""" + # MM:SS format has 4 digits, 1 colon, and 1 space after each char + digit_width = max(len(line) for line in chars["0"].splitlines()) + colon_width = max(len(line) for line in chars[":"].splitlines()) + # Total: 4 digits + 1 colon + 5 spaces (after each character) + return digit_width * 4 + colon_width + 5 + + +def get_chars_for_terminal(): + """Return the largest CHARS dictionary that fits in the current terminal.""" + width, height = shutil.get_terminal_size() + for size in DIGIT_SIZES: + chars = CHARS_BY_SIZE[size] + required_width = get_required_width(chars) + # For size 3 (smallest multi-line), allow it without padding + # For larger sizes, require 1 line of padding on top and bottom (2 total) + padding_needed = 0 if size == 3 else 2 + if size + padding_needed <= height and required_width <= width: + return chars + # If terminal is too small, return the smallest available + return CHARS_BY_SIZE[min(DIGIT_SIZES)] + + +def print_full_screen(lines, paused=False): + """Print the given lines centered in the middle of the terminal window.""" + term_width, term_height = shutil.get_terminal_size() + + # Calculate total content height + content_height = len(lines) + show_pause_text = False + if paused and content_height + 2 <= term_height: + # Only show PAUSED text if there's room + content_height += 2 # Blank line + PAUSED text + show_pause_text = True + + # Calculate vertical padding (ensure it doesn't go negative) + vertical_padding = max(0, (term_height - content_height) // 2) + + # Calculate horizontal padding for timer + max_line_width = max(len(line) for line in lines) + horizontal_padding = max(0, (term_width - max_line_width) // 2) + + # Apply red color to timer if paused + if paused: + colored_lines = [INTENSE_MAGENTA + line + RESET for line in lines] + else: + colored_lines = lines + + # Build the output + vertical_pad = "\n" * vertical_padding + padded_text = "\n".join( + " " * horizontal_padding + line for line in colored_lines + ) + + if show_pause_text: + pause_text = "PAUSED - Press any key to resume" + pause_padding = " " * max(0, (term_width - len(pause_text)) // 2) + padded_text += "\n\n" + pause_padding + pause_text + + print(CLEAR + vertical_pad + padded_text, flush=True, end="") diff --git a/src/countdown/keys.py b/src/countdown/keys.py new file mode 100644 index 0000000..3929c47 --- /dev/null +++ b/src/countdown/keys.py @@ -0,0 +1,20 @@ +"""Keyboard input interpretation.""" + + +def is_pause_key(key): + """Check if the given key is a pause/resume key (Space, p, k, Enter).""" + return key in (" ", "p", "k", "\r", "\n") + + +def is_time_adjust_key(key): + """Check if the given key is a time adjustment key (+, =, -).""" + return key in ("+", "=", "-") + + +def get_time_adjustment(key): + """Return the time adjustment in seconds for the given key.""" + if key in ("+", "="): + return 30 # Add 30 seconds + elif key == "-": + return -30 # Subtract 30 seconds + return 0 diff --git a/src/countdown/terminal.py b/src/countdown/terminal.py new file mode 100644 index 0000000..d334cc3 --- /dev/null +++ b/src/countdown/terminal.py @@ -0,0 +1,60 @@ +"""Platform-specific terminal and keyboard I/O operations.""" +# pragma: no cover + +import sys + +if sys.platform == "win32": # pragma: no cover + import msvcrt +else: # pragma: no cover + import select + import termios + import tty + + +def check_for_keypress(): # pragma: no cover + """Check if a key has been pressed (non-blocking).""" + if not sys.stdin.isatty(): + return False + if sys.platform == "win32": + return msvcrt.kbhit() + else: + return select.select([sys.stdin], [], [], 0)[0] + + +def read_key(): # pragma: no cover + """Read a single keypress.""" + if sys.platform == "win32": + key = msvcrt.getch() + else: + key = sys.stdin.read(1) + + # Convert bytes to string if needed (Windows returns bytes) + if isinstance(key, bytes): + key = key.decode("utf-8", errors="ignore") + return key + + +def drain_keypresses(): # pragma: no cover + """Consume all pending keypresses from the input buffer.""" + while check_for_keypress(): + read_key() + + +def setup_terminal(): # pragma: no cover + """Setup terminal for non-blocking input (Unix only).""" + if sys.platform != "win32" and sys.stdin.isatty(): + try: + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + tty.setcbreak(fd) + return old_settings + except (termios.error, OSError): + pass + return None + + +def restore_terminal(old_settings): # pragma: no cover + """Restore terminal settings (Unix only).""" + if sys.platform != "win32" and old_settings: + fd = sys.stdin.fileno() + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) diff --git a/src/countdown/timer.py b/src/countdown/timer.py new file mode 100644 index 0000000..8a6e288 --- /dev/null +++ b/src/countdown/timer.py @@ -0,0 +1,49 @@ +"""Time parsing and formatting utilities.""" + +import re + +DURATION_RE = re.compile( + r""" + ^ + (?: # Optional minutes + ( \d{1,2} ) # D or DD + m # "m" + )? + (?: # Optional seconds + ( \d{1,2} ) # D or DD + s # "s" + )? + $ +""", + re.VERBOSE, +) + + +def duration(string): + """Convert given XmXs string to seconds (as an integer).""" + match = DURATION_RE.search(string) + if not match: + raise ValueError(f"Invalid duration: {string}") + minutes, seconds = match.groups() + return int(minutes or 0) * 60 + int(seconds or 0) + + +def get_number_lines(seconds, chars): + """Return list of lines which make large MM:SS glyphs for given seconds. + + Args: + seconds: The time in seconds to format + chars: Dictionary of character glyphs to use for rendering + + Returns: + List of strings, one per line of the ASCII art display + """ + digit_height = len(next(iter(chars.values())).splitlines()) + lines = [""] * digit_height + minutes, seconds = divmod(seconds, 60) + time = f"{minutes:02d}:{seconds:02d}" + for char in time: + char_lines = chars[char].splitlines() + for i, line in enumerate(char_lines): + lines[i] += line + " " + return lines diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..75f9863 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,221 @@ +"""Test cases for the display module.""" + +import os +from textwrap import dedent + +from countdown import display +from countdown.digits import DIGIT_SIZES + + +def fake_size(columns, lines): + """Create a fake terminal size function for testing.""" + + def get_terminal_size(fallback=(columns, lines)): + return os.terminal_size(fallback) + + return get_terminal_size + + +def test_print_full_screen_tiny_terminal(capsys, monkeypatch): + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) + display.print_full_screen(["hello world"]) + out, err = capsys.readouterr() + assert out[:6] == "\x1b[H\x1b[J" + assert out[6:] == "\n\n\n\n hello world" + + +def test_print_full_screen_larger_terminal(capsys, monkeypatch): + monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) + display.print_full_screen(["hello world"]) + out, err = capsys.readouterr() + assert out[:6] == "\x1b[H\x1b[J" + # 24 height - 1 line = 23, 23//2 = 11 newlines + # 80 width - 11 chars = 69, 69//2 = 34 spaces + assert out[6:] == "\n" * 11 + " " * 34 + "hello world" + + +def test_print_full_screen_multiline_text(capsys, monkeypatch): + monkeypatch.setattr("shutil.get_terminal_size", fake_size(100, 30)) + display.print_full_screen( + dedent( + """\ + ██████ ██████ ██ ████ + ██ ██ ██ ███ ██ ██ + █████ ██████ ██ ████ + ██ ██ ██ ██ ██ ██ + ██████ ██████ ██ ████ + """ + ).splitlines() + ) + out, err = capsys.readouterr() + assert out[:6] == "\x1b[H\x1b[J" + assert out[6:] == ( + "\n\n\n\n\n\n\n\n\n\n\n\n" + " ██████ ██████ ██ ████\n" + " ██ ██ ██ ███ ██ ██\n" + " █████ ██████ ██ ████\n" + " ██ ██ ██ ██ ██ ██\n" + " ██████ ██████ ██ ████" + ) + + +def test_print_full_screen_paused_shows_red_and_message(capsys, monkeypatch): + """Test that paused=True shows colored timer and PAUSED message.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) + lines = ["00:05"] + display.print_full_screen(lines, paused=True) + out, err = capsys.readouterr() + # Should contain intense magenta color code + assert ( + "\x1b[95m" in out + ), "Should contain intense magenta color code when paused" + # Should contain reset code + assert "\033[0m" in out, "Should contain color reset code" + # Should contain PAUSED message + assert "PAUSED - Press any key to resume" in out + + +def test_print_full_screen_not_paused_no_red_or_message(capsys, monkeypatch): + """Test that paused=False shows normal timer without PAUSED message.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) + lines = ["00:05"] + display.print_full_screen(lines, paused=False) + out, err = capsys.readouterr() + # Should NOT contain PAUSED message + assert "PAUSED" not in out + # Red color may or may not be present depending on other features, but + # the important thing is the PAUSED message is not shown + + +def test_print_full_screen_paused_tiny_terminal_no_message(capsys, monkeypatch): + """Test that PAUSED message is hidden in tiny terminals with no room.""" + # Create a 3-line terminal with 3-line timer (no room for PAUSED text) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(20, 3)) + lines = ["line1", "line2", "line3"] + display.print_full_screen(lines, paused=True) + out, err = capsys.readouterr() + # Should still show intense magenta color + assert ( + "\x1b[95m" in out + ), "Should contain intense magenta color code when paused" + # Should NOT show PAUSED message (no room) + assert "PAUSED" not in out, "PAUSED message should not appear in tiny terminal" + + +def test_digit_sizes_available(): + """Test that expected digit sizes are available.""" + assert 16 in DIGIT_SIZES, "Size 16 digits should be available" + assert 7 in DIGIT_SIZES, "Size 7 digits should be available" + assert 5 in DIGIT_SIZES, "Size 5 digits should be available" + assert 3 in DIGIT_SIZES, "Size 3 digits should be available" + assert 1 in DIGIT_SIZES, "Size 1 digits should be available" + + +def test_all_characters_in_each_size(): + """Test that all digit characters exist in each size.""" + from countdown.digits import CHARS_BY_SIZE + + expected_chars = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":"} + for size in DIGIT_SIZES: + chars = CHARS_BY_SIZE[size] + assert set(chars.keys()) == expected_chars, ( + f"Size {size} should have all characters" + ) + + +def test_char_heights_match_size(): + """Test that character heights match the expected size.""" + from countdown.digits import CHARS_BY_SIZE + + for size in DIGIT_SIZES: + chars = CHARS_BY_SIZE[size] + for char, text in chars.items(): + height = len(text.splitlines()) + assert height == size, ( + f"Character '{char}' in size {size} should have height {size}, got {height}" + ) + + +def test_get_chars_for_terminal_selects_largest_that_fits(monkeypatch): + """Test that get_chars_for_terminal selects the largest size that fits both dimensions.""" + # Size requirements: 16(93w), 7(57w), 5(33w), 3(20w), 1(10w) + + # 80x24 terminal - size 7 fits (57w <= 80, 7h <= 24) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 7, "80x24 terminal should select size 7" + + # 100x24 terminal - size 16 fits (93w <= 100, 16h <= 24) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(100, 24)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 16, "100x24 terminal should select size 16" + + # 60x20 terminal - size 7 fits (57w <= 60, 7h <= 20) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(60, 20)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 7, "60x20 terminal should select size 7" + + # 32x10 terminal - size 3 fits (20w <= 32, 3h <= 10) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 3, "32x10 terminal should select size 3" + + # 15x5 terminal - size 1 fits (10w <= 15, 1h <= 5) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(15, 5)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 1, "15x5 terminal should select size 1" + + # Very small terminal - falls back to smallest + monkeypatch.setattr("shutil.get_terminal_size", fake_size(5, 1)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 1, "5x1 terminal should fall back to size 1" + + +def test_different_sizes_render_correctly(monkeypatch): + """Test that different sizes render correctly.""" + from countdown import timer + + # Test size 7 rendering (80x24 selects size 7) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) + chars = display.get_chars_for_terminal() + lines = timer.get_number_lines(0, chars) # 00:00 + assert len(lines) == 7, "80x24 terminal should render 7 lines" + + # Test size 3 rendering (32x10 selects size 3) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) + chars = display.get_chars_for_terminal() + lines = timer.get_number_lines(0, chars) # 00:00 + assert len(lines) == 3, "32x10 terminal should render 3 lines" + + # Test size 1 rendering (15x5 selects size 1) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(15, 5)) + chars = display.get_chars_for_terminal() + lines = timer.get_number_lines(0, chars) # 00:00 + assert len(lines) == 1, "15x5 terminal should render 1 line" + + +def test_width_constraints_force_smaller_size(monkeypatch): + """Test that narrow terminal widths force selection of smaller digit sizes.""" + # Size 7 requires 57 width - a 56x20 terminal should select size 5 instead + monkeypatch.setattr("shutil.get_terminal_size", fake_size(56, 20)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 5, "56x20 terminal too narrow for size 7, should select size 5" + + # Size 5 requires 33 width - a 32x10 terminal should select size 3 instead + monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 3, "32x10 terminal too narrow for size 5, should select size 3" + + # Size 3 requires 20 width - a 19x5 terminal should select size 1 instead + monkeypatch.setattr("shutil.get_terminal_size", fake_size(19, 5)) + chars = display.get_chars_for_terminal() + height = len(chars["0"].splitlines()) + assert height == 1, "19x5 terminal too narrow for size 3, should select size 1" diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..bea7fea --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,34 @@ +"""Test cases for the keys module.""" + +from countdown import keys + + +def test_is_pause_key_with_strings(): + """Test that is_pause_key recognizes pause keys as strings (Unix).""" + assert keys.is_pause_key(" ") is True, "Space should be a pause key" + assert keys.is_pause_key("p") is True, "p should be a pause key" + assert keys.is_pause_key("k") is True, "k should be a pause key" + assert keys.is_pause_key("\r") is True, "Carriage return should be a pause key" + assert keys.is_pause_key("\n") is True, "Newline should be a pause key" + assert keys.is_pause_key("a") is False, "a should not be a pause key" + assert keys.is_pause_key("x") is False, "x should not be a pause key" + assert keys.is_pause_key("q") is False, "q should not be a pause key" + + +def test_is_time_adjust_key_with_strings(): + """Test that is_time_adjust_key recognizes +, =, - as strings (Unix).""" + assert keys.is_time_adjust_key("+") is True, "+ should be a time adjust key" + assert keys.is_time_adjust_key("=") is True, "= should be a time adjust key" + assert keys.is_time_adjust_key("-") is True, "- should be a time adjust key" + assert keys.is_time_adjust_key("a") is False, "a should not be a time adjust key" + assert ( + keys.is_time_adjust_key(" ") is False + ), "space should not be a time adjust key" + + +def test_get_time_adjustment(): + """Test that get_time_adjustment returns correct values.""" + assert keys.get_time_adjustment("+") == 30, "+ should add 30 seconds" + assert keys.get_time_adjustment("=") == 30, "= should add 30 seconds" + assert keys.get_time_adjustment("-") == -30, "- should subtract 30 seconds" + assert keys.get_time_adjustment("a") == 0, "non-adjust key should return 0" diff --git a/tests/test_main.py b/tests/test_main.py index 1566eac..721cf16 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,8 +1,7 @@ -"""Test cases for the __main__ module.""" +"""Integration test cases for the CLI.""" import os import re -from textwrap import dedent import pytest from click.testing import CliRunner @@ -25,10 +24,7 @@ def __call__(self, seconds): raise exception -def fake_size( - columns, - lines, -): +def fake_size(columns, lines): def get_terminal_size(fallback=(columns, lines)): return os.terminal_size(fallback) @@ -42,198 +38,12 @@ def clean_main_output(output): return output -def join_lines(lines): - """Given list of lines, return string of lines with whitespace stripped.""" - return "\n".join(line.rstrip(" ") for line in lines) - - @pytest.fixture def runner(): """Fixture for invoking command-line interfaces.""" return CliRunner() -def test_invalid_duration(): - with pytest.raises(ValueError): - __main__.duration("10") - - -def test_duration_10_seconds(): - assert __main__.duration("10s") == 10 - - -def test_duration_60_seconds(): - assert __main__.duration("60s") == 60 - - -def test_duration_1_minute(): - assert __main__.duration("1m") == 60 - - -def test_duration_10_minutes(): - assert __main__.duration("10m") == 600 - - -def test_duration_25_minutes(): - assert __main__.duration("25m") == 1500 - - -def test_duration_3_minute_and_30_seconds(): - assert __main__.duration("3m30s") == 210 - - -def test_duration_2_minutes_and_8_seconds(): - assert __main__.duration("2m8s") == 128 - - -def test_get_number_lines_10_seconds(monkeypatch): - # Use 40x7 terminal to select size 5 digits (33w <= 40, 5h+2 padding <= 7) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) - assert join_lines(__main__.get_number_lines(10)) == dedent( - """ - ██████ ██████ ██ ██████ - ██ ██ ██ ██ ██ ███ ██ ██ - ██ ██ ██ ██ ██ ██ ██ - ██ ██ ██ ██ ██ ██ ██ ██ - ██████ ██████ ██ ██████ - """ - ).strip("\n") - - -def test_get_number_lines_60_seconds(monkeypatch): - # Use 40x7 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) - assert join_lines(__main__.get_number_lines(60)) == dedent( - """ - ██████ ██ ██████ ██████ - ██ ██ ███ ██ ██ ██ ██ ██ - ██ ██ ██ ██ ██ ██ ██ - ██ ██ ██ ██ ██ ██ ██ ██ - ██████ ██ ██████ ██████ - """ - ).strip("\n") - - -def test_get_number_lines_45_minutes(monkeypatch): - # Use 40x7 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) - assert join_lines(__main__.get_number_lines(2700)) == dedent( - """ - ██ ██ ██████ ██████ ██████ - ██ ██ ██ ██ ██ ██ ██ ██ - ██████ ██████ ██ ██ ██ ██ - ██ ██ ██ ██ ██ ██ ██ - ██ ██████ ██████ ██████ - """ - ).strip("\n") - - -def test_get_number_lines_17_minutes_and_four_seconds(monkeypatch): - # Use 40x7 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) - assert join_lines(__main__.get_number_lines(1024)) == ( - " ██ ██████ ██████ ██ ██\n" - " ███ ██ ██ ██ ██ ██ ██\n" - " ██ ██ ██ ██ ██████\n" - " ██ ██ ██ ██ ██ ██\n" - " ██ ██ ██████ ██" - ) - - -def test_get_number_lines_8_minutes_and_6_seconds(monkeypatch): - # Use 40x7 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) - assert join_lines(__main__.get_number_lines(486)) == dedent( - """ - ██████ ████ ██████ ██████ - ██ ██ ██ ██ ██ ██ ██ ██ - ██ ██ ████ ██ ██ ██████ - ██ ██ ██ ██ ██ ██ ██ ██ ██ - ██████ ████ ██████ ██████ - """ - ).strip("\n") - - -def test_get_number_lines_9_minutes(monkeypatch): - # Use 40x7 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) - assert join_lines(__main__.get_number_lines(540)) == dedent( - """ - ██████ ██████ ██████ ██████ - ██ ██ ██ ██ ██ ██ ██ ██ ██ - ██ ██ ██████ ██ ██ ██ ██ - ██ ██ ██ ██ ██ ██ ██ ██ - ██████ █████ ██████ ██████ - """ - ).strip("\n") - - -def test_get_number_lines_3478(monkeypatch): - # Use 40x7 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) - assert join_lines(__main__.get_number_lines(2118)) == dedent( - """ - ██████ ██████ ██ ████ - ██ ██ ██ ███ ██ ██ - █████ ██████ ██ ████ - ██ ██ ██ ██ ██ ██ - ██████ ██████ ██ ████ - """ - ).strip("\n") - - -def test_print_full_screen_tiny_terminal( - capsys, - monkeypatch, -): - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) - __main__.print_full_screen(["hello world"]) - out, err = capsys.readouterr() - assert out[:6] == "\x1b[H\x1b[J" - assert out[6:] == "\n\n\n\n hello world" - - -def test_print_full_screen_larger_terminal( - capsys, - monkeypatch, -): - monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) - __main__.print_full_screen(["hello world"]) - out, err = capsys.readouterr() - assert out[:6] == "\x1b[H\x1b[J" - # 24 height - 1 line = 23, 23//2 = 11 newlines - # 80 width - 11 chars = 69, 69//2 = 34 spaces - assert out[6:] == "\n" * 11 + " " * 34 + "hello world" - - -def test_print_full_screen_multiline_text( - capsys, - monkeypatch, -): - monkeypatch.setattr("shutil.get_terminal_size", fake_size(100, 30)) - __main__.print_full_screen( - dedent( - """\ - ██████ ██████ ██ ████ - ██ ██ ██ ███ ██ ██ - █████ ██████ ██ ████ - ██ ██ ██ ██ ██ ██ - ██████ ██████ ██ ████ - """ - ).splitlines() - ) - out, err = capsys.readouterr() - assert out[:6] == "\x1b[H\x1b[J" - assert out[6:] == ( - "\n\n\n\n\n\n\n\n\n\n\n\n" - " ██████ ██████ ██ ████\n" - " ██ ██ ██ ███ ██ ██\n" - " █████ ██████ ██ ████\n" - " ██ ██ ██ ██ ██ ██\n" - " ██████ ██████ ██ ████" - ) - - def test_main_with_no_arguments(runner): """It shows help when run without arguments.""" result = runner.invoke(__main__.main) @@ -251,10 +61,7 @@ def test_version_works(runner): assert result.exit_code == 0 -def test_main_3_seconds_sleeps_4_times( - runner, - monkeypatch, -): +def test_main_3_seconds_sleeps_4_times(runner, monkeypatch): # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h+2 <= 20) monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) fake_sleep = FakeSleep() @@ -292,10 +99,7 @@ def test_main_3_seconds_sleeps_4_times( assert fake_sleep.slept == pytest.approx(4.0, abs=0.01) -def test_main_1_minute( - runner, - monkeypatch, -): +def test_main_1_minute(runner, monkeypatch): # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) @@ -374,10 +178,7 @@ def test_main_1_minute( ) -def test_main_10_minutes_has_over_600_clear_screens( - runner, - monkeypatch, -): +def test_main_10_minutes_has_over_600_clear_screens(runner, monkeypatch): monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) fake_sleep = FakeSleep() monkeypatch.setattr("time.sleep", fake_sleep) @@ -388,10 +189,7 @@ def test_main_10_minutes_has_over_600_clear_screens( assert result.stdout.count("\033[H\033[J") == 601 -def test_main_enables_alt_buffer_and_hides_cursor_at_beginning( - runner, - monkeypatch, -): +def test_main_enables_alt_buffer_and_hides_cursor_at_beginning(runner, monkeypatch): monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) fake_sleep = FakeSleep() monkeypatch.setattr("time.sleep", fake_sleep) @@ -399,10 +197,7 @@ def test_main_enables_alt_buffer_and_hides_cursor_at_beginning( assert result.stdout.startswith("\033[?1049h\033[?25l") -def test_main_disable_alt_buffer_and_show_cursor_at_end( - runner, - monkeypatch, -): +def test_main_disable_alt_buffer_and_show_cursor_at_end(runner, monkeypatch): monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) fake_sleep = FakeSleep() monkeypatch.setattr("time.sleep", fake_sleep) @@ -410,10 +205,7 @@ def test_main_disable_alt_buffer_and_show_cursor_at_end( assert result.stdout.endswith("\033[?25h\033[?1049l") -def test_main_early_exit_still_shows_cursor_at_end( - runner, - monkeypatch, -): +def test_main_early_exit_still_shows_cursor_at_end(runner, monkeypatch): # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) @@ -427,194 +219,7 @@ def test_main_early_exit_still_shows_cursor_at_end( assert result.stdout.endswith("\033[?25h\033[?1049l") -def test_digit_sizes_available(): - """Test that expected digit sizes are available.""" - from countdown.digits import DIGIT_SIZES - - assert 16 in DIGIT_SIZES, "Size 16 digits should be available" - assert 7 in DIGIT_SIZES, "Size 7 digits should be available" - assert 5 in DIGIT_SIZES, "Size 5 digits should be available" - assert 3 in DIGIT_SIZES, "Size 3 digits should be available" - assert 1 in DIGIT_SIZES, "Size 1 digits should be available" - - -def test_all_characters_in_each_size(): - """Test that all digit characters exist in each size.""" - from countdown.digits import CHARS_BY_SIZE, DIGIT_SIZES - - expected_chars = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":"} - for size in DIGIT_SIZES: - chars = CHARS_BY_SIZE[size] - assert set(chars.keys()) == expected_chars, ( - f"Size {size} should have all characters" - ) - - -def test_char_heights_match_size(): - """Test that character heights match the expected size.""" - from countdown.digits import CHARS_BY_SIZE, DIGIT_SIZES - - for size in DIGIT_SIZES: - chars = CHARS_BY_SIZE[size] - for char, text in chars.items(): - height = len(text.splitlines()) - assert height == size, ( - f"Character '{char}' in size {size} should have height {size}, got {height}" - ) - - -def test_get_chars_for_terminal_selects_largest_that_fits(monkeypatch): - """Test that get_chars_for_terminal selects the largest size that fits both dimensions.""" - # Size requirements: 16(93w), 7(57w), 5(33w), 3(20w), 1(10w) - - # 80x24 terminal - size 7 fits (57w <= 80, 7h <= 24) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 7, "80x24 terminal should select size 7" - - # 100x24 terminal - size 16 fits (93w <= 100, 16h <= 24) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(100, 24)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 16, "100x24 terminal should select size 16" - - # 60x20 terminal - size 7 fits (57w <= 60, 7h <= 20) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(60, 20)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 7, "60x20 terminal should select size 7" - - # 32x10 terminal - size 3 fits (20w <= 32, 3h <= 10) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 3, "32x10 terminal should select size 3" - - # 15x5 terminal - size 1 fits (10w <= 15, 1h <= 5) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(15, 5)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 1, "15x5 terminal should select size 1" - - # Very small terminal - falls back to smallest - monkeypatch.setattr("shutil.get_terminal_size", fake_size(5, 1)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 1, "5x1 terminal should fall back to size 1" - - -def test_different_sizes_render_correctly(monkeypatch): - """Test that different sizes render correctly.""" - # Test size 7 rendering (80x24 selects size 7) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) - lines = __main__.get_number_lines(0) # 00:00 - assert len(lines) == 7, "80x24 terminal should render 7 lines" - - # Test size 3 rendering (32x10 selects size 3) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) - lines = __main__.get_number_lines(0) # 00:00 - assert len(lines) == 3, "32x10 terminal should render 3 lines" - - # Test size 1 rendering (15x5 selects size 1) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(15, 5)) - lines = __main__.get_number_lines(0) # 00:00 - assert len(lines) == 1, "15x5 terminal should render 1 line" - - -def test_width_constraints_force_smaller_size(monkeypatch): - """Test that narrow terminal widths force selection of smaller digit sizes.""" - # Size 7 requires 57 width - a 56x20 terminal should select size 5 instead - monkeypatch.setattr("shutil.get_terminal_size", fake_size(56, 20)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 5, ( - "56x20 terminal too narrow for size 7, should select size 5" - ) - - # Size 5 requires 33 width - a 32x10 terminal should select size 3 instead - monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 3, ( - "32x10 terminal too narrow for size 5, should select size 3" - ) - - # Size 3 requires 20 width - a 19x5 terminal should select size 1 instead - monkeypatch.setattr("shutil.get_terminal_size", fake_size(19, 5)) - chars = __main__.get_chars_for_terminal() - height = len(chars["0"].splitlines()) - assert height == 1, ( - "19x5 terminal too narrow for size 3, should select size 1" - ) - - -def test_is_pause_key_with_strings(): - """Test that is_pause_key recognizes pause keys as strings (Unix).""" - assert __main__.is_pause_key(' ') is True, "Space should be a pause key" - assert __main__.is_pause_key('p') is True, "p should be a pause key" - assert __main__.is_pause_key('k') is True, "k should be a pause key" - assert __main__.is_pause_key('\r') is True, "Carriage return should be a pause key" - assert __main__.is_pause_key('\n') is True, "Newline should be a pause key" - assert __main__.is_pause_key('a') is False, "a should not be a pause key" - assert __main__.is_pause_key('x') is False, "x should not be a pause key" - assert __main__.is_pause_key('q') is False, "q should not be a pause key" - - - - -def test_print_full_screen_paused_shows_red_and_message( - capsys, - monkeypatch, -): - """Test that paused=True shows colored timer and PAUSED message.""" - monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) - lines = ["00:05"] - __main__.print_full_screen(lines, paused=True) - out, err = capsys.readouterr() - # Should contain intense magenta color code - assert "\x1b[95m" in out, "Should contain intense magenta color code when paused" - # Should contain reset code - assert "\033[0m" in out, "Should contain color reset code" - # Should contain PAUSED message - assert "PAUSED - Press any key to resume" in out - - -def test_print_full_screen_not_paused_no_red_or_message( - capsys, - monkeypatch, -): - """Test that paused=False shows normal timer without PAUSED message.""" - monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) - lines = ["00:05"] - __main__.print_full_screen(lines, paused=False) - out, err = capsys.readouterr() - # Should NOT contain PAUSED message - assert "PAUSED" not in out - # Red color may or may not be present depending on other features, but - # the important thing is the PAUSED message is not shown - - -def test_print_full_screen_paused_tiny_terminal_no_message( - capsys, - monkeypatch, -): - """Test that PAUSED message is hidden in tiny terminals with no room.""" - # Create a 3-line terminal with 3-line timer (no room for PAUSED text) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(20, 3)) - lines = ["line1", "line2", "line3"] - __main__.print_full_screen(lines, paused=True) - out, err = capsys.readouterr() - # Should still show intense magenta color - assert "\x1b[95m" in out, "Should contain intense magenta color code when paused" - # Should NOT show PAUSED message (no room) - assert "PAUSED" not in out, "PAUSED message should not appear in tiny terminal" - - -def test_pause_key_triggers_pause( - runner, - monkeypatch, -): +def test_pause_key_triggers_pause(runner, monkeypatch): """Test that pressing a pause key triggers the pause logic.""" monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) @@ -635,7 +240,7 @@ def fake_check_for_keypress(): def fake_read_key(): read_key_called[0] = True - return ' ' # Space bar (a pause key) + return " " # Space bar (a pause key) def fake_drain(): pass # No additional keys to drain @@ -653,10 +258,7 @@ def fake_drain(): assert "\x1b[95m" in result.stdout, "Should show paused color when pause key pressed" -def test_non_pause_key_ignored( - runner, - monkeypatch, -): +def test_non_pause_key_ignored(runner, monkeypatch): """Test that non-pause keys are ignored during countdown.""" monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) @@ -675,7 +277,7 @@ def fake_check_for_keypress(): def fake_read_key(): read_key_called[0] = True - return 'x' # Not a pause key + return "x" # Not a pause key monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) monkeypatch.setattr(__main__, "read_key", fake_read_key) @@ -685,31 +287,13 @@ def fake_read_key(): # The key should have been read assert read_key_called[0], "read_key should have been called" # Output should NOT contain paused color since 'x' is not a pause key - assert "\x1b[95m" not in result.stdout, "Should not show paused color for non-pause key" + assert ( + "\x1b[95m" not in result.stdout + ), "Should not show paused color for non-pause key" assert result.exit_code == 0 -def test_pause_key_detection( - monkeypatch, -): - """Test that check_for_keypress returns False when not a TTY.""" - import sys - - # Mock stdin.isatty() to return False - original_isatty = sys.stdin.isatty - sys.stdin.isatty = lambda: False - - try: - result = __main__.check_for_keypress() - assert result is False, "Should return False when not a TTY" - finally: - sys.stdin.isatty = original_isatty - - -def test_sleep_exits_early_on_keypress( - runner, - monkeypatch, -): +def test_sleep_exits_early_on_keypress(runner, monkeypatch): """Test that sleep loop exits early when a key is pressed mid-sleep.""" monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) @@ -733,7 +317,7 @@ def fake_check_for_keypress(): return len(sleep_calls) == 3 def fake_read_key(): - return ' ' # Pause key + return " " # Pause key def fake_drain(): pass @@ -751,13 +335,12 @@ def fake_drain(): # If it didn't exit early, we'd see many more 0.05 sleep calls # The presence of the break means we don't complete all 20 chunks first_iteration_sleeps = [s for s in sleep_calls[:3] if s == 0.05] - assert len(first_iteration_sleeps) == 3, "Should have 3 chunks of 0.05s before breaking" + assert ( + len(first_iteration_sleeps) == 3 + ), "Should have 3 chunks of 0.05s before breaking" -def test_resume_from_pause_exits_early( - runner, - monkeypatch, -): +def test_resume_from_pause_exits_early(runner, monkeypatch): """Test that when paused, pressing a key to resume exits the 0.05s sleep loop.""" monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) @@ -780,7 +363,7 @@ def fake_check_for_keypress(): # Second keypress: resume after being paused (keypress 2) return keypress_count[0] in [1, 5] - keys_to_return = [' ', ' '] # Space to pause, space to resume + keys_to_return = [" ", " "] # Space to pause, space to resume key_index = [0] def fake_read_key(): @@ -813,27 +396,7 @@ def tracking_print(lines, paused=False): assert len(unpaused_sleeps) > 0, "Should have some unpaused sleep periods" -def test_is_time_adjust_key_with_strings(): - """Test that is_time_adjust_key recognizes +, =, - as strings (Unix).""" - assert __main__.is_time_adjust_key('+') is True, "+ should be a time adjust key" - assert __main__.is_time_adjust_key('=') is True, "= should be a time adjust key" - assert __main__.is_time_adjust_key('-') is True, "- should be a time adjust key" - assert __main__.is_time_adjust_key('a') is False, "a should not be a time adjust key" - assert __main__.is_time_adjust_key(' ') is False, "space should not be a time adjust key" - - -def test_get_time_adjustment(): - """Test that get_time_adjustment returns correct values.""" - assert __main__.get_time_adjustment('+') == 30, "+ should add 30 seconds" - assert __main__.get_time_adjustment('=') == 30, "= should add 30 seconds" - assert __main__.get_time_adjustment('-') == -30, "- should subtract 30 seconds" - assert __main__.get_time_adjustment('a') == 0, "non-adjust key should return 0" - - -def test_add_time_with_plus_key( - runner, - monkeypatch, -): +def test_add_time_with_plus_key(runner, monkeypatch): """Test that pressing + adds 30 seconds to the timer.""" monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) @@ -853,7 +416,7 @@ def fake_check_for_keypress(): return len(displayed_times) == 1 def fake_read_key(): - return '+' # Plus key to add time + return "+" # Plus key to add time def fake_drain(): pass @@ -870,10 +433,7 @@ def fake_drain(): assert 90 in displayed_times, "Should display 90s after adding 30s" -def test_subtract_time_with_minus_key( - runner, - monkeypatch, -): +def test_subtract_time_with_minus_key(runner, monkeypatch): """Test that pressing - subtracts 30 seconds from the timer.""" monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) @@ -893,7 +453,7 @@ def fake_check_for_keypress(): return len(displayed_times) == 1 def fake_read_key(): - return '-' # Minus key to subtract time + return "-" # Minus key to subtract time def fake_drain(): pass @@ -910,10 +470,7 @@ def fake_drain(): assert 30 in displayed_times, "Should display 30s after subtracting 30s" -def test_subtract_time_cannot_go_negative( - runner, - monkeypatch, -): +def test_subtract_time_cannot_go_negative(runner, monkeypatch): """Test that subtracting time stops at 0 (cannot go negative).""" monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) @@ -933,7 +490,7 @@ def fake_check_for_keypress(): return len(displayed_times) == 1 def fake_read_key(): - return '-' # Minus key to subtract time + return "-" # Minus key to subtract time def fake_drain(): pass @@ -948,13 +505,12 @@ def fake_drain(): # Should have displayed 10s initially, then 0s (not -20s) after pressing - assert 10 in displayed_times, "Should display initial time of 10s" assert 0 in displayed_times, "Should display 0s (not negative) after subtracting 30s" - assert all(t >= 0 for t in displayed_times), "All displayed times should be non-negative" + assert all( + t >= 0 for t in displayed_times + ), "All displayed times should be non-negative" -def test_q_key_quits_timer( - runner, - monkeypatch, -): +def test_q_key_quits_timer(runner, monkeypatch): """Test that pressing 'q' exits the timer.""" monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) @@ -969,7 +525,7 @@ def fake_check_for_keypress(): return keypress_count[0] == 1 def fake_read_key(): - return 'q' # Press q to quit + return "q" # Press q to quit monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) monkeypatch.setattr(__main__, "read_key", fake_read_key) diff --git a/tests/test_terminal.py b/tests/test_terminal.py new file mode 100644 index 0000000..055e584 --- /dev/null +++ b/tests/test_terminal.py @@ -0,0 +1,18 @@ +"""Test cases for the terminal module.""" + +import sys + +from countdown import terminal + + +def test_check_for_keypress_returns_false_when_not_a_tty(): + """Test that check_for_keypress returns False when not a TTY.""" + # Mock stdin.isatty() to return False + original_isatty = sys.stdin.isatty + sys.stdin.isatty = lambda: False + + try: + result = terminal.check_for_keypress() + assert result is False, "Should return False when not a TTY" + finally: + sys.stdin.isatty = original_isatty diff --git a/tests/test_timer.py b/tests/test_timer.py new file mode 100644 index 0000000..bce6a41 --- /dev/null +++ b/tests/test_timer.py @@ -0,0 +1,142 @@ +"""Test cases for the timer module.""" + +from textwrap import dedent + +import pytest + +from countdown import timer +from countdown.digits import CHARS_BY_SIZE + + +def join_lines(lines): + """Given list of lines, return string of lines with whitespace stripped.""" + return "\n".join(line.rstrip(" ") for line in lines) + + +def test_invalid_duration(): + with pytest.raises(ValueError): + timer.duration("10") + + +def test_duration_10_seconds(): + assert timer.duration("10s") == 10 + + +def test_duration_60_seconds(): + assert timer.duration("60s") == 60 + + +def test_duration_1_minute(): + assert timer.duration("1m") == 60 + + +def test_duration_10_minutes(): + assert timer.duration("10m") == 600 + + +def test_duration_25_minutes(): + assert timer.duration("25m") == 1500 + + +def test_duration_3_minute_and_30_seconds(): + assert timer.duration("3m30s") == 210 + + +def test_duration_2_minutes_and_8_seconds(): + assert timer.duration("2m8s") == 128 + + +def test_get_number_lines_10_seconds(): + # Use size 5 digits for consistent rendering + chars = CHARS_BY_SIZE[5] + assert join_lines(timer.get_number_lines(10, chars)) == dedent( + """ + ██████ ██████ ██ ██████ + ██ ██ ██ ██ ██ ███ ██ ██ + ██ ██ ██ ██ ██ ██ ██ + ██ ██ ██ ██ ██ ██ ██ ██ + ██████ ██████ ██ ██████ + """ + ).strip("\n") + + +def test_get_number_lines_60_seconds(): + # Use size 5 digits + chars = CHARS_BY_SIZE[5] + assert join_lines(timer.get_number_lines(60, chars)) == dedent( + """ + ██████ ██ ██████ ██████ + ██ ██ ███ ██ ██ ██ ██ ██ + ██ ██ ██ ██ ██ ██ ██ + ██ ██ ██ ██ ██ ██ ██ ██ + ██████ ██ ██████ ██████ + """ + ).strip("\n") + + +def test_get_number_lines_45_minutes(): + # Use size 5 digits + chars = CHARS_BY_SIZE[5] + assert join_lines(timer.get_number_lines(2700, chars)) == dedent( + """ + ██ ██ ██████ ██████ ██████ + ██ ██ ██ ██ ██ ██ ██ ██ + ██████ ██████ ██ ██ ██ ██ + ██ ██ ██ ██ ██ ██ ██ + ██ ██████ ██████ ██████ + """ + ).strip("\n") + + +def test_get_number_lines_17_minutes_and_four_seconds(): + # Use size 5 digits + chars = CHARS_BY_SIZE[5] + assert join_lines(timer.get_number_lines(1024, chars)) == ( + " ██ ██████ ██████ ██ ██\n" + " ███ ██ ██ ██ ██ ██ ██\n" + " ██ ██ ██ ██ ██████\n" + " ██ ██ ██ ██ ██ ██\n" + " ██ ██ ██████ ██" + ) + + +def test_get_number_lines_8_minutes_and_6_seconds(): + # Use size 5 digits + chars = CHARS_BY_SIZE[5] + assert join_lines(timer.get_number_lines(486, chars)) == dedent( + """ + ██████ ████ ██████ ██████ + ██ ██ ██ ██ ██ ██ ██ ██ + ██ ██ ████ ██ ██ ██████ + ██ ██ ██ ██ ██ ██ ██ ██ ██ + ██████ ████ ██████ ██████ + """ + ).strip("\n") + + +def test_get_number_lines_9_minutes(): + # Use size 5 digits + chars = CHARS_BY_SIZE[5] + assert join_lines(timer.get_number_lines(540, chars)) == dedent( + """ + ██████ ██████ ██████ ██████ + ██ ██ ██ ██ ██ ██ ██ ██ ██ + ██ ██ ██████ ██ ██ ██ ██ + ██ ██ ██ ██ ██ ██ ██ ██ + ██████ █████ ██████ ██████ + """ + ).strip("\n") + + +def test_get_number_lines_3478(): + # Use size 5 digits + chars = CHARS_BY_SIZE[5] + assert join_lines(timer.get_number_lines(2118, chars)) == dedent( + """ + ██████ ██████ ██ ████ + ██ ██ ██ ███ ██ ██ + █████ ██████ ██ ████ + ██ ██ ██ ██ ██ ██ + ██████ ██████ ██ ████ + """ + ).strip("\n")