Skip to content

Commit 07731d8

Browse files
authored
Merge pull request #10 from MobyNL/copilot/analyze-e2e-tests-architecture
Add structured Robot Framework e2e test architecture with 72 new tests
2 parents 93b6941 + 48841fd commit 07731d8

15 files changed

Lines changed: 3052 additions & 640 deletions

PokemonLibrary/data/pokemon_data.py

Lines changed: 458 additions & 28 deletions
Large diffs are not rendered by default.

PokemonLibraryTest/library.py

Lines changed: 260 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def _capture_screenshot(self, label: str = "Screenshot") -> None:
9393
'style="max-width:100%;border:1px solid #555;border-radius:4px;"/>'
9494
)
9595
logger.info(f"<p><b>{label}</b></p>{img_tag}", html=True)
96-
except Exception as exc: # noqa: BLE001
96+
except Exception as exc:
9797
logger.warn(f"Failed to capture screenshot: {exc}")
9898

9999
def _require_game(self) -> dict:
@@ -163,7 +163,7 @@ async def close_pokemon_terminal(self) -> None:
163163
if self._run_test_cm is not None:
164164
try:
165165
await self._run_test_cm.__aexit__(None, None, None)
166-
except Exception as exc: # noqa: BLE001
166+
except Exception as exc:
167167
logger.warn(f"Error while closing terminal: {exc}")
168168
self._app = None
169169
self._pilot = None
@@ -173,7 +173,7 @@ async def close_pokemon_terminal(self) -> None:
173173
# ── Game state setup ──────────────────────────────────────────────────────
174174

175175
@keyword("Bootstrap Game")
176-
def bootstrap_game(self, location: str = "Pallet Town") -> None:
176+
async def bootstrap_game(self, location: str = "Pallet Town") -> None:
177177
"""
178178
Reset to a clean in-game state without going through the UI new-game flow.
179179
@@ -256,6 +256,9 @@ def bootstrap_game(self, location: str = "Pallet Town") -> None:
256256
self._app._refresh_subtitle()
257257
self._app.query_one("#command-input", Input).focus()
258258

259+
# ── 5. Wait for all Textual messages to settle ───────────────────────
260+
await self._pilot.pause()
261+
259262
logger.info(f"Game bootstrapped at '{location}' via temp save")
260263

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

441442
@keyword("Output Should Not Contain")
442443
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:
648649
party = self._require_game().get("pokemon", [])
649650
index = int(index)
650651
if index >= len(party):
651-
raise AssertionError(
652-
f"Party index {index} out of range (party size: {len(party)})"
653-
)
652+
raise AssertionError(f"Party index {index} out of range (party size: {len(party)})")
654653
actual = party[index]["name"]
655654
if actual != expected_name:
656-
raise AssertionError(
657-
f"Party[{index}]: expected {expected_name!r}, got {actual!r}"
658-
)
655+
raise AssertionError(f"Party[{index}]: expected {expected_name!r}, got {actual!r}")
659656

660657
@keyword("Should Be In Battle")
661658
def should_be_in_battle(self) -> None:
@@ -689,9 +686,7 @@ def pending_command_should_be(self, expected: str) -> None:
689686
"""
690687
actual = self._app.pending_command
691688
if actual != expected:
692-
raise AssertionError(
693-
f"Pending command: expected {expected!r}, got {actual!r}"
694-
)
689+
raise AssertionError(f"Pending command: expected {expected!r}, got {actual!r}")
695690

