diff --git a/code_puppy/callbacks.py b/code_puppy/callbacks.py index 047a70a02..f748d61b5 100644 --- a/code_puppy/callbacks.py +++ b/code_puppy/callbacks.py @@ -34,6 +34,7 @@ "register_model_providers", "message_history_processor_start", "message_history_processor_end", + "register_loading_messages", ] CallbackFunc = Callable[..., Any] @@ -68,6 +69,7 @@ "register_model_providers": [], "message_history_processor_start": [], "message_history_processor_end": [], + "register_loading_messages": [], } logger = logging.getLogger(__name__) @@ -672,3 +674,31 @@ def on_message_history_processor_end( messages_added, messages_filtered, ) + + +def on_register_loading_messages() -> List[Any]: + """Trigger callbacks to register additional loading messages. + + Plugins can register callbacks that call + ``loading_messages.register_messages(category, messages)`` + to inject their own spinner / status display messages. + + This is fired once, lazily, when loading messages are first needed. + + Example callback (in a plugin's register_callbacks.py):: + + from code_puppy.callbacks import register_callback + from code_puppy.messaging.loading_messages import register_messages + + def _add_my_messages(): + register_messages("my_org", [ + "rolling back prices...", + "stocking the shelves...", + ]) + + register_callback("register_loading_messages", _add_my_messages) + + Returns: + List of results from registered callbacks. + """ + return _trigger_callbacks_sync("register_loading_messages") diff --git a/code_puppy/messaging/loading_messages.py b/code_puppy/messaging/loading_messages.py new file mode 100644 index 000000000..63b8a353d --- /dev/null +++ b/code_puppy/messaging/loading_messages.py @@ -0,0 +1,356 @@ +"""Loading messages for spinner and status display. + +Provides a shuffled deck of fun/silly loading messages that rotate +during spinner cycles. Messages are drawn from several categories +and shuffled so no message repeats until the entire deck is exhausted. + +Plugins can register additional message categories via +``register_messages(category, messages)``. +""" + +import random +import threading +from typing import Dict, List, Optional + +# --------------------------------------------------------------------------- +# 🐢 Puppy / Dog Themed +# --------------------------------------------------------------------------- +_PUPPY_SPINNER: List[str] = [ + "sniffing around...", + "wagging tail...", + "pawsing for a moment...", + "chasing tail...", + "digging up results...", + "barking at the data...", + "rolling over...", + "panting with excitement...", + "chewing on it...", + "prancing along...", + "howling at the code...", + "snuggling up to the task...", + "bounding through data...", + "fetching results...", + "playing fetch with ideas...", + "doing a happy dance...", + "perking up ears...", + "tilting head curiously...", + "burying a bone of knowledge...", + "shaking off the cobwebs...", + "begging for treats...", + "chasing the mailman...", + "guarding the codebase...", + "herding the functions...", + "learning new tricks...", + "napping on the keyboard...", + "pawing at the screen...", + "sniffing out bugs...", + "doing zoomies...", + "gnawing on a problem...", + "licking the screen...", + "being a good boy...", + "drooling over clean code...", + "marking territory...", + "digging a hole in the stack...", + "catching a frisbee...", + "whimpering at legacy code...", + "yipping with excitement...", + "rolling in the data...", + "scratching behind ear...", + "snoozing then coding...", + "burying the lede...", + "unleashing the code...", +] + +# --------------------------------------------------------------------------- +# πŸ’» Dev / Coding +# --------------------------------------------------------------------------- +_DEV_SPINNER: List[str] = [ + "refactoring reality...", + "compiling thoughts...", + "parsing the universe...", + "rebasing on reality...", + "merging timelines...", + "deploying neurons...", + "linting the cosmos...", + "cherry-picking ideas...", + "stashing thoughts...", + "hashing it out...", + "vibe-coding...", +] + +# --------------------------------------------------------------------------- +# 🎲 Random Fun / Silly +# --------------------------------------------------------------------------- +_FUN_SPINNER: List[str] = [ + "consulting the oracle...", + "asking the rubber duck...", + "reading tea leaves...", + "shaking the magic 8-ball...", + "channeling the force...", + "summoning the kraken...", + "calibrating the vibe...", + "vibing...", + "manifesting...", + "reticulating splines...", + "reversing the polarity...", + "rerouting the dilithium...", + "consulting the ancient texts...", + "interrogating the void...", + "pondering the orb...", + "adjusting the timeline...", + "wrangling the chaos...", + "herding cats...", + "spinning up hamster wheels...", + "brewing coffee...", + "microwaving leftovers...", + "ordering pizza...", + "updating the spreadsheet...", + "googling the answer...", + "Stack Overflowing...", + "reading the docs...", + "RTFM-ing...", + "blaming the intern...", + "turning it off and on...", + "wiggling the cables...", + "downloading more RAM...", + "feeding the hamsters...", + "dusting off the cobwebs...", + "polishing the pixels...", + "aligning the chakras...", + "balancing the universe...", + "crunching the numbers...", + "doing the math...", + "phoning a friend...", + "asking the audience...", + "inserting coin...", + "doing your work for you...", +] + +# --------------------------------------------------------------------------- +# ⚑ Action Verbs (short & punchy) +# --------------------------------------------------------------------------- +_ACTION_SPINNER: List[str] = [ + "deliberating...", + "contemplating...", + "hypothesizing...", + "brainstorming...", + "strategizing...", + "orchestrating...", + "crafting...", + "sculpting...", + "weaving...", + "assembling...", + "constructing...", + "investigating...", + "researching...", + "exploring...", + "discovering...", + "transforming...", + "transmuting...", + "conjuring...", + "invoking...", + "materializing...", + "crystallizing...", + "distilling...", + "curating...", + "polishing...", + "refining...", +] + + +# =========================================================================== +# STANDALONE MESSAGES (no prefix β€” used only in the status display) +# =========================================================================== +_STANDALONE_MESSAGES: List[str] = [ + # 🐢 Puppy + "Puppy pondering...", + "Nose to the ground...", + "Tail-wagging intensifies...", + "Sitting. Staying. Coding...", + "Belly rub loading...", + "Paws on keyboard...", + "Puppy eyes activated...", + # 🎲 Fun + "Loading loading screen...", + "It's not a bug...", + "Works on my machine...", + "Have you tried unplugging?", + "Percussive maintenance...", + "Deleting System32... jk...", + "50/50 lifeline used...", + "Plot twist incoming...", + "Stay tuned...", + "Warming up the flux capacitor...", + # πŸ€“ Nerdy / Pop culture + "sudo make me a sandwich...", + "There is no spoon...", + "Hello, World!...", + "I'm sorry Dave...", + "To infinity and beyond...", + "May the source be with you...", + "Use the --force, Luke...", + "Live long and prosper...", + "Beam me up, Scotty...", + "Winter is compiling...", + "One does not simply code...", + "My precious... data...", + "I am Groot (processing)...", + "Avengers, assemble code...", + "This is the way...", + "I have spoken...", + "Bazinga!...", + "Allons-y!...", + "Fantastic!...", + "Geronimo!...", + "Elementary, my dear...", + "The cake is a lie...", + "Do a barrel roll...", + "All your base are belong...", + "It's dangerous to go alone...", +] + +# =========================================================================== +# Plugin Registry +# =========================================================================== +_plugin_categories: Dict[str, List[str]] = {} +_plugins_initialized: bool = False +_plugins_init_lock = threading.Lock() +_plugin_categories_lock = threading.RLock() + + +def _ensure_plugins_loaded() -> None: + """Fire the register_loading_messages callback once. + + This is called lazily the first time messages are requested, + giving plugins time to register their callbacks at import. + Uses double-checked locking to avoid TOCTOU races from + concurrent spinner threads. + """ + global _plugins_initialized + if _plugins_initialized: + return + with _plugins_init_lock: + if _plugins_initialized: + return + _plugins_initialized = True + + try: + from code_puppy.callbacks import on_register_loading_messages + + on_register_loading_messages() + except Exception: + _plugins_initialized = False + raise + + +_RESERVED_CATEGORIES = frozenset({"puppy", "dev", "fun", "action"}) + + +def register_messages(category: str, messages: List[str]) -> None: + """Register additional loading messages from a plugin. + + All plugin-registered messages are included in the **spinner rotation** + (prefixed with ``" is "``). If you need display-only + standalone messages, use the ``"standalone"`` category β€” those are + included in ``get_all_messages()`` but excluded from the spinner. + + Parameters + ---------- + category: + A unique plugin-specific category name (e.g. ``"walmart"``). + Core category names (puppy, dev, fun, action) are + reserved and will raise ``ValueError``. If the category + already exists the new messages are **appended**. + messages: + List of message strings. For spinner messages keep them + lowercase and gerund-style (e.g. ``"rolling back prices..."``). + + Raises + ------ + ValueError + If ``category`` is empty or collides with a reserved core name. + """ + if not isinstance(category, str) or not category.strip(): + raise ValueError("category must be a non-empty string") + category = category.strip() + if category in _RESERVED_CATEGORIES: + raise ValueError( + f"'{category}' is a reserved core category; " + "use a plugin-specific category name" + ) + with _plugin_categories_lock: + if category in _plugin_categories: + _plugin_categories[category].extend(messages) + else: + _plugin_categories[category] = list(messages) + + +def unregister_messages(category: str) -> None: + """Remove a previously registered message category.""" + with _plugin_categories_lock: + _plugin_categories.pop(category, None) + + +# =========================================================================== +# Public API +# =========================================================================== + + +def _plugin_snapshot() -> Dict[str, List[str]]: + """Return a consistent shallow copy of all plugin categories.""" + with _plugin_categories_lock: + return {cat: list(msgs) for cat, msgs in _plugin_categories.items()} + + +def _all_spinner_messages( + snapshot: Optional[Dict[str, List[str]]] = None, +) -> List[str]: + """Combine built-in + plugin spinner messages (not standalone).""" + _ensure_plugins_loaded() + if snapshot is None: + snapshot = _plugin_snapshot() + combined = _PUPPY_SPINNER + _DEV_SPINNER + _FUN_SPINNER + _ACTION_SPINNER + for cat, msgs in snapshot.items(): + if cat != "standalone": + combined = combined + msgs + return combined + + +def get_spinner_messages() -> List[str]: + """Return a shuffled copy of all spinner messages. + + Each call produces a fresh shuffle so that a spinner can draw + through the entire deck without repeats. + """ + msgs = _all_spinner_messages() + random.shuffle(msgs) + return msgs + + +def get_all_messages() -> List[str]: + """Return all messages (spinner + standalone) for status display.""" + _ensure_plugins_loaded() + snapshot = _plugin_snapshot() + plugin_standalone = snapshot.pop("standalone", []) + return ( + _all_spinner_messages(snapshot) + list(_STANDALONE_MESSAGES) + plugin_standalone + ) + + +def get_messages_by_category() -> Dict[str, List[str]]: + """Return messages organized by category (useful for testing).""" + _ensure_plugins_loaded() + result = { + "puppy": list(_PUPPY_SPINNER), + "dev": list(_DEV_SPINNER), + "fun": list(_FUN_SPINNER), + "action": list(_ACTION_SPINNER), + "standalone": list(_STANDALONE_MESSAGES), + } + with _plugin_categories_lock: + for cat, msgs in _plugin_categories.items(): + if cat in result: + result[cat] = result[cat] + list(msgs) + else: + result[cat] = list(msgs) + return result diff --git a/code_puppy/messaging/spinner/console_spinner.py b/code_puppy/messaging/spinner/console_spinner.py index 114c41800..4f46dda88 100644 --- a/code_puppy/messaging/spinner/console_spinner.py +++ b/code_puppy/messaging/spinner/console_spinner.py @@ -10,6 +10,7 @@ from rich.live import Live from rich.text import Text +from .shimmer import shimmer_text from .spinner_base import SpinnerBase @@ -125,8 +126,8 @@ def _generate_spinner_panel(self): text = Text() - # Show thinking message during normal processing - text.append(SpinnerBase.THINKING_MESSAGE, style="bold cyan") + # Show rotating thinking message with shimmer animation + text.append_text(shimmer_text(self.current_thinking_message, base="cyan")) text.append(self.current_frame, style="bold cyan") context_info = SpinnerBase.get_context_info() diff --git a/code_puppy/messaging/spinner/shimmer.py b/code_puppy/messaging/spinner/shimmer.py new file mode 100644 index 000000000..09049516e --- /dev/null +++ b/code_puppy/messaging/spinner/shimmer.py @@ -0,0 +1,90 @@ +"""Shimmer / shine animation effect for Rich Text. + +Produces a bright highlight wave that sweeps left-to-right across a +string, then pauses briefly off-screen before looping. Three tiers +of brightness create a soft gradient: + + base β†’ mid β†’ peak β†’ mid β†’ base + ────shimmer zone──── + +Usage: + text_obj = shimmer_text("Rolling back prices...", base="cyan") +""" + +import time + +from rich.text import Text + +# ---- Defaults --------------------------------------------------------------- +_SPEED: float = 15.0 # chars / second +_WIDTH: int = 6 # total shimmer zone width (chars) +_PADDING: int = 12 # extra chars of "off-screen" pause between loops + +# Style tiers per base colour +_STYLE_MAP: dict[str, tuple[str, str, str]] = { + # base colour β†’ (base_style, mid_style, peak_style) + "cyan": ("cyan", "bold bright_cyan", "bold white"), + "yellow": ("yellow", "bold bright_yellow", "bold white"), + "green": ("green", "bold bright_green", "bold white"), + "magenta": ("magenta", "bold bright_magenta", "bold white"), + "blue": ("blue", "bold bright_blue", "bold white"), +} + + +def shimmer_text( + message: str, + base: str = "cyan", + *, + speed: float = _SPEED, + width: int = _WIDTH, + padding: int = _PADDING, +) -> Text: + """Return a Rich ``Text`` with a travelling shimmer highlight. + + Parameters + ---------- + message: + The plain string to render. + base: + Colour family key (see ``_STYLE_MAP``). Falls back to cyan. + speed: + How many character-positions the highlight moves per second. + width: + Width of the shimmer zone in characters. + padding: + Extra invisible distance the highlight travels off-screen + before wrapping β€” creates a natural pause between sweeps. + """ + text_len = len(message) + if text_len == 0: + return Text("") + + if speed <= 0: + raise ValueError("speed must be > 0") + if width <= 0: + raise ValueError("width must be > 0") + if padding < 0: + raise ValueError("padding must be >= 0") + + base_style, mid_style, peak_style = _STYLE_MAP.get(base, _STYLE_MAP["cyan"]) + + # The highlight travels across [0 … text_len + padding], giving it + # room to fully exit the visible text before re-entering. + total_travel = text_len + padding + half_w = width / 2.0 + + # Current centre of the shimmer (wraps continuously) + centre = (time.monotonic() * speed) % total_travel + + result = Text() + for i, char in enumerate(message): + dist = abs(i - centre) + if dist <= half_w * 0.35: + style = peak_style + elif dist <= half_w * 0.7: + style = mid_style + else: + style = base_style + result.append(char, style=style) + + return result diff --git a/code_puppy/messaging/spinner/spinner_base.py b/code_puppy/messaging/spinner/spinner_base.py index 4e7991bdb..301592ff1 100644 --- a/code_puppy/messaging/spinner/spinner_base.py +++ b/code_puppy/messaging/spinner/spinner_base.py @@ -6,6 +6,7 @@ from threading import Lock from code_puppy.config import get_puppy_name +from code_puppy.messaging.loading_messages import get_spinner_messages class SpinnerBase(ABC): @@ -25,7 +26,7 @@ class SpinnerBase(ABC): ] puppy_name = get_puppy_name().title() - # Default message when processing + # Default message when processing (kept for backward compat) THINKING_MESSAGE = f"{puppy_name} is thinking... " # Message when waiting for user input @@ -41,12 +42,23 @@ def __init__(self): """Initialize the spinner.""" self._is_spinning = False self._frame_index = 0 + # Shuffled deck of messages β€” each start() draws the next card. + # No message repeats until the whole deck is exhausted. + self._message_deck = get_spinner_messages() or ["thinking..."] + self._message_index = -1 @abstractmethod def start(self): - """Start the spinner animation.""" + """Start the spinner animation. + + Each start() picks the next message from the shuffled deck. + The message stays locked in for the entire spin cycle. + """ + if self._is_spinning: + return self._is_spinning = True self._frame_index = 0 + self._advance_message() @abstractmethod def stop(self): @@ -55,10 +67,25 @@ def stop(self): @abstractmethod def update_frame(self): - """Update to the next frame.""" + """Update to the next animation frame (puppy bounce only).""" if self._is_spinning: self._frame_index = (self._frame_index + 1) % len(self.FRAMES) + def _advance_message(self) -> None: + """Draw the next message from the deck, re-shuffling when exhausted.""" + self._message_index += 1 + if self._message_index >= len(self._message_deck): + self._message_deck = get_spinner_messages() or ["thinking..."] + self._message_index = 0 + + @property + def current_thinking_message(self) -> str: + """Get the current rotating thinking message.""" + prefix = f"{self.puppy_name} is " + idx = max(self._message_index, 0) + msg = self._message_deck[idx] if self._message_deck else "thinking..." + return f"{prefix}{msg} " + @property def current_frame(self): """Get the current frame.""" diff --git a/code_puppy/status_display.py b/code_puppy/status_display.py index 0f6afea3f..d4a7c4f4d 100644 --- a/code_puppy/status_display.py +++ b/code_puppy/status_display.py @@ -8,6 +8,8 @@ from rich.spinner import Spinner from rich.text import Text +from code_puppy.messaging.loading_messages import get_all_messages + # Global variable to track current token per second rate CURRENT_TOKEN_RATE = 0.0 _TOKEN_RATE_LOCK = threading.Lock() @@ -29,23 +31,7 @@ def __init__(self, console: Console): self.is_active = False self.task = None self.live = None - self.loading_messages = [ - "Fetching...", - "Sniffing around...", - "Wagging tail...", - "Pawsing for a moment...", - "Chasing tail...", - "Digging up results...", - "Barking at the data...", - "Rolling over...", - "Panting with excitement...", - "Chewing on it...", - "Prancing along...", - "Howling at the code...", - "Snuggling up to the task...", - "Bounding through data...", - "Puppy pondering...", - ] + self.loading_messages = get_all_messages() or ["Loading..."] self.current_message_index = 0 self.spinner = Spinner("dots", text="") diff --git a/tests/test_console_spinner_coverage.py b/tests/test_console_spinner_coverage.py index 5c470675e..1b7fdac20 100644 --- a/tests/test_console_spinner_coverage.py +++ b/tests/test_console_spinner_coverage.py @@ -377,8 +377,9 @@ def test_generate_panel_when_awaiting_input_returns_empty(self): assert str(result) == "" def test_generate_panel_includes_thinking_message(self): - """Test that panel includes thinking message.""" + """Test that panel includes a rotating loading message with puppy name.""" from code_puppy.messaging.spinner.console_spinner import ConsoleSpinner + from code_puppy.messaging.spinner.spinner_base import SpinnerBase spinner = ConsoleSpinner(console=MagicMock()) spinner._paused = False @@ -390,7 +391,9 @@ def test_generate_panel_includes_thinking_message(self): result = spinner._generate_spinner_panel() result_str = str(result) - assert "thinking" in result_str.lower() + # Should contain the puppy name prefix (e.g. "Blu is ...") + assert SpinnerBase.puppy_name.lower() in result_str.lower() + assert " is " in result_str def test_generate_panel_includes_current_frame(self): """Test that panel includes current spinner frame.""" diff --git a/tests/test_loading_messages.py b/tests/test_loading_messages.py new file mode 100644 index 000000000..970bcaac4 --- /dev/null +++ b/tests/test_loading_messages.py @@ -0,0 +1,120 @@ +"""Tests for loading_messages module.""" + +from unittest.mock import patch + +import code_puppy.messaging.loading_messages as _lm + +from code_puppy.messaging.loading_messages import ( + get_all_messages, + get_messages_by_category, + get_spinner_messages, + register_messages, + unregister_messages, +) + + +class TestGetSpinnerMessages: + """Tests for get_spinner_messages.""" + + def test_returns_list(self): + result = get_spinner_messages() + assert isinstance(result, list) + assert len(result) > 0 + + def test_returns_shuffled_copy(self): + """Returns a shuffled copy without mutating source semantics.""" + with patch( + "code_puppy.messaging.loading_messages.random.shuffle" + ) as mock_shuffle: + a = get_spinner_messages() + b = get_spinner_messages() + # shuffle was called on the returned list + mock_shuffle.assert_called_once() + # Same contents regardless of order + assert sorted(a) == sorted(b) + + def test_does_not_include_standalone(self): + """Spinner messages should not include standalone messages.""" + cats = get_messages_by_category() + spinner = get_spinner_messages() + for standalone_msg in cats["standalone"]: + assert standalone_msg not in spinner + + +class TestGetAllMessages: + def test_includes_standalone(self): + all_msgs = get_all_messages() + cats = get_messages_by_category() + for msg in cats["standalone"]: + assert msg in all_msgs + + def test_includes_spinner_messages(self): + all_msgs = get_all_messages() + cats = get_messages_by_category() + # Verify spinner messages contribute to the combined list + assert len(all_msgs) > len(cats["standalone"]) + + +class TestGetMessagesByCategory: + def test_has_expected_categories(self): + cats = get_messages_by_category() + for key in ("puppy", "dev", "fun", "action", "standalone"): + assert key in cats + assert len(cats[key]) > 0 + + +class TestPluginRegistry: + """Tests for register_messages / unregister_messages.""" + + def teardown_method(self): + """Clean up any test categories and reset plugin state.""" + with _lm._plugin_categories_lock: + _lm._plugin_categories.clear() + _lm._plugins_initialized = False + + def test_register_new_category(self): + register_messages("test_cat", ["zipping...", "zapping..."]) + msgs = get_spinner_messages() + assert "zipping..." in msgs + assert "zapping..." in msgs + + def test_register_appends_to_existing(self): + register_messages("test_cat", ["alpha..."]) + register_messages("test_cat", ["beta..."]) + cats = get_messages_by_category() + assert "alpha..." in cats["test_cat"] + assert "beta..." in cats["test_cat"] + + def test_unregister_removes_category(self): + register_messages("test_cat2", ["gone..."]) + unregister_messages("test_cat2") + cats = get_messages_by_category() + assert "test_cat2" not in cats + + def test_unregister_nonexistent_is_noop(self): + unregister_messages("does_not_exist") # should not raise + + def test_register_reserved_category_raises(self): + """Registering a reserved core category name should raise ValueError.""" + import pytest + + for reserved in ("puppy", "dev", "fun", "action"): + with pytest.raises(ValueError, match="reserved core category"): + register_messages(reserved, ["should fail..."]) + + def test_register_standalone_category_allowed(self): + """Plugins can register standalone messages for display-only use.""" + register_messages("standalone", ["custom standalone..."]) + all_msgs = get_all_messages() + assert "custom standalone..." in all_msgs + # Clean up + unregister_messages("standalone") + + def test_register_empty_category_raises(self): + """Registering an empty category name should raise ValueError.""" + import pytest + + with pytest.raises(ValueError, match="non-empty string"): + register_messages("", ["should fail..."]) + with pytest.raises(ValueError, match="non-empty string"): + register_messages(" ", ["should fail..."]) diff --git a/tests/test_shimmer.py b/tests/test_shimmer.py new file mode 100644 index 000000000..fcf626ca7 --- /dev/null +++ b/tests/test_shimmer.py @@ -0,0 +1,66 @@ +"""Tests for shimmer animation effect.""" + +from unittest.mock import patch + +from rich.text import Text + +from code_puppy.messaging.spinner.shimmer import shimmer_text + + +class TestShimmerText: + """Tests for shimmer_text function.""" + + def test_returns_rich_text(self): + result = shimmer_text("hello") + assert isinstance(result, Text) + + def test_empty_string_returns_empty_text(self): + result = shimmer_text("") + assert str(result) == "" + + def test_preserves_message_content(self): + msg = "Rolling back prices..." + result = shimmer_text(msg) + assert str(result) == msg + + def test_accepts_all_base_colours(self): + for colour in ("cyan", "yellow", "green", "magenta", "blue"): + result = shimmer_text("test", base=colour) + assert str(result) == "test" + + def test_falls_back_to_cyan_for_unknown_colour(self): + result = shimmer_text("test", base="neon_pink") + assert str(result) == "test" + + def test_shimmer_position_changes_over_time(self): + """The shimmer highlight should move β€” styles should differ at different times.""" + msg = "A" * 30 + with patch("code_puppy.messaging.spinner.shimmer.time") as mock_time: + mock_time.monotonic.return_value = 0.0 + r1 = shimmer_text(msg) + mock_time.monotonic.return_value = 1.0 + r2 = shimmer_text(msg) + # At different times the style spans should differ + assert r1.spans != r2.spans + + def test_custom_speed_width_padding(self): + result = shimmer_text("hi", speed=5.0, width=2, padding=4) + assert str(result) == "hi" + + def test_raises_on_zero_speed(self): + import pytest + + with pytest.raises(ValueError, match="speed must be > 0"): + shimmer_text("hello", speed=0) + + def test_raises_on_negative_width(self): + import pytest + + with pytest.raises(ValueError, match="width must be > 0"): + shimmer_text("hello", width=-1) + + def test_raises_on_negative_padding(self): + import pytest + + with pytest.raises(ValueError, match="padding must be >= 0"): + shimmer_text("hello", padding=-5) diff --git a/tests/test_status_display.py b/tests/test_status_display.py index c80999206..05eb1fdb9 100644 --- a/tests/test_status_display.py +++ b/tests/test_status_display.py @@ -35,7 +35,7 @@ def test_initialization(self, status_display): assert status_display.is_active is False assert status_display.task is None assert status_display.live is None - assert len(status_display.loading_messages) == 15 + assert len(status_display.loading_messages) > 0 # dynamic loading messages assert status_display.current_message_index == 0 def test_initialization_with_custom_console(self):