Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
486 changes: 458 additions & 28 deletions PokemonLibrary/data/pokemon_data.py

Large diffs are not rendered by default.

8 changes: 2 additions & 6 deletions PokemonLibrary/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,7 @@ def compose(self) -> ComposeResult:
yield Button("📦 Box 2", id="btn-pc-view-box-2", classes="pc-box-button")
yield Button("📦 Box 3", id="btn-pc-view-box-3", classes="pc-box-button")
with Horizontal(id="pc-action-buttons", classes="button-row"):
yield Button(
"📥 Deposit Pokémon", id="btn-pc-deposit", classes="pc-action-button"
)
yield Button("📥 Deposit Pokémon", id="btn-pc-deposit", classes="pc-action-button")
yield Button(
"🚪 Leave PC",
id="btn-pc-leave",
Expand Down Expand Up @@ -802,9 +800,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
slot_idx = int(button_id.split("-")[-1])
slot_num = slot_idx + 1
box_num = self.pending_command_data.get("pc_box", 1)
output.write(
f"[bold yellow]🎮 >[/bold yellow] Withdraw Box {box_num} Slot {slot_num}"
)
output.write(f"[bold yellow]🎮 >[/bold yellow] Withdraw Box {box_num} Slot {slot_num}")
self.query_one("#pc-withdraw-panel").add_class("hidden")
from . import pc_system

Expand Down
4 changes: 1 addition & 3 deletions PokemonLibrary/ui/panel_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,9 +661,7 @@ def show_pc_withdraw_panel(self, box_num: int) -> None:
box_key = f"Box {box_num}"
box = storage.get(box_key, [])
party_full = len(self.game_state.game_data.get("pokemon", [])) >= 6
self.query_one("#pc-withdraw-title", Static).update(
f"📤 Withdraw from Box {box_num}"
)
self.query_one("#pc-withdraw-title", Static).update(f"📤 Withdraw from Box {box_num}")
for i in range(6):
btn = self.query_one(f"#btn-pc-withdraw-slot-{i}", Button)
pokemon = box[i] if i < len(box) else None
Expand Down
275 changes: 260 additions & 15 deletions PokemonLibraryTest/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def _capture_screenshot(self, label: str = "Screenshot") -> None:
'style="max-width:100%;border:1px solid #555;border-radius:4px;"/>'
)
logger.info(f"<p><b>{label}</b></p>{img_tag}", html=True)
except Exception as exc: # noqa: BLE001
except Exception as exc:
logger.warn(f"Failed to capture screenshot: {exc}")

def _require_game(self) -> dict:
Expand Down Expand Up @@ -163,7 +163,7 @@ async def close_pokemon_terminal(self) -> None:
if self._run_test_cm is not None:
try:
await self._run_test_cm.__aexit__(None, None, None)
except Exception as exc: # noqa: BLE001
except Exception as exc:
logger.warn(f"Error while closing terminal: {exc}")
self._app = None
self._pilot = None
Expand All @@ -173,7 +173,7 @@ async def close_pokemon_terminal(self) -> None:
# ── Game state setup ──────────────────────────────────────────────────────

@keyword("Bootstrap Game")
def bootstrap_game(self, location: str = "Pallet Town") -> None:
async def bootstrap_game(self, location: str = "Pallet Town") -> None:
"""
Reset to a clean in-game state without going through the UI new-game flow.

Expand Down Expand Up @@ -256,6 +256,9 @@ def bootstrap_game(self, location: str = "Pallet Town") -> None:
self._app._refresh_subtitle()
self._app.query_one("#command-input", Input).focus()

# ── 5. Wait for all Textual messages to settle ───────────────────────
await self._pilot.pause()

logger.info(f"Game bootstrapped at '{location}' via temp save")

# ── Input / interaction ───────────────────────────────────────────────────
Expand Down Expand Up @@ -434,9 +437,7 @@ def output_should_contain(self, expected: str, case_insensitive: bool = False) -
haystack = text.lower() if case_insensitive else text
needle = expected.lower() if case_insensitive else expected
if needle not in haystack:
raise AssertionError(
f"Output does not contain {expected!r}.\n\nFull output:\n{text}"
)
raise AssertionError(f"Output does not contain {expected!r}.\n\nFull output:\n{text}")

@keyword("Output Should Not Contain")
def output_should_not_contain(self, unexpected: str, case_insensitive: bool = False) -> None:
Expand Down Expand Up @@ -648,14 +649,10 @@ def party_pokemon_should_be(self, index: int, expected_name: str) -> None:
party = self._require_game().get("pokemon", [])
index = int(index)
if index >= len(party):
raise AssertionError(
f"Party index {index} out of range (party size: {len(party)})"
)
raise AssertionError(f"Party index {index} out of range (party size: {len(party)})")
actual = party[index]["name"]
if actual != expected_name:
raise AssertionError(
f"Party[{index}]: expected {expected_name!r}, got {actual!r}"
)
raise AssertionError(f"Party[{index}]: expected {expected_name!r}, got {actual!r}")

@keyword("Should Be In Battle")
def should_be_in_battle(self) -> None:
Expand Down Expand Up @@ -689,9 +686,7 @@ def pending_command_should_be(self, expected: str) -> None:
"""
actual = self._app.pending_command
if actual != expected:
raise AssertionError(
f"Pending command: expected {expected!r}, got {actual!r}"
)
raise AssertionError(f"Pending command: expected {expected!r}, got {actual!r}")

