From 4ed0d08d5b70256b3e95a05fb745a9a0a025b7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:54:20 +0000 Subject: [PATCH 1/6] Fix countdown drift. --- src/countdown/__main__.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/countdown/__main__.py b/src/countdown/__main__.py index ccfd223..d26c5d3 100644 --- a/src/countdown/__main__.py +++ b/src/countdown/__main__.py @@ -1,6 +1,6 @@ """Command-line interface.""" -from time import sleep +from time import sleep, time import click @@ -41,7 +41,9 @@ def run_countdown(total_seconds): try: paused = False n = total_seconds - while n >= 0: + sleep_until = time() + total_seconds + pause_start = 0 + while time() < sleep_until or paused: lines = get_number_lines(n) print_full_screen(lines, paused=paused) @@ -53,6 +55,11 @@ def run_countdown(total_seconds): # Quit the timer break elif is_pause_key(key): + if paused: + sleep_until += time() - pause_start + pause_start = 0 + else: + pause_start = time() paused = not paused drain_keypresses() # Ignore any additional rapid keypresses lines = get_number_lines(n) @@ -60,6 +67,7 @@ def run_countdown(total_seconds): elif is_time_adjust_key(key): # Adjust the timer by +/- 30 seconds adjustment = get_time_adjustment(key) + sleep_until += adjustment n = max(0, n + adjustment) # Don't go below 0 drain_keypresses() # Ignore any additional rapid keypresses lines = get_number_lines(n) @@ -67,8 +75,9 @@ def run_countdown(total_seconds): # Only sleep and decrement if not paused if not paused: - # Sleep in small chunks to check for keypresses more frequently - for _ in range(20): # 20 x 0.05 = 1 second + display_this_second_until = sleep_until - n + 1 + while time() < display_this_second_until: + # Sleep in small chunks to check for keypresses more frequently sleep(0.05) if check_for_keypress(): break # Exit sleep early if key is pressed From 28e9b89fedebc7071350397ecbebd47550dce14c Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 27 Mar 2026 20:18:46 -0700 Subject: [PATCH 2/6] Fix tests and ensure 00:00 is still displayed --- src/countdown/__main__.py | 2 +- tests/test_main.py | 130 ++++++++++++++++++++++---------------- 2 files changed, 78 insertions(+), 54 deletions(-) diff --git a/src/countdown/__main__.py b/src/countdown/__main__.py index d26c5d3..2af3460 100644 --- a/src/countdown/__main__.py +++ b/src/countdown/__main__.py @@ -43,7 +43,7 @@ def run_countdown(total_seconds): n = total_seconds sleep_until = time() + total_seconds pause_start = 0 - while time() < sleep_until or paused: + while n >= 0 or paused: lines = get_number_lines(n) print_full_screen(lines, paused=paused) diff --git a/tests/test_main.py b/tests/test_main.py index 9bbda2b..cfa5057 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -9,14 +9,30 @@ from countdown import __main__ -class FakeSleep: - """Fake time.sleep.""" +class FakeClock: + """Fake time.time() and time.sleep() that advance together. - def __init__(self, *, raises={}): # noqa: B006 + Since run_countdown uses time() for loop control and sleep() for pacing, + both must be faked in sync to avoid tests running in real time. + """ + + def __init__(self, *, raises={}, drift_per_sleep=0.0): # noqa: B006 + self.start = 1_000_000.0 + self.current = self.start self.slept = 0 self.raises = dict(raises) + self.drift_per_sleep = drift_per_sleep + + @property + def elapsed(self): + """Total wall clock time elapsed (including any drift).""" + return self.current - self.start + + def time(self): + return self.current - def __call__(self, seconds): + def sleep(self, seconds): + self.current += seconds + self.drift_per_sleep self.slept += seconds # Check for exception with floating point tolerance for trigger_time, exception in self.raises.items(): @@ -24,6 +40,12 @@ def __call__(self, seconds): raise exception +def patch_clock(monkeypatch, clock): + """Monkeypatch both time and sleep to use the given FakeClock.""" + monkeypatch.setattr("countdown.__main__.sleep", clock.sleep) + monkeypatch.setattr("countdown.__main__.time", clock.time) + + def fake_size(columns, lines): def get_terminal_size(fallback=(columns, lines)): return os.terminal_size(fallback) @@ -61,14 +83,14 @@ 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(runner, monkeypatch): # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h+2 <= 20) monkeypatch.setattr( "countdown.display.get_terminal_size", fake_size(40, 20), ) - fake_sleep = FakeSleep() - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock() + patch_clock(monkeypatch, clock) result = runner.invoke(__main__.main, ["3s"]) assert result.exit_code == 0 assert clean_main_output(result.stdout) == ( @@ -97,9 +119,9 @@ def test_main_3_seconds_sleeps_4_times(runner, monkeypatch): " ██ ██ ██ ██ ██ ██ ██ ██ ██\n" " ██████ ██████ ██████ ██████ " ) - # 3 seconds countdown = 4 iterations (3,2,1,0), each sleeps 1 second = 4 seconds total - # Sleeping in chunks of 0.05, so total is ~4 seconds (floating point precision) - assert fake_sleep.slept == pytest.approx(4.0, abs=0.01) + # 3 seconds + 1 to display 00:00, each sleeping ~1 second + assert clock.slept == pytest.approx(3 + 1, abs=0.01) + assert clock.elapsed == pytest.approx(3 + 1, abs=0.01) def test_main_1_minute(runner, monkeypatch): @@ -109,9 +131,9 @@ def test_main_1_minute(runner, monkeypatch): fake_size(40, 10), ) - # Raise exception after 11 sleeps - fake_sleep = FakeSleep(raises={11: SystemExit(0)}) - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + # Raise exception after 11 seconds of fake sleep + clock = FakeClock(raises={11: SystemExit(0)}) + patch_clock(monkeypatch, clock) result = runner.invoke(__main__.main, ["1m"]) assert clean_main_output(result.stdout) == ( @@ -184,18 +206,18 @@ def test_main_1_minute(runner, monkeypatch): ) -def test_main_10_minutes_has_over_600_clear_screens(runner, monkeypatch): +def test_main_10_minutes_has_600_clear_screens(runner, monkeypatch): monkeypatch.setattr( "countdown.display.get_terminal_size", fake_size(32, 10), ) - fake_sleep = FakeSleep() - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock() + patch_clock(monkeypatch, clock) result = runner.invoke(__main__.main, ["10m"]) - # 10 minutes = 601 iterations, each sleeps 1 second (via 20×0.05 chunks) - # Floating point precision: 601 × 20 × 0.05 ≈ 601.0 - assert fake_sleep.slept == pytest.approx(601.0, abs=0.1) - assert result.stdout.count("\033[H\033[J") == 601 + # 10 minutes = 600 seconds + 1 to display 00:00 + assert clock.slept == pytest.approx(10 * 60 + 1, abs=0.1) + assert clock.elapsed == pytest.approx(10 * 60 + 1, abs=0.1) + assert result.stdout.count("\033[H\033[J") == 10 * 60 + 1 def test_main_enables_alt_buffer_and_hides_cursor_at_beginning( @@ -205,8 +227,8 @@ def test_main_enables_alt_buffer_and_hides_cursor_at_beginning( "countdown.display.get_terminal_size", fake_size(32, 10), ) - fake_sleep = FakeSleep() - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock() + patch_clock(monkeypatch, clock) result = runner.invoke(__main__.main, ["5m"]) assert result.stdout.startswith("\033[?1049h\033[?25l") @@ -216,8 +238,8 @@ def test_main_disable_alt_buffer_and_show_cursor_at_end(runner, monkeypatch): "countdown.display.get_terminal_size", fake_size(32, 10), ) - fake_sleep = FakeSleep() - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock() + patch_clock(monkeypatch, clock) result = runner.invoke(__main__.main, ["5m"]) assert result.stdout.endswith("\033[?25h\033[?1049l") @@ -230,12 +252,14 @@ def test_main_early_exit_still_shows_cursor_at_end(runner, monkeypatch): ) # Hit Ctrl+C after 4 seconds total sleep time (chunked sleep) - fake_sleep = FakeSleep(raises={4: KeyboardInterrupt()}) - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock(raises={4: KeyboardInterrupt()}) + patch_clock(monkeypatch, clock) result = runner.invoke(__main__.main, ["15m"]) - # After 4 seconds of sleep, we've completed 4 iterations, each prints lines - assert len(result.stdout.splitlines()) == 25, "4 seconds of lines printed" + # 4 seconds of sleep = 4 iterations, each printing 5 lines + 1 padding line + # except the last which has no trailing padding = 4*6-1 = 23 lines, + # plus 2 lines vertical padding for 10-line terminal = 25 lines + assert len(result.stdout.splitlines()) == 25 assert result.stdout.endswith("\033[?25h\033[?1049l") @@ -247,8 +271,8 @@ def test_pause_key_triggers_pause(runner, monkeypatch): ) # Exit after a short time - fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock(raises={1: KeyboardInterrupt()}) + patch_clock(monkeypatch, clock) # Track whether pause key was detected pause_key_detected = [False] @@ -290,8 +314,8 @@ def test_non_pause_key_ignored(runner, monkeypatch): fake_size(40, 20), ) - fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock(raises={1: KeyboardInterrupt()}) + patch_clock(monkeypatch, clock) # Track keypresses check_called = [False] @@ -328,22 +352,22 @@ def test_sleep_exits_early_on_keypress(runner, monkeypatch): fake_size(40, 20), ) - # Track sleep calls + # Track sleep calls and use FakeClock for time control + clock = FakeClock() + patch_clock(monkeypatch, clock) sleep_calls = [] + original_sleep = clock.sleep - def fake_sleep(seconds): + def tracking_sleep(seconds): sleep_calls.append(seconds) - # Exit after we've done a few sleep chunks + original_sleep(seconds) if len(sleep_calls) >= 5: raise KeyboardInterrupt() - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + monkeypatch.setattr("countdown.__main__.sleep", tracking_sleep) # Simulate keypress after 3rd sleep call (during chunked 1-second sleep) - check_count = [0] - def fake_check_for_keypress(): - check_count[0] += 1 # Return True on the 3rd sleep chunk to simulate keypress mid-sleep return len(sleep_calls) == 3 @@ -361,11 +385,7 @@ def fake_drain(): assert result.exit_code == 0, result.output # Should have broken out of sleep loop early (not all 20 chunks) - # We expect: 3 chunks of first iteration, then breaks, then starts paused sleep - # The key point is we don't see all 20 chunks of 0.05 before breaking assert len(sleep_calls) >= 3, "Should have at least 3 sleep calls" - # 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" @@ -379,15 +399,19 @@ def test_resume_from_pause_exits_early(runner, monkeypatch): fake_size(40, 20), ) + clock = FakeClock() + patch_clock(monkeypatch, clock) sleep_calls = [] paused_state = [False] + original_sleep = clock.sleep - def fake_sleep(seconds): + def tracking_sleep(seconds): sleep_calls.append((seconds, paused_state[0])) + original_sleep(seconds) if len(sleep_calls) >= 10: raise KeyboardInterrupt() - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + monkeypatch.setattr("countdown.__main__.sleep", tracking_sleep) # Simulate: pause immediately, then resume after a few paused sleeps keypress_count = [0] @@ -439,8 +463,8 @@ def test_add_time_with_plus_key(runner, monkeypatch): fake_size(40, 20), ) - fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock(raises={1: KeyboardInterrupt()}) + patch_clock(monkeypatch, clock) # Track the displayed times displayed_times = [] @@ -480,8 +504,8 @@ def test_subtract_time_with_minus_key(runner, monkeypatch): fake_size(40, 20), ) - fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock(raises={1: KeyboardInterrupt()}) + patch_clock(monkeypatch, clock) # Track the displayed times displayed_times = [] @@ -521,8 +545,8 @@ def test_subtract_time_cannot_go_negative(runner, monkeypatch): fake_size(40, 20), ) - fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock(raises={1: KeyboardInterrupt()}) + patch_clock(monkeypatch, clock) # Track the displayed times displayed_times = [] @@ -567,8 +591,8 @@ def test_q_key_quits_timer(runner, monkeypatch): fake_size(40, 20), ) - fake_sleep = FakeSleep() - monkeypatch.setattr("countdown.__main__.sleep", fake_sleep) + clock = FakeClock() + patch_clock(monkeypatch, clock) keypress_count = [0] From 83fde659bbff9f09fbb38922180b811a0a5dc860 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 27 Mar 2026 20:29:47 -0700 Subject: [PATCH 3/6] Add tests for drift --- tests/test_main.py | 221 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index cfa5057..68d4e8a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -626,3 +626,224 @@ def test_no_arguments_shows_help(runner): assert "DURATION" in result.output # Should show examples assert "5m" in result.output or "Examples" in result.output + + +# --- Tests for drift-fix behavior --- + + +def test_countdown_displays_each_second(runner, monkeypatch): + """Test that a 5-second countdown displays each second value.""" + monkeypatch.setattr( + "countdown.display.get_terminal_size", + fake_size(40, 20), + ) + clock = FakeClock() + patch_clock(monkeypatch, clock) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + result = runner.invoke(__main__.main, ["5s"]) + assert result.exit_code == 0 + assert displayed_times == [5, 4, 3, 2, 1, 0] + + +def test_drift_correction_with_slow_sleeps(runner, monkeypatch): + """With drift, the timer still counts down the right number of seconds. + + Each 0.05s sleep takes 0.06s (simulating OS scheduling overhead). + Without drift correction, this would make the countdown run 20% too long. + With drift correction, each second still advances based on wall clock time. + """ + monkeypatch.setattr( + "countdown.display.get_terminal_size", + fake_size(40, 20), + ) + clock = FakeClock(drift_per_sleep=0.01) + patch_clock(monkeypatch, clock) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + result = runner.invoke(__main__.main, ["5s"]) + assert result.exit_code == 0 + + # Even with drift, we should still display 5 seconds counting down + assert displayed_times == [5, 4, 3, 2, 1, 0] + + +def test_drift_correction_skips_seconds_when_very_slow(runner, monkeypatch): + """Simulate extreme drift. + + With extreme drift, individual seconds may be skipped but total time + is still bounded by wall clock time, not by sleep iteration count. + """ + monkeypatch.setattr( + "countdown.display.get_terminal_size", + fake_size(40, 20), + ) + # Each 0.05s sleep takes 0.55s (extreme drift: 10x) + clock = FakeClock(drift_per_sleep=0.5) + patch_clock(monkeypatch, clock) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + result = runner.invoke(__main__.main, ["60m"]) + assert result.exit_code == 0 + + # Timer should still start at 60m and count down + assert displayed_times[0] == 60 * 60 + # Each second is displayed for fewer sleep iterations due to drift, + # but the total still counts down correctly (each displayed value + # is less than the one before) + for i in range(1, len(displayed_times)): + assert displayed_times[i] < displayed_times[i - 1] + + +def test_pause_preserves_remaining_time(runner, monkeypatch): + """Pausing and resuming should not consume countdown time.""" + monkeypatch.setattr( + "countdown.display.get_terminal_size", + fake_size(40, 20), + ) + + clock = FakeClock() + patch_clock(monkeypatch, clock) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + # Pause on first display, then resume after 3 checks while paused + keypress_count = [0] + + def fake_check_for_keypress(): + keypress_count[0] += 1 + return keypress_count[0] in [1, 5] # pause, then resume + + keys = iter([" ", " "]) # pause, resume + + def fake_read_key(): + return next(keys) + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["3s"]) + assert result.exit_code == 0 + + # Despite pausing, all countdown seconds should still be displayed + assert 3 in displayed_times + assert 2 in displayed_times + assert 1 in displayed_times + + +def test_add_time_extends_deadline(runner, monkeypatch): + """Pressing + should extend the countdown deadline by 30 seconds.""" + monkeypatch.setattr( + "countdown.display.get_terminal_size", + fake_size(40, 20), + ) + + clock = FakeClock(raises={5: KeyboardInterrupt()}) + patch_clock(monkeypatch, clock) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + # Press + on first display + def fake_check_for_keypress(): + return len(displayed_times) == 1 + + def fake_read_key(): + return "+" + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["10s"]) + assert result.exit_code == 0 + + # After pressing + on display of 10, n jumps to 40 (10+30) + assert displayed_times[0] == 10 + assert 40 in displayed_times + # Timer should count down from 40 (not restart from 10) + idx_40 = displayed_times.index(40) + assert displayed_times[idx_40 + 1] == 39 + + +def test_subtract_time_shortens_deadline(runner, monkeypatch): + """Pressing - should shorten the countdown deadline by 30 seconds.""" + monkeypatch.setattr( + "countdown.display.get_terminal_size", + fake_size(40, 20), + ) + + clock = FakeClock() + patch_clock(monkeypatch, clock) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + # Press - on first display + def fake_check_for_keypress(): + return len(displayed_times) == 1 + + def fake_read_key(): + return "-" + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["1m"]) + assert result.exit_code == 0 + + # After pressing - on display of 60, n drops to 30 (60-30) + assert displayed_times[0] == 60 + assert 30 in displayed_times + # Timer should end at 0 after counting down ~30 seconds (not ~60) + assert displayed_times[-1] == 0 + # Total seconds displayed should be ~31 (30 down to 0, not 60 down to 0) + assert len([t for t in displayed_times if t <= 30]) < 35 From edbff72b02fb2ef56a05bb9b2f3e274ae7715cc4 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 27 Mar 2026 20:49:36 -0700 Subject: [PATCH 4/6] Use fixtures for helpers and move drift tests --- tests/conftest.py | 69 +++++++ tests/test_drift.py | 221 +++++++++++++++++++++ tests/test_main.py | 470 ++++++++------------------------------------ 3 files changed, 377 insertions(+), 383 deletions(-) create mode 100644 tests/test_drift.py diff --git a/tests/conftest.py b/tests/conftest.py index 8b93d40..0979c47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,10 @@ """PyTest configuration.""" +import os + +import pytest from _pytest.assertion import truncate +from click.testing import CliRunner truncate.DEFAULT_MAX_LINES = 40 truncate.DEFAULT_MAX_CHARS = 40 * 80 @@ -27,3 +31,68 @@ def pytest_assertrepr_compare( f"Repr Comparison: {left!r} != {right!r}", ] return None + + +class FakeClock: + """Fake time.time() and time.sleep() that advance together. + + Since run_countdown uses time() for loop control and sleep() for pacing, + both must be faked in sync to avoid tests running in real time. + """ + + def __init__(self, *, raises={}, drift_per_sleep=0.0): # noqa: B006 + self.start = 1_000_000.0 + self.current = self.start + self.slept = 0 + self.raises = dict(raises) + self.drift_per_sleep = drift_per_sleep + + @property + def elapsed(self): + """Total wall clock time elapsed (including any drift).""" + return self.current - self.start + + def time(self): + return self.current + + def sleep(self, seconds): + self.current += seconds + self.drift_per_sleep + self.slept += seconds + # Check for exception with floating point tolerance + for trigger_time, exception in self.raises.items(): + if abs(self.slept - trigger_time) < 0.001: + raise exception + + +@pytest.fixture +def fake_clock(monkeypatch): + """Fixture that patches time/sleep with a FakeClock. + + Returns the clock instance. Set attributes like ``raises`` or + ``drift_per_sleep`` before invoking the CLI to customize behavior. + """ + clock = FakeClock() + monkeypatch.setattr("countdown.__main__.sleep", clock.sleep) + monkeypatch.setattr("countdown.__main__.time", clock.time) + return clock + + +@pytest.fixture +def fake_terminal_size(monkeypatch): + """Factory fixture: sets a fake terminal size for display calculations.""" + + def _set_size(columns, lines): + def get_terminal_size(fallback=(columns, lines)): + return os.terminal_size(fallback) + + monkeypatch.setattr( + "countdown.display.get_terminal_size", get_terminal_size + ) + + return _set_size + + +@pytest.fixture +def runner(): + """Fixture for invoking command-line interfaces.""" + return CliRunner() diff --git a/tests/test_drift.py b/tests/test_drift.py new file mode 100644 index 0000000..f84e993 --- /dev/null +++ b/tests/test_drift.py @@ -0,0 +1,221 @@ +"""Tests for drift correction in the countdown timer.""" + +from countdown import __main__ + + +def test_countdown_displays_each_second( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): + """Test that a 5-second countdown displays each second value.""" + fake_terminal_size(40, 20) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + result = runner.invoke(__main__.main, ["5s"]) + assert result.exit_code == 0 + assert displayed_times == [5, 4, 3, 2, 1, 0] + + +def test_drift_correction_with_slow_sleeps( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): + """With drift, the timer still counts down the right number of seconds. + + Each 0.05s sleep takes 0.06s (simulating OS scheduling overhead). + Without drift correction, this would make the countdown run 20% too long. + With drift correction, each second still advances based on wall clock time. + """ + fake_terminal_size(40, 20) + fake_clock.drift_per_sleep = 0.01 + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + result = runner.invoke(__main__.main, ["5s"]) + assert result.exit_code == 0 + + # Even with drift, we should still display 5 seconds counting down + assert displayed_times == [5, 4, 3, 2, 1, 0] + + +def test_drift_correction_skips_seconds_when_very_slow( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): + """Simulate extreme drift. + + With extreme drift, individual seconds may be skipped but total time + is still bounded by wall clock time, not by sleep iteration count. + """ + fake_terminal_size(40, 20) + # Each 0.05s sleep takes 0.55s (extreme drift: 10x) + fake_clock.drift_per_sleep = 0.5 + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + result = runner.invoke(__main__.main, ["60m"]) + assert result.exit_code == 0 + + # Timer should still start at 60m and count down + assert displayed_times[0] == 60 * 60 + # Each second is displayed for fewer sleep iterations due to drift, + # but the total still counts down correctly (each displayed value + # is less than the one before) + for i in range(1, len(displayed_times)): + assert displayed_times[i] < displayed_times[i - 1] + + +def test_pause_preserves_remaining_time( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): + """Pausing and resuming should not consume countdown time.""" + fake_terminal_size(40, 20) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + # Pause on first display, then resume after 3 checks while paused + keypress_count = [0] + + def fake_check_for_keypress(): + keypress_count[0] += 1 + return keypress_count[0] in [1, 5] # pause, then resume + + keys = iter([" ", " "]) # pause, resume + + def fake_read_key(): + return next(keys) + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["3s"]) + assert result.exit_code == 0 + + # Despite pausing, all countdown seconds should still be displayed + assert 3 in displayed_times + assert 2 in displayed_times + assert 1 in displayed_times + + +def test_add_time_extends_deadline( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): + """Pressing + should extend the countdown deadline by 30 seconds.""" + fake_terminal_size(40, 20) + fake_clock.raises = {5: KeyboardInterrupt()} + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + # Press + on first display + def fake_check_for_keypress(): + return len(displayed_times) == 1 + + def fake_read_key(): + return "+" + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["10s"]) + assert result.exit_code == 0 + + # After pressing + on display of 10, n jumps to 40 (10+30) + assert displayed_times[0] == 10 + assert 40 in displayed_times + # Timer should count down from 40 (not restart from 10) + idx_40 = displayed_times.index(40) + assert displayed_times[idx_40 + 1] == 39 + + +def test_subtract_time_shortens_deadline( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): + """Pressing - should shorten the countdown deadline by 30 seconds.""" + fake_terminal_size(40, 20) + + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def tracking_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + # Press - on first display + def fake_check_for_keypress(): + return len(displayed_times) == 1 + + def fake_read_key(): + return "-" + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["1m"]) + assert result.exit_code == 0 + + # After pressing - on display of 60, n drops to 30 (60-30) + assert displayed_times[0] == 60 + assert 30 in displayed_times + # Timer should end at 0 after counting down ~30 seconds (not ~60) + assert displayed_times[-1] == 0 + # Total seconds displayed should be ~31 (30 down to 0, not 60 down to 0) + assert len([t for t in displayed_times if t <= 30]) < 35 diff --git a/tests/test_main.py b/tests/test_main.py index 68d4e8a..a005aa9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,58 +1,12 @@ """Integration test cases for the CLI.""" -import os import re import pytest -from click.testing import CliRunner from countdown import __main__ -class FakeClock: - """Fake time.time() and time.sleep() that advance together. - - Since run_countdown uses time() for loop control and sleep() for pacing, - both must be faked in sync to avoid tests running in real time. - """ - - def __init__(self, *, raises={}, drift_per_sleep=0.0): # noqa: B006 - self.start = 1_000_000.0 - self.current = self.start - self.slept = 0 - self.raises = dict(raises) - self.drift_per_sleep = drift_per_sleep - - @property - def elapsed(self): - """Total wall clock time elapsed (including any drift).""" - return self.current - self.start - - def time(self): - return self.current - - def sleep(self, seconds): - self.current += seconds + self.drift_per_sleep - self.slept += seconds - # Check for exception with floating point tolerance - for trigger_time, exception in self.raises.items(): - if abs(self.slept - trigger_time) < 0.001: - raise exception - - -def patch_clock(monkeypatch, clock): - """Monkeypatch both time and sleep to use the given FakeClock.""" - monkeypatch.setattr("countdown.__main__.sleep", clock.sleep) - monkeypatch.setattr("countdown.__main__.time", clock.time) - - -def fake_size(columns, lines): - def get_terminal_size(fallback=(columns, lines)): - return os.terminal_size(fallback) - - return get_terminal_size - - def clean_main_output(output): """Remove ANSI escape codes and whitespace at ends of lines.""" output = re.sub(r"\033\[(\?\d+[hl]|[HJ])", "", output) @@ -60,12 +14,6 @@ def clean_main_output(output): return output -@pytest.fixture -def runner(): - """Fixture for invoking command-line interfaces.""" - return CliRunner() - - def test_main_with_no_arguments(runner): """It shows help when run without arguments.""" result = runner.invoke(__main__.main) @@ -83,14 +31,9 @@ def test_version_works(runner): assert result.exit_code == 0 -def test_main_3_seconds(runner, monkeypatch): +def test_main_3_seconds(runner, fake_terminal_size, fake_clock): # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h+2 <= 20) - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - clock = FakeClock() - patch_clock(monkeypatch, clock) + fake_terminal_size(40, 20) result = runner.invoke(__main__.main, ["3s"]) assert result.exit_code == 0 assert clean_main_output(result.stdout) == ( @@ -120,20 +63,16 @@ def test_main_3_seconds(runner, monkeypatch): " ██████ ██████ ██████ ██████ " ) # 3 seconds + 1 to display 00:00, each sleeping ~1 second - assert clock.slept == pytest.approx(3 + 1, abs=0.01) - assert clock.elapsed == pytest.approx(3 + 1, abs=0.01) + assert fake_clock.slept == pytest.approx(3 + 1, abs=0.01) + assert fake_clock.elapsed == pytest.approx(3 + 1, abs=0.01) -def test_main_1_minute(runner, monkeypatch): +def test_main_1_minute(runner, fake_terminal_size, fake_clock): # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 10), - ) + fake_terminal_size(40, 10) # Raise exception after 11 seconds of fake sleep - clock = FakeClock(raises={11: SystemExit(0)}) - patch_clock(monkeypatch, clock) + fake_clock.raises = {11: SystemExit(0)} result = runner.invoke(__main__.main, ["1m"]) assert clean_main_output(result.stdout) == ( @@ -206,54 +145,49 @@ def test_main_1_minute(runner, monkeypatch): ) -def test_main_10_minutes_has_600_clear_screens(runner, monkeypatch): - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(32, 10), - ) - clock = FakeClock() - patch_clock(monkeypatch, clock) +def test_main_10_minutes_has_600_clear_screens( + runner, + fake_terminal_size, + fake_clock, +): + fake_terminal_size(32, 10) result = runner.invoke(__main__.main, ["10m"]) # 10 minutes = 600 seconds + 1 to display 00:00 - assert clock.slept == pytest.approx(10 * 60 + 1, abs=0.1) - assert clock.elapsed == pytest.approx(10 * 60 + 1, abs=0.1) + assert fake_clock.slept == pytest.approx(10 * 60 + 1, abs=0.1) + assert fake_clock.elapsed == pytest.approx(10 * 60 + 1, abs=0.1) assert result.stdout.count("\033[H\033[J") == 10 * 60 + 1 def test_main_enables_alt_buffer_and_hides_cursor_at_beginning( - runner, monkeypatch + runner, + fake_terminal_size, + fake_clock, ): - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(32, 10), - ) - clock = FakeClock() - patch_clock(monkeypatch, clock) + fake_terminal_size(32, 10) result = runner.invoke(__main__.main, ["5m"]) assert result.stdout.startswith("\033[?1049h\033[?25l") -def test_main_disable_alt_buffer_and_show_cursor_at_end(runner, monkeypatch): - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(32, 10), - ) - clock = FakeClock() - patch_clock(monkeypatch, clock) +def test_main_disable_alt_buffer_and_show_cursor_at_end( + runner, + fake_terminal_size, + fake_clock, +): + fake_terminal_size(32, 10) result = runner.invoke(__main__.main, ["5m"]) 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, + fake_terminal_size, + fake_clock, +): # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 10), - ) + fake_terminal_size(40, 10) # Hit Ctrl+C after 4 seconds total sleep time (chunked sleep) - clock = FakeClock(raises={4: KeyboardInterrupt()}) - patch_clock(monkeypatch, clock) + fake_clock.raises = {4: KeyboardInterrupt()} result = runner.invoke(__main__.main, ["15m"]) # 4 seconds of sleep = 4 iterations, each printing 5 lines + 1 padding line @@ -263,16 +197,17 @@ def test_main_early_exit_still_shows_cursor_at_end(runner, monkeypatch): assert result.stdout.endswith("\033[?25h\033[?1049l") -def test_pause_key_triggers_pause(runner, monkeypatch): +def test_pause_key_triggers_pause( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): """Test that pressing a pause key triggers the pause logic.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) + fake_terminal_size(40, 20) # Exit after a short time - clock = FakeClock(raises={1: KeyboardInterrupt()}) - patch_clock(monkeypatch, clock) + fake_clock.raises = {1: KeyboardInterrupt()} # Track whether pause key was detected pause_key_detected = [False] @@ -307,15 +242,15 @@ def fake_drain(): ) -def test_non_pause_key_ignored(runner, monkeypatch): +def test_non_pause_key_ignored( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): """Test that non-pause keys are ignored during countdown.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - - clock = FakeClock(raises={1: KeyboardInterrupt()}) - patch_clock(monkeypatch, clock) + fake_terminal_size(40, 20) + fake_clock.raises = {1: KeyboardInterrupt()} # Track keypresses check_called = [False] @@ -345,18 +280,18 @@ def fake_read_key(): assert result.exit_code == 0 -def test_sleep_exits_early_on_keypress(runner, monkeypatch): +def test_sleep_exits_early_on_keypress( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): """Test that sleep loop exits early when a key is pressed mid-sleep.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) + fake_terminal_size(40, 20) # Track sleep calls and use FakeClock for time control - clock = FakeClock() - patch_clock(monkeypatch, clock) sleep_calls = [] - original_sleep = clock.sleep + original_sleep = fake_clock.sleep def tracking_sleep(seconds): sleep_calls.append(seconds) @@ -392,18 +327,18 @@ def fake_drain(): ) -def test_resume_from_pause_exits_early(runner, monkeypatch): +def test_resume_from_pause_exits_early( + runner, + fake_terminal_size, + fake_clock, + monkeypatch, +): """Test that when paused, pressing a key to resume exits the 0.05s sleep loop.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) + fake_terminal_size(40, 20) - clock = FakeClock() - patch_clock(monkeypatch, clock) sleep_calls = [] paused_state = [False] - original_sleep = clock.sleep + original_sleep = fake_clock.sleep def tracking_sleep(seconds): sleep_calls.append((seconds, paused_state[0])) @@ -456,15 +391,12 @@ def tracking_print(lines, paused=False): assert len(unpaused_sleeps) > 0, "Should have some unpaused sleep periods" -def test_add_time_with_plus_key(runner, monkeypatch): +def test_add_time_with_plus_key( + runner, fake_terminal_size, fake_clock, monkeypatch +): """Test that pressing + adds 30 seconds to the timer.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - - clock = FakeClock(raises={1: KeyboardInterrupt()}) - patch_clock(monkeypatch, clock) + fake_terminal_size(40, 20) + fake_clock.raises = {1: KeyboardInterrupt()} # Track the displayed times displayed_times = [] @@ -497,15 +429,15 @@ 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, + fake_terminal_size, + fake_clock, + monkeypatch, +): """Test that pressing - subtracts 30 seconds from the timer.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - - clock = FakeClock(raises={1: KeyboardInterrupt()}) - patch_clock(monkeypatch, clock) + fake_terminal_size(40, 20) + fake_clock.raises = {1: KeyboardInterrupt()} # Track the displayed times displayed_times = [] @@ -538,15 +470,15 @@ 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, + fake_terminal_size, + fake_clock, + monkeypatch, +): """Test that subtracting time stops at 0 (cannot go negative).""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - - clock = FakeClock(raises={1: KeyboardInterrupt()}) - patch_clock(monkeypatch, clock) + fake_terminal_size(40, 20) + fake_clock.raises = {1: KeyboardInterrupt()} # Track the displayed times displayed_times = [] @@ -584,16 +516,9 @@ def fake_drain(): ) -def test_q_key_quits_timer(runner, monkeypatch): +def test_q_key_quits_timer(runner, fake_terminal_size, fake_clock, monkeypatch): """Test that pressing 'q' exits the timer.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - - clock = FakeClock() - patch_clock(monkeypatch, clock) - + fake_terminal_size(40, 20) keypress_count = [0] def fake_check_for_keypress(): @@ -626,224 +551,3 @@ def test_no_arguments_shows_help(runner): assert "DURATION" in result.output # Should show examples assert "5m" in result.output or "Examples" in result.output - - -# --- Tests for drift-fix behavior --- - - -def test_countdown_displays_each_second(runner, monkeypatch): - """Test that a 5-second countdown displays each second value.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - clock = FakeClock() - patch_clock(monkeypatch, clock) - - displayed_times = [] - original_get_number_lines = __main__.get_number_lines - - def tracking_get_number_lines(seconds): - displayed_times.append(seconds) - return original_get_number_lines(seconds) - - monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) - result = runner.invoke(__main__.main, ["5s"]) - assert result.exit_code == 0 - assert displayed_times == [5, 4, 3, 2, 1, 0] - - -def test_drift_correction_with_slow_sleeps(runner, monkeypatch): - """With drift, the timer still counts down the right number of seconds. - - Each 0.05s sleep takes 0.06s (simulating OS scheduling overhead). - Without drift correction, this would make the countdown run 20% too long. - With drift correction, each second still advances based on wall clock time. - """ - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - clock = FakeClock(drift_per_sleep=0.01) - patch_clock(monkeypatch, clock) - - displayed_times = [] - original_get_number_lines = __main__.get_number_lines - - def tracking_get_number_lines(seconds): - displayed_times.append(seconds) - return original_get_number_lines(seconds) - - monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) - result = runner.invoke(__main__.main, ["5s"]) - assert result.exit_code == 0 - - # Even with drift, we should still display 5 seconds counting down - assert displayed_times == [5, 4, 3, 2, 1, 0] - - -def test_drift_correction_skips_seconds_when_very_slow(runner, monkeypatch): - """Simulate extreme drift. - - With extreme drift, individual seconds may be skipped but total time - is still bounded by wall clock time, not by sleep iteration count. - """ - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - # Each 0.05s sleep takes 0.55s (extreme drift: 10x) - clock = FakeClock(drift_per_sleep=0.5) - patch_clock(monkeypatch, clock) - - displayed_times = [] - original_get_number_lines = __main__.get_number_lines - - def tracking_get_number_lines(seconds): - displayed_times.append(seconds) - return original_get_number_lines(seconds) - - monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) - result = runner.invoke(__main__.main, ["60m"]) - assert result.exit_code == 0 - - # Timer should still start at 60m and count down - assert displayed_times[0] == 60 * 60 - # Each second is displayed for fewer sleep iterations due to drift, - # but the total still counts down correctly (each displayed value - # is less than the one before) - for i in range(1, len(displayed_times)): - assert displayed_times[i] < displayed_times[i - 1] - - -def test_pause_preserves_remaining_time(runner, monkeypatch): - """Pausing and resuming should not consume countdown time.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - - clock = FakeClock() - patch_clock(monkeypatch, clock) - - displayed_times = [] - original_get_number_lines = __main__.get_number_lines - - def tracking_get_number_lines(seconds): - displayed_times.append(seconds) - return original_get_number_lines(seconds) - - # Pause on first display, then resume after 3 checks while paused - keypress_count = [0] - - def fake_check_for_keypress(): - keypress_count[0] += 1 - return keypress_count[0] in [1, 5] # pause, then resume - - keys = iter([" ", " "]) # pause, resume - - def fake_read_key(): - return next(keys) - - def fake_drain(): - pass - - monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) - monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) - monkeypatch.setattr(__main__, "read_key", fake_read_key) - monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) - - result = runner.invoke(__main__.main, ["3s"]) - assert result.exit_code == 0 - - # Despite pausing, all countdown seconds should still be displayed - assert 3 in displayed_times - assert 2 in displayed_times - assert 1 in displayed_times - - -def test_add_time_extends_deadline(runner, monkeypatch): - """Pressing + should extend the countdown deadline by 30 seconds.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - - clock = FakeClock(raises={5: KeyboardInterrupt()}) - patch_clock(monkeypatch, clock) - - displayed_times = [] - original_get_number_lines = __main__.get_number_lines - - def tracking_get_number_lines(seconds): - displayed_times.append(seconds) - return original_get_number_lines(seconds) - - # Press + on first display - def fake_check_for_keypress(): - return len(displayed_times) == 1 - - def fake_read_key(): - return "+" - - def fake_drain(): - pass - - monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) - monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) - monkeypatch.setattr(__main__, "read_key", fake_read_key) - monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) - - result = runner.invoke(__main__.main, ["10s"]) - assert result.exit_code == 0 - - # After pressing + on display of 10, n jumps to 40 (10+30) - assert displayed_times[0] == 10 - assert 40 in displayed_times - # Timer should count down from 40 (not restart from 10) - idx_40 = displayed_times.index(40) - assert displayed_times[idx_40 + 1] == 39 - - -def test_subtract_time_shortens_deadline(runner, monkeypatch): - """Pressing - should shorten the countdown deadline by 30 seconds.""" - monkeypatch.setattr( - "countdown.display.get_terminal_size", - fake_size(40, 20), - ) - - clock = FakeClock() - patch_clock(monkeypatch, clock) - - displayed_times = [] - original_get_number_lines = __main__.get_number_lines - - def tracking_get_number_lines(seconds): - displayed_times.append(seconds) - return original_get_number_lines(seconds) - - # Press - on first display - def fake_check_for_keypress(): - return len(displayed_times) == 1 - - def fake_read_key(): - return "-" - - def fake_drain(): - pass - - monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) - monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) - monkeypatch.setattr(__main__, "read_key", fake_read_key) - monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) - - result = runner.invoke(__main__.main, ["1m"]) - assert result.exit_code == 0 - - # After pressing - on display of 60, n drops to 30 (60-30) - assert displayed_times[0] == 60 - assert 30 in displayed_times - # Timer should end at 0 after counting down ~30 seconds (not ~60) - assert displayed_times[-1] == 0 - # Total seconds displayed should be ~31 (30 down to 0, not 60 down to 0) - assert len([t for t in displayed_times if t <= 30]) < 35 From 7c6dcac0c53757eeae9d25ba4dfd61f9db166691 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 27 Mar 2026 22:15:22 -0700 Subject: [PATCH 5/6] Fix sleep_until desync on time subtraction and use None for pause_start When subtracting time clamped n to 0, sleep_until was adjusted by the full 30 seconds instead of the actual amount subtracted. Now sleep_until is only adjusted by the actual change to n. Also use None instead of 0 as the pause_start sentinel for clarity. --- src/countdown/__main__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/countdown/__main__.py b/src/countdown/__main__.py index 2af3460..e1edbeb 100644 --- a/src/countdown/__main__.py +++ b/src/countdown/__main__.py @@ -42,7 +42,7 @@ def run_countdown(total_seconds): paused = False n = total_seconds sleep_until = time() + total_seconds - pause_start = 0 + pause_start = None while n >= 0 or paused: lines = get_number_lines(n) print_full_screen(lines, paused=paused) @@ -57,7 +57,7 @@ def run_countdown(total_seconds): elif is_pause_key(key): if paused: sleep_until += time() - pause_start - pause_start = 0 + pause_start = None else: pause_start = time() paused = not paused @@ -67,14 +67,16 @@ def run_countdown(total_seconds): elif is_time_adjust_key(key): # Adjust the timer by +/- 30 seconds adjustment = get_time_adjustment(key) - sleep_until += adjustment - n = max(0, n + adjustment) # Don't go below 0 + new_n = max(0, n + adjustment) # Don't go below 0 + sleep_until += new_n - n + n = new_n drain_keypresses() # Ignore any additional rapid keypresses lines = get_number_lines(n) print_full_screen(lines, paused=paused) # Only sleep and decrement if not paused if not paused: + # Wall-clock time at which to move from displaying n to n-1 display_this_second_until = sleep_until - n + 1 while time() < display_this_second_until: # Sleep in small chunks to check for keypresses more frequently From b2d89a86cc486f106c6a97151117757a23df7db3 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 27 Mar 2026 22:16:33 -0700 Subject: [PATCH 6/6] Fix inaccurate test comments and strengthen assertions - Fix wrong reasoning in line count comment (test_main.py) - Remove stale "20 chunks" and "chunked 1-second" references - Fix "10x" drift comment to show actual values - Fix "fewer sleep iterations" to "seconds are skipped entirely" - Assert extreme drift test ends at 0 - Assert pause test displays 0 --- tests/test_drift.py | 11 ++++++----- tests/test_main.py | 9 ++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_drift.py b/tests/test_drift.py index f84e993..5952aab 100644 --- a/tests/test_drift.py +++ b/tests/test_drift.py @@ -67,7 +67,7 @@ def test_drift_correction_skips_seconds_when_very_slow( is still bounded by wall clock time, not by sleep iteration count. """ fake_terminal_size(40, 20) - # Each 0.05s sleep takes 0.55s (extreme drift: 10x) + # Drift of 0.5s per 0.05s sleep call (each sleep takes 0.55s total) fake_clock.drift_per_sleep = 0.5 displayed_times = [] @@ -83,11 +83,11 @@ def tracking_get_number_lines(seconds): # Timer should still start at 60m and count down assert displayed_times[0] == 60 * 60 - # Each second is displayed for fewer sleep iterations due to drift, - # but the total still counts down correctly (each displayed value - # is less than the one before) + # With extreme drift, seconds are skipped entirely, but the countdown + # still proceeds monotonically downward for i in range(1, len(displayed_times)): assert displayed_times[i] < displayed_times[i - 1] + assert displayed_times[-1] == 0 def test_pause_preserves_remaining_time( @@ -106,7 +106,7 @@ def tracking_get_number_lines(seconds): displayed_times.append(seconds) return original_get_number_lines(seconds) - # Pause on first display, then resume after 3 checks while paused + # Pause on first check (count=1), resume on fifth check (count=5) keypress_count = [0] def fake_check_for_keypress(): @@ -133,6 +133,7 @@ def fake_drain(): assert 3 in displayed_times assert 2 in displayed_times assert 1 in displayed_times + assert 0 in displayed_times def test_add_time_extends_deadline( diff --git a/tests/test_main.py b/tests/test_main.py index a005aa9..0aa8b67 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -190,9 +190,8 @@ def test_main_early_exit_still_shows_cursor_at_end( fake_clock.raises = {4: KeyboardInterrupt()} result = runner.invoke(__main__.main, ["15m"]) - # 4 seconds of sleep = 4 iterations, each printing 5 lines + 1 padding line - # except the last which has no trailing padding = 4*6-1 = 23 lines, - # plus 2 lines vertical padding for 10-line terminal = 25 lines + # 4 iterations x 6 newlines each (2 padding + 4 between 5 content lines) + # = 24 newlines, no trailing newline (end=""), so splitlines() gives 25 assert len(result.stdout.splitlines()) == 25 assert result.stdout.endswith("\033[?25h\033[?1049l") @@ -301,7 +300,7 @@ def tracking_sleep(seconds): monkeypatch.setattr("countdown.__main__.sleep", tracking_sleep) - # Simulate keypress after 3rd sleep call (during chunked 1-second sleep) + # Simulate keypress after 3rd sleep call def fake_check_for_keypress(): # Return True on the 3rd sleep chunk to simulate keypress mid-sleep return len(sleep_calls) == 3 @@ -319,7 +318,7 @@ def fake_drain(): result = runner.invoke(__main__.main, ["10s"]) assert result.exit_code == 0, result.output - # Should have broken out of sleep loop early (not all 20 chunks) + # Should have broken out of sleep loop early assert len(sleep_calls) >= 3, "Should have at least 3 sleep calls" first_iteration_sleeps = [s for s in sleep_calls[:3] if s == 0.05] assert len(first_iteration_sleeps) == 3, (