Coverage for tests/test_main.py: 100%

263 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-07 20:45 -0800

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 FakeSleep: 

13 """Fake time.sleep.""" 

14 

15 def __init__(self, *, raises={}): # noqa: B006 

16 self.slept = 0 

17 self.raises = dict(raises) 

18 

19 def __call__(self, seconds): 

20 self.slept += seconds 

21 # Check for exception with floating point tolerance 

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

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

24 raise exception 

25 

26 

27def fake_size(columns, lines): 

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

29 return os.terminal_size(fallback) 

30 

31 return get_terminal_size 

32 

33 

34def clean_main_output(output): 

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

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

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

38 return output 

39 

40 

41@pytest.fixture 

42def runner(): 

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

44 return CliRunner() 

45 

46 

47def test_main_with_no_arguments(runner): 

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

49 result = runner.invoke(__main__.main) 

50 # Should show help (not error) 

51 assert result.exit_code == 0 

52 assert "Usage:" in result.output 

53 assert "DURATION" in result.output 

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

55 

56 

57def test_version_works(runner): 

58 """It can print the version.""" 

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

60 assert ", version" in result.stdout 

61 assert result.exit_code == 0 

62 

63 

64def test_main_3_seconds_sleeps_4_times(runner, monkeypatch): 

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

66 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

67 fake_sleep = FakeSleep() 

68 monkeypatch.setattr("time.sleep", fake_sleep) 

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

70 assert result.exit_code == 0 

71 assert clean_main_output(result.stdout) == ( 

72 "\n\n\n\n\n\n\n" 

73 " ██████ ██████ ██████ ██████\n" 

74 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

75 " ██ ██ ██ ██ ██ ██ █████\n" 

76 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

77 " ██████ ██████ ██████ ██████\n" 

78 "\n\n\n\n\n\n" 

79 " ██████ ██████ ██████ ██████\n" 

80 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

81 " ██ ██ ██ ██ ██ ██ ██████\n" 

82 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

83 " ██████ ██████ ██████ ██████\n" 

84 "\n\n\n\n\n\n" 

85 " ██████ ██████ ██████ ██\n" 

86 " ██ ██ ██ ██ ██ ██ ██ ███\n" 

87 " ██ ██ ██ ██ ██ ██ ██\n" 

88 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

89 " ██████ ██████ ██████ ██\n" 

90 "\n\n\n\n\n\n" 

91 " ██████ ██████ ██████ ██████\n" 

92 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n" 

93 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

94 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n" 

95 " ██████ ██████ ██████ ██████ " 

96 ) 

97 # 3 seconds countdown = 4 iterations (3,2,1,0), each sleeps 1 second = 4 seconds total 

98 # Sleeping in chunks of 0.05, so total is ~4 seconds (floating point precision) 

99 assert fake_sleep.slept == pytest.approx(4.0, abs=0.01) 

100 

101 

102def test_main_1_minute(runner, monkeypatch): 

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

104 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) 

105 

106 # Raise exception after 11 sleeps 

107 fake_sleep = FakeSleep(raises={11: SystemExit(0)}) 

108 monkeypatch.setattr("time.sleep", fake_sleep) 

109 

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

111 assert clean_main_output(result.stdout) == ( 

112 "\n\n" 

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

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

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

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

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

118 "\n" 

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

120 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

121 " ██ ██ ██ ██ ██████ ██████\n" 

122 " ██ ██ ██ ██ ██ ██ ██\n" 

123 " ██████ ██████ ██████ █████\n" 

124 "\n" 

125 " ██████ ██████ ██████ ████\n" 

126 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

127 " ██ ██ ██ ██ ██████ ████\n" 

128 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

129 " ██████ ██████ ██████ ████\n" 

130 "\n" 

131 " ██████ ██████ ██████ ██████\n" 

132 " ██ ██ ██ ██ ██ ██ ██\n" 

133 " ██ ██ ██ ██ ██████ ██\n" 

134 " ██ ██ ██ ██ ██ ██ ██\n" 

135 " ██████ ██████ ██████ ██\n" 

136 "\n" 

137 " ██████ ██████ ██████ ██████\n" 

138 " ██ ██ ██ ██ ██ ██ ██\n" 

139 " ██ ██ ██ ██ ██████ ██████\n" 

140 " ██ ██ ██ ██ ██ ██ ██ ██\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 " ██████ ██████ ██████ ██████ " 

178 ) 

179 

180 

181def test_main_10_minutes_has_over_600_clear_screens(runner, monkeypatch): 

182 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) 