@keyword("Pending Command Should Be Empty")
def pending_command_should_be_empty(self) -> None:
Expand Down Expand Up @@ -738,3 +733,253 @@ def widget_should_be_hidden(self, selector: str) -> None:
widget = self._app.query_one(css)
if not widget.has_class("hidden"):
raise AssertionError(f"Widget '{selector}' is visible but expected to be hidden")

# ── Additional game-state manipulation keywords ───────────────────────────

@keyword("Add Badge")
def add_badge(self, badge_name: str) -> None:
"""
Add a gym badge to the player's badge collection.

Accepts the full badge name (e.g. ``Boulder Badge``) and converts it
to the internal badge ID before storing, which is the format used by
``game_data["badges"]``.

Arguments:
- ``badge_name``: Full badge name, e.g. ``Boulder Badge``, ``Cascade Badge``.

Example:
| Add Badge | Boulder Badge |
| Add Badge | Cascade Badge |
"""
from PokemonLibrary.gym_system import BADGES

badge_entry = BADGES.get(badge_name)
if badge_entry is None:
raise ValueError(f"Unknown badge: {badge_name!r}. Valid badges: {list(BADGES.keys())}")
badge_id = badge_entry["id"]

gd = self._require_game()
badges = gd.setdefault("badges", [])
if badge_id not in badges:
badges.append(badge_id)

@keyword("Set Party Pokemon Level")
def set_party_pokemon_level(self, index: int, level: int) -> None:
"""
Set the level of a party Pokemon at *index* to *level*.

Arguments:
- ``index``: 0-based party slot index.
- ``level``: New level value (1-100).

Example:
| Set Party Pokemon Level | 0 | 20 |
"""
party = self._require_game().get("pokemon", [])
index = int(index)
if index >= len(party):
raise AssertionError(f"Party index {index} out of range (party size: {len(party)})")
party[index]["level"] = int(level)
self._app.game_state._deserialize_party()

@keyword("Set Pokemon HP")
def set_pokemon_hp(self, index: int, hp: int) -> None:
"""
Set the current HP of a party Pokemon at *index*.

Arguments:
- ``index``: 0-based party slot index.
- ``hp``: New HP value.

Example:
| Set Pokemon HP | 0 | 1 |
"""
party = self._require_game().get("pokemon", [])
index = int(index)
if index >= len(party):
raise AssertionError(f"Party index {index} out of range (party size: {len(party)})")
party[index]["hp"] = int(hp)
self._app.game_state._deserialize_party()

# ── Additional game-state assertion keywords ──────────────────────────────

@keyword("Badge Count Should Be")
def badge_count_should_be(self, expected: int) -> None:
"""
Fail if the number of earned badges does not equal *expected*.

Arguments:
- ``expected``: Expected badge count (integer).

Example:
| Badge Count Should Be | 0 |
| Badge Count Should Be | 3 |
"""
badges = self._require_game().get("badges", [])
actual = len(badges)
expected = int(expected)
if actual != expected:
raise AssertionError(f"Badge count: expected {expected}, got {actual} ({badges})")

@keyword("Party Size Should Be")
def party_size_should_be(self, expected: int) -> None:
"""
Fail if the number of Pokemon in the party does not equal *expected*.

Arguments:
- ``expected``: Expected party size (integer, 0-6).

Example:
| Party Size Should Be | 1 |
| Party Size Should Be | 3 |
"""
party = self._require_game().get("pokemon", [])
actual = len(party)
expected = int(expected)
if actual != expected:
raise AssertionError(f"Party size: expected {expected}, got {actual}")

@keyword("Party Pokemon Level Should Be")
def party_pokemon_level_should_be(self, index: int, expected_level: int) -> None:
"""
Fail if the level of the party Pokemon at *index* does not equal *expected_level*.

Arguments:
- ``index``: 0-based party slot index.
- ``expected_level``: Expected level value.

Example:
| Party Pokemon Level Should Be | 0 | 11 |
"""
party = self._require_game().get("pokemon", [])
index = int(index)
if index >= len(party):
raise AssertionError(f"Party index {index} out of range (party size: {len(party)})")
actual = party[index].get("level", 0)
expected_level = int(expected_level)
if actual != expected_level:
raise AssertionError(f"Party[{index}] level: expected {expected_level}, got {actual}")

