Coverage for tests/test_main.py: 100%

406 statements  

« prev     ^ index     » next       coverage.py v7.11.1, created at 2026-03-27 20:23 -0700

1"""Integration test cases for the CLI.""" 

2 

3import os 

4import re 

5 

6import pytest 

7from click.testing import CliRunner 

8 

9from countdown import __main__ 

10 

11 

12class FakeClock: 

13 """Fake time.time() and time.sleep() that advance together. 

14 

15 Since run_countdown uses time() for loop control and sleep() for pacing, 

16 both must be faked in sync to avoid tests running in real time. 

17 """ 

18 

19 def __init__(self, *, raises={}, drift_per_sleep=0.0): # noqa: B006 

20 self.start = 1_000_000.0 

21 self.current = self.start 

22 self.slept = 0 

23 self.raises = dict(raises) 

24 self.drift_per_sleep = drift_per_sleep 

25 

26 @property 

27 def elapsed(self): 

28 """Total wall clock time elapsed (including any drift).""" 

29 return self.current - self.start 

30 

31 def time(self): 

32 return self.current 

33 

34 def sleep(self, seconds): 

35 self.current += seconds + self.drift_per_sleep 

36 self.slept += seconds 

37 # Check for exception with floating point tolerance 

38 for trigger_time, exception in self.raises.items(): 

39 if abs(self.slept - trigger_time) < 0.001: 

40 raise exception 

41 

42 

43def patch_clock(monkeypatch, clock): 

44 """Monkeypatch both time and sleep to use the given FakeClock.""" 

45 monkeypatch.setattr("countdown.__main__.sleep", clock.sleep) 

46 monkeypatch.setattr("countdown.__main__.time", clock.time) 

47 

48 

49def fake_size(columns, lines): 

50 def get_terminal_size(fallback=(columns, lines)): 

51 return os.terminal_size(fallback) 

52 

53 return get_terminal_size 

54 

55 

56def clean_main_output(output): 

57 """Remove ANSI escape codes and whitespace at ends of lines.""" 

58 output = re.sub(r"\033\[(\?\d+[hl]|[HJ])", "", output) 

59 output = re.sub(r" *\n", "\n", output) 

60 return output 

61 

62 

63@pytest.fixture 

64def runner(): 

65 """Fixture for invoking command-line interfaces.""" 

66 return CliRunner() 

67 

68 

69def test_main_with_no_arguments(runner): 

70 """It shows help when run without arguments.""" 

71 result = runner.invoke(__main__.main) 

72 # Should show help (not error) 

73 assert result.exit_code == 0 

74 assert "Usage:" in result.output 

75 assert "DURATION" in result.output 

76 assert "5m" in result.output # Should show examples 

77 

78 

79def test_version_works(runner): 

80 """It can print the version.""" 

81 result = runner.invoke(__main__.main, ["--version"]) 

82 assert ", version" in result.stdout 

83 assert result.exit_code == 0 

84 

85 

86def test_main_3_seconds(runner, monkeypatch): 

87 # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h+2 <= 20) 

88 monkeypatch.setattr( 

89 "countdown.display.get_terminal_size", 

90 fake_size(40, 20), 

91 ) 

92 clock = FakeClock() 

93 patch_clock(monkeypatch, clock) 

94 result = runner.invoke(__main__.main, ["3s"]) 

95 assert result.exit_code == 0 

96 assert clean_main_output(result.stdout) == ( 

97 "\n\n\n\n\n\n\n" 

98 " ██████ ██████ ██████ ██████\n" 

99 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

100 " ██ ██ ██ ██ ██ ██ █████\n" 

101 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

102 " ██████ ██████ ██████ ██████\n" 

103 "\n\n\n\n\n\n" 

104 " ██████ ██████ ██████ ██████\n" 

105 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

106 " ██ ██ ██ ██ ██ ██ ██████\n" 

107 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

108 " ██████ ██████ ██████ ██████\n" 

109 "\n\n\n\n\n\n" 

110 " ██████ ██████ ██████ ██\n" 

111 " ██ ██ ██ ██ ██ ██ ██ ███\n" 

112 " ██ ██ ██ ██ ██ ██ ██\n" 

113 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

114 " ██████ ██████ ██████ ██\n" 

115 "\n\n\n\n\n\n" 

116 " ██████ ██████ ██████ ██████\n" 

117 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n" 

118 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

119 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n" 

120 " ██████ ██████ ██████ ██████ " 

121 ) 

