Coverage for tests/test_main.py: 100%

199 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-06 17:54 -0800

1"""Test cases for the __main__ module.""" 

2 

3import os 

4import re 

5from textwrap import dedent 

6 

7import pytest 

8from click.testing import CliRunner 

9 

10from countdown import __main__ 

11 

12 

13class FakeSleep: 

14 """Fake time.sleep.""" 

15 

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

17 self.slept = 0 

18 self.raises = dict(raises) 

19 

20 def __call__(self, seconds): 

21 self.slept += seconds 

22 if self.slept in self.raises: 

23 raise self.raises[self.slept] 

24 

25 

26def fake_size( 

27 columns, 

28 lines, 

29): 

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

31 return os.terminal_size(fallback) 

32 

33 return get_terminal_size 

34 

35 

36def clean_main_output(output): 

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

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

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

40 return output 

41 

42 

43def join_lines(lines): 

44 """Given list of lines, return string of lines with whitespace stripped.""" 

45 return "\n".join(line.rstrip(" ") for line in lines) 

46 

47 

48@pytest.fixture 

49def runner(): 

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

51 return CliRunner() 

52 

53 

54def test_invalid_duration(): 

55 with pytest.raises(ValueError): 

56 __main__.duration("10") 

57 

58 

59def test_duration_10_seconds(): 

60 assert __main__.duration("10s") == 10 

61 

62 

63def test_duration_60_seconds(): 

64 assert __main__.duration("60s") == 60 

65 

66 

67def test_duration_1_minute(): 

68 assert __main__.duration("1m") == 60 

69 

70 

71def test_duration_10_minutes(): 

72 assert __main__.duration("10m") == 600 

73 

74 

75def test_duration_25_minutes(): 

76 assert __main__.duration("25m") == 1500 

77 

78 

79def test_duration_3_minute_and_30_seconds(): 

80 assert __main__.duration("3m30s") == 210 

81 

82 

83def test_duration_2_minutes_and_8_seconds(): 

84 assert __main__.duration("2m8s") == 128 

85 

86 

87def test_get_number_lines_10_seconds(monkeypatch): 

88 # Use 40x6 terminal to select size 5 digits (33w <= 40, 5h <= 6) 

89 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) 

90 assert join_lines(__main__.get_number_lines(10)) == dedent( 

91 """ 

92 ██████ ██████ ██ ██████ 

93 ██ ██ ██ ██ ██ ███ ██ ██ 

94 ██ ██ ██ ██ ██ ██ ██ 

95 ██ ██ ██ ██ ██ ██ ██ ██ 

96 ██████ ██████ ██ ██████ 

97 """ 

98 ).strip("\n") 

99 

100 

101def test_get_number_lines_60_seconds(monkeypatch): 

102 # Use 40x6 terminal to select size 5 digits 

103 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) 

104 assert join_lines(__main__.get_number_lines(60)) == dedent( 

105 """ 

106 ██████ ██ ██████ ██████ 

107 ██ ██ ███ ██ ██ ██ ██ ██ 

108 ██ ██ ██ ██ ██ ██ ██ 

109 ██ ██ ██ ██ ██ ██ ██ ██ 

110 ██████ ██ ██████ ██████ 

111 """ 

112 ).strip("\n") 

113 

114 

115def test_get_number_lines_45_minutes(monkeypatch): 

116 # Use 40x6 terminal to select size 5 digits 

117 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) 

118 assert join_lines(__main__.get_number_lines(2700)) == dedent( 

119 """ 

120 ██ ██ ██████ ██████ ██████ 

121 ██ ██ ██ ██ ██ ██ ██ ██ 

122 ██████ ██████ ██ ██ ██ ██ 

123 ██ ██ ██ ██ ██ ██ ██ 

124 ██ ██████ ██████ ██████ 

125 """ 

126 ).strip("\n") 

127 

128 

129def test_get_number_lines_17_minutes_and_four_seconds(monkeypatch): 

130 # Use 40x6 terminal to select size 5 digits 

131 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) 

132 assert join_lines(__main__.get_number_lines(1024)) == ( 

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

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

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

136 " ██ ██ ██ ██ ██ ██\n" 

137 " ██ ██ ██████ ██" 

138 ) 

