1 import io

2 import time

3 from typing import Callable, Any

4

5 import pytest

6

7 from muutils.spinner import (

8 SPINNERS,

9 spinner_decorator,

10 SpinnerContext,

11 Spinner,

12 SpinnerConfig,

13)

14

15

16 def test_spinner_simple():

17 @spinner_decorator(update_interval=0.05)

18 def long_running_function_simple() -> str:

19 """

20 An example function decorated with spinner_decorator, using only the spinner and elapsed time.

21

22 Returns:

23 str: A completion message.

24 """

25 for _ in range(1):

26 time.sleep(0.1) # Simulate some work

27 return "Simple function completed"

28

29 print("\nRunning simple function with only spinner and elapsed time:")

30 result2: str = long_running_function_simple()

31 print(result2)

32

33

34 def test_spinner_complex():

35 # Example usage

36 @spinner_decorator(

37 message="Current value: ",

38 mutable_kwarg_key="update_status",

39 update_interval=0.05,

40 )

41 def long_running_function_with_status(

42 normal_arg: int, update_status: Callable[[Any], None]

43 ) -> str:

44 """

45 An example function decorated with spinner_decorator, using all features.

46

47 Args:

48 normal_arg (int): A normal argument to demonstrate that other arguments still work.

49 update_status (Callable[[Any], None]): Function to update the status displayed by the spinner.

50

51 Returns:

52 str: A completion message.

53 """

54 for i in range(normal_arg):

55 time.sleep(0.1) # Simulate some work

56 update_status(f"Step {i+1} of {normal_arg}")

57 return "Function with status completed"

58

59 # Run the example functions

60 print("Running function with status updates:")

61 result1: str = long_running_function_with_status(1)

62 print(result1)

63

64

65 def test_spinner_decorator_bare():

66 @spinner_decorator()

67 def example_function():

68 return "Done"

69

70 result = example_function()

71 assert result == "Done"

72

73

74 def test_spinner_ctx_mgr():

75 with SpinnerContext(message="Current value: ", update_interval=0.05) as spinner:

76 for i in range(1):

77 time.sleep(0.1)

78 spinner.update_value(f"Step {i+1}")

79 print("Done!")

80

81

82 def test_spinner_initialization():

83 spinner = Spinner()

84 assert isinstance(spinner, Spinner)

85 assert isinstance(spinner.config.working, list)

86 assert isinstance(spinner.config.success, str)

87 assert isinstance(spinner.config.fail, str)

88 assert isinstance(spinner.update_interval, float)

89 assert isinstance(spinner.current_value, str)

90 assert isinstance(spinner.message, str)

91 assert isinstance(spinner.format_string, str)

92 assert hasattr(spinner.output_stream, "write")

93 assert callable(spinner.output_stream.write)

94 assert callable(spinner.update_value)

95

96 assert spinner.config.working == ["|", "/", "-", "\\"]

97 assert spinner.format_string == "\r{spinner} ({elapsed_time:.2f}s) {message}{value}"

98 assert spinner.update_interval == 0.1

99

100

101 def test_spinner_update_value():

102 spinner = Spinner()

103 spinner.update_value("Test")

104 assert spinner.current_value == "Test"

105

106

107 def test_spinner_context_manager():

108 string_io = io.StringIO()

109 with SpinnerContext(output_stream=string_io):

110 pass

111 assert string_io.getvalue().endswith("\n")

112

113

114@spinner_decorator()

115 def example_function():

116 return "Done"

117

118

119 def test_spinner_decorator():

120 result = example_function()

121 assert result == "Done"

122

123

124 def test_spinner_custom_chars():

125 spinner = Spinner(config=["A", "B", "C"])

126 assert spinner.config.working == ["A", "B", "C"]

127

128

129 def test_spinner_custom_time_format():

130 spinner = Spinner(format_string="[{elapsed_time:.1f}s]")

131 assert spinner.format_string == "[{elapsed_time:.1f}s]"

132

133

134 def test_spinner_context_manager_with_updates():