122 # 3 seconds + 1 to display 00:00, each sleeping ~1 second 

123 assert clock.slept == pytest.approx(3 + 1, abs=0.01) 

124 assert clock.elapsed == pytest.approx(3 + 1, abs=0.01) 

125 

126 

127def test_main_1_minute(runner, monkeypatch): 

128 # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) 

129 monkeypatch.setattr( 

130 "countdown.display.get_terminal_size", 

131 fake_size(40, 10), 

132 ) 

133 

134 # Raise exception after 11 seconds of fake sleep 

135 clock = FakeClock(raises={11: SystemExit(0)}) 

136 patch_clock(monkeypatch, clock) 

137 

138 result = runner.invoke(__main__.main, ["1m"]) 

139 assert clean_main_output(result.stdout) == ( 

140 "\n\n" 

141 " ██████ ██ ██████ ██████\n" 

142 " ██ ██ ███ ██ ██ ██ ██ ██\n" 

143 " ██ ██ ██ ██ ██ ██ ██\n" 

144 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

145 " ██████ ██ ██████ ██████\n" 

146 "\n" 

147 " ██████ ██████ ██████ ██████\n" 

148 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

149 " ██ ██ ██ ██ ██████ ██████\n" 

150 " ██ ██ ██ ██ ██ ██ ██\n" 

151 " ██████ ██████ ██████ █████\n" 

152 "\n" 

153 " ██████ ██████ ██████ ████\n" 

154 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

155 " ██ ██ ██ ██ ██████ ████\n" 

156 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

157 " ██████ ██████ ██████ ████\n" 

158 "\n" 

159 " ██████ ██████ ██████ ██████\n" 

160 " ██ ██ ██ ██ ██ ██ ██\n" 

161 " ██ ██ ██ ██ ██████ ██\n" 

162 " ██ ██ ██ ██ ██ ██ ██\n" 

163 " ██████ ██████ ██████ ██\n" 

164 "\n" 

165 " ██████ ██████ ██████ ██████\n" 

166 " ██ ██ ██ ██ ██ ██ ██\n" 

167 " ██ ██ ██ ██ ██████ ██████\n" 

168 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

169 " ██████ ██████ ██████ ██████\n" 

170 "\n" 

171 " ██████ ██████ ██████ ██████\n" 

172 " ██ ██ ██ ██ ██ ██ ██\n" 

173 " ██ ██ ██ ██ ██████ ██████\n" 

174 " ██ ██ ██ ██ ██ ██ ██\n" 

175 " ██████ ██████ ██████ ██████\n" 

176 "\n" 

177 " ██████ ██████ ██████ ██ ██\n" 

178 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

179 " ██ ██ ██ ██ ██████ ██████\n" 

180 " ██ ██ ██ ██ ██ ██ ██\n" 

181 " ██████ ██████ ██████ ██\n" 

182 "\n" 

183 " ██████ ██████ ██████ ██████\n" 

184 " ██ ██ ██ ██ ██ ██ ██\n" 

185 " ██ ██ ██ ██ ██████ █████\n" 

186 " ██ ██ ██ ██ ██ ██ ██\n" 

187 " ██████ ██████ ██████ ██████\n" 

188 "\n" 

189 " ██████ ██████ ██████ ██████\n" 

190 " ██ ██ ██ ██ ██ ██ ██\n" 

191 " ██ ██ ██ ██ ██████ ██████\n" 

192 " ██ ██ ██ ██ ██ ██ ██\n" 

193 " ██████ ██████ ██████ ██████\n" 

194 "\n" 

195 " ██████ ██████ ██████ ██\n" 

196 " ██ ██ ██ ██ ██ ██ ███\n" 

197 " ██ ██ ██ ██ ██████ ██\n" 

198 " ██ ██ ██ ██ ██ ██ ██\n" 

199 " ██████ ██████ ██████ ██\n" 

200 "\n" 

201 " ██████ ██████ ██████ ██████\n" 

202 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

203 " ██ ██ ██ ██ ██████ ██ ██\n" 

204 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

205 " ██████ ██████ ██████ ██████ " 

206 ) 