@keyword("Story Flag Should Be Set")
def story_flag_should_be_set(self, flag_name: str) -> None:
"""
Fail if *flag_name* is not set (or is set to a falsy value) in story_flags.

Arguments:
- ``flag_name``: Story flag key, e.g. ``rival_cerulean_beaten``.

Example:
| Story Flag Should Be Set | rival_cerulean_beaten |
"""
flags = self._require_game().get("story_flags", {})
if not flags.get(flag_name):
raise AssertionError(f"Story flag '{flag_name}' is not set. Flags: {flags}")

@keyword("Story Flag Should Not Be Set")
def story_flag_should_not_be_set(self, flag_name: str) -> None:
"""
Fail if *flag_name* IS set (and truthy) in story_flags.

Arguments:
- ``flag_name``: Story flag key, e.g. ``rival_cerulean_beaten``.

Example:
| Story Flag Should Not Be Set | rival_cerulean_beaten |
"""
flags = self._require_game().get("story_flags", {})
if flags.get(flag_name):
raise AssertionError(f"Story flag '{flag_name}' is set but expected to be absent/falsy")

@keyword("Set Learn Move Prompt")
def set_learn_move_prompt(
self, move_name: str, remaining: str = "", post_action: str = "wild_end"
) -> None:
"""
Inject a ``learn_move_choice`` pending-command so the lead Pokemon
is offered a new move.

The lead Pokemon (party slot 0) must already have 4 moves; use
``Set Lead Pokemon`` before calling this keyword if needed.

Arguments:
- ``move_name``: The name of the new move being offered (ALL CAPS).
- ``remaining``: Space-separated list of additional queued move names (default empty).
- ``post_action``: Value for ``learn_post_action`` (default ``wild_end``).

Example:
| Set Lead Pokemon | CHARMANDER |
| Set Learn Move Prompt | RAGE |
| Type Command | 1 |
| Pending Command Should Be Empty |

| Set Learn Move Prompt | RAGE | remaining=SLASH |
"""
from PokemonLibrary.models import PartyPokemon

gd = self._require_game()
party = gd.get("pokemon", [])
if not party:
raise RuntimeError("Party is empty — call 'Bootstrap Game' first.")

# Convert PartyPokemon model to a plain dict so the game_flow handler can
# use dict-style access (pokemon["moves"][slot]["name"] = …).
lead_raw = party[0]
if isinstance(lead_raw, PartyPokemon):
lead = lead_raw.to_dict()
else:
lead = dict(lead_raw) # copy to avoid mutation side-effects

# Ensure the lead has exactly 4 moves (fill with defaults if needed)
moves = lead.setdefault("moves", [])
default_moves = [
{"name": "TACKLE", "pp": 35, "max_pp": 35},
{"name": "GROWL", "pp": 40, "max_pp": 40},
{"name": "SCRATCH", "pp": 35, "max_pp": 35},
{"name": "LEER", "pp": 30, "max_pp": 30},
]
while len(moves) < 4:
moves.append(default_moves[len(moves)])

# Replace slot 0 with the plain dict so the handler can write to it directly
party[0] = lead

remaining_list = [m.strip() for m in remaining.split() if m.strip()]

self._app.pending_command = "learn_move_choice"
self._app.pending_command_data = {
"learn_pokemon": lead,
"learn_move_name": move_name.upper(),
"learn_remaining": remaining_list,
"learn_post_action": post_action,
}
logger.info(f"Learn-move prompt set: offering {move_name.upper()} to {lead['name']}")

@keyword("Register Pokemon As Seen")
def register_pokemon_as_seen(self, species_name: str) -> None:
"""
Register a Pokemon as seen in the Pokedex (test helper).

This allows viewing Pokedex entries without actually encountering the Pokemon.

Arguments:
- ``species_name``: Pokemon species name (e.g. "Pikachu", "Zubat").

Example:
| Register Pokemon As Seen | Zubat |
| Type Command | pokedex entry zubat |
| Output Should Contain | ZUBAT |
"""
game_data = self._require_game()
pokedex = game_data.get("pokedex", {"seen": [], "caught": []})
if "pokedex" not in game_data:
game_data["pokedex"] = pokedex

species_upper = species_name.upper().replace(" ", "_")

if species_upper not in pokedex.get("seen", []):
seen_list = pokedex.get("seen", [])
seen_list.append(species_upper)
pokedex["seen"] = seen_list

logger.info(f"Registered {species_upper} as seen in Pokedex")
Empty file removed atests/__init__.py
Empty file.
Loading
Loading