135 string_io = io.StringIO()

136 with SpinnerContext(

137 message="Status: ", output_stream=string_io, update_interval=0.05

138 ) as spinner:

139 spinner.update_value("Working")

140 time.sleep(0.1)

141 spinner.update_value("Finishing")

142 time.sleep(0.1)

143

144 output = string_io.getvalue()

145 print(output)

146 assert "Status: Working" in output

147 assert "Status: Finishing" in output

148

149

150 def test_spinner_context_exception_handling():

151 string_io = io.StringIO()

152 try:

153 with SpinnerContext(output_stream=string_io, update_interval=0.05) as spinner:

154 spinner.update_value("Before exception")

155 time.sleep(0.1)

156 raise ValueError("Test exception")

157 except ValueError:

158 pass

159

160 output = string_io.getvalue()

161 print(output)

162 assert "Before exception" in output

163 assert output.endswith("\n")

164

165

166 def test_spinner_long_running_task():

167 string_io = io.StringIO()

168

169 @spinner_decorator(

170 message="Iteration: ",

171 output_stream=string_io,

172 update_interval=0.05,

173 mutable_kwarg_key="update",

174 )

175 def long_task(iterations, update):

176 for i in range(iterations):

177 update(i + 1)

178 time.sleep(0.1)

179

180 long_task(3, update=lambda x: x)

181

182 output = string_io.getvalue()

183 print(output)

184 for i in range(1, 4):

185 assert f"Iteration: {i}" in output

186

187

188 def test_spinner_init_from_config():

189 """Test various ways of initializing spinner with config parameter"""

190 # From string key

191 sp1 = Spinner(config="clock")

192 assert sp1.config.working == [

193 "🕛",

194 "🕐",

195 "🕑",

196 "🕒",

197 "🕓",

198 "🕔",

199 "🕕",

200 "🕖",

201 "🕗",

202 "🕘",

203 "🕙",

204 "🕚",

205 ]

206 assert sp1.config.success == "✔️"

207 assert sp1.config.fail == "❌"

208

209 # From list

210 sp2 = Spinner(config=["A", "B", "C"])

211 assert sp2.config.working == ["A", "B", "C"]

212 assert sp2.config.success == "✔️" # Default

213 assert sp2.config.fail == "❌" # Default

214

215 # From dict

216 sp3 = Spinner(config=dict(working=["1", "2"], success="OK", fail="NO"))

217 assert sp3.config.working == ["1", "2"]

218 assert sp3.config.success == "OK"

219 assert sp3.config.fail == "NO"

220

221 # From SpinnerConfig

222 cfg = SpinnerConfig(working=["X", "Y"], success="DONE", fail="FAIL")

223 sp4 = Spinner(config=cfg)

224 assert sp4.config.working == ["X", "Y"]

225 assert sp4.config.success == "DONE"

226 assert sp4.config.fail == "FAIL"

227

228

229 def test_spinner_exception_messages():

230 """Test that exception messages are preserved during spinner execution"""

231 string_io = io.StringIO()

232 custom_message = "Custom error occurred!"

233

234 # Test with context manager

235 try:

236 with SpinnerContext(output_stream=string_io, message="Processing: ") as spinner:

237 spinner.update_value("Step 1")

238 time.sleep(0.1)

239 raise ValueError(custom_message)

240 except ValueError as e:

241 assert str(e) == custom_message

242 output = string_io.getvalue()

243 assert "Processing: Step 1" in output

244 assert output.count("\n") == 1 # Should have exactly one newline at the end

245

246 # Test with decorator

247 string_io = io.StringIO()

248

249 @spinner_decorator(output_stream=string_io, message="Working: ")

250 def failing_function():

251 time.sleep(0.1)

252 raise RuntimeError(custom_message)

253

254 with pytest.raises(RuntimeError) as exc_info:

255 failing_function()

256

257 assert str(exc_info.value) == custom_message

258 output = string_io.getvalue()

259 assert "Working: " in output

260 assert output.count("\n") == 1

261

262