207 

208 

209def test_main_10_minutes_has_600_clear_screens(runner, monkeypatch): 

210 monkeypatch.setattr( 

211 "countdown.display.get_terminal_size", 

212 fake_size(32, 10), 

213 ) 

214 clock = FakeClock() 

215 patch_clock(monkeypatch, clock) 

216 result = runner.invoke(__main__.main, ["10m"]) 

217 # 10 minutes = 600 seconds + 1 to display 00:00 

218 assert clock.slept == pytest.approx(10 * 60 + 1, abs=0.1) 

219 assert clock.elapsed == pytest.approx(10 * 60 + 1, abs=0.1) 

220 assert result.stdout.count("\033[H\033[J") == 10 * 60 + 1 

221 

222 

223def test_main_enables_alt_buffer_and_hides_cursor_at_beginning( 

224 runner, monkeypatch 

225): 

226 monkeypatch.setattr( 

227 "countdown.display.get_terminal_size", 

228 fake_size(32, 10), 

229 ) 

230 clock = FakeClock() 

231 patch_clock(monkeypatch, clock) 

232 result = runner.invoke(__main__.main, ["5m"]) 

233 assert result.stdout.startswith("\033[?1049h\033[?25l") 

234 

235 

236def test_main_disable_alt_buffer_and_show_cursor_at_end(runner, monkeypatch): 

237 monkeypatch.setattr( 

238 "countdown.display.get_terminal_size", 

239 fake_size(32, 10), 

240 ) 

241 clock = FakeClock() 

242 patch_clock(monkeypatch, clock) 

243 result = runner.invoke(__main__.main, ["5m"]) 

244 assert result.stdout.endswith("\033[?25h\033[?1049l") 

245 

246 

247def test_main_early_exit_still_shows_cursor_at_end(runner, monkeypatch): 

248 # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) 

249 monkeypatch.setattr( 

250 "countdown.display.get_terminal_size", 

251 fake_size(40, 10), 

252 ) 

253 

254 # Hit Ctrl+C after 4 seconds total sleep time (chunked sleep) 

255 clock = FakeClock(raises={4: KeyboardInterrupt()}) 

256 patch_clock(monkeypatch, clock) 

257 

258 result = runner.invoke(__main__.main, ["15m"]) 

259 # 4 seconds of sleep = 4 iterations, each printing 5 lines + 1 padding line 

260 # except the last which has no trailing padding = 4*6-1 = 23 lines, 

261 # plus 2 lines vertical padding for 10-line terminal = 25 lines 

262 assert len(result.stdout.splitlines()) == 25 

263 assert result.stdout.endswith("\033[?25h\033[?1049l") 

264 

265 

266def test_pause_key_triggers_pause(runner, monkeypatch): 

267 """Test that pressing a pause key triggers the pause logic.""" 

268 monkeypatch.setattr( 

269 "countdown.display.get_terminal_size", 

270 fake_size(40, 20), 

271 ) 

272 

273 # Exit after a short time 

274 clock = FakeClock(raises={1: KeyboardInterrupt()}) 

275 patch_clock(monkeypatch, clock) 

276 

277 # Track whether pause key was detected 

278 pause_key_detected = [False] 

279 read_key_called = [False] 

280 

281 def fake_check_for_keypress(): 

282 # Return True once to simulate a keypress during first iteration 