139 

140 

141def test_get_number_lines_8_minutes_and_6_seconds(monkeypatch): 

142 # Use 40x6 terminal to select size 5 digits 

143 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) 

144 assert join_lines(__main__.get_number_lines(486)) == dedent( 

145 """ 

146 ██████ ████ ██████ ██████ 

147 ██ ██ ██ ██ ██ ██ ██ ██ 

148 ██ ██ ████ ██ ██ ██████ 

149 ██ ██ ██ ██ ██ ██ ██ ██ ██ 

150 ██████ ████ ██████ ██████ 

151 """ 

152 ).strip("\n") 

153 

154 

155def test_get_number_lines_9_minutes(monkeypatch): 

156 # Use 40x6 terminal to select size 5 digits 

157 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) 

158 assert join_lines(__main__.get_number_lines(540)) == dedent( 

159 """ 

160 ██████ ██████ ██████ ██████ 

161 ██ ██ ██ ██ ██ ██ ██ ██ ██ 

162 ██ ██ ██████ ██ ██ ██ ██ 

163 ██ ██ ██ ██ ██ ██ ██ ██ 

164 ██████ █████ ██████ ██████ 

165 """ 

166 ).strip("\n") 

167 

168 

169def test_get_number_lines_3478(monkeypatch): 

170 # Use 40x6 terminal to select size 5 digits 

171 monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) 

172 assert join_lines(__main__.get_number_lines(2118)) == dedent( 

173 """ 

174 ██████ ██████ ██ ████ 

175 ██ ██ ██ ███ ██ ██ 

176 █████ ██████ ██ ████ 

177 ██ ██ ██ ██ ██ ██ 

178 ██████ ██████ ██ ████ 

179 """ 

180 ).strip("\n") 

181 

182 

183def test_print_full_screen_tiny_terminal( 

184 capsys, 

185 monkeypatch, 

186): 

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

188 __main__.print_full_screen(["hello world"]) 

189 out, err = capsys.readouterr() 

190 assert out[:6] == "\x1b[H\x1b[J" 

191 assert out[6:] == "\n\n\n\n hello world" 

192 

193 

194def test_print_full_screen_larger_terminal( 

195 capsys, 

196 monkeypatch, 

197): 

198 monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) 

199 __main__.print_full_screen(["hello world"]) 

200 out, err = capsys.readouterr() 

201 assert out[:6] == "\x1b[H\x1b[J" 

202 # 24 height - 1 line = 23, 23//2 = 11 newlines 

203 # 80 width - 11 chars = 69, 69//2 = 34 spaces 

204 assert out[6:] == "\n" * 11 + " " * 34 + "hello world" 

205 

206 

207def test_print_full_screen_multiline_text( 

208 capsys, 

209 monkeypatch, 

210): 

211 monkeypatch.setattr("shutil.get_terminal_size", fake_size(100, 30)) 

212 __main__.print_full_screen( 

213 dedent( 

214 """\ 

215 ██████ ██████ ██ ████ 

216 ██ ██ ██ ███ ██ ██ 

217 █████ ██████ ██ ████ 

218 ██ ██ ██ ██ ██ ██ 

219 ██████ ██████ ██ ████ 

220 """ 

221 ).splitlines() 

222 ) 

223 out, err = capsys.readouterr() 

224 assert out[:6] == "\x1b[H\x1b[J" 

225 assert out[6:] == ( 

226 "\n\n\n\n\n\n\n\n\n\n\n\n" 

227 " ██████ ██████ ██ ████\n" 

228 " ██ ██ ██ ███ ██ ██\n" 

229 " █████ ██████ ██ ████\n" 

230 " ██ ██ ██ ██ ██ ██\n" 

231 " ██████ ██████ ██ ████" 

232 ) 

233 

234 

235def test_main_with_no_arguments(runner): 

236 """It exits with a status code of zero.""" 

237 result = runner.invoke(__main__.main) 

