Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/countdown/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Command-line interface."""

from time import sleep
from time import sleep, time

import click

Expand Down Expand Up @@ -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 = None
while n >= 0 or paused:
lines = get_number_lines(n)
print_full_screen(lines, paused=paused)

Expand All @@ -53,22 +55,31 @@ def run_countdown(total_seconds):
# Quit the timer
break
elif is_pause_key(key):
if paused:
sleep_until += time() - pause_start
pause_start = None
else:
pause_start = time()
paused = not paused
drain_keypresses() # Ignore any additional rapid keypresses
lines = get_number_lines(n)
print_full_screen(lines, paused=paused)
elif is_time_adjust_key(key):
# Adjust the timer by +/- 30 seconds
adjustment = get_time_adjustment(key)
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:
# Sleep in small chunks to check for keypresses more frequently
for _ in range(20): # 20 x 0.05 = 1 second
# 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
sleep(0.05)
if check_for_keypress():
break # Exit sleep early if key is pressed
Expand Down
69 changes: 69 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
222 changes: 222 additions & 0 deletions tests/test_drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"""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)
# Drift of 0.5s per 0.05s sleep call (each sleep takes 0.55s total)
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
# 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(
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 check (count=1), resume on fifth check (count=5)
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
assert 0 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
Loading
Loading