283 if not pause_key_detected[0]: 

284 pause_key_detected[0] = True 

285 return True 

286 return False 

287 

288 def fake_read_key(): 

289 read_key_called[0] = True 

290 return " " # Space bar (a pause key) 

291 

292 def fake_drain(): 

293 pass # No additional keys to drain 

294 

295 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

296 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

297 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

298 

299 result = runner.invoke(__main__.main, ["5s"]) 

300 

301 # The pause key should have been detected and read 

302 assert pause_key_detected[0], "Pause key detection should have been called" 

303 assert read_key_called[0], "read_key should have been called" 

304 # Output should contain the paused color since we pressed a pause key 

305 assert "\x1b[95m" in result.stdout, ( 

306 "Should show paused color when pause key pressed" 

307 ) 

308 

309 

310def test_non_pause_key_ignored(runner, monkeypatch): 

311 """Test that non-pause keys are ignored during countdown.""" 

312 monkeypatch.setattr( 

313 "countdown.display.get_terminal_size", 

314 fake_size(40, 20), 

315 ) 

316 

317 clock = FakeClock(raises={1: KeyboardInterrupt()}) 

318 patch_clock(monkeypatch, clock) 

319 

320 # Track keypresses 

321 check_called = [False] 

322 read_key_called = [False] 

323 

324 def fake_check_for_keypress(): 

325 if not check_called[0]: 

326 check_called[0] = True 

327 return True 

328 return False 

329 

330 def fake_read_key(): 

331 read_key_called[0] = True 

332 return "x" # Not a pause key 

333 

334 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

335 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

336 

337 result = runner.invoke(__main__.main, ["5s"]) 

338 

339 # The key should have been read 

340 assert read_key_called[0], "read_key should have been called" 

341 # Output should NOT contain paused color since 'x' is not a pause key 

342 assert "\x1b[95m" not in result.stdout, ( 

343 "Should not show paused color for non-pause key" 

344 ) 

345 assert result.exit_code == 0 

346 

347 

348def test_sleep_exits_early_on_keypress(runner, monkeypatch): 

349 """Test that sleep loop exits early when a key is pressed mid-sleep.""" 

350 monkeypatch.setattr( 

351 "countdown.display.get_terminal_size", 

352 fake_size(40, 20), 

353 ) 

354 

355 # Track sleep calls and use FakeClock for time control 

356 clock = FakeClock() 

357 patch_clock(monkeypatch, clock) 

358 sleep_calls = [] 

359 original_sleep = clock.sleep 

360 

361 def tracking_sleep(seconds): 

362 sleep_calls.append(seconds) 

363 original_sleep(seconds) 

364 if len(sleep_calls) >= 5: 

365 raise KeyboardInterrupt() 

366 

367 monkeypatch.setattr("countdown.__main__.sleep", tracking_sleep) 

368 

369 # Simulate keypress after 3rd sleep call (during chunked 1-second sleep) 

370 def fake_check_for_keypress(): 

371 # Return True on the 3rd sleep chunk to simulate keypress mid-sleep 

372 return len(sleep_calls) == 3 

373 

374 def fake_read_key(): 

375 return " " # Pause key 

376 

377 def fake_drain(): 

378 pass 

379 

380 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

381 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

382 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

383 

384 result = runner.invoke(__main__.main, ["10s"]) 

385 assert result.exit_code == 0, result.output 

386 

387 # Should have broken out of sleep loop early (not all 20 chunks) 

388 assert len(sleep_calls) >= 3, "Should have at least 3 sleep calls" 

389 first_iteration_sleeps = [s for s in sleep_calls[:3] if s == 0.05] 

390 assert len(first_iteration_sleeps) == 3, ( 

391 "Should have 3 chunks of 0.05s before breaking" 

392 ) 

393 

394 

395def test_resume_from_pause_exits_early(runner, monkeypatch): 

396 """Test that when paused, pressing a key to resume exits the 0.05s sleep loop.""" 