238 assert result.output == dedent( 

239 """\ 

240 Usage: main [OPTIONS] DURATION 

241 Try 'main --help' for help. 

242 

243 Error: Missing argument 'DURATION'. 

244 """ 

245 ) 

246 assert result.exit_code == 2 

247 

248 

249def test_version_works(runner): 

250 """It can print the version.""" 

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

252 assert ", version" in result.stdout 

253 assert result.exit_code == 0 

254 

255 

256def test_main_3_seconds_sleeps_4_times( 

257 runner, 

258 monkeypatch, 

259): 

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

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

262 fake_sleep = FakeSleep() 

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

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

265 assert result.exit_code == 0 

266 assert clean_main_output(result.stdout) == ( 

267 "\n\n\n\n\n\n\n" 

268 " ██████ ██████ ██████ ██████\n" 

269 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

270 " ██ ██ ██ ██ ██ ██ █████\n" 

271 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

272 " ██████ ██████ ██████ ██████\n" 

273 "\n\n\n\n\n\n" 

274 " ██████ ██████ ██████ ██████\n" 

275 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

276 " ██ ██ ██ ██ ██ ██ ██████\n" 

277 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

278 " ██████ ██████ ██████ ██████\n" 

279 "\n\n\n\n\n\n" 

280 " ██████ ██████ ██████ ██\n" 

281 " ██ ██ ██ ██ ██ ██ ██ ███\n" 

282 " ██ ██ ██ ██ ██ ██ ██\n" 

283 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

284 " ██████ ██████ ██████ ██\n" 

285 "\n\n\n\n\n\n" 

286 " ██████ ██████ ██████ ██████\n" 

287 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n" 

288 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

289 " ██ ██ ██ ██ ██ ██ ██ ██ ██\n" 

290 " ██████ ██████ ██████ ██████ " 

291 ) 

292 assert fake_sleep.slept == 4 # 3 seconds = 4 sleeps 

293 

294 

295def test_main_1_minute( 

296 runner, 

297 monkeypatch, 

298): 

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

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

301 

302 # Raise exception after 11 sleeps 

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

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

305 

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

307 assert clean_main_output(result.stdout) == ( 

308 "\n\n" 

309 " ██████ ██ ██████ ██████\n" 

310 " ██ ██ ███ ██ ██ ██ ██ ██\n" 

311 " ██ ██ ██ ██ ██ ██ ██\n" 

312 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

313 " ██████ ██ ██████ ██████\n" 

314 "\n" 

315 " ██████ ██████ ██████ ██████\n" 

316 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

317 " ██ ██ ██ ██ ██████ ██████\n" 

318 " ██ ██ ██ ██ ██ ██ ██\n" 

319 " ██████ ██████ ██████ █████\n" 

320 "\n" 

321 " ██████ ██████ ██████ ████\n" 

322 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

323 " ██ ██ ██ ██ ██████ ████\n" 

324 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

325 " ██████ ██████ ██████ ████\n" 

326 "\n" 

327 " ██████ ██████ ██████ ██████\n" 

328 " ██ ██ ██ ██ ██ ██ ██\n" 

329 " ██ ██ ██ ██ ██████ ██\n" 

330 " ██ ██ ██ ██ ██ ██ ██\n" 

331 " ██████ ██████ ██████ ██\n" 

332 "\n" 

333 " ██████ ██████ ██████ ██████\n" 

334 " ██ ██ ██ ██ ██ ██ ██\n" 

335 " ██ ██ ██ ██ ██████ ██████\n" 

336 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

337 " ██████ ██████ ██████ ██████\n" 

338 "\n" 

339 " ██████ ██████ ██████ ██████\n" 

340 " ██ ██ ██ ██ ██ ██ ██\n" 

341 " ██ ██ ██ ██ ██████ ██████\n" 

342 " ██ ██ ██ ██ ██ ██ ██\n" 

343 " ██████ ██████ ██████ ██████\n" 

344 "\n" 

345 " ██████ ██████ ██████ ██ ██\n" 

346 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

347 " ██ ██ ██ ██ ██████ ██████\n" 

348 " ██ ██ ██ ██ ██ ██ ██\n" 

349 " ██████ ██████ ██████ ██\n" 

350 "\n" 

351 " ██████ ██████ ██████ ██████\n" 

352 " ██ ██ ██ ██ ██ ██ ██\n" 

353 " ██ ██ ██ ██ ██████ █████\n" 

354 " ██ ██ ██ ██ ██ ██ ██\n" 

355 " ██████ ██████ ██████ ██████\n" 

356 "\n" 

357 " ██████ ██████ ██████ ██████\n" 

358 " ██ ██ ██ ██ ██ ██ ██\n" 

359 " ██ ██ ██ ██ ██████ ██████\n" 

360 " ██ ██ ██ ██ ██ ██ ██\n" 

361 " ██████ ██████ ██████ ██████\n" 

362 "\n" 

363 " ██████ ██████ ██████ ██\n" 

364 " ██ ██ ██ ██ ██ ██ ███\n" 

365 " ██ ██ ██ ██ ██████ ██\n" 

366 " ██ ██ ██ ██ ██ ██ ██\n" 

367 " ██████ ██████ ██████ ██\n" 

368 "\n" 

369 " ██████ ██████ ██████ ██████\n" 

370 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

371 " ██ ██ ██ ██ ██████ ██ ██\n" 

372 " ██ ██ ██ ██ ██ ██ ██ ██\n" 

373 " ██████ ██████ ██████ ██████ " 

374 ) 