263 def test_spinner_state_transitions():

264 """Test that spinner state transitions work correctly"""

265 spinner = Spinner()

266 assert spinner.state == "initialized"

267

268 spinner.start()

269 assert spinner.state == "running"

270

271 spinner.stop()

272 assert spinner.state == "success"

273

274 # New spinner for failure test

275 spinner = Spinner()

276 spinner.start()

277 spinner.stop(failed=True)

278 assert spinner.state == "fail"

279

280

281 def test_spinner_nested():

282 """Test nested spinners behavior"""

283 string_io = io.StringIO()

284

285 with SpinnerContext(output_stream=string_io, message="Outer: ") as outer:

286 outer.update_value("Step 1")

287 time.sleep(0.1)

288

289 with SpinnerContext(output_stream=string_io, message="Inner: ") as inner:

290 inner.update_value("Processing")

291 time.sleep(0.1)

292

293 outer.update_value("Step 2")

294 time.sleep(0.1)

295

296 output = string_io.getvalue()

297 assert "Outer: Step 1" in output

298 assert "Inner: Processing" in output

299 assert "Outer: Step 2" in output

300

301

302 def test_spinner_value_updates():

303 """Test that the spinner correctly stores and formats values"""

304 spinner = Spinner(format_string="{spinner} {value}")

305

306 # Test various types of values

307 test_values = [

308 42,

309 3.14,

310 ["a", "b", "c"],

311 {"key": "value"},

312 (1, 2, 3),

313 None,

314 True,

315 b"bytes",

316 ]

317

318 for value in test_values:

319 spinner.update_value(value)

320 assert spinner.current_value == value

321

322 # Test format string rendering without output stream

323 rendered = spinner.format_string.format(

324 spinner=spinner.config.working[0], elapsed_time=0.0, message="", value=value

325 )

326 assert str(value) in rendered

327

328

329@pytest.mark.skip(

330 reason="""shows a warning:

331 tests/unit/test_spinner.py::test_spinner_output_stream_errors

332 .venv/Lib/site-packages/_pytest/threadexception.py:82: PytestUnhandledThreadExceptionWarning: Exception in thread Thread-16 (spin)

333

334 Traceback (most recent call last):

335 File "Lib/threading.py", line 1052, in _bootstrap_inner

336 self.run()

337 File "Lib/threading.py", line 989, in run

338 self._target(*self._args, **self._kwargs)

339 File "muutils/spinner.py", line 369, in spin

340 self.output_stream.write(output)

341 File "tests/unit/test_spinner.py", line 328, in write

342 raise IOError("Write failed")

343 OSError: Write failed

344

345 warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg))

346

347"""

348)

349 def test_spinner_output_stream_errors():

350 """Test spinner behavior with problematic output streams"""

351

352 class BrokenStream:

353 def write(self, _):

354 raise IOError("Write failed")

355

356 def flush(self):

357 raise IOError("Flush failed")

358

359 # Test with broken stream

360 with pytest.raises(IOError):

361 with SpinnerContext(output_stream=BrokenStream()): # type: ignore

362 time.sleep(0.01)

363

364

365 def test_spinner_width_calculations():

366 """Test that individual updates are width-compliant"""

367 narrow_width = 40

368 spinner = Spinner()

369 spinner.term_width = narrow_width

370

371 test_value = "ABC"

372 spinner.update_value(test_value)

373

374 # Format a single update line

375 output_line = spinner.format_string.format(

376 spinner=spinner.config.working[0],

377 elapsed_time=0.0,

378 message="Test: ",

379 value=test_value,

380 ).rstrip() # Remove any trailing spaces but keep \r

381

382 # Check the actual content length (excluding \r)

383 content_length = len(output_line.replace("\r", ""))

384 assert (

385 content_length <= narrow_width

386 ), f"Line too long: {content_length} > {narrow_width}"

387

388

389 def test_format_string_updates():

390 """Test that format_string_when_updated modifies the formatting as expected"""

391 spinner = Spinner(

392 format_string="Normal: {value}", format_string_when_updated="Update: {value}"

393 )