397 monkeypatch.setattr( 

398 "countdown.display.get_terminal_size", 

399 fake_size(40, 20), 

400 ) 

401 

402 clock = FakeClock() 

403 patch_clock(monkeypatch, clock) 

404 sleep_calls = [] 

405 paused_state = [False] 

406 original_sleep = clock.sleep 

407 

408 def tracking_sleep(seconds): 

409 sleep_calls.append((seconds, paused_state[0])) 

410 original_sleep(seconds) 

411 if len(sleep_calls) >= 10: 

412 raise KeyboardInterrupt() 

413 

414 monkeypatch.setattr("countdown.__main__.sleep", tracking_sleep) 

415 

416 # Simulate: pause immediately, then resume after a few paused sleeps 

417 keypress_count = [0] 

418 

419 def fake_check_for_keypress(): 

420 keypress_count[0] += 1 

421 # First keypress: pause immediately (keypress 1) 

422 # Second keypress: resume after being paused (keypress 2) 

423 return keypress_count[0] in [1, 5] 

424 

425 keys_to_return = [" ", " "] # Space to pause, space to resume 

426 key_index = [0] 

427 

428 def fake_read_key(): 

429 key = keys_to_return[key_index[0]] 

430 key_index[0] = min(key_index[0] + 1, len(keys_to_return) - 1) 

431 return key 

432 

433 def fake_drain(): 

434 pass 

435 

436 # Track pause state transitions 

437 original_print = __main__.print_full_screen 

438 

439 def tracking_print(lines, paused=False): 

440 paused_state[0] = paused 

441 return original_print(lines, paused=paused) 

442 

443 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

444 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

445 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

446 monkeypatch.setattr(__main__, "print_full_screen", tracking_print) 

447 

448 result = runner.invoke(__main__.main, ["10s"]) 

449 assert result.exit_code == 0, result.output 

450 

451 # Should have some paused sleeps (0.05) and some regular chunked sleeps (0.05) 

452 paused_sleeps = [s for s, p in sleep_calls if p] 

453 unpaused_sleeps = [s for s, p in sleep_calls if not p] 

454 

455 assert len(paused_sleeps) > 0, "Should have some paused sleep periods" 

456 assert len(unpaused_sleeps) > 0, "Should have some unpaused sleep periods" 

457 

458 

459def test_add_time_with_plus_key(runner, monkeypatch): 

460 """Test that pressing + adds 30 seconds to the timer.""" 

461 monkeypatch.setattr( 

462 "countdown.display.get_terminal_size", 

463 fake_size(40, 20), 

464 ) 

465 

466 clock = FakeClock(raises={1: KeyboardInterrupt()}) 

467 patch_clock(monkeypatch, clock) 

468 

469 # Track the displayed times 

470 displayed_times = [] 

471 original_get_number_lines = __main__.get_number_lines 

472 

473 def fake_get_number_lines(seconds): 

474 displayed_times.append(seconds) 

475 return original_get_number_lines(seconds) 

476 

477 def fake_check_for_keypress(): 

478 # Return True once to simulate a keypress 

479 return len(displayed_times) == 1 

480 

481 def fake_read_key(): 

482 return "+" # Plus key to add time 

483 

484 def fake_drain(): 

485 pass 

486 

487 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines) 

488 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

489 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

490 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

491 

492 result = runner.invoke(__main__.main, ["1m"]) 

493 assert result.exit_code == 0, result.output 

494 

495 # Should have displayed 60s initially, then 90s after pressing + 

496 assert 60 in displayed_times, "Should display initial time of 60s" 

497 assert 90 in displayed_times, "Should display 90s after adding 30s" 

498 

499 

500def test_subtract_time_with_minus_key(runner, monkeypatch): 

501 """Test that pressing - subtracts 30 seconds from the timer.""" 

502 monkeypatch.setattr( 

503 "countdown.display.get_terminal_size", 

504 fake_size(40, 20), 

505 ) 

506 