183 fake_sleep = FakeSleep() 

184 monkeypatch.setattr("time.sleep", fake_sleep) 

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

186 # 10 minutes = 601 iterations, each sleeps 1 second (via 20×0.05 chunks) 

187 # Floating point precision: 601 × 20 × 0.05 ≈ 601.0 

188 assert fake_sleep.slept == pytest.approx(601.0, abs=0.1) 

189 assert result.stdout.count("\033[H\033[J") == 601 

190 

191 

192def test_main_enables_alt_buffer_and_hides_cursor_at_beginning(runner, monkeypatch): 

193 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) 

194 fake_sleep = FakeSleep() 

195 monkeypatch.setattr("time.sleep", fake_sleep) 

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

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

198 

199 

200def test_main_disable_alt_buffer_and_show_cursor_at_end(runner, monkeypatch): 

201 monkeypatch.setattr("shutil.get_terminal_size", fake_size(32, 10)) 

202 fake_sleep = FakeSleep() 

203 monkeypatch.setattr("time.sleep", fake_sleep) 

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

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

206 

207 

208def test_main_early_exit_still_shows_cursor_at_end(runner, monkeypatch): 

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

210 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) 

211 

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

213 fake_sleep = FakeSleep(raises={4: KeyboardInterrupt()}) 

214 monkeypatch.setattr("time.sleep", fake_sleep) 

215 

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

217 # After 4 seconds of sleep, we've completed 4 iterations, each prints lines 

218 assert len(result.stdout.splitlines()) == 25, "4 seconds of lines printed" 

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

220 

221 

222def test_pause_key_triggers_pause(runner, monkeypatch): 

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

224 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

225 

226 # Exit after a short time 

227 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) 

228 monkeypatch.setattr("time.sleep", fake_sleep) 

229 

230 # Track whether pause key was detected 

231 pause_key_detected = [False] 

232 read_key_called = [False] 

233 

234 def fake_check_for_keypress(): 

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

236 if not pause_key_detected[0]: 

237 pause_key_detected[0] = True 

238 return True 

239 return False 

240 

241 def fake_read_key(): 

242 read_key_called[0] = True 

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

244 

245 def fake_drain(): 

246 pass # No additional keys to drain 

247 

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

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

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

251 

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

253 

254 # The pause key should have been detected and read 

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

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

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

258 assert "\x1b[95m" in result.stdout, "Should show paused color when pause key pressed" 

259 

260 

261def test_non_pause_key_ignored(runner, monkeypatch): 

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

263 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

264 

265 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) 

266 monkeypatch.setattr("time.sleep", fake_sleep) 

267 

268 # Track keypresses 

269 check_called = [False] 

270 read_key_called = [False] 

271 

272 def fake_check_for_keypress(): 

273 if not check_called[0]: 

274 check_called[0] = True 

275 return True 

276 return False 

277 

278 def fake_read_key(): 

279 read_key_called[0] = True 

280 return "x" # Not a pause key 

281 

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

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

284 

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

286 

287 # The key should have been read 

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

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

290 assert ( 

291 "\x1b[95m" not in result.stdout 

292 ), "Should not show paused color for non-pause key" 

293 assert result.exit_code == 0 

294 

295 

296def test_sleep_exits_early_on_keypress(runner, monkeypatch): 

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

298 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

299 

300 # Track sleep calls 

301 sleep_calls = [] 

302 

303 def fake_sleep(seconds): 

304 sleep_calls.append(seconds) 

305 # Exit after we've done a few sleep chunks 

306 if len(sleep_calls) >= 5: 

307 raise KeyboardInterrupt() 

308 

309 monkeypatch.setattr("time.sleep", fake_sleep) 

310 

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

312 check_count = [0] 

313 

314 def fake_check_for_keypress(): 

315 check_count[0] += 1 

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

317 return len(sleep_calls) == 3 

318 

319 def fake_read_key(): 

320 return " " # Pause key 

321 

322 def fake_drain(): 

323 pass 

324 

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

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

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

328 

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

330 

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

332 # We expect: 3 chunks of first iteration, then breaks, then starts paused sleep 

333 # The key point is we don't see all 20 chunks of 0.05 before breaking 

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

335 # If it didn't exit early, we'd see many more 0.05 sleep calls 

336 # The presence of the break means we don't complete all 20 chunks 

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

338 assert ( 

339 len(first_iteration_sleeps) == 3 

340 ), "Should have 3 chunks of 0.05s before breaking" 

341 

342 

343def test_resume_from_pause_exits_early(runner, monkeypatch): 

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