696691
@keyword("Pending Command Should Be Empty")
697692
def pending_command_should_be_empty(self) -> None:
@@ -738,3 +733,253 @@ def widget_should_be_hidden(self, selector: str) -> None:
738733
widget = self._app.query_one(css)
739734
if not widget.has_class("hidden"):
740735
raise AssertionError(f"Widget '{selector}' is visible but expected to be hidden")
736+
737+
# ── Additional game-state manipulation keywords ───────────────────────────
738+
739+
@keyword("Add Badge")
740+
def add_badge(self, badge_name: str) -> None:
741+
"""
742+
Add a gym badge to the player's badge collection.
743+
744+
Accepts the full badge name (e.g. ``Boulder Badge``) and converts it
745+
to the internal badge ID before storing, which is the format used by
746+
``game_data["badges"]``.
747+
748+
Arguments:
749+
- ``badge_name``: Full badge name, e.g. ``Boulder Badge``, ``Cascade Badge``.
750+
751+
Example:
752+
| Add Badge | Boulder Badge |
753+
| Add Badge | Cascade Badge |
754+
"""
755+
from PokemonLibrary.gym_system import BADGES
756+
757+
badge_entry = BADGES.get(badge_name)
758+
if badge_entry is None:
759+
raise ValueError(f"Unknown badge: {badge_name!r}. Valid badges: {list(BADGES.keys())}")
760+
badge_id = badge_entry["id"]
761+
762+
gd = self._require_game()
763+
badges = gd.setdefault("badges", [])
764+
if badge_id not in badges:
765+
badges.append(badge_id)
766+
767+
@keyword("Set Party Pokemon Level")
768+
def set_party_pokemon_level(self, index: int, level: int) -> None:
769+
"""
770+
Set the level of a party Pokemon at *index* to *level*.
771+
772+
Arguments:
773+
- ``index``: 0-based party slot index.
774+
- ``level``: New level value (1-100).
775+
776+
Example:
777+
| Set Party Pokemon Level | 0 | 20 |
778+
"""
779+
party = self._require_game().get("pokemon", [])
780+
index = int(index)
781+
if index >= len(party):
782+
raise AssertionError(f"Party index {index} out of range (party size: {len(party)})")
783+
party[index]["level"] = int(level)
784+
self._app.game_state._deserialize_party()
785+
786+
@keyword("Set Pokemon HP")
787+
def set_pokemon_hp(self, index: int, hp: int) -> None:
788+
"""
789+
Set the current HP of a party Pokemon at *index*.
790+
791+
Arguments:
792+
- ``index``: 0-based party slot index.
793+
- ``hp``: New HP value.
794+
795+
Example:
796+
| Set Pokemon HP | 0 | 1 |
797+
"""
798+
party = self._require_game().get("pokemon", [])
799+
index = int(index)
800+
if index >= len(party):
801+
raise AssertionError(f"Party index {index} out of range (party size: {len(party)})")
802+
party[index]["hp"] = int(hp)
803+
self._app.game_state._deserialize_party()
804+
805+
# ── Additional game-state assertion keywords ──────────────────────────────
806+
807+
@keyword("Badge Count Should Be")
808+
def badge_count_should_be(self, expected: int) -> None:
809+
"""
810+
Fail if the number of earned badges does not equal *expected*.
811+
812+
Arguments:
813+
- ``expected``: Expected badge count (integer).
814+
815+
Example:
816+
| Badge Count Should Be | 0 |
817+
| Badge Count Should Be | 3 |
818+
"""
819+
badges = self._require_game().get("badges", [])
820+
actual = len(badges)
821+
expected = int(expected)
822+
if actual != expected:
823+
raise AssertionError(f"Badge count: expected {expected}, got {actual} ({badges})")
824+
825+
@keyword("Party Size Should Be")
826+
def party_size_should_be(self, expected: int) -> None:
827+
"""
828+
Fail if the number of Pokemon in the party does not equal *expected*.
829+
830+
Arguments:
831+
- ``expected``: Expected party size (integer, 0-6).
832+
833+
Example:
834+
| Party Size Should Be | 1 |
835+
| Party Size Should Be | 3 |
836+
"""
837+
party = self._require_game().get("pokemon", [])
838+
actual = len(party)
839+
expected = int(expected)
840+
if actual != expected:
841+
raise AssertionError(f"Party size: expected {expected}, got {actual}")
842+
843+
@keyword("Party Pokemon Level Should Be")
844+
def party_pokemon_level_should_be(self, index: int, expected_level: int) -> None:
845+
"""
846+
Fail if the level of the party Pokemon at *index* does not equal *expected_level*.
847+
848+
Arguments:
849+
- ``index``: 0-based party slot index.
850+
- ``expected_level``: Expected level value.
851+
852+
Example:
853+
| Party Pokemon Level Should Be | 0 | 11 |
854+
"""
855+
party = self._require_game().get("pokemon", [])
856+
index = int(index)
857+
if index >= len(party):
858+
raise AssertionError(f"Party index {index} out of range (party size: {len(party)})")
859+
actual = party[index].get("level", 0)
860+
expected_level = int(expected_level)
861+
if actual != expected_level:
862+
raise AssertionError(f"Party[{index}] level: expected {expected_level}, got {actual}")
863+
864+
@keyword("Story Flag Should Be Set")
865+
def story_flag_should_be_set(self, flag_name: str) -> None:
866+
"""
867+
Fail if *flag_name* is not set (or is set to a falsy value) in story_flags.
868+
869+
Arguments:
870+
- ``flag_name``: Story flag key, e.g. ``rival_cerulean_beaten``.
871+
872+
Example:
873+
| Story Flag Should Be Set | rival_cerulean_beaten |
874+
"""
875+
flags = self._require_game().get("story_flags", {})
876+
if not flags.get(flag_name):
877+
raise AssertionError(f"Story flag '{flag_name}' is not set. Flags: {flags}")
878+
879+
@keyword("Story Flag Should Not Be Set")
880+
def story_flag_should_not_be_set(self, flag_name: str) -> None:
881+
"""
882+
Fail if *flag_name* IS set (and truthy) in story_flags.
883+
884+
Arguments:
885+
- ``flag_name``: Story flag key, e.g. ``rival_cerulean_beaten``.
886+
887+
Example:
888+
| Story Flag Should Not Be Set | rival_cerulean_beaten |
889+
"""
890+
flags = self._require_game().get("story_flags", {})
891+
if flags.get(flag_name):
892+
raise AssertionError(f"Story flag '{flag_name}' is set but expected to be absent/falsy")
893+
894+
@keyword("Set Learn Move Prompt")
895+
def set_learn_move_prompt(
896+
self, move_name: str, remaining: str = "", post_action: str = "wild_end"
897+
) -> None:
898+
"""
899+
Inject a ``learn_move_choice`` pending-command so the lead Pokemon
900+
is offered a new move.
901+
902+
The lead Pokemon (party slot 0) must already have 4 moves; use
903+
``Set Lead Pokemon`` before calling this keyword if needed.
904+
905+
Arguments:
906+
- ``move_name``: The name of the new move being offered (ALL CAPS).
907+
- ``remaining``: Space-separated list of additional queued move names (default empty).
908+
- ``post_action``: Value for ``learn_post_action`` (default ``wild_end``).
909+
910+
Example:
911+
| Set Lead Pokemon | CHARMANDER |
912+
| Set Learn Move Prompt | RAGE |
913+
| Type Command | 1 |
914+
| Pending Command Should Be Empty |
915+
916+
| Set Learn Move Prompt | RAGE | remaining=SLASH |
917+
"""
918+
from PokemonLibrary.models import PartyPokemon
919+
920+
gd = self._require_game()
921+
party = gd.get("pokemon", [])
922+
if not party:
923+
raise RuntimeError("Party is empty — call 'Bootstrap Game' first.")
924+
925+
# Convert PartyPokemon model to a plain dict so the game_flow handler can
926+
# use dict-style access (pokemon["moves"][slot]["name"] = …).
927+
lead_raw = party[0]
928+
if isinstance(lead_raw, PartyPokemon):
929+
lead = lead_raw.to_dict()
930+
else:
931+
lead = dict(lead_raw) # copy to avoid mutation side-effects
932+
933+
# Ensure the lead has exactly 4 moves (fill with defaults if needed)
934+
moves = lead.setdefault("moves", [])
935+
default_moves = [
936+
{"name": "TACKLE", "pp": 35, "max_pp": 35},
937+
{"name": "GROWL", "pp": 40, "max_pp": 40},
938+
{"name": "SCRATCH", "pp": 35, "max_pp": 35},
939+
{"name": "LEER", "pp": 30, "max_pp": 30},
940+
]
941+
while len(moves) < 4:
942+
moves.append(default_moves[len(moves)])
943+
944+
# Replace slot 0 with the plain dict so the handler can write to it directly
945+
party[0] = lead
946+
947+
remaining_list = [m.strip() for m in remaining.split() if m.strip()]
948+
949+
self._app.pending_command = "learn_move_choice"
950+
self._app.pending_command_data = {
951+
"learn_pokemon": lead,
952+
"learn_move_name": move_name.upper(),
953+
"learn_remaining": remaining_list,
954+
"learn_post_action": post_action,
955+
}
956+
logger.info(f"Learn-move prompt set: offering {move_name.upper()} to {lead['name']}")
957+
958+
@keyword("Register Pokemon As Seen")
959+
def register_pokemon_as_seen(self, species_name: str) -> None:
960+
"""
961+
Register a Pokemon as seen in the Pokedex (test helper).
962+
963+
This allows viewing Pokedex entries without actually encountering the Pokemon.
964+
965+
Arguments:
966+
- ``species_name``: Pokemon species name (e.g. "Pikachu", "Zubat").
967+
968+
Example:
969+
| Register Pokemon As Seen | Zubat |
970+
| Type Command | pokedex entry zubat |
971+
| Output Should Contain | ZUBAT |
972+
"""
973+
game_data = self._require_game()
974+
pokedex = game_data.get("pokedex", {"seen": [], "caught": []})
975+
if "pokedex" not in game_data:
976+
game_data["pokedex"] = pokedex
977+
978+
species_upper = species_name.upper().replace(" ", "_")
979+
980+
if species_upper not in pokedex.get("seen", []):
981+
seen_list = pokedex.get("seen", [])
982+
seen_list.append(species_upper)
983+
pokedex["seen"] = seen_list
984+
985+
logger.info(f"Registered {species_upper} as seen in Pokedex")

atests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)