507 clock = FakeClock(raises={1: KeyboardInterrupt()}) 

508 patch_clock(monkeypatch, clock) 

509 

510 # Track the displayed times 

511 displayed_times = [] 

512 original_get_number_lines = __main__.get_number_lines 

513 

514 def fake_get_number_lines(seconds): 

515 displayed_times.append(seconds) 

516 return original_get_number_lines(seconds) 

517 

518 def fake_check_for_keypress(): 

519 # Return True once to simulate a keypress 

520 return len(displayed_times) == 1 

521 

522 def fake_read_key(): 

523 return "-" # Minus key to subtract time 

524 

525 def fake_drain(): 

526 pass 

527 

528 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines) 

529 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

530 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

531 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

532 

533 result = runner.invoke(__main__.main, ["1m"]) 

534 assert result.exit_code == 0, result.output 

535 

536 # Should have displayed 60s initially, then 30s after pressing - 

537 assert 60 in displayed_times, "Should display initial time of 60s" 

538 assert 30 in displayed_times, "Should display 30s after subtracting 30s" 

539 

540 

541def test_subtract_time_cannot_go_negative(runner, monkeypatch): 

542 """Test that subtracting time stops at 0 (cannot go negative).""" 

543 monkeypatch.setattr( 

544 "countdown.display.get_terminal_size", 

545 fake_size(40, 20), 

546 ) 

547 

548 clock = FakeClock(raises={1: KeyboardInterrupt()}) 

549 patch_clock(monkeypatch, clock) 

550 

551 # Track the displayed times 

552 displayed_times = [] 

553 original_get_number_lines = __main__.get_number_lines 

554 

555 def fake_get_number_lines(seconds): 

556 displayed_times.append(seconds) 

557 return original_get_number_lines(seconds) 

558 

559 def fake_check_for_keypress(): 

560 # Return True once to simulate a keypress 

561 return len(displayed_times) == 1 

562 

563 def fake_read_key(): 

564 return "-" # Minus key to subtract time 

565 

566 def fake_drain(): 

567 pass 

568 

569 monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines) 

570 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

571 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

572 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

573 

574 result = runner.invoke(__main__.main, ["10s"]) 

575 assert result.exit_code == 0, result.output 

576 

577 # Should have displayed 10s initially, then 0s (not -20s) after pressing - 

578 assert 10 in displayed_times, "Should display initial time of 10s" 

579 assert 0 in displayed_times, ( 

580 "Should display 0s (not negative) after subtracting 30s" 

581 ) 

582 assert all(t >= 0 for t in displayed_times), ( 

583 "All displayed times should be non-negative" 

584 ) 

585 

586 

587def test_q_key_quits_timer(runner, monkeypatch): 

588 """Test that pressing 'q' exits the timer.""" 

589 monkeypatch.setattr( 

590 "countdown.display.get_terminal_size", 

591 fake_size(40, 20), 

592 ) 

593 

594 clock = FakeClock() 

595 patch_clock(monkeypatch, clock) 

596 

597 keypress_count = [0] 

598 

599 def fake_check_for_keypress(): 

600 keypress_count[0] += 1 

601 # Return True on first check to simulate pressing q 

602 return keypress_count[0] == 1 

603 

604 def fake_read_key(): 

605 return "q" # Press q to quit 

606 

607 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

608 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

609 

610 result = runner.invoke(__main__.main, ["10m"]) 

611 

612 # Should exit cleanly with code 0 

613 assert result.exit_code == 0 

614 # Should have shown cursor and disabled alt buffer on exit 

615 assert result.stdout.endswith("\033[?25h\033[?1049l") 

616 

617 

618def test_no_arguments_shows_help(runner): 

619 """Test that running without arguments shows help message.""" 

620 result = runner.invoke(__main__.main, []) 

621 

622 # Should exit with code 0 (not an error) 

623 assert result.exit_code == 0 

624 # Should show usage information 

625 assert "Usage:" in result.output 

626 assert "DURATION" in result.output 