394

395 # Test normal format

396 normal = spinner.format_string.format(

397 spinner=spinner.config.working[0], elapsed_time=0.0, message="", value="test"

398 )

399 assert normal.startswith("Normal: ")

400

401 # Test update format

402 spinner.value_changed = True

403 if spinner.format_string_when_updated:

404 update = spinner.format_string_when_updated.format(

405 spinner=spinner.config.working[0],

406 elapsed_time=0.0,

407 message="",

408 value="test",

409 )

410 assert update.startswith("Update: ")

411

412

413 def test_spinner_state_handling():

414 """Test spinner state transitions and error handling"""

415 spinner = Spinner()

416 assert spinner.state == "initialized"

417

418 # Test normal flow

419 spinner.start()

420 assert spinner.state == "running"

421 spinner.stop(failed=False)

422 assert spinner.state == "success"

423

424 # Test error flow

425 spinner = Spinner()

426 spinner.start()

427 spinner.stop(failed=True)

428 assert spinner.state == "fail"

429

430 # Test context manager error handling

431 error_caught = False

432 try:

433 with SpinnerContext() as sp:

434 raise ValueError("Test error")

435 except ValueError:

436 error_caught = True

437 assert sp.state == "fail"

438

439 assert error_caught, "Context manager should propagate errors"

440

441

442@pytest.mark.parametrize(

443 "kwargs, expected_substrings, find_all",

444 [

445 (

446 # Input

447 dict(

448 format_string="Test: {message}{value}", # Include message placeholder

449 message="MSG:",

450 initial_value="VAL",

451 ),

452 # Expected substrings

453 ["Test:", "MSG:", "VAL"],

454 True,

455 ),

456 (

457 # Input with empty values

458 dict(format_string="{spinner}{value}", message="", initial_value=""),

459 # Should at least contain a spinner char

460 [char for char in Spinner().config.working],

461 False,

462 ),

463 ],

464)

465 def test_spinner_output_formatting(kwargs, expected_substrings, find_all):

466 """Test that spinner formatting is applied correctly without checking exact output"""

467

468 spinner = Spinner(**kwargs)

469 # Test format string compilation

470 output = spinner.format_string.format(

471 spinner=spinner.config.working[0],

472 elapsed_time=0.0,

473 message=spinner.message,

474 value=spinner.current_value,

475 )

476

477 # Verify required parts are present

478 if find_all:

479 for part in expected_substrings:

480 assert part in output, f"Missing {part} in output: {output}"

481 else:

482 found = False

483 for part in expected_substrings:

484 if part in output:

485 found = True

486 break

487 assert found, f"None of {expected_substrings} found in output: {output}"

488

489

490@pytest.mark.parametrize(

491 "config",

492 [

493 "default",

494 "clock",

495 ["A", "B"],

496 ["A", "B", "C"],

497 dict(),

498 dict(working=["X", "Y"]),

499 dict(success="OK"),

500 dict(fail="NO"),

501 dict(working=["1", "2"], success="OK"),

502 dict(working=["1", "2"], fail="NO"),

503 dict(success="OK", fail="NO"),

504 dict(working=["1", "2"], success="OK", fail="NO"),

505 SpinnerConfig(working=["1", "2"]),

506 SpinnerConfig(working=["1", "2"], success="OK", fail="NO"),

507 ],

508)

509 def test_spinner_config_validation(config):

510 """Test that spinner configuration is validated correctly"""

511 sp = Spinner(config=config)

512 if isinstance(config, str):

513 pass

514 elif isinstance(config, list):

515 assert sp.config.working == config

516 elif isinstance(config, dict):

517 if "working" in config:

518 assert sp.config.working == config["working"]

519 if "success" in config:

520 assert sp.config.success == config["success"]

521 if "fail" in config:

522 assert sp.config.fail == config["fail"]

523 elif isinstance(config, SpinnerConfig):

524 assert sp.config == config

525 assert sp.config.working == config.working

526 assert sp.config.success == config.success

