From f24824195a39af8fb67344caff6df40fe76549ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:11:09 +0000 Subject: [PATCH 1/5] Initial plan From 1ce629444e4563c07e8c68611ef9cde836dcba69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:46:45 +0000 Subject: [PATCH 2/5] Changes before error encountered Co-authored-by: MobyNL <59473010+MobyNL@users.noreply.github.com> --- PokemonLibrary/battle/battle_actions.py | 125 ++++++++++++++----- PokemonLibrary/buildings.py | 19 ++- PokemonLibrary/ui/battle_mixin.py | 158 ++++++++++++++++++++---- PokemonLibrary/ui/building_mixin.py | 23 +++- PokemonLibrary/ui/game_flow_mixin.py | 21 +++- PokemonLibrary/ui/text_animation.py | 60 ++++++++- tests/battle/test_battle_mixin.py | 62 ++++++---- tests/game/test_buildings.py | 27 ++-- 8 files changed, 396 insertions(+), 99 deletions(-) diff --git a/PokemonLibrary/battle/battle_actions.py b/PokemonLibrary/battle/battle_actions.py index 09bcf5f..8e9c80f 100644 --- a/PokemonLibrary/battle/battle_actions.py +++ b/PokemonLibrary/battle/battle_actions.py @@ -436,27 +436,33 @@ def attempt_flee( handle_pokemon_fainted_callback(output) -def attempt_catch_pokemon( +def _begin_catch_attempt( game_state: "GameState", output: RichLog, pending_command_callback, show_battle_options_callback, - end_battle_callback, - handle_pokemon_fainted_callback, ball_type: str = "Pokeball", -) -> None: +) -> "Optional[tuple]": """ - Attempt to catch the wild Pokemon with a Pokeball. + Validate a catch attempt, consume the ball, and compute the shake result. + + This is the first phase of the catch sequence used by the animated mixin + method. Handles error cases (trainer battle, no balls) and returns the + catch result tuple for the caller to animate and finalise. Args: game_state: The game state output: The RichLog widget to write to pending_command_callback: Callback to set pending command show_battle_options_callback: Callback to show battle options - end_battle_callback: Callback to end battle - handle_pokemon_fainted_callback: Callback to handle Pokemon fainted - ball_type: Type of ball to use (Pokeball, Great Ball, Ultra Ball, Master Ball) + ball_type: Type of ball to use + + Returns: + ``(caught, shakes, wild, messages)`` on success, or ``None`` if an + early-exit error was already written to *output*. """ + from typing import Optional # noqa: F401 (TYPE_CHECKING guard) + battle = game_state.battle_state # Can't catch trainer Pokemon! @@ -468,13 +474,11 @@ def attempt_catch_pokemon( output.write("") show_battle_options_callback(output) pending_command_callback("battle") - return + return None items = game_state.game_data.get("items", {}) - # Check inventory for the chosen ball type; fall back to any available ball if items.get(ball_type, 0) <= 0: - # Try to find any available ball to inform the player output.write("") if ball_type == "Pokeball": output.write("[red]❌ You have no Pokeballs![/red]") @@ -485,7 +489,7 @@ def attempt_catch_pokemon( output.write("") show_battle_options_callback(output) pending_command_callback("battle") - return + return None wild = battle.wild_pokemon @@ -498,20 +502,41 @@ def attempt_catch_pokemon( output.write(f"[bold cyan]You threw a {ball_type} at wild {wild['name']}![/bold cyan]") output.write("") - # Attempt catch caught, shakes, messages = battle.attempt_catch(ball_type) + return caught, shakes, wild, messages - # Show custom messages from catch attempt - for msg in messages: - output.write(msg) - # Show wiggle animation - wiggle_text = "● " * shakes + "○ " * (4 - shakes) - output.write(f"[dim]{wiggle_text.strip()}[/dim]") - output.write("") +def _finish_catch_attempt( + game_state: "GameState", + output: RichLog, + caught: bool, + shakes: int, + wild: dict, + pending_command_callback, + show_battle_options_callback, + end_battle_callback, + handle_pokemon_fainted_callback, +) -> None: + """ + Finalise a catch attempt after the shake animation has been shown. + + Handles adding the Pokemon to the party (or PC), Pokédex registration, + and failed-catch aftermath. + + Args: + game_state: The game state + output: The RichLog widget to write to + caught: Whether the Pokemon was caught + shakes: Number of successful shakes before breaking free + wild: The wild Pokemon dict + pending_command_callback: Callback to set pending command + show_battle_options_callback: Callback to show battle options + end_battle_callback: Callback to end the battle + handle_pokemon_fainted_callback: Callback to handle player Pokemon fainting + """ + battle = game_state.battle_state if caught: - # Successful catch! pokemon_party = game_state.game_data.get("pokemon", []) if len(pokemon_party) >= 6: @@ -554,8 +579,6 @@ def attempt_catch_pokemon( f"[red] Your party and PC are both full! {wild['name']} could not be stored.[/red]" ) else: - # Add to party - # Remove battle-specific fields and prepare for party caught_pokemon = { "name": wild["name"], "number": wild.get("number", 0), @@ -573,7 +596,6 @@ def attempt_catch_pokemon( pokemon_party.append(caught_pokemon) output.write(f"[bold green]★ Gotcha! {wild['name']} was caught! ★[/bold green]") - # Mark as caught in Pokedex species = wild.get("species", wild["name"]).upper() if pokedex.mark_as_caught(game_state, species): output.write(f"[dim]📖 Pokedex: {wild['name']} was registered as caught![/dim]") @@ -588,11 +610,9 @@ def attempt_catch_pokemon( output.write(f"[green]✓ {wild['name']} was added to your party![/green]") output.write("") - # End battle on successful catch _stats.record_catch(game_state, wild.get("species", wild["name"])) end_battle_callback(output) else: - # Failed catch if shakes == 0: output.write(f"[yellow]Oh no! {wild['name']} broke free immediately![/yellow]") elif shakes == 1: @@ -603,10 +623,8 @@ def attempt_catch_pokemon( output.write("[yellow]Gah! It was so close, too![/yellow]") output.write("") - # Wild Pokemon gets a free turn execute_wild_pokemon_turn(game_state, output) - # Check if player fainted if battle.player_pokemon["hp"] <= 0: handle_pokemon_fainted_callback(output) else: @@ -614,6 +632,57 @@ def attempt_catch_pokemon( pending_command_callback("battle") +def attempt_catch_pokemon( + game_state: "GameState", + output: RichLog, + pending_command_callback, + show_battle_options_callback, + end_battle_callback, + handle_pokemon_fainted_callback, + ball_type: str = "Pokeball", +) -> None: + """ + Attempt to catch the wild Pokemon with a Pokeball. + + Args: + game_state: The game state + output: The RichLog widget to write to + pending_command_callback: Callback to set pending command + show_battle_options_callback: Callback to show battle options + end_battle_callback: Callback to end battle + handle_pokemon_fainted_callback: Callback to handle Pokemon fainted + ball_type: Type of ball to use (Pokeball, Great Ball, Ultra Ball, Master Ball) + """ + result = _begin_catch_attempt( + game_state, output, pending_command_callback, show_battle_options_callback, ball_type + ) + if result is None: + return + + caught, shakes, wild, messages = result + + # Show custom messages from catch attempt + for msg in messages: + output.write(msg) + + # Show synchronous wiggle animation (animated version lives in BattleMixin) + wiggle_text = "● " * shakes + "○ " * (4 - shakes) + output.write(f"[dim]{wiggle_text.strip()}[/dim]") + output.write("") + + _finish_catch_attempt( + game_state, + output, + caught, + shakes, + wild, + pending_command_callback, + show_battle_options_callback, + end_battle_callback, + handle_pokemon_fainted_callback, + ) + + def execute_switch( game_state: "GameState", target: str, diff --git a/PokemonLibrary/buildings.py b/PokemonLibrary/buildings.py index 3c6c68e..75ae652 100644 --- a/PokemonLibrary/buildings.py +++ b/PokemonLibrary/buildings.py @@ -907,14 +907,18 @@ def enter_players_house( output.write("") -def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) -> None: +async def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) -> None: """ - Actually perform the Pokemon Center healing. + Actually perform the Pokemon Center healing with animated reveals. Args: game_state: The game state object output: The RichLog widget to write to """ + import asyncio + + from .ui.text_animation import reveal_lines + pokemon = game_state.game_data.get("pokemon", []) # Record this as the last visited Pokemon Center @@ -928,7 +932,8 @@ def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) -> Non output.write("[cyan] ♪ Healing sound ♪[/cyan]") output.write("") - # Show healed Pokemon and restore HP + PP + status + # Build per-Pokemon healing lines and restore stats up front + heal_lines = [] for p in pokemon: if not isinstance(p, str): p["hp"] = p.get("max_hp", p["hp"]) @@ -938,11 +943,13 @@ def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) -> Non p["status"] = None name = p["name"] if had_status: - output.write(f" [green]✓ {name} restored to full health and cured![/green]") + heal_lines.append(f" [green]✓ {name} restored to full health and cured![/green]") else: - output.write(f" [green]✓ {name} restored to full health![/green]") + heal_lines.append(f" [green]✓ {name} restored to full health![/green]") else: - output.write(f" [green]✓ {p} restored to full health![/green]") + heal_lines.append(f" [green]✓ {p} restored to full health![/green]") + + await reveal_lines(output, heal_lines, delay=0.5) output.write("") output.write("[bold]Nurse Joy:[/bold] [magenta]Your Pokemon are now fully healed![/magenta]") diff --git a/PokemonLibrary/ui/battle_mixin.py b/PokemonLibrary/ui/battle_mixin.py index f70e065..f8e3402 100644 --- a/PokemonLibrary/ui/battle_mixin.py +++ b/PokemonLibrary/ui/battle_mixin.py @@ -6,18 +6,39 @@ item use during battle. """ +import asyncio +import inspect import random -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, List, Optional from textual.widgets import RichLog, Static from ..battle import battle_actions, battle_ui from ..ui.formatters import format_hp_bar +from ..ui.text_animation import progressive_reveal, reveal_lines, simple_delay if TYPE_CHECKING: pass # Avoid circular imports — self is always a PokemonTerminal at runtime +def _run_coro(coro) -> None: + """Schedule or run a coroutine depending on whether an event loop is running. + + If *coro* is not a coroutine (e.g. a sync stub in tests), the call is a + no-op so that tests with synchronous mock overrides continue to work. + """ + if not asyncio.iscoroutine(coro): + return + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.create_task(coro) + else: + loop.run_until_complete(coro) + except RuntimeError: + asyncio.run(coro) + + class BattleMixin: """Mixin providing the full battle system for PokemonTerminal.""" @@ -154,16 +175,16 @@ def process_battle_command(self, command: str, output: RichLog) -> None: self.pending_command = "battle" elif cmd in ("throw pokeball", "pokeball", "catch", "ball", "throw"): - self.attempt_catch_pokemon(output) + _run_coro(self.attempt_catch_pokemon(output)) elif cmd in ("throw great ball", "great ball"): - self.attempt_catch_pokemon(output, ball_type="Great Ball") + _run_coro(self.attempt_catch_pokemon(output, ball_type="Great Ball")) elif cmd in ("throw ultra ball", "ultra ball"): - self.attempt_catch_pokemon(output, ball_type="Ultra Ball") + _run_coro(self.attempt_catch_pokemon(output, ball_type="Ultra Ball")) elif cmd in ("throw master ball", "master ball"): - self.attempt_catch_pokemon(output, ball_type="Master Ball") + _run_coro(self.attempt_catch_pokemon(output, ball_type="Master Ball")) elif cmd in ("use super potion", "super potion"): self._use_heal_item(output, "Super Potion", 50) @@ -234,8 +255,8 @@ def parse_move_choice(self, command: str, player: dict) -> Optional[dict]: return move return None - def execute_player_move(self, command: str, output: RichLog) -> None: - """Execute the player's chosen move and then run the opponent's turn.""" + async def execute_player_move(self, command: str, output: RichLog) -> None: + """Execute the player's chosen move with animated message reveals.""" battle = self.game_state.battle_state player = battle.player_pokemon @@ -260,11 +281,12 @@ def execute_player_move(self, command: str, output: RichLog) -> None: return # ── Player's turn ─────────────────────────────────────────────────── - output.write("") - output.write(f"[bold]{player['name']} used {move['name']}![/bold]") - messages = battle.execute_move(player, battle.wild_pokemon, move["name"]) - for msg in messages: - output.write(msg) + player_messages: List[str] = [ + "", + f"[bold]{player['name']} used {move['name']}![/bold]", + ] + player_messages.extend(battle.execute_move(player, battle.wild_pokemon, move["name"])) + await reveal_lines(output, player_messages, delay=0.3) if battle.wild_pokemon["hp"] <= 0: self.handle_battle_victory(output) @@ -337,21 +359,67 @@ def attempt_flee(self, output: RichLog) -> None: self.handle_pokemon_fainted, ) - def attempt_catch_pokemon(self, output: RichLog, ball_type: str = "Pokeball") -> None: + async def attempt_catch_pokemon(self, output: RichLog, ball_type: str = "Pokeball") -> None: """Attempt to catch the wild Pokemon with a Pokeball. + Uses the progressive shake animation from ``text_animation`` to reveal + each shake before showing the final catch result. + Args: output: RichLog widget. ball_type: Type of ball to throw (Pokeball, Great Ball, Ultra Ball, Master Ball). """ - battle_actions.attempt_catch_pokemon( + result = battle_actions._begin_catch_attempt( + self.game_state, + output, + lambda cmd: setattr(self, "pending_command", cmd), + self.show_battle_options, + ball_type, + ) + if result is None: + return + + caught, shakes, wild, messages = result + + for msg in messages: + output.write(msg) + + # Build shake steps and final message for the animation + shake_steps = ["●"] * shakes if shakes > 0 else [] + if caught: + final_msg = f"[bold green]★ Gotcha! {wild['name']} was caught! ★[/bold green]" + elif shakes == 0: + final_msg = f"[yellow]Oh no! {wild['name']} broke free immediately![/yellow]" + elif shakes == 1: + final_msg = "[yellow]Aww! It appeared to be caught![/yellow]" + elif shakes == 2: + final_msg = "[yellow]Aargh! Almost had it![/yellow]" + else: + final_msg = "[yellow]Gah! It was so close, too![/yellow]" + + if shake_steps: + await progressive_reveal( + output, + base_text="[dim]The ball is shaking...[/dim]", + steps=shake_steps, + delay=0.8, + final_text=final_msg, + ) + else: + output.write(final_msg) + + output.write("") + + battle_actions._finish_catch_attempt( self.game_state, output, + caught, + shakes, + wild, lambda cmd: setattr(self, "pending_command", cmd), self.show_battle_options, self.end_battle, self.handle_pokemon_fainted, - ball_type=ball_type, ) def show_pokemon_switch_menu(self, output: RichLog) -> None: @@ -464,7 +532,7 @@ def handle_battle_victory(self, output: RichLog) -> None: self.show_battle_options, self.handle_trainer_defeated, self.end_battle, - self._queue_evolution_pending, + lambda *a: _run_coro(self._queue_evolution_pending(*a)), self._queue_move_learn, ) @@ -474,8 +542,27 @@ def handle_trainer_defeated(self, output: RichLog) -> None: if battle and battle.pending_evolution: player_ref, evo_target = battle.pending_evolution battle.pending_evolution = None - self._queue_evolution_pending(player_ref, evo_target, "trainer_defeated", output) + _run_coro( + self._queue_evolution_pending(player_ref, evo_target, "trainer_defeated", output) + ) return + # Add badge ceremony animation for gym leaders + if battle and battle.trainer_data.get("trainer_class") == "Gym Leader": + _run_coro(self._animated_trainer_defeated(output)) + else: + battle_actions.handle_trainer_defeated(self.game_state, output, self.end_battle) + + async def _animated_trainer_defeated(self, output: RichLog) -> None: + """Play a ceremonial badge reveal then finalise trainer defeat.""" + battle = self.game_state.battle_state + trainer = battle.trainer_data if battle else {} + badge_ceremony: List[str] = [ + "", + f"[bold yellow]★ You defeated {trainer.get('name', 'the Gym Leader')}! ★[/bold yellow]", + "", + ] + await reveal_lines(output, badge_ceremony, delay=0.6) + await simple_delay(1.0) battle_actions.handle_trainer_defeated(self.game_state, output, self.end_battle) def handle_pokemon_fainted(self, output: RichLog) -> None: @@ -621,7 +708,9 @@ def _resume_after_move_learn(self, post_action: str, output: RichLog) -> None: evo_target = None if evo_target: battle.pending_evolution = None - self._queue_evolution_pending(pokemon, evo_target, post_action, output) + _run_coro( + self._queue_evolution_pending(pokemon, evo_target, post_action, output) + ) return # Resume post_action @@ -646,22 +735,37 @@ def _resume_after_move_learn(self, post_action: str, output: RichLog) -> None: output.write("") self.end_battle(output) - def _queue_evolution_pending( + async def _queue_evolution_pending( self, pokemon: dict, evolved_into: str, post_action: str, output: RichLog ) -> None: - """Queue a pending evolution for player confirmation via the evolution panel.""" + """Queue a pending evolution for player confirmation via the evolution panel. + + Plays a dramatic reveal sequence before showing the evolution panel. + """ pokemon_name = pokemon.get("name", "POKÉMON") self.pending_command_data["evolving_pokemon"] = pokemon self.pending_command_data["evolves_into"] = evolved_into self.pending_command_data["evolution_post_action"] = post_action - output.write("") - output.write(f"[bold yellow]✨ What? {pokemon_name} is evolving![/bold yellow]") - output.write("") - output.write("[dim] ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇[/dim]") - output.write("") - output.write(f" [dim]{pokemon_name} → {evolved_into}[/dim]") - output.write("") + evolution_intro = [ + "", + f"[bold yellow]✨ What? {pokemon_name} is evolving![/bold yellow]", + "", + ] + await reveal_lines(output, evolution_intro, delay=0.8) + await simple_delay(1.0) + + output.write("[bold cyan]★ ★ ★ ★ ★ ★ ★ ★ ★ ★[/bold cyan]") + await simple_delay(0.5) + + await reveal_lines( + output, + [ + f" [dim]{pokemon_name} → {evolved_into}[/dim]", + "", + ], + delay=0.8, + ) try: self.query_one("#evolution-title", Static).update( diff --git a/PokemonLibrary/ui/building_mixin.py b/PokemonLibrary/ui/building_mixin.py index dc19cf6..226fe06 100644 --- a/PokemonLibrary/ui/building_mixin.py +++ b/PokemonLibrary/ui/building_mixin.py @@ -5,6 +5,7 @@ Pokemon Center and Mom healing flows, and exploration delegates. """ +import asyncio import random from typing import TYPE_CHECKING @@ -17,6 +18,24 @@ pass # Avoid circular imports — self is always a PokemonTerminal at runtime +def _run_coro(coro) -> None: + """Schedule or run a coroutine depending on whether an event loop is running. + + If *coro* is not a coroutine (e.g. a sync stub in tests), the call is a + no-op so that tests with synchronous mock overrides continue to work. + """ + if not asyncio.iscoroutine(coro): + return + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.create_task(coro) + else: + loop.run_until_complete(coro) + except RuntimeError: + asyncio.run(coro) + + class BuildingMixin: """Mixin providing buildings, shop, location movement, and name selection.""" @@ -66,7 +85,7 @@ def handle_heal_center_confirmation(self, response: str, output: RichLog) -> Non """Handle Pokemon Center healing confirmation.""" response_lower = response.lower().strip() if response_lower in ("yes", "y", "heal"): - buildings.perform_pokemon_center_heal(self.game_state, output) + _run_coro(buildings.perform_pokemon_center_heal(self.game_state, output)) elif response_lower in ("no", "n", "leave", "exit"): self.hide_all_panels() output.write("") @@ -132,7 +151,7 @@ def _handle_pokemon_center_command(self, user_input: str, output: RichLog) -> No """Handle text commands while in the Pokemon Center lobby.""" cmd = user_input.lower().strip() if cmd in ("heal", "heal pokemon", "yes"): - buildings.perform_pokemon_center_heal(self.game_state, output) + _run_coro(buildings.perform_pokemon_center_heal(self.game_state, output)) self._return_to_pokemon_center(output) elif cmd in ("pc", "use pc", "computer", "bill", "bill's pc"): self._open_pc_from_center(output) diff --git a/PokemonLibrary/ui/game_flow_mixin.py b/PokemonLibrary/ui/game_flow_mixin.py index 98bf19e..c1ad1e5 100644 --- a/PokemonLibrary/ui/game_flow_mixin.py +++ b/PokemonLibrary/ui/game_flow_mixin.py @@ -6,6 +6,7 @@ main App class. """ +import asyncio from typing import TYPE_CHECKING, Optional from textual.widgets import RichLog @@ -16,6 +17,24 @@ pass # Avoid circular imports — self is always a PokemonTerminal at runtime +def _run_coro(coro) -> None: + """Schedule or run a coroutine depending on whether an event loop is running. + + If *coro* is not a coroutine (e.g. a sync stub in tests), the call is a + no-op so that tests with synchronous mock overrides continue to work. + """ + if not asyncio.iscoroutine(coro): + return + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.create_task(coro) + else: + loop.run_until_complete(coro) + except RuntimeError: + asyncio.run(coro) + + class GameFlowMixin: """Mixin providing menu, save/load, quit, and evolution-resume flows.""" @@ -139,7 +158,7 @@ def handle_pending_command(self, user_input: str, output: RichLog) -> None: elif cmd_type == "select_move": output.write(f"[bold yellow]\U0001f3ae >[/bold yellow] {user_input}") - self.execute_player_move(user_input, output) + _run_coro(self.execute_player_move(user_input, output)) elif cmd_type == "shop": self.process_shop_command(user_input, output) diff --git a/PokemonLibrary/ui/text_animation.py b/PokemonLibrary/ui/text_animation.py index 63fccf8..7449ae5 100644 --- a/PokemonLibrary/ui/text_animation.py +++ b/PokemonLibrary/ui/text_animation.py @@ -1,14 +1,72 @@ """ Text animation utilities for Pokemon Terminal. -Provides functions to display text with animation effects like typewriter. +Provides functions to display text with animation effects like typewriter, +line-by-line reveals, and progressive shake sequences. """ +import asyncio from typing import Callable, List, Optional from textual.widgets import RichLog +async def reveal_lines(output: RichLog, lines: List[str], delay: float = 0.4) -> None: + """Write lines one at a time with a pause between each. + + Useful for dialogue sequences, healing animations, and narration. + + Args: + output: The RichLog widget to write to + lines: List of text lines (may include Rich markup) + delay: Seconds between lines (0.2=fast, 0.4=medium, 0.8=slow) + """ + for line in lines: + output.write(line) + await asyncio.sleep(delay) + + +async def progressive_reveal( + output: RichLog, + base_text: str, + steps: List[str], + delay: float = 0.8, + final_text: Optional[str] = None, +) -> None: + """Show a progressive build-up animation (e.g. Pokeball shake sequence). + + Writes a base message, then reveals each step with a delay, building up + an accumulated display. After all steps, writes the optional final text. + + Args: + output: The RichLog widget to write to + base_text: Introductory line shown before the steps + steps: Sequence of step markers (e.g. ["●", "●", "●"] for 3 shakes) + delay: Seconds between each step reveal + final_text: Optional closing line shown after all steps complete + """ + if base_text: + output.write(base_text) + accumulated: List[str] = [] + for step in steps: + accumulated.append(step) + output.write(f"[dim]{' '.join(accumulated)}[/dim]") + await asyncio.sleep(delay) + if final_text: + output.write(final_text) + + +async def simple_delay(seconds: float) -> None: + """Wait for a specified duration without writing any output. + + Useful for dramatic pauses between animation phases. + + Args: + seconds: Duration to wait in seconds + """ + await asyncio.sleep(seconds) + + class AnimatedTextWriter: """Helper class to manage animated text writing.""" diff --git a/tests/battle/test_battle_mixin.py b/tests/battle/test_battle_mixin.py index 02b9d1c..2826552 100644 --- a/tests/battle/test_battle_mixin.py +++ b/tests/battle/test_battle_mixin.py @@ -5,7 +5,9 @@ handle_battle_victory, handle_pokemon_fainted, etc. """ +import asyncio import random +from unittest.mock import AsyncMock, patch import pytest @@ -297,37 +299,41 @@ def test_q_shows_cannot_quit(self, term, output): class TestExecutePlayerMove: - def test_back_shows_battle_options(self, term, output): + @pytest.fixture + def mock_sleep(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + + async def test_back_shows_battle_options(self, term, output, mock_sleep): setup_wild_battle(term) - term.execute_player_move("back", output) + await term.execute_player_move("back", output) assert term._calls.get("show_battle_options") is True assert term.pending_command == "battle" - def test_invalid_move_shows_error(self, term, output): + async def test_invalid_move_shows_error(self, term, output, mock_sleep): setup_wild_battle(term) - term.execute_player_move("xyz_invalid_move", output) + await term.execute_player_move("xyz_invalid_move", output) assert "Unknown move" in output.combined or "❌" in output.combined assert term.pending_command == "select_move" - def test_valid_move_by_number(self, term, output): + async def test_valid_move_by_number(self, term, output, mock_sleep): setup_wild_battle(term, "PIKACHU", "RATTATA", 5) - term.execute_player_move("1", output) + await term.execute_player_move("1", output) assert len(output.lines) > 0 - def test_move_with_no_pp(self, term, output): + async def test_move_with_no_pp(self, term, output, mock_sleep): bs = setup_wild_battle(term) # Zero out PP on first move bs.player_pokemon["moves"][0]["pp"] = 0 - term.execute_player_move("1", output) + await term.execute_player_move("1", output) assert "no PP" in output.combined.lower() or "❌" in output.combined - def test_kills_wild_triggers_victory(self, term, output): + async def test_kills_wild_triggers_victory(self, term, output, mock_sleep): bs = setup_wild_battle(term) # Make wild very weak bs.wild_pokemon["hp"] = 1 bs.wild_pokemon["max_hp"] = 1 # Use first move - term.execute_player_move("1", output) + await term.execute_player_move("1", output) # Either handle_battle_victory was called or wild still alive (randomness) assert len(output.lines) > 0 @@ -706,28 +712,32 @@ def test_no_match_returns_none(self, ext_term): class TestExecutePlayerMoveFaintPaths: - def test_player_faints_after_opponent_move(self, ext_term, ext_output): + @pytest.fixture + def mock_sleep(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + + async def test_player_faints_after_opponent_move(self, ext_term, ext_output, mock_sleep): """Player pokemon faints after opponent's counter-attack.""" bs = setup_wild_ext(ext_term) # Give player 1 hp so almost any opponent move faints them bs.player_pokemon["hp"] = 1 - ext_term.execute_player_move("1", ext_output) + await ext_term.execute_player_move("1", ext_output) # No crash expected assert len(ext_output.lines) > 0 - def test_wild_faints_after_player_move(self, ext_term, ext_output): + async def test_wild_faints_after_player_move(self, ext_term, ext_output, mock_sleep): """Wild pokemon faints after player move — victory triggered.""" bs = setup_wild_ext(ext_term) # Make wild very weak bs.wild_pokemon["hp"] = 1 bs.wild_pokemon["max_hp"] = 1 - ext_term.execute_player_move("1", ext_output) + await ext_term.execute_player_move("1", ext_output) assert len(ext_output.lines) > 0 - def test_zero_pp_move_rejected(self, ext_term, ext_output): + async def test_zero_pp_move_rejected(self, ext_term, ext_output, mock_sleep): bs = setup_wild_ext(ext_term) bs.player_pokemon["moves"][0]["pp"] = 0 - ext_term.execute_player_move("1", ext_output) + await ext_term.execute_player_move("1", ext_output) assert "no PP" in ext_output.combined.lower() or "❌" in ext_output.combined def test_trainer_battle_uses_trainer_ai(self, ext_term, ext_output): @@ -880,10 +890,11 @@ def test_end_battle_in_gym_lobby(self, ext_term, ext_output): class TestQueueEvolutionPending: - def test_sets_pending_command(self, ext_term, ext_output): + async def test_sets_pending_command(self, ext_term, ext_output, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) setup_wild_ext(ext_term) pokemon = ext_term.game_state.battle_state.player_pokemon - ext_term._queue_evolution_pending(pokemon, "RAICHU", "wild_end", ext_output) + await ext_term._queue_evolution_pending(pokemon, "RAICHU", "wild_end", ext_output) assert ext_term.pending_command == "confirm_evolution" assert "RAICHU" in ext_output.combined assert ext_term.pending_command_data.get("evolves_into") == "RAICHU" @@ -1126,7 +1137,8 @@ def _force_faint(output): class TestEndOfTurnEffects: - def test_eot_kills_wild(self): + async def test_eot_kills_wild(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) term = FaintableTerminal() bs = setup_wild_ext(term) bs.wild_pokemon["hp"] = 200 @@ -1140,10 +1152,11 @@ def _mock_wild_turn(output): term.execute_wild_pokemon_turn = _mock_wild_turn output = MockRichLog() - term.execute_player_move("1", output) + await term.execute_player_move("1", output) assert len(output.lines) > 0 - def test_eot_kills_player(self): + async def test_eot_kills_player(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) term = FaintableTerminal() bs = setup_wild_ext(term) bs.wild_pokemon["hp"] = 200 @@ -1157,7 +1170,7 @@ def _mock_wild_turn_poison_player(output): term.execute_wild_pokemon_turn = _mock_wild_turn_poison_player output = MockRichLog() - term.execute_player_move("1", output) + await term.execute_player_move("1", output) assert len(output.lines) > 0 @@ -1370,13 +1383,14 @@ def query_one(self, selector, widget_type=None): class TestQueueEvolutionException: - def test_exception_in_try_block_is_swallowed(self): + async def test_exception_in_try_block_is_swallowed(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) term = EvolutionExceptionTerminal() bs = setup_wild_ext(term) pokemon = bs.player_pokemon output = MockRichLog() # Should not raise despite exception in query_one - term._queue_evolution_pending(pokemon, "RAICHU", "wild_end", output) + await term._queue_evolution_pending(pokemon, "RAICHU", "wild_end", output) assert term.pending_command == "confirm_evolution" diff --git a/tests/game/test_buildings.py b/tests/game/test_buildings.py index 0520325..f2fcfbf 100644 --- a/tests/game/test_buildings.py +++ b/tests/game/test_buildings.py @@ -6,6 +6,9 @@ perform_mom_heal, enter_rivals_house, enter_oaks_lab, choose_starter_pokemon. """ +import asyncio +from unittest.mock import AsyncMock + import pytest from PokemonLibrary.buildings import ( @@ -268,33 +271,37 @@ def test_player_name_shown(self, gs, output): class TestPerformPokemonCenterHeal: - def test_heals_all_pokemon(self, gs, output): + @pytest.fixture + def mock_sleep(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + + async def test_heals_all_pokemon(self, gs, output, mock_sleep): p = make_party_pokemon(gs, hp=1) - perform_pokemon_center_heal(gs, output) + await perform_pokemon_center_heal(gs, output) assert p["hp"] == p["max_hp"] - def test_cures_status(self, gs, output): + async def test_cures_status(self, gs, output, mock_sleep): p = make_party_pokemon(gs) p["status"] = "POISON" - perform_pokemon_center_heal(gs, output) + await perform_pokemon_center_heal(gs, output) assert p["status"] is None - def test_restores_pp(self, gs, output): + async def test_restores_pp(self, gs, output, mock_sleep): p = make_party_pokemon(gs) for move in p.get("moves", []): move["pp"] = 0 - perform_pokemon_center_heal(gs, output) + await perform_pokemon_center_heal(gs, output) for move in p.get("moves", []): assert move["pp"] > 0 - def test_writes_output(self, gs, output): + async def test_writes_output(self, gs, output, mock_sleep): make_party_pokemon(gs) - perform_pokemon_center_heal(gs, output) + await perform_pokemon_center_heal(gs, output) assert len(output.lines) > 0 - def test_records_last_pokemon_center(self, gs, output): + async def test_records_last_pokemon_center(self, gs, output, mock_sleep): make_party_pokemon(gs) - perform_pokemon_center_heal(gs, output) + await perform_pokemon_center_heal(gs, output) assert gs.game_data.get("last_pokemon_center") is not None From 2edb87cc0afb342a407dd85d45ceb53b783ba758 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:37:32 +0000 Subject: [PATCH 3/5] Fix CI failures: add test_text_animation.py, fix ruff format/lint, remove duplicate catch message Co-authored-by: MobyNL <59473010+MobyNL@users.noreply.github.com> --- PokemonLibrary/battle/battle_actions.py | 34 ++-- PokemonLibrary/buildings.py | 2 - PokemonLibrary/ui/battle_mixin.py | 13 +- PokemonLibrary/ui/building_mixin.py | 7 +- PokemonLibrary/ui/game_flow_mixin.py | 7 +- tests/battle/test_battle_mixin.py | 2 +- tests/ui/test_text_animation.py | 237 ++++++++++++++++++++++++ 7 files changed, 266 insertions(+), 36 deletions(-) create mode 100644 tests/ui/test_text_animation.py diff --git a/PokemonLibrary/battle/battle_actions.py b/PokemonLibrary/battle/battle_actions.py index 8e9c80f..0c66f1c 100644 --- a/PokemonLibrary/battle/battle_actions.py +++ b/PokemonLibrary/battle/battle_actions.py @@ -442,7 +442,7 @@ def _begin_catch_attempt( pending_command_callback, show_battle_options_callback, ball_type: str = "Pokeball", -) -> "Optional[tuple]": +) -> Optional[tuple]: # (caught, shakes, wild_dict, messages) """ Validate a catch attempt, consume the ball, and compute the shake result. @@ -461,8 +461,6 @@ def _begin_catch_attempt( ``(caught, shakes, wild, messages)`` on success, or ``None`` if an early-exit error was already written to *output*. """ - from typing import Optional # noqa: F401 (TYPE_CHECKING guard) - battle = game_state.battle_state # Can't catch trainer Pokemon! @@ -516,6 +514,7 @@ def _finish_catch_attempt( show_battle_options_callback, end_battle_callback, handle_pokemon_fainted_callback, + already_announced: bool = False, ) -> None: """ Finalise a catch attempt after the shake animation has been shown. @@ -533,6 +532,9 @@ def _finish_catch_attempt( show_battle_options_callback: Callback to show battle options end_battle_callback: Callback to end the battle handle_pokemon_fainted_callback: Callback to handle player Pokemon fainting + already_announced: When True the catch/escape announcement was already + written by the caller (e.g. via ``progressive_reveal``); skip writing + it again to avoid duplicates. """ battle = game_state.battle_state @@ -558,7 +560,8 @@ def _finish_catch_attempt( } placed_box = pc_system.send_to_pc(game_state, caught_pokemon) if placed_box: - output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]") + if not already_announced: + output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]") output.write( f"[yellow] Your party is full — {wild['name']} was sent to {placed_box}![/yellow]" ) @@ -574,7 +577,8 @@ def _finish_catch_attempt( if type_msg: output.write(f"[yellow]{type_msg}[/yellow]") else: - output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]") + if not already_announced: + output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]") output.write( f"[red] Your party and PC are both full! {wild['name']} could not be stored.[/red]" ) @@ -594,7 +598,8 @@ def _finish_catch_attempt( "no_evolve": False, } pokemon_party.append(caught_pokemon) - output.write(f"[bold green]★ Gotcha! {wild['name']} was caught! ★[/bold green]") + if not already_announced: + output.write(f"[bold green]★ Gotcha! {wild['name']} was caught! ★[/bold green]") species = wild.get("species", wild["name"]).upper() if pokedex.mark_as_caught(game_state, species): @@ -613,14 +618,15 @@ def _finish_catch_attempt( _stats.record_catch(game_state, wild.get("species", wild["name"])) end_battle_callback(output) else: - if shakes == 0: - output.write(f"[yellow]Oh no! {wild['name']} broke free immediately![/yellow]") - elif shakes == 1: - output.write("[yellow]Aww! It appeared to be caught![/yellow]") - elif shakes == 2: - output.write("[yellow]Aargh! Almost had it![/yellow]") - elif shakes == 3: - output.write("[yellow]Gah! It was so close, too![/yellow]") + if not already_announced: + if shakes == 0: + output.write(f"[yellow]Oh no! {wild['name']} broke free immediately![/yellow]") + elif shakes == 1: + output.write("[yellow]Aww! It appeared to be caught![/yellow]") + elif shakes == 2: + output.write("[yellow]Aargh! Almost had it![/yellow]") + elif shakes == 3: + output.write("[yellow]Gah! It was so close, too![/yellow]") output.write("") execute_wild_pokemon_turn(game_state, output) diff --git a/PokemonLibrary/buildings.py b/PokemonLibrary/buildings.py index 75ae652..8a70fc7 100644 --- a/PokemonLibrary/buildings.py +++ b/PokemonLibrary/buildings.py @@ -915,8 +915,6 @@ async def perform_pokemon_center_heal(game_state: "GameState", output: RichLog) game_state: The game state object output: The RichLog widget to write to """ - import asyncio - from .ui.text_animation import reveal_lines pokemon = game_state.game_data.get("pokemon", []) diff --git a/PokemonLibrary/ui/battle_mixin.py b/PokemonLibrary/ui/battle_mixin.py index f8e3402..eb24ed3 100644 --- a/PokemonLibrary/ui/battle_mixin.py +++ b/PokemonLibrary/ui/battle_mixin.py @@ -7,7 +7,6 @@ """ import asyncio -import inspect import random from typing import TYPE_CHECKING, List, Optional @@ -30,11 +29,8 @@ def _run_coro(coro) -> None: if not asyncio.iscoroutine(coro): return try: - loop = asyncio.get_event_loop() - if loop.is_running(): - loop.create_task(coro) - else: - loop.run_until_complete(coro) + loop = asyncio.get_running_loop() + loop.create_task(coro) # noqa: RUF006 except RuntimeError: asyncio.run(coro) @@ -420,6 +416,7 @@ async def attempt_catch_pokemon(self, output: RichLog, ball_type: str = "Pokebal self.show_battle_options, self.end_battle, self.handle_pokemon_fainted, + already_announced=True, ) def show_pokemon_switch_menu(self, output: RichLog) -> None: @@ -708,9 +705,7 @@ def _resume_after_move_learn(self, post_action: str, output: RichLog) -> None: evo_target = None if evo_target: battle.pending_evolution = None - _run_coro( - self._queue_evolution_pending(pokemon, evo_target, post_action, output) - ) + _run_coro(self._queue_evolution_pending(pokemon, evo_target, post_action, output)) return # Resume post_action diff --git a/PokemonLibrary/ui/building_mixin.py b/PokemonLibrary/ui/building_mixin.py index 226fe06..187a318 100644 --- a/PokemonLibrary/ui/building_mixin.py +++ b/PokemonLibrary/ui/building_mixin.py @@ -27,11 +27,8 @@ def _run_coro(coro) -> None: if not asyncio.iscoroutine(coro): return try: - loop = asyncio.get_event_loop() - if loop.is_running(): - loop.create_task(coro) - else: - loop.run_until_complete(coro) + loop = asyncio.get_running_loop() + loop.create_task(coro) # noqa: RUF006 except RuntimeError: asyncio.run(coro) diff --git a/PokemonLibrary/ui/game_flow_mixin.py b/PokemonLibrary/ui/game_flow_mixin.py index c1ad1e5..175ed0f 100644 --- a/PokemonLibrary/ui/game_flow_mixin.py +++ b/PokemonLibrary/ui/game_flow_mixin.py @@ -26,11 +26,8 @@ def _run_coro(coro) -> None: if not asyncio.iscoroutine(coro): return try: - loop = asyncio.get_event_loop() - if loop.is_running(): - loop.create_task(coro) - else: - loop.run_until_complete(coro) + loop = asyncio.get_running_loop() + loop.create_task(coro) # noqa: RUF006 except RuntimeError: asyncio.run(coro) diff --git a/tests/battle/test_battle_mixin.py b/tests/battle/test_battle_mixin.py index 2826552..43fbadb 100644 --- a/tests/battle/test_battle_mixin.py +++ b/tests/battle/test_battle_mixin.py @@ -7,7 +7,7 @@ import asyncio import random -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest diff --git a/tests/ui/test_text_animation.py b/tests/ui/test_text_animation.py new file mode 100644 index 0000000..9f5e560 --- /dev/null +++ b/tests/ui/test_text_animation.py @@ -0,0 +1,237 @@ +""" +Tests for PokemonLibrary/ui/text_animation.py. + +Covers: reveal_lines, progressive_reveal, simple_delay, AnimatedTextWriter. +""" + +import asyncio +from unittest.mock import AsyncMock + +from PokemonLibrary.ui.text_animation import ( + AnimatedTextWriter, + progressive_reveal, + reveal_lines, + simple_delay, +) + + +class MockRichLog: + """Minimal stand-in for Textual's RichLog widget.""" + + def __init__(self): + self.lines: list = [] + + def write(self, text: str) -> None: + self.lines.append(str(text)) + + @property + def combined(self) -> str: + return "\n".join(self.lines) + + +# =========================================================================== +# reveal_lines +# =========================================================================== + + +class TestRevealLines: + async def test_writes_all_lines(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await reveal_lines(output, ["line 1", "line 2", "line 3"]) + assert output.lines == ["line 1", "line 2", "line 3"] + + async def test_empty_list_writes_nothing(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await reveal_lines(output, []) + assert output.lines == [] + + async def test_sleep_called_once_per_line(self, monkeypatch): + mock_sleep = AsyncMock(return_value=None) + monkeypatch.setattr(asyncio, "sleep", mock_sleep) + output = MockRichLog() + await reveal_lines(output, ["a", "b", "c"]) + assert mock_sleep.call_count == 3 + + async def test_sleep_uses_given_delay(self, monkeypatch): + mock_sleep = AsyncMock(return_value=None) + monkeypatch.setattr(asyncio, "sleep", mock_sleep) + output = MockRichLog() + await reveal_lines(output, ["x", "y"], delay=0.3) + for call in mock_sleep.call_args_list: + assert call.args[0] == 0.3 + + async def test_rich_markup_preserved(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await reveal_lines(output, ["[bold green]Hello![/bold green]"]) + assert "[bold green]Hello![/bold green]" in output.lines[0] + + async def test_default_delay_is_0_4(self, monkeypatch): + mock_sleep = AsyncMock(return_value=None) + monkeypatch.setattr(asyncio, "sleep", mock_sleep) + output = MockRichLog() + await reveal_lines(output, ["only"]) + assert mock_sleep.call_args.args[0] == 0.4 + + +# =========================================================================== +# progressive_reveal +# =========================================================================== + + +class TestProgressiveReveal: + async def test_writes_base_text(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await progressive_reveal(output, "Base message", []) + assert "Base message" in output.combined + + async def test_empty_base_text_skipped(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await progressive_reveal(output, "", ["●"]) + # First line should not be the empty string + assert output.lines[0] != "" + + async def test_steps_accumulate(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await progressive_reveal(output, "", ["●", "●", "●"], delay=0.8) + # Should have three lines (one per shake), each containing dim markup + dim_lines = [line for line in output.lines if "[dim]" in line] + assert len(dim_lines) == 3 + # Last accumulated line contains all three markers + assert "● ● ●" in dim_lines[-1] + + async def test_final_text_written_after_steps(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await progressive_reveal(output, "Start", ["●"], final_text="Done!") + assert output.lines[-1] == "Done!" + + async def test_no_final_text_omitted(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await progressive_reveal(output, "Start", ["●"]) + assert "Done!" not in output.combined + + async def test_sleep_called_once_per_step(self, monkeypatch): + mock_sleep = AsyncMock(return_value=None) + monkeypatch.setattr(asyncio, "sleep", mock_sleep) + output = MockRichLog() + await progressive_reveal(output, "", ["●", "●"], delay=0.8) + assert mock_sleep.call_count == 2 + + async def test_no_steps_no_sleep(self, monkeypatch): + mock_sleep = AsyncMock(return_value=None) + monkeypatch.setattr(asyncio, "sleep", mock_sleep) + output = MockRichLog() + await progressive_reveal(output, "intro", []) + assert mock_sleep.call_count == 0 + + +# =========================================================================== +# simple_delay +# =========================================================================== + + +class TestSimpleDelay: + async def test_sleeps_for_given_duration(self, monkeypatch): + mock_sleep = AsyncMock(return_value=None) + monkeypatch.setattr(asyncio, "sleep", mock_sleep) + await simple_delay(1.5) + mock_sleep.assert_called_once_with(1.5) + + async def test_writes_no_output(self, monkeypatch): + monkeypatch.setattr(asyncio, "sleep", AsyncMock(return_value=None)) + output = MockRichLog() + await simple_delay(0.5) + # simple_delay doesn't take an output; just confirm it runs cleanly + assert output.lines == [] + + async def test_zero_delay(self, monkeypatch): + mock_sleep = AsyncMock(return_value=None) + monkeypatch.setattr(asyncio, "sleep", mock_sleep) + await simple_delay(0.0) + mock_sleep.assert_called_once_with(0.0) + + +# =========================================================================== +# AnimatedTextWriter +# =========================================================================== + + +class MockApp: + """Minimal app stub that records set_interval calls.""" + + def __init__(self): + self.intervals: list = [] + self._timer_stub = type("Timer", (), {"stop": lambda s: None})() + + def set_interval(self, delay, callback): + self.intervals.append((delay, callback)) + return self._timer_stub + + +class TestAnimatedTextWriter: + def test_write_instant_writes_all_lines(self): + app = MockApp() + writer = AnimatedTextWriter(app) + output = MockRichLog() + writer.write_instant(output, ["a", "b", "c"]) + assert output.lines == ["a", "b", "c"] + + def test_write_lines_with_delay_writes_first_line_immediately(self): + app = MockApp() + writer = AnimatedTextWriter(app) + output = MockRichLog() + writer.write_lines_with_delay(output, ["first", "second"], delay=0.5) + assert "first" in output.lines + + def test_write_lines_with_delay_sets_interval_for_remaining(self): + app = MockApp() + writer = AnimatedTextWriter(app) + output = MockRichLog() + writer.write_lines_with_delay(output, ["a", "b", "c"], delay=0.3) + assert len(app.intervals) == 1 + assert app.intervals[0][0] == 0.3 + + def test_write_fast_uses_0_2_delay(self): + app = MockApp() + writer = AnimatedTextWriter(app) + output = MockRichLog() + writer.write_fast(output, ["x", "y"]) + assert app.intervals[0][0] == 0.2 + + def test_write_medium_uses_0_4_delay(self): + app = MockApp() + writer = AnimatedTextWriter(app) + output = MockRichLog() + writer.write_medium(output, ["x", "y"]) + assert app.intervals[0][0] == 0.4 + + def test_write_slow_uses_0_8_delay(self): + app = MockApp() + writer = AnimatedTextWriter(app) + output = MockRichLog() + writer.write_slow(output, ["x", "y"]) + assert app.intervals[0][0] == 0.8 + + def test_cancel_stops_current_timer(self): + app = MockApp() + writer = AnimatedTextWriter(app) + output = MockRichLog() + writer.write_lines_with_delay(output, ["a", "b"], delay=1.0) + writer.cancel() + assert writer.current_timer is None + + def test_write_single_line_no_interval_set(self): + app = MockApp() + writer = AnimatedTextWriter(app) + output = MockRichLog() + writer.write_lines_with_delay(output, ["only one"], delay=0.4) + # With only one line, the first write happens immediately; no interval needed + assert len(app.intervals) == 0 + assert output.lines == ["only one"] From 4574aaee1b7a53908eb4619bbaeff691d5724e32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:51:23 +0000 Subject: [PATCH 4/5] Fix battle animations from button clicks and add post-attack delay Co-authored-by: MobyNL <59473010+MobyNL@users.noreply.github.com> --- PokemonLibrary/terminal.py | 6 +++--- PokemonLibrary/ui/battle_mixin.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/PokemonLibrary/terminal.py b/PokemonLibrary/terminal.py index 4c59814..fb9fb9e 100644 --- a/PokemonLibrary/terminal.py +++ b/PokemonLibrary/terminal.py @@ -465,7 +465,7 @@ def _refresh_subtitle(self) -> None: # ── Event handlers ─────────────────────────────────────────────────────── - def on_button_pressed(self, event: Button.Pressed) -> None: + async def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" button_id = event.button.id output = self.query_one("#output", RichLog) @@ -570,7 +570,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif button_id.startswith("btn-move-"): if button_id == "btn-move-back": output.write("[bold yellow]🎮 >[/bold yellow] Back") - self.execute_player_move("back", output) + await self.execute_player_move("back", output) else: move_index = int(button_id.split("-")[-1]) if self.game_state.battle_state: @@ -579,7 +579,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if move_index < len(moves): move_name = moves[move_index]["name"] output.write(f"[bold yellow]🎮 >[/bold yellow] {move_name}") - self.execute_player_move(move_name, output) + await self.execute_player_move(move_name, output) # Starter Pokemon selection buttons elif button_id.startswith("btn-starter-"): diff --git a/PokemonLibrary/ui/battle_mixin.py b/PokemonLibrary/ui/battle_mixin.py index eb24ed3..dd4c03f 100644 --- a/PokemonLibrary/ui/battle_mixin.py +++ b/PokemonLibrary/ui/battle_mixin.py @@ -290,6 +290,7 @@ async def execute_player_move(self, command: str, output: RichLog) -> None: # ── Opponent's turn ───────────────────────────────────────────────── self.execute_wild_pokemon_turn(output) + await simple_delay(0.5) if player["hp"] <= 0: self.handle_pokemon_fainted(output) return From fa38c860a2a3919075ac3680ce87a0d76bf22a11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:39 +0000 Subject: [PATCH 5/5] ci: add pytest-asyncio to CI install step to fix async test failures Co-authored-by: MobyNL <59473010+MobyNL@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9a8160..fb28a4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: run: | pip install --upgrade pip pip install robotframework robotframework-pythonlibcore textual rich \ - mypy ruff pytest pytest-cov + mypy ruff pytest pytest-cov pytest-asyncio - name: Format check (ruff format) run: ruff format --check PokemonLibrary/ tests/