627 # Should show examples 

628 assert "5m" in result.output or "Examples" in result.output 

629 

630 

631# --- Tests for drift-fix behavior --- 

632 

633 

634def test_countdown_displays_each_second(runner, monkeypatch): 

635 """Test that a 5-second countdown displays each second value.""" 

636 monkeypatch.setattr( 

637 "countdown.display.get_terminal_size", 

638 fake_size(40, 20), 

639 ) 

640 clock = FakeClock() 

641 patch_clock(monkeypatch, clock) 

642 

643 displayed_times = [] 

644 original_get_number_lines = __main__.get_number_lines 

645 

646 def tracking_get_number_lines(seconds): 

647 displayed_times.append(seconds) 

648 return original_get_number_lines(seconds) 

649 

650 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) 

651 result = runner.invoke(__main__.main, ["5s"]) 

652 assert result.exit_code == 0 

653 assert displayed_times == [5, 4, 3, 2, 1, 0] 

654 

655 

656def test_drift_correction_with_slow_sleeps(runner, monkeypatch): 

657 """With drift, the timer still counts down the right number of seconds. 

658 

659 Each 0.05s sleep takes 0.06s (simulating OS scheduling overhead). 

660 Without drift correction, this would make the countdown run 20% too long. 

661 With drift correction, each second still advances based on wall clock time. 

662 """ 

663 monkeypatch.setattr( 

664 "countdown.display.get_terminal_size", 

665 fake_size(40, 20), 

666 ) 

667 clock = FakeClock(drift_per_sleep=0.01) 

668 patch_clock(monkeypatch, clock) 

669 

670 displayed_times = [] 

671 original_get_number_lines = __main__.get_number_lines 

672 

673 def tracking_get_number_lines(seconds): 

674 displayed_times.append(seconds) 

675 return original_get_number_lines(seconds) 

676 

677 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) 

678 result = runner.invoke(__main__.main, ["5s"]) 

679 assert result.exit_code == 0 

680 

681 # Even with drift, we should still display 5 seconds counting down 

682 assert displayed_times == [5, 4, 3, 2, 1, 0] 

683 

684 

685def test_drift_correction_skips_seconds_when_very_slow(runner, monkeypatch): 

686 """Simulate extreme drift. 

687 

688 With extreme drift, individual seconds may be skipped but total time 

689 is still bounded by wall clock time, not by sleep iteration count. 

690 """ 

691 monkeypatch.setattr( 

692 "countdown.display.get_terminal_size", 

693 fake_size(40, 20), 

694 ) 

695 # Each 0.05s sleep takes 0.55s (extreme drift: 10x) 

696 clock = FakeClock(drift_per_sleep=0.5) 

697 patch_clock(monkeypatch, clock) 

698 

699 displayed_times = [] 

700 original_get_number_lines = __main__.get_number_lines 

701 

702 def tracking_get_number_lines(seconds): 

703 displayed_times.append(seconds) 

704 return original_get_number_lines(seconds) 

705 

706 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) 

707 result = runner.invoke(__main__.main, ["60m"]) 

708 assert result.exit_code == 0 

709 

710 # Timer should still start at 60m and count down 

711 assert displayed_times[0] == 60 * 60 

712 # Each second is displayed for fewer sleep iterations due to drift, 

713 # but the total still counts down correctly (each displayed value 

714 # is less than the one before) 

715 for i in range(1, len(displayed_times)): 

716 assert displayed_times[i] < displayed_times[i - 1] 

717 

718 

719def test_pause_preserves_remaining_time(runner, monkeypatch): 

720 """Pausing and resuming should not consume countdown time.""" 

721 monkeypatch.setattr( 

722 "countdown.display.get_terminal_size", 

723 fake_size(40, 20), 

724 ) 

725 

726 clock = FakeClock() 

727 patch_clock(monkeypatch, clock) 

728 

729 displayed_times = [] 

730 original_get_number_lines = __main__.get_number_lines 

731 

