diff --git a/PokemonLibrary/data/pokemon_data.py b/PokemonLibrary/data/pokemon_data.py index a723a5f..7fed037 100644 --- a/PokemonLibrary/data/pokemon_data.py +++ b/PokemonLibrary/data/pokemon_data.py @@ -458,6 +458,40 @@ def get(self, key: str, default=None): }, evolution=None, ), + # Ekans line — Nugget Bridge trainers + 23: SpeciesData( + name="EKANS", + number=23, + types=[POISON], + stats=StatsData(hp=35, attack=60, defense=44, special=40, speed=55), + catch_rate=255, + base_exp=62, + learnset={ + 1: ["WRAP", "LEER"], + 10: ["POISON STING"], + 17: ["BITE"], + 24: ["GLARE"], + 31: ["SCREECH"], + 38: ["SLUDGE"], + }, + evolution=LevelEvolution(level=22, into_species="ARBOK"), + ), + 24: SpeciesData( + name="ARBOK", + number=24, + types=[POISON], + stats=StatsData(hp=60, attack=85, defense=69, special=65, speed=80), + catch_rate=90, + base_exp=147, + learnset={ + 1: ["WRAP", "LEER", "POISON STING"], + 27: ["BITE"], + 36: ["GLARE"], + 47: ["SCREECH"], + 58: ["SLUDGE"], + }, + evolution=None, + ), 25: SpeciesData( name="PIKACHU", number=25, @@ -475,6 +509,312 @@ def get(self, key: str, default=None): }, evolution=ItemEvolution(item="THUNDER STONE", into_species="RAICHU"), ), + # Sandshrew line — Pewter Gym + 27: SpeciesData( + name="SANDSHREW", + number=27, + types=[GROUND], + stats=StatsData(hp=50, attack=75, defense=85, special=30, speed=40), + catch_rate=255, + base_exp=93, + learnset={ + 1: ["SCRATCH"], + 10: ["SAND ATTACK"], + 17: ["SLASH"], + 24: ["POISON STING"], + 31: ["SWIFT"], + 38: ["FURY SWIPES"], + }, + evolution=LevelEvolution(level=22, into_species="SANDSLASH"), + ), + 28: SpeciesData( + name="SANDSLASH", + number=28, + types=[GROUND], + stats=StatsData(hp=75, attack=100, defense=110, special=55, speed=65), + catch_rate=90, + base_exp=163, + learnset={ + 1: ["SCRATCH", "SAND ATTACK"], + 27: ["SLASH"], + 36: ["POISON STING"], + 47: ["SWIFT"], + 58: ["FURY SWIPES"], + }, + evolution=None, + ), + # Clefairy line — Mt. Moon + 35: SpeciesData( + name="CLEFAIRY", + number=35, + types=[NORMAL], + stats=StatsData(hp=70, attack=45, defense=48, special=60, speed=35), + catch_rate=150, + base_exp=68, + learnset={ + 1: ["POUND", "GROWL"], + 13: ["SING"], + 18: ["DOUBLE SLAP"], + 24: ["MINIMIZE"], + 31: ["METRONOME"], + 39: ["DEFENSE CURL"], + 48: ["LIGHT SCREEN"], + }, + evolution=ItemEvolution(item="MOON STONE", into_species="CLEFABLE"), + ), + 36: SpeciesData( + name="CLEFABLE", + number=36, + types=[NORMAL], + stats=StatsData(hp=95, attack=70, defense=73, special=85, speed=60), + catch_rate=25, + base_exp=129, + learnset={ + 1: ["SING", "DOUBLE SLAP", "MINIMIZE", "METRONOME"], + }, + evolution=None, + ), + # Jigglypuff line — Route 3, Route 4 + 39: SpeciesData( + name="JIGGLYPUFF", + number=39, + types=[NORMAL], + stats=StatsData(hp=115, attack=45, defense=20, special=25, speed=20), + catch_rate=170, + base_exp=76, + learnset={ + 1: ["SING"], + 9: ["POUND"], + 14: ["DISABLE"], + 19: ["DEFENSE CURL"], + 24: ["DOUBLE SLAP"], + 29: ["REST"], + 34: ["BODY SLAM"], + 39: ["DOUBLE-EDGE"], + }, + evolution=ItemEvolution(item="MOON STONE", into_species="WIGGLYTUFF"), + ), + 40: SpeciesData( + name="WIGGLYTUFF", + number=40, + types=[NORMAL], + stats=StatsData(hp=140, attack=70, defense=45, special=50, speed=45), + catch_rate=50, + base_exp=109, + learnset={ + 1: ["SING", "DISABLE", "DEFENSE CURL", "DOUBLE SLAP"], + }, + evolution=None, + ), + # Zubat line — Mt. Moon, Route 22 + 41: SpeciesData( + name="ZUBAT", + number=41, + types=[POISON, FLYING], + stats=StatsData(hp=40, attack=45, defense=35, special=40, speed=55), + catch_rate=255, + base_exp=54, + learnset={ + 1: ["LEECH LIFE"], + 10: ["SUPERSONIC"], + 15: ["BITE"], + 21: ["CONFUSE RAY"], + 28: ["WING ATTACK"], + }, + evolution=LevelEvolution(level=22, into_species="GOLBAT"), + ), + 42: SpeciesData( + name="GOLBAT", + number=42, + types=[POISON, FLYING], + stats=StatsData(hp=75, attack=80, defense=70, special=75, speed=90), + catch_rate=90, + base_exp=171, + learnset={ + 1: ["LEECH LIFE", "SUPERSONIC"], + 21: ["BITE"], + 28: ["CONFUSE RAY"], + 36: ["WING ATTACK"], + }, + evolution=None, + ), + # Oddish line — Route 24 + 43: SpeciesData( + name="ODDISH", + number=43, + types=[GRASS, POISON], + stats=StatsData(hp=45, attack=50, defense=55, special=75, speed=30), + catch_rate=255, + base_exp=78, + learnset={ + 1: ["ABSORB"], + 15: ["POISONPOWDER"], + 17: ["STUN SPORE"], + 19: ["SLEEP POWDER"], + 24: ["SLUDGE"], + 33: ["MEGA DRAIN"], + 46: ["SOLARBEAM"], + }, + evolution=LevelEvolution(level=21, into_species="GLOOM"), + ), + 44: SpeciesData( + name="GLOOM", + number=44, + types=[GRASS, POISON], + stats=StatsData(hp=60, attack=65, defense=70, special=85, speed=40), + catch_rate=120, + base_exp=132, + learnset={ + 1: ["ABSORB", "POISONPOWDER"], + 28: ["STUN SPORE"], + 33: ["SLEEP POWDER"], + 38: ["SLUDGE"], + 52: ["MEGA DRAIN"], + }, + evolution=ItemEvolution(item="LEAF STONE", into_species="VILEPLUME"), + ), + 45: SpeciesData( + name="VILEPLUME", + number=45, + types=[GRASS, POISON], + stats=StatsData(hp=75, attack=80, defense=85, special=100, speed=50), + catch_rate=45, + base_exp=184, + learnset={ + 1: ["STUN SPORE", "SLEEP POWDER", "SLUDGE"], + }, + evolution=None, + ), + # Meowth line — Route 3 + 52: SpeciesData( + name="MEOWTH", + number=52, + types=[NORMAL], + stats=StatsData(hp=40, attack=45, defense=35, special=40, speed=90), + catch_rate=255, + base_exp=69, + learnset={ + 1: ["SCRATCH", "GROWL"], + 12: ["BITE"], + 17: ["PAY DAY"], + 24: ["SCREECH"], + 33: ["FURY SWIPES"], + 44: ["SLASH"], + }, + evolution=LevelEvolution(level=28, into_species="PERSIAN"), + ), + 53: SpeciesData( + name="PERSIAN", + number=53, + types=[NORMAL], + stats=StatsData(hp=65, attack=70, defense=60, special=65, speed=115), + catch_rate=90, + base_exp=148, + learnset={ + 1: ["SCRATCH", "GROWL", "BITE"], + 29: ["PAY DAY"], + 38: ["SCREECH"], + 51: ["FURY SWIPES"], + 64: ["SLASH"], + }, + evolution=None, + ), + # Abra line — Route 24 + 63: SpeciesData( + name="ABRA", + number=63, + types=[PSYCHIC], + stats=StatsData(hp=25, attack=20, defense=15, special=105, speed=90), + catch_rate=200, + base_exp=73, + learnset={ + 1: ["TELEPORT"], + }, + evolution=LevelEvolution(level=16, into_species="KADABRA"), + ), + 64: SpeciesData( + name="KADABRA", + number=64, + types=[PSYCHIC], + stats=StatsData(hp=40, attack=35, defense=30, special=120, speed=105), + catch_rate=100, + base_exp=145, + learnset={ + 1: ["TELEPORT", "CONFUSION"], + 16: ["CONFUSION"], + 20: ["DISABLE"], + 27: ["PSYBEAM"], + 31: ["RECOVER"], + 38: ["PSYCHIC"], + 42: ["REFLECT"], + }, + evolution=LevelEvolution(level=38, into_species="ALAKAZAM"), + ), + 65: SpeciesData( + name="ALAKAZAM", + number=65, + types=[PSYCHIC], + stats=StatsData(hp=55, attack=50, defense=45, special=135, speed=120), + catch_rate=50, + base_exp=186, + learnset={ + 1: ["TELEPORT", "CONFUSION", "DISABLE"], + 27: ["PSYBEAM"], + 31: ["RECOVER"], + 38: ["PSYCHIC"], + 42: ["REFLECT"], + }, + evolution=None, + ), + # Bellsprout line — Route 24 + 69: SpeciesData( + name="BELLSPROUT", + number=69, + types=[GRASS, POISON], + stats=StatsData(hp=50, attack=75, defense=35, special=70, speed=40), + catch_rate=255, + base_exp=84, + learnset={ + 1: ["VINE WHIP", "GROWTH"], + 13: ["WRAP"], + 15: ["POISONPOWDER"], + 18: ["SLEEP POWDER"], + 21: ["STUN SPORE"], + 26: ["SLUDGE"], + 33: ["RAZOR LEAF"], + 42: ["SLAM"], + }, + evolution=LevelEvolution(level=21, into_species="WEEPINBELL"), + ), + 70: SpeciesData( + name="WEEPINBELL", + number=70, + types=[GRASS, POISON], + stats=StatsData(hp=65, attack=90, defense=50, special=85, speed=55), + catch_rate=120, + base_exp=151, + learnset={ + 1: ["VINE WHIP", "GROWTH", "WRAP"], + 23: ["POISONPOWDER"], + 29: ["SLEEP POWDER"], + 38: ["STUN SPORE"], + 49: ["SLUDGE"], + 54: ["RAZOR LEAF"], + }, + evolution=ItemEvolution(item="LEAF STONE", into_species="VICTREEBEL"), + ), + 71: SpeciesData( + name="VICTREEBEL", + number=71, + types=[GRASS, POISON], + stats=StatsData(hp=80, attack=105, defense=65, special=100, speed=70), + catch_rate=45, + base_exp=191, + learnset={ + 1: ["SLEEP POWDER", "STUN SPORE", "SLUDGE", "RAZOR LEAF"], + }, + evolution=None, + ), # Geodude family — Brock's team 74: SpeciesData( name="GEODUDE", @@ -511,6 +851,22 @@ def get(self, key: str, default=None): }, evolution=LevelEvolution(level=36, into_species="GOLEM"), ), + 76: SpeciesData( + name="GOLEM", + number=76, + types=[ROCK, GROUND], + stats=StatsData(hp=80, attack=110, defense=130, special=55, speed=45), + catch_rate=45, + base_exp=177, + learnset={ + 1: ["TACKLE", "DEFENSE CURL", "ROCK THROW"], + 29: ["SELFDESTRUCT"], + 36: ["HARDEN"], + 43: ["EARTHQUAKE"], + 50: ["EXPLOSION"], + }, + evolution=None, + ), # Onix — Brock's ace 95: SpeciesData( name="ONIX", @@ -529,42 +885,129 @@ def get(self, key: str, default=None): }, evolution=None, ), + # Horsea line — Cerulean Gym + 116: SpeciesData( + name="HORSEA", + number=116, + types=[WATER], + stats=StatsData(hp=30, attack=40, defense=70, special=70, speed=60), + catch_rate=225, + base_exp=83, + learnset={ + 1: ["BUBBLE"], + 19: ["SMOKESCREEN"], + 24: ["LEER"], + 30: ["WATER GUN"], + 37: ["AGILITY"], + 45: ["HYDRO PUMP"], + }, + evolution=LevelEvolution(level=32, into_species="SEADRA"), + ), + 117: SpeciesData( + name="SEADRA", + number=117, + types=[WATER], + stats=StatsData(hp=55, attack=65, defense=95, special=95, speed=85), + catch_rate=75, + base_exp=155, + learnset={ + 1: ["BUBBLE", "SMOKESCREEN"], + 32: ["LEER"], + 40: ["WATER GUN"], + 48: ["AGILITY"], + 55: ["HYDRO PUMP"], + }, + evolution=None, + ), + # Goldeen line — Cerulean Gym + 118: SpeciesData( + name="GOLDEEN", + number=118, + types=[WATER], + stats=StatsData(hp=45, attack=67, defense=60, special=50, speed=63), + catch_rate=225, + base_exp=111, + learnset={ + 1: ["PECK", "TAIL WHIP"], + 19: ["SUPERSONIC"], + 24: ["HORN ATTACK"], + 30: ["FURY ATTACK"], + 37: ["WATERFALL"], + 45: ["HORN DRILL"], + 54: ["AGILITY"], + }, + evolution=LevelEvolution(level=33, into_species="SEAKING"), + ), + 119: SpeciesData( + name="SEAKING", + number=119, + types=[WATER], + stats=StatsData(hp=80, attack=92, defense=65, special=80, speed=68), + catch_rate=60, + base_exp=170, + learnset={ + 1: ["PECK", "TAIL WHIP", "SUPERSONIC"], + 33: ["HORN ATTACK"], + 41: ["FURY ATTACK"], + 49: ["WATERFALL"], + 54: ["HORN DRILL"], + 61: ["AGILITY"], + }, + evolution=None, + ), + # Staryu line — Misty's team + 120: SpeciesData( + name="STARYU", + number=120, + types=[WATER], + stats=StatsData(hp=30, attack=45, defense=55, special=70, speed=85), + catch_rate=225, + base_exp=106, + learnset={ + 1: ["TACKLE"], + 17: ["WATER GUN"], + 22: ["HARDEN"], + 27: ["RECOVER"], + 32: ["SWIFT"], + 37: ["MINIMIZE"], + 42: ["LIGHT SCREEN"], + 47: ["HYDRO PUMP"], + }, + evolution=ItemEvolution(item="WATER STONE", into_species="STARMIE"), + ), + 121: SpeciesData( + name="STARMIE", + number=121, + types=[WATER, PSYCHIC], + stats=StatsData(hp=60, attack=75, defense=85, special=100, speed=115), + catch_rate=60, + base_exp=207, + learnset={ + 1: ["TACKLE", "WATER GUN", "HARDEN", "RECOVER"], + }, + evolution=None, + ), } # Populate any missing national dex entries with placeholder stubs so # code that iterates 1-151 doesn't explode. Full data omitted for brevity # (this is a joke library after all). _STUB_NAMES = { - 23: "EKANS", - 24: "ARBOK", 26: "RAICHU", - 27: "SANDSHREW", - 28: "SANDSLASH", 29: "NIDORAN♀", 30: "NIDORINA", 31: "NIDOQUEEN", 32: "NIDORAN♂", 33: "NIDORINO", 34: "NIDOKING", - 35: "CLEFAIRY", - 36: "CLEFABLE", 37: "VULPIX", 38: "NINETALES", - 39: "JIGGLYPUFF", - 40: "WIGGLYTUFF", - 41: "ZUBAT", - 42: "GOLBAT", - 43: "ODDISH", - 44: "GLOOM", - 45: "VILEPLUME", 46: "PARAS", 47: "PARASECT", 48: "VENONAT", 49: "VENOMOTH", 50: "DIGLETT", 51: "DUGTRIO", - 52: "MEOWTH", - 53: "PERSIAN", 54: "PSYDUCK", 55: "GOLDUCK", 56: "MANKEY", @@ -574,18 +1017,11 @@ def get(self, key: str, default=None): 60: "POLIWAG", 61: "POLIWHIRL", 62: "POLIWRATH", - 63: "ABRA", - 64: "KADABRA", - 65: "ALAKAZAM", 66: "MACHOP", 67: "MACHOKE", 68: "MACHAMP", - 69: "BELLSPROUT", - 70: "WEEPINBELL", - 71: "VICTREEBEL", 72: "TENTACOOL", 73: "TENTACRUEL", - 76: "GOLEM", 77: "PONYTA", 78: "RAPIDASH", 79: "SLOWPOKE", @@ -624,12 +1060,6 @@ def get(self, key: str, default=None): 113: "CHANSEY", 114: "TANGELA", 115: "KANGASKHAN", - 116: "HORSEA", - 117: "SEADRA", - 118: "GOLDEEN", - 119: "SEAKING", - 120: "STARYU", - 121: "STARMIE", 122: "MR. MIME", 123: "SCYTHER", 124: "JYNX", diff --git a/PokemonLibrary/terminal.py b/PokemonLibrary/terminal.py index d9fb9be..8dc6d0a 100644 --- a/PokemonLibrary/terminal.py +++ b/PokemonLibrary/terminal.py @@ -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", @@ -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 diff --git a/PokemonLibrary/ui/panel_mixin.py b/PokemonLibrary/ui/panel_mixin.py index 52d745a..a99eef3 100644 --- a/PokemonLibrary/ui/panel_mixin.py +++ b/PokemonLibrary/ui/panel_mixin.py @@ -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 diff --git a/PokemonLibraryTest/library.py b/PokemonLibraryTest/library.py index 0a6a3d4..c6bc78a 100644 --- a/PokemonLibraryTest/library.py +++ b/PokemonLibraryTest/library.py @@ -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"

