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