732 def tracking_get_number_lines(seconds): 

733 displayed_times.append(seconds) 

734 return original_get_number_lines(seconds) 

735 

736 # Pause on first display, then resume after 3 checks while paused 

737 keypress_count = [0] 

738 

739 def fake_check_for_keypress(): 

740 keypress_count[0] += 1 

741 return keypress_count[0] in [1, 5] # pause, then resume 

742 

743 keys = iter([" ", " "]) # pause, resume 

744 

745 def fake_read_key(): 

746 return next(keys) 

747 

748 def fake_drain(): 

749 pass 

750 

751 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) 

752 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

753 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

754 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

755 

756 result = runner.invoke(__main__.main, ["3s"]) 

757 assert result.exit_code == 0 

758 

759 # Despite pausing, all countdown seconds should still be displayed 

760 assert 3 in displayed_times 

761 assert 2 in displayed_times 

762 assert 1 in displayed_times 

763 

764 

765def test_add_time_extends_deadline(runner, monkeypatch): 

766 """Pressing + should extend the countdown deadline by 30 seconds.""" 

767 monkeypatch.setattr( 

768 "countdown.display.get_terminal_size", 

769 fake_size(40, 20), 

770 ) 

771 

772 clock = FakeClock(raises={5: KeyboardInterrupt()}) 

773 patch_clock(monkeypatch, clock) 

774 

775 displayed_times = [] 

776 original_get_number_lines = __main__.get_number_lines 

777 

778 def tracking_get_number_lines(seconds): 

779 displayed_times.append(seconds) 

780 return original_get_number_lines(seconds) 

781 

782 # Press + on first display 

783 def fake_check_for_keypress(): 

784 return len(displayed_times) == 1 

785 

786 def fake_read_key(): 

787 return "+" 

788 

789 def fake_drain(): 

790 pass 

791 

792 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) 

793 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

794 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

795 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

796 

797 result = runner.invoke(__main__.main, ["10s"]) 

798 assert result.exit_code == 0 

799 

800 # After pressing + on display of 10, n jumps to 40 (10+30) 

801 assert displayed_times[0] == 10 

802 assert 40 in displayed_times 

803 # Timer should count down from 40 (not restart from 10) 

804 idx_40 = displayed_times.index(40) 

805 assert displayed_times[idx_40 + 1] == 39 

806 

807 

808def test_subtract_time_shortens_deadline(runner, monkeypatch): 

809 """Pressing - should shorten the countdown deadline by 30 seconds.""" 

810 monkeypatch.setattr( 

811 "countdown.display.get_terminal_size", 

812 fake_size(40, 20), 

813 ) 

814 

815 clock = FakeClock() 

816 patch_clock(monkeypatch, clock) 

817 

818 displayed_times = [] 

819 original_get_number_lines = __main__.get_number_lines 

820 

821 def tracking_get_number_lines(seconds): 

822 displayed_times.append(seconds) 

823 return original_get_number_lines(seconds) 

824 

825 # Press - on first display 

826 def fake_check_for_keypress(): 

827 return len(displayed_times) == 1 

828 

829 def fake_read_key(): 

830 return "-" 

831 

832 def fake_drain(): 

833 pass 

834 

835 monkeypatch.setattr(__main__, "get_number_lines", tracking_get_number_lines) 

836 monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) 

837 monkeypatch.setattr(__main__, "read_key", fake_read_key) 

838 monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) 

839 

840 result = runner.invoke(__main__.main, ["1m"]) 

841 assert result.exit_code == 0 

842 

843 # After pressing - on display of 60, n drops to 30 (60-30) 

844 assert displayed_times[0] == 60 

845 assert 30 in displayed_times 

846 # Timer should end at 0 after counting down ~30 seconds (not ~60) 

847 assert displayed_times[-1] == 0 

848 # Total seconds displayed should be ~31 (30 down to 0, not 60 down to 0) 

849 assert len([t for t in displayed_times if t <= 30]) < 35