{label}

{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: @@ -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 @@ -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. @@ -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 ─────────────────────────────────────────────────── @@ -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: @@ -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: @@ -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: @@ -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") diff --git a/atests/__init__.py b/atests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/atests/badges.robot b/atests/badges.robot new file mode 100644 index 0000000..0c96e89 --- /dev/null +++ b/atests/badges.robot @@ -0,0 +1,81 @@ +*** Settings *** +Documentation Badge-case and gym-related end-to-end tests. +... +... Covers the ``show badges`` command, badge display logic, +... and gym entry checks. + +Library PokemonLibraryTest +Resource resources/common.resource + +Suite Setup Open Pokemon Terminal +Suite Teardown Close Pokemon Terminal + + +*** Test Cases *** + +# ---------------------------------------------------------------------------- +# Badge case display +# ---------------------------------------------------------------------------- + +Show Badges Displays Badge Section Header + [Documentation] 'show badges' renders a badge-case header in the output. + [Tags] badges smoke + [Setup] Setup Game + Type Command show badges + Output Should Contain Badge case_insensitive=True + +Show Badges With No Badges Shows Zero Count + [Documentation] A newly bootstrapped game has no badges; the display reflects this. + [Tags] badges + [Setup] Setup Game + Badge Count Should Be 0 + Type Command show badges + Output Should Contain No badges case_insensitive=True + +Show Badges After Earning Boulder Badge Lists It + [Documentation] Adding the Boulder Badge causes 'show badges' to mention it. + [Tags] badges + [Setup] Setup Game With Badges Boulder Badge + Type Command show badges + Output Should Contain Boulder Badge + +Show Badges After Two Badges Lists Both + [Documentation] Adding two badges causes both to appear in the badge case. + [Tags] badges + [Setup] Setup Game With Badges Boulder Badge Cascade Badge + Type Command show badges + Output Should Contain Boulder Badge + Output Should Contain Cascade Badge + +Badge Alias Works Same As Show Badges + [Documentation] The 'badge' alias is equivalent to 'show badges'. + [Tags] badges + [Setup] Setup Game With Badges Boulder Badge + Type Command badge + Output Should Contain Boulder Badge + +Status Shows Badge Count After Earning Badges + [Documentation] After earning a badge, 'status' shows the updated badge count. + [Tags] badges status + [Setup] Setup Game With Badges Boulder Badge Cascade Badge + Type Command status + Output Should Contain 2/8 + +# ---------------------------------------------------------------------------- +# Gym entry +# ---------------------------------------------------------------------------- + +Entering Gym In Town Without Badge Requirement Shows Gym + [Documentation] Entering the Gym in Pewter City (needs 0 badges) should show the gym. + [Tags] badges gym + [Setup] Setup Game At Location Pewter City + Type Command enter gym + Output Should Contain PEWTER CITY GYM case_insensitive=True + +Entering Gym In Town With Badge Shows Already Earned + [Documentation] Entering a gym after already earning its badge shows a congratulatory note. + [Tags] badges gym + [Setup] Setup Game At Location Pewter City + Add Badge Boulder Badge + Type Command enter gym + Output Should Contain Boulder Badge diff --git a/atests/e2e_tests.robot b/atests/e2e_tests.robot index cd25aa4..1e4e121 100644 --- a/atests/e2e_tests.robot +++ b/atests/e2e_tests.robot @@ -5,6 +5,7 @@ Documentation End-to-end tests for PokemonLibrary using the PokemonLibrary ... Each test bootstraps its own game state and exercises real TUI code paths. Library PokemonLibraryTest +Resource resources/common.resource Suite Setup Open Pokemon Terminal Suite Teardown Close Pokemon Terminal @@ -35,10 +36,7 @@ Clicking New Game Opens Name Selection Sell Nugget Increases Money [Documentation] Selling a Nugget from the shop adds ₽5000 to the player's wallet. [Tags] shop sell - Bootstrap Game - Set Money 500 - Set Item Nugget 1 - Set Pending Command shop _current_shop_catalog=basic + [Setup] Setup Shop With Items 500 Nugget 1 Type Command sell nugget Output Should Contain Sold 1x Nugget Money Should Be 5500 @@ -47,10 +45,7 @@ Sell Nugget Increases Money Sell Multiple Potions At Once [Documentation] 'sell 3 potion' deducts all three and awards 3 × 150 = 450. [Tags] shop sell - Bootstrap Game - Set Money 0 - Set Item Potion 3 - Set Pending Command shop _current_shop_catalog=basic + [Setup] Setup Shop With Items 0 Potion 3 Type Command sell 3 potion Output Should Contain Sold 3x Potion Money Should Be 450 @@ -59,9 +54,7 @@ Sell Multiple Potions At Once Selling Item Not Owned Shows Error [Documentation] Attempting to sell an item the player doesn't own shows an error. [Tags] shop sell error - Bootstrap Game - Set Money 500 - Set Pending Command shop _current_shop_catalog=basic + [Setup] Setup Shop money=500 Type Command sell nugget Output Should Contain don't have case_insensitive=True Money Should Be 500 @@ -69,10 +62,7 @@ Selling Item Not Owned Shows Error Sell Qty Clamped To Stock [Documentation] Requesting more than owned quantity sells only what is available. [Tags] shop sell - Bootstrap Game - Set Money 0 - Set Item Potion 2 - Set Pending Command shop _current_shop_catalog=basic + [Setup] Setup Shop With Items 0 Potion 2 Type Command sell 10 potion Money Should Be 300 Item Count Should Be Potion 0 @@ -80,9 +70,7 @@ Sell Qty Clamped To Stock Buy Still Works After Sell Feature Added [Documentation] Regression: the existing buy command still works. [Tags] shop buy regression - Bootstrap Game - Set Money 1000 - Set Pending Command shop _current_shop_catalog=basic + [Setup] Setup Shop money=1000 Type Command buy potion Output Should Contain Bought @@ -93,9 +81,7 @@ Buy Still Works After Sell Feature Added Fire Stone Evolves Growlithe To Arcanine [Documentation] Using a Fire Stone on GROWLITHE produces ARCANINE and consumes the stone. [Tags] evolution stone - Bootstrap Game - Set Lead Pokemon GROWLITHE - Set Item Fire Stone 1 + [Setup] Setup Evolution Test GROWLITHE Fire Stone Type Command use fire stone on growlithe Output Should Contain ARCANINE Party Pokemon Should Be 0 ARCANINE @@ -104,9 +90,7 @@ Fire Stone Evolves Growlithe To Arcanine Moon Stone Evolves Clefairy To Clefable [Documentation] Using a Moon Stone on CLEFAIRY produces CLEFABLE. [Tags] evolution stone - Bootstrap Game - Set Lead Pokemon CLEFAIRY - Set Item Moon Stone 1 + [Setup] Setup Evolution Test CLEFAIRY Moon Stone Type Command use moon stone on clefairy Output Should Contain CLEFABLE Party Pokemon Should Be 0 CLEFABLE @@ -115,8 +99,7 @@ Moon Stone Evolves Clefairy To Clefable Wrong Stone Has No Effect And Is Not Consumed [Documentation] Using a Fire Stone on SQUIRTLE has no effect and keeps the stone. [Tags] evolution stone error - Bootstrap Game - Set Item Fire Stone 1 + [Setup] Setup Game With Item Fire Stone 1 Type Command use fire stone on squirtle Output Should Contain no effect case_insensitive=True Party Pokemon Should Be 0 SQUIRTLE @@ -125,9 +108,7 @@ Wrong Stone Has No Effect And Is Not Consumed Thunder Stone Evolves Pikachu To Raichu [Documentation] Using a Thunder Stone on PIKACHU produces RAICHU. [Tags] evolution stone - Bootstrap Game - Set Lead Pokemon PIKACHU - Set Item Thunder Stone 1 + [Setup] Setup Evolution Test PIKACHU Thunder Stone Type Command use thunder stone on pikachu Output Should Contain RAICHU Party Pokemon Should Be 0 RAICHU @@ -140,12 +121,7 @@ Thunder Stone Evolves Pikachu To Raichu Rival Battle Triggers After All Nugget Trainers Beaten [Documentation] Entering Nugget Bridge with all 5 trainers defeated triggers the rival. [Tags] rival battle - Bootstrap Game location=Cerulean City - Add Defeated Trainer nugget_trainer_1 - Add Defeated Trainer nugget_trainer_2 - Add Defeated Trainer nugget_trainer_3 - Add Defeated Trainer nugget_trainer_4 - Add Defeated Trainer nugget_trainer_5 + [Setup] Setup Rival Battle Conditions Type Command enter nugget bridge Output Should Contain familiar voice case_insensitive=True Should Be In Battle @@ -154,12 +130,107 @@ Rival Battle Triggers After All Nugget Trainers Beaten Rival Battle Not Triggered If Already Beaten [Documentation] If the rival_cerulean_beaten flag is set no second battle fires. [Tags] rival battle flag - Bootstrap Game location=Cerulean City - Add Defeated Trainer nugget_trainer_1 - Add Defeated Trainer nugget_trainer_2 - Add Defeated Trainer nugget_trainer_3 - Add Defeated Trainer nugget_trainer_4 - Add Defeated Trainer nugget_trainer_5 + [Setup] Setup Rival Battle Conditions Set Story Flag rival_cerulean_beaten Type Command enter nugget bridge Should Not Be In Battle + +# ---------------------------------------------------------------------------- +# Battle tests with newly implemented Pokemon +# ---------------------------------------------------------------------------- + +# TODO: Enable when Set Wild Encounter keyword is implemented +#Zubat Can Battle Against Wild Pokemon +# [Documentation] ZUBAT can engage in wild Pokemon battles successfully. +# [Tags] battle phase1 zubat +# [Setup] Setup Game With Pokemon ZUBAT 15 +# Set Wild Encounter RATTATA level=10 +# Type Command explore +# Should Be In Battle +# Output Should Contain ZUBAT + +# TODO: Enable when Set Wild Encounter keyword is implemented +#Clefairy Learns Moves And Can Use Them +# [Documentation] CLEFAIRY has proper moves and can use them in battle. +# [Tags] battle phase1 clefairy +# [Setup] Setup Game With Pokemon CLEFAIRY 20 +# Set Wild Encounter RATTATA level=10 +# Type Command explore +# Should Be In Battle +# Type Command moves +# # Should show at least one move other than just Tackle + +# TODO: Enable when Set Wild Encounter keyword is implemented +#Jigglypuff Has High HP In Battle +# [Documentation] JIGGLYPUFF displays its signature high HP during battles. +# [Tags] battle phase1 jigglypuff +# [Setup] Setup Game With Pokemon JIGGLYPUFF 20 +# Set Wild Encounter RATTATA level=10 +# Type Command explore +# Should Be In Battle +# # HP bar should show Jigglypuff has more HP than typical Pokemon + +Staryu Can Battle In Gym + [Documentation] STARYU functions properly in gym battles (Misty's team). + [Tags] battle phase2 gym staryu + [Setup] Setup Game At Location Cerulean City + Add Badge Boulder Badge + Set Lead Pokemon PIKACHU 25 + Type Command enter gym + Click Widget id=btn-gym-challenge + Should Be In Battle + Output Should Contain Misty + +# TODO: Enable when Set Wild Encounter keyword is implemented +#Abra Teleport Move Works In Battle +# [Documentation] ABRA's signature TELEPORT move functions in battle (flee mechanic). +# [Tags] battle phase3 abra +# [Setup] Setup Game With Pokemon ABRA 10 +# Set Wild Encounter RATTATA level=5 +# Type Command explore +# Should Be In Battle +# Type Command moves +# Output Should Contain TELEPORT + +# TODO: Enable when Set Wild Encounter keyword is implemented +#Ekans Poison Type Advantage Works +# [Documentation] EKANS (Poison type) gets proper type matchups in battle. +# [Tags] battle phase3 ekans +# [Setup] Setup Game With Pokemon EKANS 20 +# Set Wild Encounter ODDISH level=15 +# Type Command explore +# Should Be In Battle +# # Poison vs Grass should have type effectiveness + +# TODO: Enable when Set Wild Encounter keyword is implemented +#Alakazam High Special Stat Affects Damage +# [Documentation] ALAKAZAM with high Special stat deals appropriate damage. +# [Tags] battle phase4 alakazam +# [Setup] Setup Game With Pokemon ALAKAZAM 50 +# Set Wild Encounter RATTATA level=10 +# Type Command explore +# Should Be In Battle +# # Alakazam should have very high special stat visible + +# TODO: Enable when Set Wild Encounter keyword is implemented +#Water Stone Evolution Creates Battle Ready Starmie +# [Documentation] STARMIE evolved via Water Stone is immediately battle-ready. +# [Tags] battle phase2 evolution starmie +# [Setup] Setup Evolution Test STARYU Water Stone +# Type Command use water stone on staryu +# Party Pokemon Should Be 0 STARMIE +# Set Wild Encounter RATTATA level=10 +# Type Command explore +# Should Be In Battle +# Output Should Contain STARMIE + +# TODO: Enable when Set Wild Encounter keyword is implemented +#Dual Type Pokemon Show Both Types In Battle +# [Documentation] Dual-type Pokemon (like Oddish: Grass/Poison) display both types. +# [Tags] battle phase3 oddish +# [Setup] Setup Game With Pokemon ODDISH 15 +# Set Wild Encounter RATTATA level=10 +# Type Command explore +# Should Be In Battle +# Type Command inspect oddish +# Output Should Contain Grass diff --git a/atests/items.robot b/atests/items.robot new file mode 100644 index 0000000..e4e5c31 --- /dev/null +++ b/atests/items.robot @@ -0,0 +1,124 @@ +*** Settings *** +Documentation Item usage outside of battle end-to-end tests. +... +... Covers the ``use ``, ``use on `` commands +... for healing, stat boosters, Rare Candy, Repel, and Escape Rope. + +Library PokemonLibraryTest +Resource resources/common.resource + +Suite Setup Open Pokemon Terminal +Suite Teardown Close Pokemon Terminal + + +*** Test Cases *** + +# ---------------------------------------------------------------------------- +# Rare Candy +# ---------------------------------------------------------------------------- + +Rare Candy Increases Lead Pokemon Level + [Documentation] Using a Rare Candy on the lead Pokemon increases its level by 1. + [Tags] items rare_candy + [Setup] Setup Game With Item Rare Candy 1 + Type Command use rare candy on squirtle + Output Should Contain grew to Level case_insensitive=True + Item Count Should Be Rare Candy 0 + +Rare Candy On Named Pokemon Works + [Documentation] 'use rare candy on squirtle' targets SQUIRTLE specifically. + [Tags] items rare_candy + [Setup] Setup Game With Item Rare Candy 1 + Party Pokemon Level Should Be 0 10 + Type Command use rare candy on squirtle + Output Should Contain grew to Level case_insensitive=True + Party Pokemon Level Should Be 0 11 + +Rare Candy Consumed After Use + [Documentation] The Rare Candy is removed from the bag after use. + [Tags] items rare_candy + [Setup] Setup Game With Item Rare Candy 3 + Type Command use rare candy on squirtle + Item Count Should Be Rare Candy 2 + +# ---------------------------------------------------------------------------- +# Potion / healing items +# ---------------------------------------------------------------------------- + +Potion Heals Damaged Pokemon + [Documentation] Using a Potion on a damaged Pokemon restores some HP. + [Tags] items heal + [Setup] Setup Game With Damaged Pokemon Potion 1 5 + Type Command use potion + Output Should Contain recovered case_insensitive=True + Item Count Should Be Potion 0 + +Potion On Full HP Shows Warning + [Documentation] Using a Potion when the Pokemon is at full HP shows a message. + [Tags] items heal edge_case + [Setup] Setup Game With Item Potion 1 + Type Command use potion + Output Should Contain already full case_insensitive=True + +Potion Heals Specific Named Pokemon + [Documentation] 'use potion on squirtle' heals SQUIRTLE specifically. + [Tags] items heal + [Setup] Setup Game With Damaged Pokemon Potion 1 5 + Type Command use potion on squirtle + Output Should Contain recovered case_insensitive=True + +# ---------------------------------------------------------------------------- +# Repel +# ---------------------------------------------------------------------------- + +Repel Activates And Is Consumed + [Documentation] Using a Repel activates the repel effect and removes it from the bag. + [Tags] items repel + [Setup] Setup Game With Item Repel 1 + Type Command use repel + Output Should Contain Repel used case_insensitive=True + Item Count Should Be Repel 0 + +Super Repel Provides More Explores Than Repel + [Documentation] Super Repel covers more explores than a standard Repel. + [Tags] items repel + [Setup] Setup Game With Item Super Repel 1 + Type Command use super repel + Output Should Contain 20 explores case_insensitive=True + Item Count Should Be Super Repel 0 + +# ---------------------------------------------------------------------------- +# Escape Rope +# ---------------------------------------------------------------------------- + +Escape Rope In Town Shows Already In Town Message + [Documentation] Using an Escape Rope while already in a town shows a message. + [Tags] items escape_rope + [Setup] Setup Game With Item Escape Rope 1 + Type Command use escape rope + Output Should Contain already in a town case_insensitive=True + +# ---------------------------------------------------------------------------- +# Error cases +# ---------------------------------------------------------------------------- + +Use Item Not In Bag Shows Error + [Documentation] Using an item the player does not own shows an error. + [Tags] items error + [Setup] Setup Game + Type Command use hyper potion + Output Should Contain no Hyper Potion case_insensitive=True + +Use Unknown Item Shows Error + [Documentation] Using a completely unknown item name shows an error. + [Tags] items error + [Setup] Setup Game + Type Command use unobtainium + Output Should Contain Unknown item case_insensitive=True + +Pokeball Cannot Be Used Outside Battle + [Documentation] Pokeballs have no out-of-battle effect. + [Tags] items error ball + [Setup] Setup Game With Item Pokeball 1 + Type Command use pokeball + Output Should Contain can only be used in battle case_insensitive=True diff --git a/atests/move_learn.robot b/atests/move_learn.robot new file mode 100644 index 0000000..e64fc43 --- /dev/null +++ b/atests/move_learn.robot @@ -0,0 +1,74 @@ +*** Settings *** +Documentation Interactive move-learn prompt end-to-end tests using Robot Framework. +... +... Covers the ``learn_move_choice`` pending-command flow: +... choosing a slot to replace, typing 'no' to skip, out-of-range +... input re-prompting, and queued multiple moves. + +Library PokemonLibraryTest +Resource resources/common.resource + +Suite Setup Open Pokemon Terminal +Suite Teardown Close Pokemon Terminal + + +*** Variables *** +${CHARMANDER_LEVEL} 13 + + +*** Test Cases *** + +# ---------------------------------------------------------------------------- +# Slot replacement +# ---------------------------------------------------------------------------- + +Choosing Slot 1 Replaces First Move + [Documentation] When the lead Pokemon has a full moveset and a new move is queued, + ... typing '1' replaces the first move slot with the new move. + [Tags] move_learn slot + [Setup] Setup Move Learn Test RAGE + Type Command 1 + Output Should Contain RAGE + Pending Command Should Be Empty + +Choosing Slot 4 Replaces Last Move + [Documentation] Typing '4' replaces the fourth (last) move slot. + [Tags] move_learn slot + [Setup] Setup Move Learn Test SLASH + Type Command 4 + Output Should Contain SLASH + Pending Command Should Be Empty + +# ---------------------------------------------------------------------------- +# Skip learning +# ---------------------------------------------------------------------------- + +Typing No Skips Learning New Move + [Documentation] Typing 'no' declines the new move without modifying the moveset. + [Tags] move_learn skip + [Setup] Setup Move Learn Test RAGE + Type Command no + Output Should Contain did not learn case_insensitive=True + Pending Command Should Be Empty + +# ---------------------------------------------------------------------------- +# Input validation +# ---------------------------------------------------------------------------- + +Out Of Range Slot Number Re-Prompts + [Documentation] Entering '9' (out of range 1–4) keeps the prompt active. + [Tags] move_learn validation + [Setup] Setup Move Learn Test RAGE + Type Command 9 + Pending Command Should Be learn_move_choice + +# ---------------------------------------------------------------------------- +# Queued moves +# ---------------------------------------------------------------------------- + +Second Queued Move Shows Next Prompt After First Is Resolved + [Documentation] When two moves are queued, resolving the first advances to the second. + [Tags] move_learn queue + [Setup] Setup Move Learn Test RAGE SLASH + Type Command 1 + Pending Command Should Be learn_move_choice diff --git a/atests/navigation.robot b/atests/navigation.robot new file mode 100644 index 0000000..914101d --- /dev/null +++ b/atests/navigation.robot @@ -0,0 +1,180 @@ +*** Settings *** +Documentation Navigation and exploration end-to-end tests. +... +... Covers the ``look``, ``status``, ``move to``, ``show party``, +... ``show bag``, ``explore`` and ``help`` commands. + +Library PokemonLibraryTest +Resource resources/common.resource + +Suite Setup Open Pokemon Terminal +Suite Teardown Close Pokemon Terminal + + +*** Test Cases *** + +# ---------------------------------------------------------------------------- +# look around +# ---------------------------------------------------------------------------- + +Look Around Shows Current Location Name + [Documentation] 'look around' outputs the name of the current location. + [Tags] navigation look + [Setup] Setup Game + Type Command look around + Output Should Contain Pallet Town + +Look Around Shows Available Paths + [Documentation] 'look around' lists at least one available exit. + [Tags] navigation look + [Setup] Setup Game + Type Command look around + Output Should Contain Available Paths + +Look Around Shows Buildings In Town + [Documentation] 'look around' lists buildings when the player is in a town. + [Tags] navigation look + [Setup] Setup Game + Type Command look around + Output Should Contain Buildings + +Look Around In Viridian City Shows That Location + [Documentation] 'look around' while in Viridian City outputs 'Viridian City'. + [Tags] navigation look + [Setup] Setup Game At Location Viridian City + Type Command look around + Output Should Contain Viridian City + +Look Alias Works Same As Look Around + [Documentation] The short alias 'look' is equivalent to 'look around'. + [Tags] navigation look + [Setup] Setup Game + Type Command look + Output Should Contain Pallet Town + +# ---------------------------------------------------------------------------- +# status +# ---------------------------------------------------------------------------- + +Status Shows Player Name + [Documentation] 'status' outputs the player's trainer name. + [Tags] navigation status + [Setup] Setup Game + Type Command status + Output Should Contain Ash + +Status Shows Current Location + [Documentation] 'status' outputs the current location name. + [Tags] navigation status + [Setup] Setup Game + Type Command status + Output Should Contain Pallet Town + +Status Shows Money Amount + [Documentation] 'status' displays the player's money. + [Tags] navigation status + [Setup] Setup Game + Set Money 9999 + Type Command status + Output Should Contain 9999 + +Status Shows Badge Count + [Documentation] 'status' shows badge count (0/8 initially). + [Tags] navigation status badges + [Setup] Setup Game + Type Command status + Output Should Contain 0/8 + +# ---------------------------------------------------------------------------- +# movement +# ---------------------------------------------------------------------------- + +Move To Route 1 From Pallet Town Succeeds + [Documentation] Player can travel north from Pallet Town to Route 1. + [Tags] navigation move + [Setup] Setup Game + Type Command move to route 1 + Output Should Contain Route 1 + +Move To Invalid Location Shows Error + [Documentation] Attempting to move to a non-existent location shows an error. + [Tags] navigation move error + [Setup] Setup Game + Type Command move to atlantis + Output Should Contain can't go case_insensitive=True + +Status After Moving Shows New Location + [Documentation] After teleporting to Viridian City, 'status' reflects the new location. + [Tags] navigation move status + [Setup] Setup Game + Set Location Viridian City + Type Command status + Output Should Contain Viridian City + +# ---------------------------------------------------------------------------- +# show party +# ---------------------------------------------------------------------------- + +Show Party Opens Party Panel + [Documentation] 'show party' opens the party panel. + [Tags] navigation party + [Setup] Setup Game + Type Command show party + Widget Should Be Visible id=party-panel + +Party Alias Opens Party Panel + [Documentation] The 'party' alias also opens the party panel. + [Tags] navigation party + [Setup] Setup Game + Type Command party + Widget Should Be Visible id=party-panel + +Status Shows Party Pokemon + [Documentation] 'status' shows the Pokemon in the party (in the output log). + [Tags] navigation party status + [Setup] Setup Game + Type Command status + Output Should Contain Pokemon Party + +# ---------------------------------------------------------------------------- +# show bag +# ---------------------------------------------------------------------------- + +Show Bag When Empty Reports Empty + [Documentation] 'show bag' reports an empty bag when the player has no items. + [Tags] navigation bag + [Setup] Setup Game + Type Command show bag + Output Should Contain empty case_insensitive=True + +Show Bag With Items Lists Item Names + [Documentation] 'show bag' lists items the player currently holds. + [Tags] navigation bag + [Setup] Setup Game With Item Potion 3 + Type Command show bag + Output Should Contain Potion + +Show Bag Lists Item Quantity + [Documentation] 'show bag' shows the quantity of each item. + [Tags] navigation bag + [Setup] Setup Game With Item Pokeball 5 + Type Command show bag + Output Should Contain 5 + +Bag Alias Works Same As Show Bag + [Documentation] The 'bag' alias is equivalent to 'show bag'. + [Tags] navigation bag + [Setup] Setup Game With Item Potion 1 + Type Command bag + Output Should Contain Potion + +# ---------------------------------------------------------------------------- +# help +# ---------------------------------------------------------------------------- + +Help Command Shows Command List + [Documentation] 'help' outputs the list of available commands. + [Tags] navigation help + [Setup] Setup Game + Type Command help + Output Should Contain help case_insensitive=True diff --git a/atests/pokedex.robot b/atests/pokedex.robot new file mode 100644 index 0000000..9291708 --- /dev/null +++ b/atests/pokedex.robot @@ -0,0 +1,324 @@ +*** Settings *** +Documentation Pokédex viewing and entry lookup end-to-end tests. +... +... Covers the ``pokedex``, ``dex``, ``inspect``, +... ``pokedex entry ``, ``pokedex seen``, ``pokedex caught`` +... and ``pokedex missing`` commands. + +Library PokemonLibraryTest +Resource resources/common.resource + +Suite Setup Open Pokemon Terminal +Suite Teardown Close Pokemon Terminal + + +*** Test Cases *** + +# ---------------------------------------------------------------------------- +# Basic Pokédex display +# ---------------------------------------------------------------------------- + +Pokedex Command Shows Header + [Documentation] 'pokedex' renders a Pokédex header in the output. + [Tags] pokedex smoke + [Setup] Setup Game + Type Command pokedex + Output Should Contain POKEDEX case_insensitive=True + +Dex Alias Works Same As Pokedex + [Documentation] The 'dex' alias is equivalent to 'pokedex'. + [Tags] pokedex + [Setup] Setup Game + Type Command dex + Output Should Contain POKEDEX case_insensitive=True + +Pokedex Shows Seen And Caught Counts + [Documentation] The Pokédex overview shows how many Pokemon have been seen/caught. + [Tags] pokedex + [Setup] Setup Game + Type Command pokedex + Output Should Contain seen case_insensitive=True + Output Should Contain caught case_insensitive=True + +# ---------------------------------------------------------------------------- +# Pokédex entry lookup +# ---------------------------------------------------------------------------- + +Pokedex Entry Shows Species Name + [Documentation] 'pokedex entry squirtle' displays SQUIRTLE's entry. + [Tags] pokedex entry + [Setup] Setup Game + Type Command pokedex entry squirtle + Output Should Contain SQUIRTLE + +Pokedex Entry Shows Species Type + [Documentation] The Pokédex entry for SQUIRTLE includes its Water type. + [Tags] pokedex entry + [Setup] Setup Game + Type Command pokedex entry squirtle + Output Should Contain Water + +Pokedex Entry By Number Works + [Documentation] 'pokedex 7' resolves to SQUIRTLE (Pokédex #7). + [Tags] pokedex entry + [Setup] Setup Game + Type Command pokedex 7 + Output Should Contain SQUIRTLE + +Pokedex Entry By Name Works + [Documentation] 'pokedex squirtle' shows SQUIRTLE's entry (starter is already in party/seen). + [Tags] pokedex entry + [Setup] Setup Game + Type Command pokedex squirtle + Output Should Contain SQUIRTLE + +# ---------------------------------------------------------------------------- +# Filtered views +# ---------------------------------------------------------------------------- + +Pokedex Seen Filter Shows Header + [Documentation] 'pokedex seen' shows a header or listing for seen Pokemon. + [Tags] pokedex filter + [Setup] Setup Game + Type Command pokedex seen + Output Should Contain seen case_insensitive=True + +Pokedex Caught Filter Shows Header + [Documentation] 'pokedex caught' shows a header or listing for caught Pokemon. + [Tags] pokedex filter + [Setup] Setup Game + Type Command pokedex caught + Output Should Contain caught case_insensitive=True + +Pokedex Missing Filter Shows Header + [Documentation] 'pokedex missing' shows the Pokemon not yet caught. + [Tags] pokedex filter + [Setup] Setup Game + Type Command pokedex missing + Output Should Contain missing case_insensitive=True + +# ---------------------------------------------------------------------------- +# Inspect command +# ---------------------------------------------------------------------------- + +Inspect Shows Species Name + [Documentation] 'inspect squirtle' shows SQUIRTLE's detailed info. + [Tags] pokedex inspect + [Setup] Setup Game + Type Command inspect squirtle + Output Should Contain SQUIRTLE + +Inspect Shows Level + [Documentation] 'inspect squirtle' shows the Pokemon's level. + [Tags] pokedex inspect + [Setup] Setup Game + Type Command inspect squirtle + Output Should Contain Level + +Inspect Shows HP Stats + [Documentation] 'inspect squirtle' shows the HP stats. + [Tags] pokedex inspect + [Setup] Setup Game + Type Command inspect squirtle + Output Should Contain HP + +Inspect Non-Existent Pokemon Shows Error + [Documentation] Inspecting a Pokemon not in the party shows an error. + [Tags] pokedex inspect error + [Setup] Setup Game + Type Command inspect bulbasaur + Output Should Contain Could not find case_insensitive=True + +# ---------------------------------------------------------------------------- +# Pagination +# ---------------------------------------------------------------------------- + +Pokedex Next Page Advances The Page + [Documentation] 'pokedex next' shows a different page of entries. + [Tags] pokedex pagination + [Setup] Setup Game + Type Command pokedex + Type Command pokedex next + Output Should Contain Page case_insensitive=True + +Pokedex Page Jump Works + [Documentation] 'pokedex page 2' jumps directly to page 2. + [Tags] pokedex pagination + [Setup] Setup Game + Type Command pokedex page 2 + Output Should Contain Page case_insensitive=True + +# ---------------------------------------------------------------------------- +# Phase 1-4 Pokemon Pokedex entries +# ---------------------------------------------------------------------------- + +Pokedex Entry For Zubat Shows Poison Flying Type + [Documentation] ZUBAT (#41) Pokédex entry shows Poison/Flying type. + [Tags] pokedex entry phase1 zubat + [Setup] Setup Game + Register Pokemon As Seen Zubat + Type Command pokedex entry zubat + Output Should Contain ZUBAT + Output Should Contain Poison + +Pokedex Entry For Clefairy Shows Normal Type + [Documentation] CLEFAIRY (#35) Pokédex entry shows Normal type. + [Tags] pokedex entry phase1 clefairy + [Setup] Setup Game + Register Pokemon As Seen Clefairy + Type Command pokedex entry clefairy + Output Should Contain CLEFAIRY + Output Should Contain Normal + +Pokedex Entry For Jigglypuff By Number + [Documentation] 'pokedex 39' resolves to JIGGLYPUFF. + [Tags] pokedex entry phase1 jigglypuff + [Setup] Setup Game + Register Pokemon As Seen Jigglypuff + Type Command pokedex 39 + Output Should Contain JIGGLYPUFF + +Pokedex Entry For Meowth Shows Normal Type + [Documentation] MEOWTH (#52) Pokédex entry shows Normal type. + [Tags] pokedex entry phase1 meowth + [Setup] Setup Game + Register Pokemon As Seen Meowth + Type Command pokedex entry meowth + Output Should Contain MEOWTH + Output Should Contain Normal + +Pokedex Entry For Staryu Shows Water Type + [Documentation] STARYU (#120) Pokédex entry shows Water type. + [Tags] pokedex entry phase2 staryu + [Setup] Setup Game + Register Pokemon As Seen Staryu + Type Command pokedex entry staryu + Output Should Contain STARYU + Output Should Contain Water + +Pokedex Entry For Starmie Shows Water Psychic Type + [Documentation] STARMIE (#121) Pokédex entry shows Water/Psychic dual type. + [Tags] pokedex entry phase2 starmie + [Setup] Setup Game + Register Pokemon As Seen Starmie + Type Command pokedex entry starmie + Output Should Contain STARMIE + Output Should Contain Water + +Pokedex Entry For Horsea By Number + [Documentation] 'pokedex 116' resolves to HORSEA. + [Tags] pokedex entry phase2 horsea + [Setup] Setup Game + Register Pokemon As Seen Horsea + Type Command pokedex 116 + Output Should Contain HORSEA + +Pokedex Entry For Goldeen Shows Water Type + [Documentation] GOLDEEN (#118) Pokédex entry shows Water type. + [Tags] pokedex entry phase2 goldeen + [Setup] Setup Game + Register Pokemon As Seen Goldeen + Type Command pokedex entry goldeen + Output Should Contain GOLDEEN + Output Should Contain Water + +Pokedex Entry For Ekans Shows Poison Type + [Documentation] EKANS (#23) Pokédex entry shows Poison type. + [Tags] pokedex entry phase3 ekans + [Setup] Setup Game + Register Pokemon As Seen Ekans + Type Command pokedex entry ekans + Output Should Contain EKANS + Output Should Contain Poison + +Pokedex Entry For Sandshrew Shows Ground Type + [Documentation] SANDSHREW (#27) Pokédex entry shows Ground type. + [Tags] pokedex entry phase3 sandshrew + [Setup] Setup Game + Register Pokemon As Seen Sandshrew + Type Command pokedex entry sandshrew + Output Should Contain SANDSHREW + Output Should Contain Ground + +Pokedex Entry For Abra Shows Psychic Type + [Documentation] ABRA (#63) Pokédex entry shows Psychic type. + [Tags] pokedex entry phase3 abra + [Setup] Setup Game + Register Pokemon As Seen Abra + Type Command pokedex entry abra + Output Should Contain ABRA + Output Should Contain Psychic + +Pokedex Entry For Oddish Shows Grass Poison Type + [Documentation] ODDISH (#43) Pokédex entry shows Grass/Poison dual type. + [Tags] pokedex entry phase3 oddish + [Setup] Setup Game + Register Pokemon As Seen Oddish + Type Command pokedex entry oddish + Output Should Contain ODDISH + Output Should Contain Grass + +Pokedex Entry For Bellsprout Shows Grass Poison Type + [Documentation] BELLSPROUT (#69) Pokédex entry shows Grass/Poison dual type. + [Tags] pokedex entry phase3 bellsprout + [Setup] Setup Game + Register Pokemon As Seen Bellsprout + Type Command pokedex entry bellsprout + Output Should Contain BELLSPROUT + Output Should Contain Grass + +Pokedex Entry For Alakazam By Number + [Documentation] 'pokedex 65' resolves to ALAKAZAM. + [Tags] pokedex entry phase4 alakazam + [Setup] Setup Game + Register Pokemon As Seen Alakazam + Type Command pokedex 65 + Output Should Contain ALAKAZAM + +Pokedex Entry For Golem Shows Rock Ground Type + [Documentation] GOLEM (#76) Pokédex entry shows Rock/Ground dual type. + [Tags] pokedex entry phase4 golem + [Setup] Setup Game + Register Pokemon As Seen Golem + Type Command pokedex entry golem + Output Should Contain GOLEM + Output Should Contain Rock + +Pokedex Entry For Vileplume By Number + [Documentation] 'pokedex 45' resolves to VILEPLUME. + [Tags] pokedex entry phase4 vileplume + [Setup] Setup Game + Register Pokemon As Seen Vileplume + Type Command pokedex 45 + Output Should Contain VILEPLUME + +Pokedex Entry For Victreebel Shows Grass Poison Type + [Documentation] VICTREEBEL (#71) Pokédex entry shows Grass/Poison dual type. + [Tags] pokedex entry phase4 victreebel + [Setup] Setup Game + Register Pokemon As Seen Victreebel + Type Command pokedex entry victreebel + Output Should Contain VICTREEBEL + Output Should Contain Grass + +# ---------------------------------------------------------------------------- +# Verify Pokemon are not stubs in Pokedex +# ---------------------------------------------------------------------------- + +Newly Implemented Pokemon Have Complete Pokedex Entries + [Documentation] Newly implemented Pokemon show complete entries, not stub data. + [Tags] pokedex entry phase1234 + [Setup] Setup Game + # Test a few representative Pokemon from each phase + Register Pokemon As Seen Zubat + Type Command pokedex entry zubat + Output Should Contain ZUBAT + Register Pokemon As Seen Starmie + Type Command pokedex entry starmie + Output Should Contain STARMIE + Register Pokemon As Seen Abra + Type Command pokedex entry abra + Output Should Contain ABRA + Register Pokemon As Seen Alakazam + Type Command pokedex entry alakazam + Output Should Contain ALAKAZAM diff --git a/atests/pokemon_phases.robot b/atests/pokemon_phases.robot new file mode 100644 index 0000000..fae19b1 --- /dev/null +++ b/atests/pokemon_phases.robot @@ -0,0 +1,265 @@ +*** Settings *** +Documentation End-to-end tests for Phase 1-4 Pokemon implementation. +... +... Tests stats, stone evolutions, and gym battles +... for the 28 newly-implemented Pokemon species from Pallet Town to Cerulean City. +... +... NOTE: Tests requiring Set Wild Encounter and Level Up Pokemon keywords +... are commented out pending those keywords' implementation. + +Library PokemonLibraryTest +Resource resources/common.resource + +Suite Setup Open Pokemon Terminal +Suite Teardown Close Pokemon Terminal + + +*** Test Cases *** + +# ============================================================================ +# PHASE 1: Critical Wild Encounters - Stats & Evolution Tests +# ============================================================================ + +Zubat Has Correct Stats Not Stub Values + [Documentation] ZUBAT has proper stats (not stub values of 50/50/50). + [Tags] phase1 zubat stats + [Setup] Setup Game With Pokemon ZUBAT 10 + Party Pokemon Should Be 0 ZUBAT + Party Pokemon Level Should Be 0 10 + +Clefairy Evolves With Moon Stone + [Documentation] Using Moon Stone on CLEFAIRY produces CLEFABLE. + [Tags] phase1 evolution stone clefairy + [Setup] Setup Evolution Test CLEFAIRY Moon Stone + Type Command use moon stone on clefairy + Output Should Contain CLEFABLE + Party Pokemon Should Be 0 CLEFABLE + Item Count Should Be Moon Stone 0 + +Clefairy Has Normal Type + [Documentation] CLEFAIRY is Normal type (single type). + [Tags] phase1 clefairy stats + [Setup] Setup Game With Pokemon CLEFAIRY 10 + Party Pokemon Should Be 0 CLEFAIRY + +Jigglypuff Has High HP Stat + [Documentation] JIGGLYPUFF has signature high HP (115 base HP). + [Tags] phase1 jigglypuff stats + [Setup] Setup Game With Pokemon JIGGLYPUFF 20 + Party Pokemon Should Be 0 JIGGLYPUFF + +Jigglypuff Evolves To Wigglytuff With Moon Stone + [Documentation] Using Moon Stone on JIGGLYPUFF produces WIGGLYTUFF. + [Tags] phase1 evolution stone jigglypuff + [Setup] Setup Evolution Test JIGGLYPUFF Moon Stone + Type Command use moon stone on jigglypuff + Output Should Contain WIGGLYTUFF + Party Pokemon Should Be 0 WIGGLYTUFF + Item Count Should Be Moon Stone 0 + +Meowth Has Normal Type + [Documentation] MEOWTH is Normal type with proper stats. + [Tags] phase1 meowth stats + [Setup] Setup Game With Pokemon MEOWTH 15 + Party Pokemon Should Be 0 MEOWTH + +# ============================================================================ +# PHASE 2: Gym Leaders - Brock & Misty Pokemon +# ============================================================================ + +Staryu Is Used By Misty In Gym Battle + [Documentation] Gym Leader Misty uses STARYU in her team. + [Tags] phase2 gym misty staryu + [Setup] Setup Game At Location Cerulean City + Add Badge Boulder Badge + Set Lead Pokemon PIKACHU 25 + Type Command enter gym + Click Widget id=btn-gym-challenge + Should Be In Battle + +Starmie Is Misty Signature Pokemon + [Documentation] Gym Leader Misty's signature Pokemon is STARMIE (Water/Psychic). + [Tags] phase2 gym misty starmie + [Setup] Setup Misty Battle With Starmie + Should Be In Battle + +Staryu Evolves With Water Stone + [Documentation] Using Water Stone on STARYU produces STARMIE. + [Tags] phase2 evolution stone staryu + [Setup] Setup Evolution Test STARYU Water Stone + Type Command use water stone on staryu + Output Should Contain STARMIE + Party Pokemon Should Be 0 STARMIE + Item Count Should Be Water Stone 0 + +Starmie Has Water Type + [Documentation] STARMIE is Water/Psychic dual-type. + [Tags] phase2 starmie stats + [Setup] Setup Game With Pokemon STARMIE 20 + Party Pokemon Should Be 0 STARMIE + +Horsea Can Be Added To Party + [Documentation] HORSEA can be added to the party and has proper stats. + [Tags] phase2 horsea stats + [Setup] Setup Game With Pokemon HORSEA 18 + Party Pokemon Should Be 0 HORSEA + +Goldeen Can Be Added To Party + [Documentation] GOLDEEN can be added to the party and has proper stats. + [Tags] phase2 goldeen stats + [Setup] Setup Game With Pokemon GOLDEEN 18 + Party Pokemon Should Be 0 GOLDEEN + +# ============================================================================ +# PHASE 3: Trainer Battles - Route Trainers +# ============================================================================ + +Ekans Has Poison Type + [Documentation] EKANS is Poison type with proper stats. + [Tags] phase3 ekans stats + [Setup] Setup Game With Pokemon EKANS 15 + Party Pokemon Should Be 0 EKANS + +Sandshrew Has Ground Type + [Documentation] SANDSHREW is Ground type with proper stats. + [Tags] phase3 sandshrew stats + [Setup] Setup Game With Pokemon SANDSHREW 15 + Party Pokemon Should Be 0 SANDSHREW + +Abra Has Psychic Type + [Documentation] ABRA is Psychic type with proper stats. + [Tags] phase3 abra stats + [Setup] Setup Game With Pokemon ABRA 10 + Party Pokemon Should Be 0 ABRA + +Abra Only Knows Teleport Initially + [Documentation] ABRA's signature trait - it only knows TELEPORT at low levels. + [Tags] phase3 abra moves + [Setup] Setup Game With Pokemon ABRA 5 + Party Pokemon Should Be 0 ABRA + +Gloom Evolves To Vileplume With Leaf Stone + [Documentation] Using Leaf Stone on GLOOM produces VILEPLUME. + [Tags] phase3 evolution stone oddish + [Setup] Setup Evolution Test GLOOM Leaf Stone + Type Command use leaf stone on gloom + Output Should Contain VILEPLUME + Party Pokemon Should Be 0 VILEPLUME + Item Count Should Be Leaf Stone 0 + +Oddish Has Grass Poison Dual Type + [Documentation] ODDISH is Grass/Poison dual-type. + [Tags] phase3 oddish stats + [Setup] Setup Game With Pokemon ODDISH 15 + Party Pokemon Should Be 0 ODDISH + +Weepinbell Evolves To Victreebel With Leaf Stone + [Documentation] Using Leaf Stone on WEEPINBELL produces VICTREEBEL. + [Tags] phase3 evolution stone bellsprout + [Setup] Setup Evolution Test WEEPINBELL Leaf Stone + Type Command use leaf stone on weepinbell + Output Should Contain VICTREEBEL + Party Pokemon Should Be 0 VICTREEBEL + Item Count Should Be Leaf Stone 0 + +Bellsprout Has Grass Poison Dual Type + [Documentation] BELLSPROUT is Grass/Poison dual-type. + [Tags] phase3 bellsprout stats + [Setup] Setup Game With Pokemon BELLSPROUT 15 + Party Pokemon Should Be 0 BELLSPROUT + +# ============================================================================ +# PHASE 4: Complete Evolution Lines +# ============================================================================ + +Alakazam Can Be In Party + [Documentation] ALAKAZAM has very high Special stat (135 base). + [Tags] phase4 alakazam stats + [Setup] Setup Game With Pokemon ALAKAZAM 50 + Party Pokemon Should Be 0 ALAKAZAM + +Golem Has Rock Ground Dual Type + [Documentation] GOLEM is Rock/Ground dual-type. + [Tags] phase4 golem stats + [Setup] Setup Game With Pokemon GOLEM 40 + Party Pokemon Should Be 0 GOLEM + +Wigglytuff Is Final Evolution + [Documentation] WIGGLYTUFF is a final evolution. + [Tags] phase4 evolution wigglytuff + [Setup] Setup Game With Pokemon WIGGLYTUFF 50 + Party Pokemon Should Be 0 WIGGLYTUFF + +Persian Is Final Evolution + [Documentation] PERSIAN is a final evolution. + [Tags] phase4 evolution persian + [Setup] Setup Game With Pokemon PERSIAN 50 + Party Pokemon Should Be 0 PERSIAN + +Vileplume Is Final Evolution + [Documentation] VILEPLUME is a final evolution. + [Tags] phase4 evolution vileplume + [Setup] Setup Game With Pokemon VILEPLUME 50 + Party Pokemon Should Be 0 VILEPLUME + +Victreebel Is Final Evolution + [Documentation] VICTREEBEL is a final evolution. + [Tags] phase4 evolution victreebel + [Setup] Setup Game With Pokemon VICTREEBEL 50 + Party Pokemon Should Be 0 VICTREEBEL + +Golbat Has Poison Flying Dual Type + [Documentation] GOLBAT is Poison/Flying dual-type. + [Tags] phase4 golbat stats + [Setup] Setup Game With Pokemon GOLBAT 30 + Party Pokemon Should Be 0 GOLBAT + +Seadra Is Water Type + [Documentation] SEADRA is Water type. + [Tags] phase4 seadra stats + [Setup] Setup Game With Pokemon SEADRA 35 + Party Pokemon Should Be 0 SEADRA + +Seaking Is Water Type + [Documentation] SEAKING is Water type. + [Tags] phase4 seaking stats + [Setup] Setup Game With Pokemon SEAKING 35 + Party Pokemon Should Be 0 SEAKING + +Arbok Is Poison Type + [Documentation] ARBOK is Poison type. + [Tags] phase4 arbok stats + [Setup] Setup Game With Pokemon ARBOK 25 + Party Pokemon Should Be 0 ARBOK + +Sandslash Is Ground Type + [Documentation] SANDSLASH is Ground type. + [Tags] phase4 sandslash stats + [Setup] Setup Game With Pokemon SANDSLASH 25 + Party Pokemon Should Be 0 SANDSLASH + +Kadabra Has Psychic Type + [Documentation] KADABRA is Psychic type. + [Tags] phase4 kadabra stats + [Setup] Setup Game With Pokemon KADABRA 25 + Party Pokemon Should Be 0 KADABRA + +Clefable Is Normal Type + [Documentation] CLEFABLE is Normal type. + [Tags] phase4 clefable stats + [Setup] Setup Game With Pokemon CLEFABLE 25 + Party Pokemon Should Be 0 CLEFABLE + + +*** Keywords *** + +# ── Custom setup for gym battles ───────────────────────────────────────── + +Setup Misty Battle With Starmie + [Documentation] Set up game state ready to battle Misty who has Starmie. + Bootstrap Game location=Cerulean City + Add Badge Boulder Badge + Set Lead Pokemon PIKACHU 25 + Type Command enter gym + Click Widget id=btn-gym-challenge + diff --git a/atests/resources/common.resource b/atests/resources/common.resource new file mode 100644 index 0000000..31c99f9 --- /dev/null +++ b/atests/resources/common.resource @@ -0,0 +1,138 @@ +*** Settings *** +Documentation Shared settings and keywords for PokemonLibrary end-to-end tests. +... +... Import this resource in every e2e test suite: +... +... | Resource resources/common.resource | +... +... Suite lifecycle: +... | Suite Setup | Open Pokemon Terminal | +... | Suite Teardown | Close Pokemon Terminal | +... +... Each test case can use ``[Setup]`` with one of the keywords below +... instead of calling ``Bootstrap Game`` directly. + +Library PokemonLibraryTest + + +*** Keywords *** + +# ── Basic game setup ────────────────────────────────────────────────────── + +Setup Game + [Documentation] Bootstrap a clean game at Pallet Town with default starter. + ... Use this as [Setup] for tests that don't need special configuration. + Bootstrap Game + +Setup Game At Location + [Documentation] Bootstrap game at a specific location. + ... Example: [Setup] Setup Game At Location Viridian City + [Arguments] ${location} + Bootstrap Game location=${location} + +# ── Item-focused setups ─────────────────────────────────────────────────── + +Setup Game With Item + [Documentation] Bootstrap game and add one item to the bag. + ... Example: [Setup] Setup Game With Item Potion 3 + [Arguments] ${item_name} ${quantity}=1 + Bootstrap Game + Set Item ${item_name} ${quantity} + +Setup Game With Items + [Documentation] Bootstrap game and add multiple items. + ... Pass item/quantity pairs as arguments. + ... Example: [Setup] Setup Game With Items Potion 3 Pokeball 5 + [Arguments] @{items} + Bootstrap Game + ${item_count}= Get Length ${items} + FOR ${i} IN RANGE 0 ${item_count} 2 + ${item_name}= Set Variable ${items}[${i}] + ${quantity}= Set Variable ${items}[${i+1}] + Set Item ${item_name} ${quantity} + END + +# ── Pokemon-focused setups ──────────────────────────────────────────────── + +Setup Game With Pokemon + [Documentation] Bootstrap game and set a specific lead Pokemon at the given level. + ... Example: [Setup] Setup Game With Pokemon CHARMANDER 15 + [Arguments] ${species} ${level}=15 + Bootstrap Game + Set Lead Pokemon ${species} level=${level} + +Setup Game With Damaged Pokemon + [Documentation] Bootstrap game with a damaged lead Pokemon and a healing item. + ... Example: [Setup] Setup Game With Damaged Pokemon Potion 1 5 + [Arguments] ${item_name} ${item_qty}=1 ${hp}=5 + Bootstrap Game + Set Item ${item_name} ${item_qty} + Set Pokemon HP 0 ${hp} + +# ── Shop setups ─────────────────────────────────────────────────────────── + +Setup Shop + [Documentation] Bootstrap game and enter shop pending-command state with basic catalog. + ... Useful for buy/sell tests. Optionally set initial money. + ... Example: [Setup] Setup Shop money=1000 + [Arguments] ${money}=3000 + Bootstrap Game + Set Money ${money} + Set Pending Command shop _current_shop_catalog=basic + +Setup Shop With Items + [Documentation] Bootstrap game, set money, add items, and enter shop state. + ... Example: [Setup] Setup Shop With Items 500 Nugget 1 + [Arguments] ${money} ${item_name} ${item_qty} + Bootstrap Game + Set Money ${money} + Set Item ${item_name} ${item_qty} + Set Pending Command shop _current_shop_catalog=basic + +# ── Evolution test setups ───────────────────────────────────────────────── + +Setup Evolution Test + [Documentation] Bootstrap game with a specific Pokemon and evolution stone. + ... Example: [Setup] Setup Evolution Test GROWLITHE Fire Stone + [Arguments] ${pokemon_species} ${stone_name} + Bootstrap Game + Set Lead Pokemon ${pokemon_species} + Set Item ${stone_name} 1 + +# ── Badge/gym setups ────────────────────────────────────────────────────── + +Setup Game With Badges + [Documentation] Bootstrap game and add one or more badges. + ... Example: [Setup] Setup Game With Badges Boulder Badge Cascade Badge + [Arguments] @{badge_names} + Bootstrap Game + FOR ${badge} IN @{badge_names} + Add Badge ${badge} + END + +# ── Battle/trainer setups ───────────────────────────────────────────────── + +Setup Rival Battle Conditions + [Documentation] Bootstrap at location with all nugget trainers defeated. + ... Example: [Setup] Setup Rival Battle Conditions + [Arguments] ${location}=Cerulean City + Bootstrap Game location=${location} + Add Defeated Trainer nugget_trainer_1 + Add Defeated Trainer nugget_trainer_2 + Add Defeated Trainer nugget_trainer_3 + Add Defeated Trainer nugget_trainer_4 + Add Defeated Trainer nugget_trainer_5 + +# ── Move learning setups ────────────────────────────────────────────────── + +Setup Move Learn Test + [Documentation] Bootstrap game with CHARMANDER at level 13 and queue a move prompt. + ... Example: [Setup] Setup Move Learn Test RAGE + [Arguments] ${move_name} ${remaining}=${EMPTY} + Bootstrap Game + Set Lead Pokemon CHARMANDER level=13 + IF '${remaining}' != '' + Set Learn Move Prompt ${move_name} remaining=${remaining} + ELSE + Set Learn Move Prompt ${move_name} + END diff --git a/atests/settings.robot b/atests/settings.robot new file mode 100644 index 0000000..0f1fffc --- /dev/null +++ b/atests/settings.robot @@ -0,0 +1,109 @@ +*** Settings *** +Documentation Settings and autosave end-to-end tests. +... +... Covers the ``show settings``, ``enable autosave``, +... ``disable autosave``, and ``autosave `` commands. + +Library PokemonLibraryTest +Resource resources/common.resource + +Suite Setup Open Pokemon Terminal +Suite Teardown Close Pokemon Terminal + + +*** Test Cases *** + +# ---------------------------------------------------------------------------- +# Settings display +# ---------------------------------------------------------------------------- + +Show Settings Displays Settings Header + [Documentation] 'show settings' outputs a settings section header. + [Tags] settings smoke + [Setup] Setup Game + Type Command show settings + Output Should Contain Settings case_insensitive=True + +Settings Alias Works Same As Show Settings + [Documentation] 'settings' is an alias for 'show settings'. + [Tags] settings + [Setup] Setup Game + Type Command settings + Output Should Contain Settings case_insensitive=True + +Show Settings Mentions Autosave + [Documentation] The settings screen mentions the autosave configuration. + [Tags] settings + [Setup] Setup Game + Type Command show settings + Output Should Contain autosave case_insensitive=True + +# ---------------------------------------------------------------------------- +# Enable / disable autosave +# ---------------------------------------------------------------------------- + +Enable Autosave Shows Confirmation + [Documentation] 'enable autosave' outputs a confirmation message. + [Tags] settings autosave + [Setup] Setup Game + Type Command enable autosave + Output Should Contain Autosave enabled case_insensitive=True + +Disable Autosave Shows Confirmation + [Documentation] 'disable autosave' outputs a confirmation message. + [Tags] settings autosave + [Setup] Setup Game + Type Command disable autosave + Output Should Contain Autosave disabled case_insensitive=True + +Enable Then Disable Autosave Works + [Documentation] Autosave can be toggled on and then off without error. + [Tags] settings autosave + [Setup] Setup Game + Type Command enable autosave + Output Should Contain enabled case_insensitive=True + Type Command disable autosave + Output Should Contain disabled case_insensitive=True + +# ---------------------------------------------------------------------------- +# Autosave frequency +# ---------------------------------------------------------------------------- + +Set Autosave Frequency To Valid Value + [Documentation] 'autosave 10' sets the autosave frequency to 10 commands. + [Tags] settings autosave frequency + [Setup] Setup Game + Type Command autosave 10 + Output Should Contain 10 case_insensitive=True + +Set Autosave Frequency Out Of Range Shows Error + [Documentation] A frequency outside 5–100 should produce an error. + [Tags] settings autosave frequency error + [Setup] Setup Game + Type Command autosave 3 + Output Should Contain must be between case_insensitive=True + +# ---------------------------------------------------------------------------- +# Misc info commands +# ---------------------------------------------------------------------------- + +Hello Command Shows Greeting + [Documentation] The 'hello' easter-egg command shows a greeting. + [Tags] settings misc + [Setup] Setup Game + Type Command hello + Output Should Contain Hello case_insensitive=True + +About Command Shows Version Info + [Documentation] 'about' outputs version and project information. + [Tags] settings misc + [Setup] Setup Game + Type Command about + Output Should Contain Robot Framework case_insensitive=True + +Adventure Stats Shows Statistics + [Documentation] 'stats' shows adventure statistics. + [Tags] settings stats + [Setup] Setup Game + Type Command stats + Output Should Contain stats case_insensitive=True diff --git a/atests/test_e2e_terminal.py b/atests/test_e2e_terminal.py deleted file mode 100644 index 650c982..0000000 --- a/atests/test_e2e_terminal.py +++ /dev/null @@ -1,554 +0,0 @@ -"""End-to-end tests for PokemonTerminal using Textual's Pilot API. - -Each test runs the full app in headless mode via ``app.run_test()``. -A ``Pilot`` instance is used to simulate keyboard input and button clicks, -exercising the real TUI code paths (compose → event handlers → game logic). - -Coverage note: ``terminal.py`` is excluded from the unit-coverage report -because it requires a display. These tests provide the missing integration -coverage for the new P1 features: - - Sell items at Pokemart - - Interactive move learn prompt - - Stone-based evolution via the ``use`` command - - Rival battle triggers after Nugget Bridge -""" - -from textual.widgets import Input, RichLog - -from PokemonLibrary.terminal import PokemonTerminal - -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - - -def _output_text(app: PokemonTerminal) -> str: - """Return all plain text currently in the main #output RichLog.""" - log = app.query_one("#output", RichLog) - return "\n".join(strip.text for strip in log.lines) - - -async def _cmd(app: PokemonTerminal, pilot, text: str) -> None: - """Put *text* into the command input, press Enter, then let messages settle.""" - app.query_one("#command-input", Input).value = text - await pilot.press("enter") - await pilot.pause() - - -def _bootstrap_game(app: PokemonTerminal, location_name: str = "Pallet Town") -> None: - """ - Start an in-game session without going through the new-game UI flow. - - Gives the player a Lv.10 SQUIRTLE. Individual tests may overwrite - ``app.game_state.game_data["pokemon"]`` to suit their needs. - """ - from PokemonLibrary.engine import BattleState - from PokemonLibrary.locations import get_location - - app.game_state.start_new_game() - app.game_state.current_location = get_location(location_name) - app.game_state.game_data["location"] = location_name - app.game_state.game_data["player_name"] = "Ash" - app.game_state.game_data["rival_name"] = "Gary" - app.game_state.game_data["rival_starter"] = "CHARMANDER" - - bs = BattleState() - starter = bs.generate_wild_pokemon("SQUIRTLE", 10) - app.game_state.game_data["pokemon"] = [starter] - - -def _charmander_full_moves() -> dict: - """Return a CHARMANDER dict that already has a full 4-move set.""" - return { - "name": "CHARMANDER", - "number": 4, - "level": 13, - "types": ["Fire"], - "hp": 35, - "max_hp": 35, - "stats": {"attack": 15, "defense": 12, "special": 14, "speed": 15}, - "moves": [ - {"name": "SCRATCH", "pp": 35, "max_pp": 35}, - {"name": "GROWL", "pp": 40, "max_pp": 40}, - {"name": "EMBER", "pp": 25, "max_pp": 25}, - {"name": "SMOKESCREEN", "pp": 20, "max_pp": 20}, - ], - "status": None, - "experience": 0, - "next_level_exp": 343, - "no_evolve": False, - } - - -# --------------------------------------------------------------------------- -# Main menu -# --------------------------------------------------------------------------- - - -class TestE2EMainMenu: - async def test_app_shows_main_menu_on_startup(self): - """App launches in headless mode and renders the main-menu panel.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - - # The main-menu-actions container should be visible - panel = app.query_one("#main-menu-actions") - assert not panel.has_class("hidden") - - # Game state is still in the menu (no game started yet) - assert app.game_state.in_menu is True - - # Some menu text should be in the output log - output = _output_text(app) - assert output # non-empty - - async def test_clicking_new_game_opens_name_selection(self): - """Clicking '🆕 Start New Game' shows the name-selection panel.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - await pilot.click("#btn-new-game") - await pilot.pause() - - name_panel = app.query_one("#name-selection") - assert not name_panel.has_class("hidden") - - -# --------------------------------------------------------------------------- -# Sell items at Pokemart -# --------------------------------------------------------------------------- - - -class TestE2ESellItems: - async def test_sell_nugget_increases_money(self): - """Inject a shop context and sell a Nugget — money increases by ₽5000.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - app.game_state.game_data["items"]["Nugget"] = 1 - app.game_state.game_data["money"] = 500 - app.game_state.game_data["_current_shop_catalog"] = "basic" - app.pending_command = "shop" - - await _cmd(app, pilot, "sell nugget") - - output = _output_text(app) - assert "Sold 1x Nugget" in output - assert app.game_state.game_data["money"] == 5500 - assert app.game_state.game_data["items"].get("Nugget", 0) == 0 - - async def test_sell_multiple_qty_potions(self): - """'sell 3 potion' removes 3 Potions and awards 3 * 150 = 450.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - app.game_state.game_data["items"]["Potion"] = 3 - app.game_state.game_data["money"] = 0 - app.game_state.game_data["_current_shop_catalog"] = "basic" - app.pending_command = "shop" - - await _cmd(app, pilot, "sell 3 potion") - - output = _output_text(app) - assert "Sold 3x Potion" in output - # Potion catalog price = 300, sell = 150 * 3 = 450 - assert app.game_state.game_data["money"] == 450 - assert app.game_state.game_data["items"].get("Potion", 0) == 0 - - async def test_sell_item_not_owned_shows_error_and_no_money_change(self): - """Selling an item the player doesn't have shows an error message.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - app.game_state.game_data["money"] = 500 - app.game_state.game_data["_current_shop_catalog"] = "basic" - app.pending_command = "shop" - - await _cmd(app, pilot, "sell nugget") - - output = _output_text(app) - assert "don't have" in output.lower() - assert app.game_state.game_data["money"] == 500 - - async def test_sell_qty_clamped_to_stock(self): - """Requesting more than stock sells only what's available.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - app.game_state.game_data["items"]["Potion"] = 2 - app.game_state.game_data["money"] = 0 - app.game_state.game_data["_current_shop_catalog"] = "basic" - app.pending_command = "shop" - - # Ask for 10 but only have 2 - await _cmd(app, pilot, "sell 10 potion") - - # Should sell exactly 2 - assert app.game_state.game_data["money"] == 300 # 150 * 2 - assert app.game_state.game_data["items"].get("Potion", 0) == 0 - - async def test_buy_command_still_works_after_sell_feature_added(self): - """Regression: existing 'buy' still works after the sell branch was added.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - app.game_state.game_data["money"] = 1000 - app.game_state.game_data["_current_shop_catalog"] = "basic" - app.pending_command = "shop" - - await _cmd(app, pilot, "buy potion") - - output = _output_text(app) - assert "Bought" in output - assert app.game_state.game_data["items"].get("Potion", 0) >= 1 - - -# --------------------------------------------------------------------------- -# Interactive move learn prompt -# --------------------------------------------------------------------------- - - -class TestE2EMoveLearn: - async def test_choice_1_replaces_first_move(self): - """Typing '1' in the learn-move prompt swaps the first move for the new one.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - pokemon = _charmander_full_moves() - app.game_state.game_data["pokemon"] = [pokemon] - - app.pending_command = "learn_move_choice" - app.pending_command_data = { - "learn_pokemon": pokemon, - "learn_move_name": "RAGE", - "learn_remaining": [], - "learn_post_action": "wild_end", - } - - await _cmd(app, pilot, "1") - - output = _output_text(app) - assert "RAGE" in output - assert pokemon["moves"][0]["name"] == "RAGE" - assert app.pending_command != "learn_move_choice" - - async def test_choice_4_replaces_last_move(self): - """Typing '4' replaces the fourth (last) move slot.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - pokemon = _charmander_full_moves() - app.game_state.game_data["pokemon"] = [pokemon] - - app.pending_command = "learn_move_choice" - app.pending_command_data = { - "learn_pokemon": pokemon, - "learn_move_name": "RAGE", - "learn_remaining": [], - "learn_post_action": "wild_end", - } - - await _cmd(app, pilot, "4") - - output = _output_text(app) - assert "RAGE" in output - assert pokemon["moves"][3]["name"] == "RAGE" - # First three moves unchanged - assert pokemon["moves"][0]["name"] == "SCRATCH" - - async def test_typing_no_skips_learning(self): - """Typing 'no' skips learning without touching the moveset.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - pokemon = _charmander_full_moves() - original_moves = [m["name"] for m in pokemon["moves"]] - app.game_state.game_data["pokemon"] = [pokemon] - - app.pending_command = "learn_move_choice" - app.pending_command_data = { - "learn_pokemon": pokemon, - "learn_move_name": "RAGE", - "learn_remaining": [], - "learn_post_action": "wild_end", - } - - await _cmd(app, pilot, "no") - - output = _output_text(app) - assert "did not learn" in output.lower() - assert [m["name"] for m in pokemon["moves"]] == original_moves - assert app.pending_command != "learn_move_choice" - - async def test_out_of_range_choice_re_prompts(self): - """An out-of-range number re-prompts without altering state.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - pokemon = _charmander_full_moves() - app.game_state.game_data["pokemon"] = [pokemon] - - app.pending_command = "learn_move_choice" - app.pending_command_data = { - "learn_pokemon": pokemon, - "learn_move_name": "RAGE", - "learn_remaining": [], - "learn_post_action": "wild_end", - } - - await _cmd(app, pilot, "9") - - # Prompt stays active - assert app.pending_command == "learn_move_choice" - assert pokemon["moves"][0]["name"] == "SCRATCH" - - async def test_second_queued_move_shows_next_prompt(self): - """When two moves are queued, choosing first advances to second prompt.""" - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - pokemon = _charmander_full_moves() - app.game_state.game_data["pokemon"] = [pokemon] - - app.pending_command = "learn_move_choice" - app.pending_command_data = { - "learn_pokemon": pokemon, - "learn_move_name": "RAGE", - "learn_remaining": ["SLASH"], # second move queued - "learn_post_action": "wild_end", - } - - # Choose slot 1 for the first move - await _cmd(app, pilot, "1") - await pilot.pause() - - # Should now be prompting for SLASH - assert app.pending_command == "learn_move_choice" - assert app.pending_command_data.get("learn_move_name") == "SLASH" - assert pokemon["moves"][0]["name"] == "RAGE" - - -# --------------------------------------------------------------------------- -# Stone-based evolution via the 'use' command -# --------------------------------------------------------------------------- - - -class TestE2EStoneEvolution: - async def test_fire_stone_evolves_growlithe_to_arcanine(self): - """'use fire stone on growlithe' evolves GROWLITHE and consumes the stone.""" - from PokemonLibrary.engine import BattleState - - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - bs = BattleState() - growlithe = bs.generate_wild_pokemon("GROWLITHE", 15) - app.game_state.game_data["pokemon"] = [growlithe] - app.game_state.game_data["items"]["Fire Stone"] = 1 - - await _cmd(app, pilot, "use fire stone on growlithe") - - output = _output_text(app) - assert "ARCANINE" in output - assert app.game_state.game_data["pokemon"][0]["name"] == "ARCANINE" - assert app.game_state.game_data["items"].get("Fire Stone", 0) == 0 - - async def test_moon_stone_evolves_clefairy_to_clefable(self): - """'use moon stone on clefairy' evolves CLEFAIRY to CLEFABLE.""" - from PokemonLibrary.engine import BattleState - - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - bs = BattleState() - clefairy = bs.generate_wild_pokemon("CLEFAIRY", 20) - app.game_state.game_data["pokemon"] = [clefairy] - app.game_state.game_data["items"]["Moon Stone"] = 1 - - await _cmd(app, pilot, "use moon stone on clefairy") - - output = _output_text(app) - assert "CLEFABLE" in output - assert app.game_state.game_data["pokemon"][0]["name"] == "CLEFABLE" - assert app.game_state.game_data["items"].get("Moon Stone", 0) == 0 - - async def test_wrong_stone_shows_no_effect_and_is_not_consumed(self): - """Using a stone that has no match leaves the Pokemon and the stone intact.""" - from PokemonLibrary.engine import BattleState - - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - bs = BattleState() - squirtle = bs.generate_wild_pokemon("SQUIRTLE", 10) - app.game_state.game_data["pokemon"] = [squirtle] - app.game_state.game_data["items"]["Fire Stone"] = 1 - - await _cmd(app, pilot, "use fire stone on squirtle") - - output = _output_text(app) - assert "no effect" in output.lower() - assert app.game_state.game_data["pokemon"][0]["name"] == "SQUIRTLE" - # Stone must NOT be consumed on a no-match - assert app.game_state.game_data["items"].get("Fire Stone", 0) == 1 - - async def test_thunder_stone_evolves_pikachu_to_raichu(self): - """'use thunder stone on pikachu' evolves PIKACHU to RAICHU.""" - from PokemonLibrary.engine import BattleState - - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app) - - bs = BattleState() - pikachu = bs.generate_wild_pokemon("PIKACHU", 18) - app.game_state.game_data["pokemon"] = [pikachu] - app.game_state.game_data["items"]["Thunder Stone"] = 1 - - await _cmd(app, pilot, "use thunder stone on pikachu") - - output = _output_text(app) - assert "RAICHU" in output - assert app.game_state.game_data["pokemon"][0]["name"] == "RAICHU" - - -# --------------------------------------------------------------------------- -# Rival battle after Nugget Bridge -# --------------------------------------------------------------------------- - - -class TestE2ERivalCerulean: - async def test_rival_battle_triggers_after_all_nugget_trainers_beaten(self): - """Entering Nugget Bridge with all 5 trainers beaten triggers the rival.""" - from PokemonLibrary.engine import BattleState - from PokemonLibrary.locations import get_location - - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app, location_name="Cerulean City") - - app.game_state.current_location = get_location("Cerulean City") - app.game_state.game_data["location"] = "Cerulean City" - - # A strong enough Pokemon for the battle setup not to error - bs = BattleState() - party_pokemon = bs.generate_wild_pokemon("SQUIRTLE", 18) - app.game_state.game_data["pokemon"] = [party_pokemon] - - # All five Nugget Bridge trainers defeated - app.game_state.game_data["defeated_trainers"] = [ - "nugget_trainer_1", - "nugget_trainer_2", - "nugget_trainer_3", - "nugget_trainer_4", - "nugget_trainer_5", - ] - app.game_state.game_data["rival_starter"] = "CHARMANDER" - app.game_state.game_data["rival_name"] = "Gary" - - await _cmd(app, pilot, "enter nugget bridge") - - output = _output_text(app) - - # Rival ambush flavour text - assert "familiar voice" in output.lower() or "gary" in output.lower() - - # Battle should now be active - assert app.game_state.battle_state is not None - assert app.game_state.battle_state.is_trainer_battle is True - assert app.pending_command == "battle" - - async def test_rival_uses_evolved_starter_charmeleon(self): - """Rival's first Pokemon is CHARMELEON when the rival chose CHARMANDER.""" - from PokemonLibrary.engine import BattleState - from PokemonLibrary.locations import get_location - - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app, location_name="Cerulean City") - - app.game_state.current_location = get_location("Cerulean City") - app.game_state.game_data["location"] = "Cerulean City" - - bs = BattleState() - party_pokemon = bs.generate_wild_pokemon("BLASTOISE", 18) - app.game_state.game_data["pokemon"] = [party_pokemon] - - app.game_state.game_data["defeated_trainers"] = [ - "nugget_trainer_1", - "nugget_trainer_2", - "nugget_trainer_3", - "nugget_trainer_4", - "nugget_trainer_5", - ] - app.game_state.game_data["rival_starter"] = "CHARMANDER" - - await _cmd(app, pilot, "enter nugget bridge") - - battle = app.game_state.battle_state - assert battle is not None - # Wild Pokemon slot holds the current battling opponent - assert battle.wild_pokemon["name"] == "CHARMELEON" - - async def test_rival_battle_not_triggered_if_already_beaten(self): - """If rival_cerulean_beaten flag is set, no second battle is triggered.""" - from PokemonLibrary.engine import BattleState - from PokemonLibrary.locations import get_location - - app = PokemonTerminal() - async with app.run_test(size=(120, 40)) as pilot: - await pilot.pause() - _bootstrap_game(app, location_name="Cerulean City") - - app.game_state.current_location = get_location("Cerulean City") - app.game_state.game_data["location"] = "Cerulean City" - - bs = BattleState() - party_pokemon = bs.generate_wild_pokemon("SQUIRTLE", 18) - app.game_state.game_data["pokemon"] = [party_pokemon] - - app.game_state.game_data["defeated_trainers"] = [ - "nugget_trainer_1", - "nugget_trainer_2", - "nugget_trainer_3", - "nugget_trainer_4", - "nugget_trainer_5", - ] - # Mark rival as already beaten - app.game_state.game_data.setdefault("story_flags", {})["rival_cerulean_beaten"] = True - - await _cmd(app, pilot, "enter nugget bridge") - - # No new battle should have fired - assert app.game_state.battle_state is None - assert app.pending_command != "battle" diff --git a/test.robot b/test.robot index b7eae79..1285095 100644 --- a/test.robot +++ b/test.robot @@ -33,5 +33,5 @@ Play Pokemon Game Rate My Save - Get Save Game Stats save_name=save_mark - Rate My Trainer Skills save_name=save_mark \ No newline at end of file + Get Save Game Stats save_name=mark + Rate My Trainer Skills save_name=mark \ No newline at end of file diff --git a/tests/test_pokemon_data.py b/tests/test_pokemon_data.py new file mode 100644 index 0000000..3f0b35f --- /dev/null +++ b/tests/test_pokemon_data.py @@ -0,0 +1,925 @@ +"""Tests for PokemonLibrary/data/pokemon_data.py — verifying newly-implemented species data.""" + +import pytest + +from PokemonLibrary.data.pokemon_data import ( + FLYING, + GRASS, + GROUND, + NORMAL, + POISON, + POKEMON, + PSYCHIC, + WATER, + ItemEvolution, + LevelEvolution, +) + +# =========================================================================== +# Phase 1: Critical Wild Encounters +# =========================================================================== + + +class TestPhase1WildEncounters: + """Tests for Phase 1: Critical wild encounter Pokemon.""" + + def test_zubat_exists_and_not_stub(self): + """Zubat (#41) has full data (not stub).""" + assert 41 in POKEMON + zubat = POKEMON[41] + assert zubat.name == "ZUBAT" + assert zubat.number == 41 + # Verify not stub (stubs have 50 in all stats) + assert zubat.stats.hp != 50 or zubat.stats.attack != 50 + + def test_zubat_dual_type(self): + """Zubat is Poison/Flying.""" + zubat = POKEMON[41] + assert len(zubat.types) == 2 + assert POISON in zubat.types + assert FLYING in zubat.types + + def test_zubat_evolution(self): + """Zubat evolves to Golbat at level 22.""" + zubat = POKEMON[41] + assert zubat.evolution is not None + assert isinstance(zubat.evolution, LevelEvolution) + assert zubat.evolution.level == 22 + assert zubat.evolution.into_species == "GOLBAT" + + def test_zubat_learnset_valid(self): + """Zubat has a valid learnset.""" + zubat = POKEMON[41] + assert len(zubat.learnset) >= 3 + assert 1 in zubat.learnset + assert len(zubat.learnset[1]) >= 1 + + def test_clefairy_exists_and_not_stub(self): + """Clefairy (#35) has full data (not stub).""" + assert 35 in POKEMON + clefairy = POKEMON[35] + assert clefairy.name == "CLEFAIRY" + assert clefairy.number == 35 + assert clefairy.stats.hp != 50 or clefairy.stats.attack != 50 + + def test_clefairy_type(self): + """Clefairy is Normal.""" + clefairy = POKEMON[35] + assert clefairy.types == [NORMAL] + + def test_clefairy_evolution(self): + """Clefairy evolves with Moon Stone to Clefable.""" + clefairy = POKEMON[35] + assert clefairy.evolution is not None + assert isinstance(clefairy.evolution, ItemEvolution) + assert clefairy.evolution.item == "MOON STONE" + assert clefairy.evolution.into_species == "CLEFABLE" + + def test_clefairy_learnset_valid(self): + """Clefairy has a valid learnset.""" + clefairy = POKEMON[35] + assert len(clefairy.learnset) >= 3 + assert 1 in clefairy.learnset + + def test_jigglypuff_exists_and_not_stub(self): + """Jigglypuff (#39) has full data (not stub).""" + assert 39 in POKEMON + jiggly = POKEMON[39] + assert jiggly.name == "JIGGLYPUFF" + assert jiggly.number == 39 + assert jiggly.stats.hp != 50 or jiggly.stats.attack != 50 + + def test_jigglypuff_type(self): + """Jigglypuff is Normal.""" + jiggly = POKEMON[39] + assert jiggly.types == [NORMAL] + + def test_jigglypuff_evolution(self): + """Jigglypuff evolves with Moon Stone to Wigglytuff.""" + jiggly = POKEMON[39] + assert jiggly.evolution is not None + assert isinstance(jiggly.evolution, ItemEvolution) + assert jiggly.evolution.item == "MOON STONE" + assert jiggly.evolution.into_species == "WIGGLYTUFF" + + def test_jigglypuff_high_hp(self): + """Jigglypuff has famously high HP.""" + jiggly = POKEMON[39] + assert jiggly.stats.hp == 115 + + def test_meowth_exists_and_not_stub(self): + """Meowth (#52) has full data (not stub).""" + assert 52 in POKEMON + meowth = POKEMON[52] + assert meowth.name == "MEOWTH" + assert meowth.number == 52 + assert meowth.stats.hp != 50 or meowth.stats.attack != 50 + + def test_meowth_type(self): + """Meowth is Normal.""" + meowth = POKEMON[52] + assert meowth.types == [NORMAL] + + def test_meowth_evolution(self): + """Meowth evolves to Persian at level 28.""" + meowth = POKEMON[52] + assert meowth.evolution is not None + assert isinstance(meowth.evolution, LevelEvolution) + assert meowth.evolution.level == 28 + assert meowth.evolution.into_species == "PERSIAN" + + def test_meowth_learnset_valid(self): + """Meowth has a valid learnset.""" + meowth = POKEMON[52] + assert len(meowth.learnset) >= 3 + assert 1 in meowth.learnset + + +# =========================================================================== +# Phase 2: Gym Leaders +# =========================================================================== + + +class TestPhase2GymLeaders: + """Tests for Phase 2: Gym leader Pokemon.""" + + def test_staryu_exists_and_not_stub(self): + """Staryu (#120) has full data (not stub).""" + assert 120 in POKEMON + staryu = POKEMON[120] + assert staryu.name == "STARYU" + assert staryu.number == 120 + assert staryu.stats.hp != 50 or staryu.stats.attack != 50 + + def test_staryu_type(self): + """Staryu is Water.""" + staryu = POKEMON[120] + assert staryu.types == [WATER] + + def test_staryu_evolution(self): + """Staryu evolves with Water Stone to Starmie.""" + staryu = POKEMON[120] + assert staryu.evolution is not None + assert isinstance(staryu.evolution, ItemEvolution) + assert staryu.evolution.item == "WATER STONE" + assert staryu.evolution.into_species == "STARMIE" + + def test_starmie_exists_and_not_stub(self): + """Starmie (#121) has full data (not stub).""" + assert 121 in POKEMON + starmie = POKEMON[121] + assert starmie.name == "STARMIE" + assert starmie.number == 121 + assert starmie.stats.hp != 50 or starmie.stats.attack != 50 + + def test_starmie_dual_type(self): + """Starmie is Water/Psychic.""" + starmie = POKEMON[121] + assert len(starmie.types) == 2 + assert WATER in starmie.types + assert PSYCHIC in starmie.types + + def test_starmie_no_evolution(self): + """Starmie does not evolve.""" + starmie = POKEMON[121] + assert starmie.evolution is None + + def test_horsea_exists_and_not_stub(self): + """Horsea (#116) has full data (not stub).""" + assert 116 in POKEMON + horsea = POKEMON[116] + assert horsea.name == "HORSEA" + assert horsea.number == 116 + assert horsea.stats.hp != 50 or horsea.stats.attack != 50 + + def test_horsea_type(self): + """Horsea is Water.""" + horsea = POKEMON[116] + assert horsea.types == [WATER] + + def test_horsea_evolution(self): + """Horsea evolves to Seadra at level 32.""" + horsea = POKEMON[116] + assert horsea.evolution is not None + assert isinstance(horsea.evolution, LevelEvolution) + assert horsea.evolution.level == 32 + assert horsea.evolution.into_species == "SEADRA" + + def test_goldeen_exists_and_not_stub(self): + """Goldeen (#118) has full data (not stub).""" + assert 118 in POKEMON + goldeen = POKEMON[118] + assert goldeen.name == "GOLDEEN" + assert goldeen.number == 118 + assert goldeen.stats.hp != 50 or goldeen.stats.attack != 50 + + def test_goldeen_type(self): + """Goldeen is Water.""" + goldeen = POKEMON[118] + assert goldeen.types == [WATER] + + def test_goldeen_evolution(self): + """Goldeen evolves to Seaking at level 33.""" + goldeen = POKEMON[118] + assert goldeen.evolution is not None + assert isinstance(goldeen.evolution, LevelEvolution) + assert goldeen.evolution.level == 33 + assert goldeen.evolution.into_species == "SEAKING" + + +# =========================================================================== +# Phase 3: Trainer Battles +# =========================================================================== + + +class TestPhase3TrainerBattles: + """Tests for Phase 3: Trainer battle Pokemon.""" + + def test_ekans_exists_and_not_stub(self): + """Ekans (#23) has full data (not stub).""" + assert 23 in POKEMON + ekans = POKEMON[23] + assert ekans.name == "EKANS" + assert ekans.number == 23 + assert ekans.stats.hp != 50 or ekans.stats.attack != 50 + + def test_ekans_type(self): + """Ekans is Poison.""" + ekans = POKEMON[23] + assert ekans.types == [POISON] + + def test_ekans_evolution(self): + """Ekans evolves to Arbok at level 22.""" + ekans = POKEMON[23] + assert ekans.evolution is not None + assert isinstance(ekans.evolution, LevelEvolution) + assert ekans.evolution.level == 22 + assert ekans.evolution.into_species == "ARBOK" + + def test_sandshrew_exists_and_not_stub(self): + """Sandshrew (#27) has full data (not stub).""" + assert 27 in POKEMON + sandshrew = POKEMON[27] + assert sandshrew.name == "SANDSHREW" + assert sandshrew.number == 27 + assert sandshrew.stats.hp != 50 or sandshrew.stats.attack != 50 + + def test_sandshrew_type(self): + """Sandshrew is Ground.""" + sandshrew = POKEMON[27] + assert sandshrew.types == [GROUND] + + def test_sandshrew_evolution(self): + """Sandshrew evolves to Sandslash at level 22.""" + sandshrew = POKEMON[27] + assert sandshrew.evolution is not None + assert isinstance(sandshrew.evolution, LevelEvolution) + assert sandshrew.evolution.level == 22 + assert sandshrew.evolution.into_species == "SANDSLASH" + + def test_abra_exists_and_not_stub(self): + """Abra (#63) has full data (not stub).""" + assert 63 in POKEMON + abra = POKEMON[63] + assert abra.name == "ABRA" + assert abra.number == 63 + assert abra.stats.hp != 50 or abra.stats.attack != 50 + + def test_abra_type(self): + """Abra is Psychic.""" + abra = POKEMON[63] + assert abra.types == [PSYCHIC] + + def test_abra_evolution(self): + """Abra evolves to Kadabra at level 16.""" + abra = POKEMON[63] + assert abra.evolution is not None + assert isinstance(abra.evolution, LevelEvolution) + assert abra.evolution.level == 16 + assert abra.evolution.into_species == "KADABRA" + + def test_abra_teleport_only(self): + """Abra only knows Teleport at level 1.""" + abra = POKEMON[63] + assert 1 in abra.learnset + assert "TELEPORT" in abra.learnset[1] + + def test_oddish_exists_and_not_stub(self): + """Oddish (#43) has full data (not stub).""" + assert 43 in POKEMON + oddish = POKEMON[43] + assert oddish.name == "ODDISH" + assert oddish.number == 43 + assert oddish.stats.hp != 50 or oddish.stats.attack != 50 + + def test_oddish_dual_type(self): + """Oddish is Grass/Poison.""" + oddish = POKEMON[43] + assert len(oddish.types) == 2 + assert GRASS in oddish.types + assert POISON in oddish.types + + def test_oddish_evolution(self): + """Oddish evolves to Gloom at level 21.""" + oddish = POKEMON[43] + assert oddish.evolution is not None + assert isinstance(oddish.evolution, LevelEvolution) + assert oddish.evolution.level == 21 + assert oddish.evolution.into_species == "GLOOM" + + def test_bellsprout_exists_and_not_stub(self): + """Bellsprout (#69) has full data (not stub).""" + assert 69 in POKEMON + bellsprout = POKEMON[69] + assert bellsprout.name == "BELLSPROUT" + assert bellsprout.number == 69 + assert bellsprout.stats.hp != 50 or bellsprout.stats.attack != 50 + + def test_bellsprout_dual_type(self): + """Bellsprout is Grass/Poison.""" + bellsprout = POKEMON[69] + assert len(bellsprout.types) == 2 + assert GRASS in bellsprout.types + assert POISON in bellsprout.types + + def test_bellsprout_evolution(self): + """Bellsprout evolves to Weepinbell at level 21.""" + bellsprout = POKEMON[69] + assert bellsprout.evolution is not None + assert isinstance(bellsprout.evolution, LevelEvolution) + assert bellsprout.evolution.level == 21 + assert bellsprout.evolution.into_species == "WEEPINBELL" + + +# =========================================================================== +# Phase 4: Evolutions +# =========================================================================== + + +class TestPhase4Evolutions: + """Tests for Phase 4: Evolution line completion.""" + + def test_arbok_exists_and_not_stub(self): + """Arbok (#24) has full data (not stub).""" + assert 24 in POKEMON + arbok = POKEMON[24] + assert arbok.name == "ARBOK" + assert arbok.number == 24 + assert arbok.stats.hp != 50 or arbok.stats.attack != 50 + + def test_arbok_no_evolution(self): + """Arbok does not evolve.""" + arbok = POKEMON[24] + assert arbok.evolution is None + + def test_sandslash_exists_and_not_stub(self): + """Sandslash (#28) has full data (not stub).""" + assert 28 in POKEMON + sandslash = POKEMON[28] + assert sandslash.name == "SANDSLASH" + assert sandslash.number == 28 + assert sandslash.stats.hp != 50 or sandslash.stats.attack != 50 + + def test_sandslash_no_evolution(self): + """Sandslash does not evolve.""" + sandslash = POKEMON[28] + assert sandslash.evolution is None + + def test_clefable_exists_and_not_stub(self): + """Clefable (#36) has full data (not stub).""" + assert 36 in POKEMON + clefable = POKEMON[36] + assert clefable.name == "CLEFABLE" + assert clefable.number == 36 + assert clefable.stats.hp != 50 or clefable.stats.attack != 50 + + def test_clefable_no_evolution(self): + """Clefable does not evolve.""" + clefable = POKEMON[36] + assert clefable.evolution is None + + def test_wigglytuff_exists_and_not_stub(self): + """Wigglytuff (#40) has full data (not stub).""" + assert 40 in POKEMON + wigglytuff = POKEMON[40] + assert wigglytuff.name == "WIGGLYTUFF" + assert wigglytuff.number == 40 + assert wigglytuff.stats.hp != 50 or wigglytuff.stats.attack != 50 + + def test_wigglytuff_no_evolution(self): + """Wigglytuff does not evolve.""" + wigglytuff = POKEMON[40] + assert wigglytuff.evolution is None + + def test_wigglytuff_even_higher_hp(self): + """Wigglytuff has even higher HP than Jigglypuff.""" + wigglytuff = POKEMON[40] + assert wigglytuff.stats.hp == 140 + + def test_persian_exists_and_not_stub(self): + """Persian (#53) has full data (not stub).""" + assert 53 in POKEMON + persian = POKEMON[53] + assert persian.name == "PERSIAN" + assert persian.number == 53 + assert persian.stats.hp != 50 or persian.stats.attack != 50 + + def test_persian_no_evolution(self): + """Persian does not evolve.""" + persian = POKEMON[53] + assert persian.evolution is None + + def test_golbat_exists_and_not_stub(self): + """Golbat (#42) has full data (not stub).""" + assert 42 in POKEMON + golbat = POKEMON[42] + assert golbat.name == "GOLBAT" + assert golbat.number == 42 + assert golbat.stats.hp != 50 or golbat.stats.attack != 50 + + def test_golbat_dual_type(self): + """Golbat is Poison/Flying.""" + golbat = POKEMON[42] + assert len(golbat.types) == 2 + assert POISON in golbat.types + assert FLYING in golbat.types + + def test_golbat_no_evolution(self): + """Golbat does not evolve (in Gen 1).""" + golbat = POKEMON[42] + assert golbat.evolution is None + + def test_golem_exists_and_not_stub(self): + """Golem (#76) has full data (not stub).""" + assert 76 in POKEMON + golem = POKEMON[76] + assert golem.name == "GOLEM" + assert golem.number == 76 + assert golem.stats.hp != 50 or golem.stats.attack != 50 + + def test_golem_no_evolution(self): + """Golem does not evolve.""" + golem = POKEMON[76] + assert golem.evolution is None + + def test_seadra_exists_and_not_stub(self): + """Seadra (#117) has full data (not stub).""" + assert 117 in POKEMON + seadra = POKEMON[117] + assert seadra.name == "SEADRA" + assert seadra.number == 117 + assert seadra.stats.hp != 50 or seadra.stats.attack != 50 + + def test_seadra_no_evolution(self): + """Seadra does not evolve (in Gen 1).""" + seadra = POKEMON[117] + assert seadra.evolution is None + + def test_seaking_exists_and_not_stub(self): + """Seaking (#119) has full data (not stub).""" + assert 119 in POKEMON + seaking = POKEMON[119] + assert seaking.name == "SEAKING" + assert seaking.number == 119 + assert seaking.stats.hp != 50 or seaking.stats.attack != 50 + + def test_seaking_no_evolution(self): + """Seaking does not evolve.""" + seaking = POKEMON[119] + assert seaking.evolution is None + + def test_kadabra_exists_and_not_stub(self): + """Kadabra (#64) has full data (not stub).""" + assert 64 in POKEMON + kadabra = POKEMON[64] + assert kadabra.name == "KADABRA" + assert kadabra.number == 64 + assert kadabra.stats.hp != 50 or kadabra.stats.attack != 50 + + def test_kadabra_type(self): + """Kadabra is Psychic.""" + kadabra = POKEMON[64] + assert kadabra.types == [PSYCHIC] + + def test_kadabra_evolution(self): + """Kadabra evolves to Alakazam at level 38.""" + kadabra = POKEMON[64] + assert kadabra.evolution is not None + assert isinstance(kadabra.evolution, LevelEvolution) + assert kadabra.evolution.level == 38 + assert kadabra.evolution.into_species == "ALAKAZAM" + + def test_alakazam_exists_and_not_stub(self): + """Alakazam (#65) has full data (not stub).""" + assert 65 in POKEMON + alakazam = POKEMON[65] + assert alakazam.name == "ALAKAZAM" + assert alakazam.number == 65 + assert alakazam.stats.hp != 50 or alakazam.stats.attack != 50 + + def test_alakazam_type(self): + """Alakazam is Psychic.""" + alakazam = POKEMON[65] + assert alakazam.types == [PSYCHIC] + + def test_alakazam_no_evolution(self): + """Alakazam does not evolve.""" + alakazam = POKEMON[65] + assert alakazam.evolution is None + + def test_alakazam_high_special(self): + """Alakazam has very high Special stat.""" + alakazam = POKEMON[65] + assert alakazam.stats.special >= 120 + + def test_gloom_exists_and_not_stub(self): + """Gloom (#44) has full data (not stub).""" + assert 44 in POKEMON + gloom = POKEMON[44] + assert gloom.name == "GLOOM" + assert gloom.number == 44 + assert gloom.stats.hp != 50 or gloom.stats.attack != 50 + + def test_gloom_dual_type(self): + """Gloom is Grass/Poison.""" + gloom = POKEMON[44] + assert len(gloom.types) == 2 + assert GRASS in gloom.types + assert POISON in gloom.types + + def test_gloom_evolution(self): + """Gloom evolves with Leaf Stone to Vileplume.""" + gloom = POKEMON[44] + assert gloom.evolution is not None + assert isinstance(gloom.evolution, ItemEvolution) + assert gloom.evolution.item == "LEAF STONE" + assert gloom.evolution.into_species == "VILEPLUME" + + def test_vileplume_exists_and_not_stub(self): + """Vileplume (#45) has full data (not stub).""" + assert 45 in POKEMON + vileplume = POKEMON[45] + assert vileplume.name == "VILEPLUME" + assert vileplume.number == 45 + assert vileplume.stats.hp != 50 or vileplume.stats.attack != 50 + + def test_vileplume_dual_type(self): + """Vileplume is Grass/Poison.""" + vileplume = POKEMON[45] + assert len(vileplume.types) == 2 + assert GRASS in vileplume.types + assert POISON in vileplume.types + + def test_vileplume_no_evolution(self): + """Vileplume does not evolve.""" + vileplume = POKEMON[45] + assert vileplume.evolution is None + + def test_weepinbell_exists_and_not_stub(self): + """Weepinbell (#70) has full data (not stub).""" + assert 70 in POKEMON + weepinbell = POKEMON[70] + assert weepinbell.name == "WEEPINBELL" + assert weepinbell.number == 70 + assert weepinbell.stats.hp != 50 or weepinbell.stats.attack != 50 + + def test_weepinbell_dual_type(self): + """Weepinbell is Grass/Poison.""" + weepinbell = POKEMON[70] + assert len(weepinbell.types) == 2 + assert GRASS in weepinbell.types + assert POISON in weepinbell.types + + def test_weepinbell_evolution(self): + """Weepinbell evolves with Leaf Stone to Victreebel.""" + weepinbell = POKEMON[70] + assert weepinbell.evolution is not None + assert isinstance(weepinbell.evolution, ItemEvolution) + assert weepinbell.evolution.item == "LEAF STONE" + assert weepinbell.evolution.into_species == "VICTREEBEL" + + def test_victreebel_exists_and_not_stub(self): + """Victreebel (#71) has full data (not stub).""" + assert 71 in POKEMON + victreebel = POKEMON[71] + assert victreebel.name == "VICTREEBEL" + assert victreebel.number == 71 + assert victreebel.stats.hp != 50 or victreebel.stats.attack != 50 + + def test_victreebel_dual_type(self): + """Victreebel is Grass/Poison.""" + victreebel = POKEMON[71] + assert len(victreebel.types) == 2 + assert GRASS in victreebel.types + assert POISON in victreebel.types + + def test_victreebel_no_evolution(self): + """Victreebel does not evolve.""" + victreebel = POKEMON[71] + assert victreebel.evolution is None + + +# =========================================================================== +# Cross-Phase Validation Tests +# =========================================================================== + + +class TestStatsValidation: + """Validate that stats are realistic (not all 50s).""" + + @pytest.mark.parametrize( + "number", + [ + 23, + 24, + 27, + 28, + 35, + 36, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 52, + 53, + 63, + 64, + 65, + 69, + 70, + 71, + 76, + 116, + 117, + 118, + 119, + 120, + 121, + ], + ) + def test_hp_in_realistic_range(self, number): + """All newly-implemented Pokemon have HP between 20-255.""" + pokemon = POKEMON[number] + assert 20 <= pokemon.stats.hp <= 255 + + @pytest.mark.parametrize( + "number", + [ + 23, + 24, + 27, + 28, + 35, + 36, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 52, + 53, + 63, + 64, + 65, + 69, + 70, + 71, + 76, + 116, + 117, + 118, + 119, + 120, + 121, + ], + ) + def test_attack_in_realistic_range(self, number): + """All newly-implemented Pokemon have Attack between 5-255.""" + pokemon = POKEMON[number] + assert 5 <= pokemon.stats.attack <= 255 + + @pytest.mark.parametrize( + "number", + [ + 23, + 24, + 27, + 28, + 35, + 36, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 52, + 53, + 63, + 64, + 65, + 69, + 70, + 71, + 76, + 116, + 117, + 118, + 119, + 120, + 121, + ], + ) + def test_not_all_stats_are_50(self, number): + """Newly-implemented Pokemon are not stubs (not all stats = 50).""" + pokemon = POKEMON[number] + stats = [ + pokemon.stats.hp, + pokemon.stats.attack, + pokemon.stats.defense, + pokemon.stats.special, + pokemon.stats.speed, + ] + # At least one stat must differ from 50 + assert not all(s == 50 for s in stats) + + +class TestLearnsetValidation: + """Validate that all Pokemon have valid learnsets.""" + + @pytest.mark.parametrize( + "number", + [ + 23, + 24, + 27, + 28, + 35, + 36, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 52, + 53, + 63, + 64, + 65, + 69, + 70, + 71, + 76, + 116, + 117, + 118, + 119, + 120, + 121, + ], + ) + def test_has_level_1_move(self, number): + """All newly-implemented Pokemon have at least one move at level 1.""" + pokemon = POKEMON[number] + assert 1 in pokemon.learnset + assert len(pokemon.learnset[1]) >= 1 + + @pytest.mark.parametrize( + "number", + [ + 23, + 24, + 27, + 28, + 35, + 36, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 52, + 53, + 63, + 64, + 65, + 69, + 70, + 71, + 76, + 116, + 117, + 118, + 119, + 120, + 121, + ], + ) + def test_has_minimum_moves(self, number): + """All newly-implemented Pokemon have at least 1 level in learnset.""" + pokemon = POKEMON[number] + assert len(pokemon.learnset) >= 1 + + +class TestEvolutionChains: + """Validate complete evolution chains.""" + + def test_ekans_to_arbok_chain(self): + """Ekans → Arbok chain is complete.""" + ekans = POKEMON[23] + arbok = POKEMON[24] + assert ekans.evolution.into_species == "ARBOK" + assert arbok.evolution is None + + def test_sandshrew_to_sandslash_chain(self): + """Sandshrew → Sandslash chain is complete.""" + sandshrew = POKEMON[27] + sandslash = POKEMON[28] + assert sandshrew.evolution.into_species == "SANDSLASH" + assert sandslash.evolution is None + + def test_clefairy_to_clefable_chain(self): + """Clefairy → Clefable chain is complete.""" + clefairy = POKEMON[35] + clefable = POKEMON[36] + assert clefairy.evolution.into_species == "CLEFABLE" + assert clefable.evolution is None + + def test_jigglypuff_to_wigglytuff_chain(self): + """Jigglypuff → Wigglytuff chain is complete.""" + jiggly = POKEMON[39] + wiggly = POKEMON[40] + assert jiggly.evolution.into_species == "WIGGLYTUFF" + assert wiggly.evolution is None + + def test_meowth_to_persian_chain(self): + """Meowth → Persian chain is complete.""" + meowth = POKEMON[52] + persian = POKEMON[53] + assert meowth.evolution.into_species == "PERSIAN" + assert persian.evolution is None + + def test_zubat_to_golbat_chain(self): + """Zubat → Golbat chain is complete.""" + zubat = POKEMON[41] + golbat = POKEMON[42] + assert zubat.evolution.into_species == "GOLBAT" + assert golbat.evolution is None + + def test_abra_kadabra_alakazam_chain(self): + """Abra → Kadabra → Alakazam chain is complete.""" + abra = POKEMON[63] + kadabra = POKEMON[64] + alakazam = POKEMON[65] + assert abra.evolution.into_species == "KADABRA" + assert kadabra.evolution.into_species == "ALAKAZAM" + assert alakazam.evolution is None + + def test_oddish_gloom_vileplume_chain(self): + """Oddish → Gloom → Vileplume chain is complete.""" + oddish = POKEMON[43] + gloom = POKEMON[44] + vileplume = POKEMON[45] + assert oddish.evolution.into_species == "GLOOM" + assert gloom.evolution.into_species == "VILEPLUME" + assert vileplume.evolution is None + + def test_bellsprout_weepinbell_victreebel_chain(self): + """Bellsprout → Weepinbell → Victreebel chain is complete.""" + bellsprout = POKEMON[69] + weepinbell = POKEMON[70] + victreebel = POKEMON[71] + assert bellsprout.evolution.into_species == "WEEPINBELL" + assert weepinbell.evolution.into_species == "VICTREEBEL" + assert victreebel.evolution is None + + def test_horsea_to_seadra_chain(self): + """Horsea → Seadra chain is complete.""" + horsea = POKEMON[116] + seadra = POKEMON[117] + assert horsea.evolution.into_species == "SEADRA" + assert seadra.evolution is None + + def test_goldeen_to_seaking_chain(self): + """Goldeen → Seaking chain is complete.""" + goldeen = POKEMON[118] + seaking = POKEMON[119] + assert goldeen.evolution.into_species == "SEAKING" + assert seaking.evolution is None + + def test_staryu_to_starmie_chain(self): + """Staryu → Starmie chain is complete.""" + staryu = POKEMON[120] + starmie = POKEMON[121] + assert staryu.evolution.into_species == "STARMIE" + assert starmie.evolution is None