375 

376 

377def test_main_10_minutes_has_over_600_clear_screens( 

378 runner, 

379 monkeypatch, 

380): 

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

382 fake_sleep = FakeSleep() 

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

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

385 assert fake_sleep.slept == 601 # 10 minutes = 601 sleeps 

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

387 

388 

389def test_main_enables_alt_buffer_and_hides_cursor_at_beginning( 

390 runner, 

391 monkeypatch, 

392): 

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

394 fake_sleep = FakeSleep() 

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

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

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

398 

399 

400def test_main_disable_alt_buffer_and_show_cursor_at_end( 

401 runner, 

402 monkeypatch, 

403): 

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

405 fake_sleep = FakeSleep() 

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

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

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

409 

410 

411def test_main_early_exit_still_shows_cursor_at_end( 

412 runner, 

413 monkeypatch, 

414): 

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

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

417 

418 # Hit Ctrl+C after 4 seconds 

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

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

421 

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

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

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

425 

426 

427def test_digit_sizes_available(): 

428 """Test that expected digit sizes are available.""" 

429 from countdown.digits import DIGIT_SIZES 

430 

431 assert 16 in DIGIT_SIZES, "Size 16 digits should be available" 

432 assert 7 in DIGIT_SIZES, "Size 7 digits should be available" 

433 assert 5 in DIGIT_SIZES, "Size 5 digits should be available" 

434 assert 3 in DIGIT_SIZES, "Size 3 digits should be available" 

435 assert 1 in DIGIT_SIZES, "Size 1 digits should be available" 

436 

437 

438def test_all_characters_in_each_size(): 

439 """Test that all digit characters exist in each size.""" 

440 from countdown.digits import CHARS_BY_SIZE, DIGIT_SIZES 

441 

442 expected_chars = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":"} 

443 for size in DIGIT_SIZES: 

444 chars = CHARS_BY_SIZE[size] 

445 assert set(chars.keys()) == expected_chars, ( 

446 f"Size {size} should have all characters" 

447 ) 

448 

449 

450def test_char_heights_match_size(): 

451 """Test that character heights match the expected size.""" 

452 from countdown.digits import CHARS_BY_SIZE, DIGIT_SIZES 

453 

454 for size in DIGIT_SIZES: 

455 chars = CHARS_BY_SIZE[size] 

456 for char, text in chars.items(): 

457 height = len(text.splitlines()) 

458 assert height == size, ( 

459 f"Character '{char}' in size {size} should have height {size}, got {height}" 

460 ) 

461 

462 

463def test_get_chars_for_terminal_selects_largest_that_fits(monkeypatch): 

464 """Test that get_chars_for_terminal selects the largest size that fits both dimensions.""" 

465 # Size requirements: 16(93w), 7(57w), 5(33w), 3(20w), 1(10w) 

466 