345 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

346 

347 sleep_calls = [] 

348 paused_state = [False] 

349 

350 def fake_sleep(seconds): 

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

352 if len(sleep_calls) >= 10: 

353 raise KeyboardInterrupt() 

354 

355 monkeypatch.setattr("time.sleep", fake_sleep) 

356 

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

358 keypress_count = [0] 

359 

360 def fake_check_for_keypress(): 

361 keypress_count[0] += 1 

362 # First keypress: pause immediately (keypress 1) 

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

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

365 

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

367 key_index = [0] 

368 

369 def fake_read_key(): 

370 key = keys_to_return[key_index[0]] 

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

372 return key 

373 

374 def fake_drain(): 

375 pass 

376 

377 # Track pause state transitions 

378 original_print = __main__.print_full_screen 

379 

380 def tracking_print(lines, paused=False): 

381 paused_state[0] = paused 

382 return original_print(lines, paused=paused) 

383 

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

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

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

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

388 

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

390 

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

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

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

394 

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

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

397 

398 

399def test_add_time_with_plus_key(runner, monkeypatch): 

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

401 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

402 

403 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) 

404 monkeypatch.setattr("time.sleep", fake_sleep) 

405 

406 # Track the displayed times 

407 displayed_times = [] 

408 original_get_number_lines = __main__.get_number_lines 

409 

410 def fake_get_number_lines(seconds): 

411 displayed_times.append(seconds) 

412 return original_get_number_lines(seconds) 

413 

414 def fake_check_for_keypress(): 

415 # Return True once to simulate a keypress 

416 return len(displayed_times) == 1 

417 

418 def fake_read_key(): 

419 return "+" # Plus key to add time 

420 

421 def fake_drain(): 

422 pass 

423 

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

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

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

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

428 

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

430 

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

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

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

434 

435 

436def test_subtract_time_with_minus_key(runner, monkeypatch): 

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

438 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

439 

440 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) 

441 monkeypatch.setattr("time.sleep", fake_sleep) 

442 

443 # Track the displayed times 

444 displayed_times = [] 

445 original_get_number_lines = __main__.get_number_lines 

446 

447 def fake_get_number_lines(seconds): 

448 displayed_times.append(seconds) 

449 return original_get_number_lines(seconds) 

450 

451 def fake_check_for_keypress(): 

452 # Return True once to simulate a keypress 

453 return len(displayed_times) == 1 

454 

455 def fake_read_key(): 

456 return "-" # Minus key to subtract time 

457 

458 def fake_drain(): 

459 pass 

460 

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

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

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

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

465 

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

467 

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

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

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

471 

472 

473def test_subtract_time_cannot_go_negative(runner, monkeypatch): 

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

475 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

476 

477 fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) 

478 monkeypatch.setattr("time.sleep", fake_sleep) 

479 

480 # Track the displayed times 

481 displayed_times = [] 

482 original_get_number_lines = __main__.get_number_lines 

483 

484 def fake_get_number_lines(seconds): 

485 displayed_times.append(seconds) 

486 return original_get_number_lines(seconds) 

487 

488 def fake_check_for_keypress(): 

489 # Return True once to simulate a keypress 

490 return len(displayed_times) == 1 

491 

492 def fake_read_key(): 

493 return "-" # Minus key to subtract time 

494 

495 def fake_drain(): 

496 pass 

497 

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

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

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

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

502 

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

504 

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

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

507 assert 0 in displayed_times, "Should display 0s (not negative) after subtracting 30s" 

508 assert all( 

509 t >= 0 for t in displayed_times 

510 ), "All displayed times should be non-negative" 

511 

512 

513def test_q_key_quits_timer(runner, monkeypatch): 

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

515 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) 

516 

517 fake_sleep = FakeSleep() 

518 monkeypatch.setattr("time.sleep", fake_sleep) 

519 

520 keypress_count = [0] 

521 

522 def fake_check_for_keypress(): 

523 keypress_count[0] += 1 

524 # Return True on first check to simulate pressing q 

525 return keypress_count[0] == 1 

526 

527 def fake_read_key(): 

528 return "q" # Press q to quit 

529 

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

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

532 

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

534 

535 # Should exit cleanly with code 0 

536 assert result.exit_code == 0 

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

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

539 

540 

541def test_no_arguments_shows_help(runner): 

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

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

544 

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

546 assert result.exit_code == 0 

547 # Should show usage information 

548 assert "Usage:" in result.output 

549 assert "DURATION" in result.output 

550 # Should show examples 

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