527 assert sp.config.fail == config.fail

528

529

530@pytest.mark.parametrize(

531 "kwargs, exception_type",

532 [

533 (dict(config=123), TypeError),

534 (dict(config=123.45), TypeError),

535 (dict(config=True), TypeError),

536 (dict(config=None), TypeError),

537 (dict(config=""), KeyError),

538 (dict(config="nonexistent_style"), KeyError),

539 (dict(config=[]), ValueError),

540 (dict(config=dict(nonexistent_key="stuff")), TypeError),

541 (dict(format_string="{invalid_key}"), ValueError),

542 (dict(unknown_param=123), ValueError),

543 ],

544)

545 def test_spinner_init_invalid(kwargs, exception_type):

546 """Test invalid initialization scenarios"""

547 with pytest.raises(exception_type):

548 Spinner(**kwargs)

549

550

551@pytest.mark.parametrize(

552 "config,expected",

553 [

554 # ASCII-only configurations

555 (SpinnerConfig(working=["|", "/", "-", "\\"], success="#", fail="X"), True),

556 (SpinnerConfig(working=[".", "o", "O"], success="*", fail="x"), True),

557 (SpinnerConfig(working=["[ ]", "[- ]"], success="[--]", fail="[XX]"), True),

558 # Non-ASCII configurations

559 (SpinnerConfig(working=["←", "↑", "→", "↓"], success="►", fail="✖"), False),

560 (SpinnerConfig(working=["🌍", "🌎", "🌏"], success="✔️", fail="❌"), False),

561 (SpinnerConfig(working=["⠋", "⠙", "⠹"], success="⣿", fail="⢿"), False),

562 # Mixed ASCII and Unicode

563 (SpinnerConfig(working=["->", "=>", "→"], success="✓", fail="X"), False),

564 # Whitespace

565 (SpinnerConfig(working=[" ", " |", "| "], success=" ", fail=" "), True),

566 ],

567)

568 def test_is_ascii(config: SpinnerConfig, expected: bool):

569 """Test is_ascii() method with various configurations"""

570 assert config.is_ascii() is expected

571

572

573@pytest.mark.parametrize(

574 "config,expected",

575 [

576 # Equal length configurations

577 (SpinnerConfig(working=["abc", "def", "ghi"], success="xyz", fail="err"), True),

578 (SpinnerConfig(working=["🌍 ", "🌎 ", "🌏 "], success="✔️ ", fail="❌ "), False),

579 (SpinnerConfig(working=["[=]", "[-]", "[|]"], success="[#]", fail="[X]"), True),

580 # Varying length configurations

581 (SpinnerConfig(working=[".", "..", "..."], success="***", fail="x"), False),

582 (

583 SpinnerConfig(

584 working=["[ ]", "[= ]", "[===]"], success="[==]", fail="[X]"

585 ),

586 False,

587 ),

588 (

589 SpinnerConfig(working=["📡 ", "📡· ", "📡···"], success="📡", fail="❌"),

590 False,

591 ),

592 # Whitespace variations

593 (SpinnerConfig(working=[" ", " ", " "], success=" ", fail=" "), False),

594 # Single characters

595 (SpinnerConfig(working=["a", "b", "c"], success="x", fail="y"), True),

596 ],

597)

598 def test_eq_lens(config: SpinnerConfig, expected: bool):

599 """Test eq_lens() method with various configurations"""

600 assert config.eq_lens() is expected

601

602

603@pytest.mark.parametrize(

604 "config",

605 [

606 # Valid configurations

607 SpinnerConfig(working=["|", "/", "-", "\\"], success="#", fail="X"),

608 SpinnerConfig(working=["🌍", "🌎", "🌏"], success="✔️", fail="❌"),

609 SpinnerConfig(working=["⠋"], success="⣿", fail="⢿"), # Single working char

610 SpinnerConfig(working=[""], success="", fail=""), # Empty strings

611 ],

612)

613 def test_valid_configs(config: SpinnerConfig):

614 """Test valid spinner configurations"""