467 # 80x24 terminal - size 7 fits (57w <= 80, 7h <= 24) 

468 monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) 

469 chars = __main__.get_chars_for_terminal() 

470 height = len(chars["0"].splitlines()) 

471 assert height == 7, "80x24 terminal should select size 7" 

472 

473 # 100x24 terminal - size 16 fits (93w <= 100, 16h <= 24) 

474 monkeypatch.setattr("shutil.get_terminal_size", fake_size(100, 24)) 

475 chars = __main__.get_chars_for_terminal() 

476 height = len(chars["0"].splitlines()) 

477 assert height == 16, "100x24 terminal should select size 16" 

478 

479 # 60x20 terminal - size 7 fits (57w <= 60, 7h <= 20) 

480 monkeypatch.setattr("shutil.get_terminal_size", fake_size(60, 20)) 

481 chars = __main__.get_chars_for_terminal() 

482 height = len(chars["0"].splitlines()) 

483 assert height == 7, "60x20 terminal should select size 7" 

484 

485 # 32x10 terminal - size 3 fits (20w <= 32, 3h <= 10) 

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

487 chars = __main__.get_chars_for_terminal() 

488 height = len(chars["0"].splitlines()) 

489 assert height == 3, "32x10 terminal should select size 3" 

490 

491 # 15x5 terminal - size 1 fits (10w <= 15, 1h <= 5) 

492 monkeypatch.setattr("shutil.get_terminal_size", fake_size(15, 5)) 

493 chars = __main__.get_chars_for_terminal() 

494 height = len(chars["0"].splitlines()) 

495 assert height == 1, "15x5 terminal should select size 1" 

496 

497 # Very small terminal - falls back to smallest 

498 monkeypatch.setattr("shutil.get_terminal_size", fake_size(5, 1)) 

499 chars = __main__.get_chars_for_terminal() 

500 height = len(chars["0"].splitlines()) 

501 assert height == 1, "5x1 terminal should fall back to size 1" 

502 

503 

504def test_different_sizes_render_correctly(monkeypatch): 

505 """Test that different sizes render correctly.""" 

506 # Test size 7 rendering (80x24 selects size 7) 

507 monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) 

508 lines = __main__.get_number_lines(0) # 00:00 

509 assert len(lines) == 7, "80x24 terminal should render 7 lines" 

510 

511 # Test size 3 rendering (32x10 selects size 3) 

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

513 lines = __main__.get_number_lines(0) # 00:00 

514 assert len(lines) == 3, "32x10 terminal should render 3 lines" 

515 

516 # Test size 1 rendering (15x5 selects size 1) 

517 monkeypatch.setattr("shutil.get_terminal_size", fake_size(15, 5)) 

518 lines = __main__.get_number_lines(0) # 00:00 

519 assert len(lines) == 1, "15x5 terminal should render 1 line" 

520 

521 

522def test_width_constraints_force_smaller_size(monkeypatch): 

523 """Test that narrow terminal widths force selection of smaller digit sizes.""" 

524 # Size 7 requires 57 width - a 56x20 terminal should select size 5 instead 

525 monkeypatch.setattr("shutil.get_terminal_size", fake_size(56, 20)) 

526 chars = __main__.get_chars_for_terminal() 

527 height = len(chars["0"].splitlines()) 

528 assert height == 5, ( 

529 "56x20 terminal too narrow for size 7, should select size 5" 

530 ) 

531 

532 # Size 5 requires 33 width - a 32x10 terminal should select size 3 instead 

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

534 chars = __main__.get_chars_for_terminal() 

535 height = len(chars["0"].splitlines()) 

536 assert height == 3, ( 

537 "32x10 terminal too narrow for size 5, should select size 3" 

538 ) 

539 

540 # Size 3 requires 20 width - a 19x5 terminal should select size 1 instead 

541 monkeypatch.setattr("shutil.get_terminal_size", fake_size(19, 5)) 

542 chars = __main__.get_chars_for_terminal() 

543 height = len(chars["0"].splitlines()) 

544 assert height == 1, ( 

545 "19x5 terminal too narrow for size 3, should select size 1" 

546 )