615 assert config.is_valid() is True

616

617

618@pytest.mark.parametrize(

619 "invalid_config",

620 [

621 # Invalid working list type

622 pytest.param(

623 lambda: SpinnerConfig(working="|/-\\", success="#", fail="X"), # type: ignore[arg-type]

624 id="string-instead-of-list",

625 ),

626 # Invalid working list contents

627 pytest.param(

628 lambda: SpinnerConfig(working=[1, 2, 3], success="#", fail="X"), # type: ignore[list-item]

629 id="non-string-items",

630 ),

631 # Invalid success type

632 pytest.param(

633 lambda: SpinnerConfig(working=["|", "/"], success=123, fail="X"), # type: ignore[arg-type]

634 id="non-string-success",

635 ),

636 # Invalid fail type

637 pytest.param(

638 lambda: SpinnerConfig(working=["|", "/"], success="#", fail=None), # type: ignore[arg-type]

639 id="none-fail",

640 ),

641 # Empty working list

642 pytest.param(

643 lambda: SpinnerConfig(working=[], success="#", fail="X"),

644 id="empty-working-list",

645 ),

646 ],

647)

648 def test_invalid_configs(invalid_config):

649 """Test that invalid configurations raise ValueError"""

650 with pytest.raises(ValueError):

651 invalid_config()

652

653

654@pytest.mark.parametrize(

655 "config,ascii_result,eq_lens_result",

656 [

657 # Mixed ASCII/Unicode test cases

658 (

659 SpinnerConfig(working=["->", "=>", "→"], success="✓", fail="X"),

660 False, # is_ascii

661 False, # eq_lens

662 ),

663 # Multi-byte Unicode test cases

664 (

665 SpinnerConfig(working=["🌍", "🌎"], success="🎉", fail="💥"),

666 False, # is_ascii

667 True, # eq_lens

668 ),

669 # Whitespace test cases

670 (

671 SpinnerConfig(working=[" ", " |", "| "], success="VV", fail="XX"),

672 True, # is_ascii

673 True, # eq_lens

674 ),

675 # Empty string test cases

676 (

677 SpinnerConfig(working=[""], success="", fail=""),

678 True, # is_ascii

679 True, # eq_lens

680 ),

681 ],

682)

683 def test_combined_validations(

684 config: SpinnerConfig, ascii_result: bool, eq_lens_result: bool

685):

686 """Test multiple validation methods together"""

687 assert config.is_valid() is True

688 assert config.is_ascii() is ascii_result

689 assert config.eq_lens() is eq_lens_result

690

691

692# Additional edge cases and stress tests

693@pytest.mark.parametrize(

694 "test_input,expected_valid",

695 [

696 # Very long strings

697 (

698 SpinnerConfig(

699 working=["a" * 1000, "b" * 1000], success="c" * 1000, fail="d" * 1000

700 ),

701 True,

702 ),

703 # Special characters

704 (SpinnerConfig(working=["\n", "\t", "\r"], success="\n", fail="\r"), True),

705 # Mixed newlines and spaces

706 (SpinnerConfig(working=[" \n ", " \t ", " \r "], success=" ", fail=" "), True),

707 ],

708)

709 def test_edge_cases(test_input: SpinnerConfig, expected_valid: bool):

710 """Test edge cases and special scenarios"""

711 assert test_input.is_valid() is expected_valid

712

713

714@pytest.mark.parametrize(

715 "name, config",

716 list(SPINNERS.items()),

717)

718 def test_all(name: str, config: SpinnerConfig):

719 """Test all spinner configurations"""

720 assert config.is_valid()

721 # just test the methods run, they won't all be ASCII or equal length

722 config.is_ascii()

723 config.eq_lens()

724

725 # creating config from this config

726 assert SpinnerConfig.from_any(name) == config

727 assert SpinnerConfig.from_any(config) == config

728 assert (

729 SpinnerConfig.from_any(

730 dict(working=config.working, success=config.success, fail=config.fail)

731 )

732 == config

733 )