From 22f29aad5c4db48aae24d381599c0ff19c29ed9e Mon Sep 17 00:00:00 2001 From: Vicaversa Date: Thu, 5 Mar 2026 14:50:27 +0300 Subject: [PATCH] Add test suite for memory reader, state builder, and pathfinding 79 tests covering: - RedBlueMemoryReader: player, party, bag, battle, dialog, map, flags, status decoding, Gen-1 encoding table, name tables - State builder: build_game_state sections, error handling, partial failures, build_state_summary output - Pathfinding: A* search, collision maps, wall avoidance, manhattan distance, neighbors, navigate, path_length All tests use mock emulator (no ROM required). --- tests/__init__.py | 0 tests/test_memory_reader.py | 421 ++++++++++++++++++++++++++++++++++++ tests/test_pathfinding.py | 173 +++++++++++++++ tests/test_state_builder.py | 262 ++++++++++++++++++++++ 4 files changed, 856 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_memory_reader.py create mode 100644 tests/test_pathfinding.py create mode 100644 tests/test_state_builder.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_memory_reader.py b/tests/test_memory_reader.py new file mode 100644 index 0000000..863a98b --- /dev/null +++ b/tests/test_memory_reader.py @@ -0,0 +1,421 @@ +"""Tests for pokemon_agent.memory.red (RedBlueMemoryReader). + +Uses a mock Emulator so tests run without a ROM or PyBoy installed. +""" + +import pytest +from unittest.mock import MagicMock + +from pokemon_agent.memory.red import ( + RedBlueMemoryReader, + GEN1_ENCODING, + SPECIES_NAMES, + MOVE_NAMES, + ITEM_NAMES, + MAP_NAMES, + TYPE_NAMES, + BADGE_NAMES, + FACING_NAMES, + ADDR_PLAYER_NAME, + ADDR_RIVAL_NAME, + ADDR_MONEY, + ADDR_BADGES, + ADDR_MAP_ID, + ADDR_MAP_Y, + ADDR_MAP_X, + ADDR_FACING, + ADDR_PARTY_COUNT, + ADDR_PARTY_DATA, + ADDR_PARTY_NICKS, + ADDR_BAG_COUNT, + ADDR_BAG_ITEMS, + ADDR_BATTLE_TYPE, + ADDR_TEXT_BOX_ID, + ADDR_JOY_IGNORE, + ADDR_DEX_OWNED, + ADDR_DEX_SEEN, + ADDR_OAK_PARCEL, + ADDR_POKEDEX_FLAG, + ADDR_PLAYTIME_H, + ADDR_PLAYTIME_M, + ADDR_PLAYTIME_S, + PARTY_MON_SIZE, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_mock_emu(): + """Create a mock Emulator with read_u8, read_u16, read_range.""" + emu = MagicMock() + emu._ram = bytearray(0x10000) # 64KB RAM + emu.read_u8 = lambda addr: emu._ram[addr] + emu.read_u16 = lambda addr: emu._ram[addr] | (emu._ram[addr + 1] << 8) + emu.read_range = lambda addr, n: bytes(emu._ram[addr:addr + n]) + return emu + + +def _encode_gen1_text(text: str) -> bytes: + """Encode a string to Gen-1 format (terminated by 0x50).""" + reverse = {v: k for k, v in GEN1_ENCODING.items() if v and k != 0x50} + result = [] + for ch in text: + if ch in reverse: + result.append(reverse[ch]) + else: + result.append(0x50) + break + result.append(0x50) + return bytes(result) + + +def _write_gen1_text(ram: bytearray, addr: int, text: str): + """Write a Gen-1 encoded string into RAM.""" + encoded = _encode_gen1_text(text) + ram[addr:addr + len(encoded)] = encoded + + +def _write_bcd(ram: bytearray, addr: int, value: int, num_bytes: int): + """Write a BCD-encoded integer into RAM.""" + digits = str(value).zfill(num_bytes * 2) + for i in range(num_bytes): + hi = int(digits[i * 2]) + lo = int(digits[i * 2 + 1]) + ram[addr + i] = (hi << 4) | lo + + +def _build_party_mon( + species_id=25, level=20, hp=50, max_hp=55, + status=0, type1=23, type2=23, + moves=(84, 85, 86, 98), + attack=40, defense=30, speed=60, special=45, +): + """Build a 44-byte party Pokemon structure.""" + data = bytearray(PARTY_MON_SIZE) + data[0] = species_id + data[1] = (hp >> 8) & 0xFF + data[2] = hp & 0xFF + data[3] = level # box level + data[4] = status + data[5] = type1 + data[6] = type2 + for i, mid in enumerate(moves[:4]): + data[8 + i] = mid + data[29 + i] = 25 # PP + data[33] = level # party level + data[34] = (max_hp >> 8) & 0xFF + data[35] = max_hp & 0xFF + data[36] = (attack >> 8) & 0xFF + data[37] = attack & 0xFF + data[38] = (defense >> 8) & 0xFF + data[39] = defense & 0xFF + data[40] = (speed >> 8) & 0xFF + data[41] = speed & 0xFF + data[42] = (special >> 8) & 0xFF + data[43] = special & 0xFF + return bytes(data) + + +# --------------------------------------------------------------------------- +# Encoding table +# --------------------------------------------------------------------------- + +class TestGen1Encoding: + def test_uppercase_letters(self): + for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ"): + assert GEN1_ENCODING[0x80 + i] == c + + def test_lowercase_letters(self): + for i, c in enumerate("abcdefghijklmnopqrstuvwxyz"): + assert GEN1_ENCODING[0xA0 + i] == c + + def test_digits(self): + for i, c in enumerate("0123456789"): + assert GEN1_ENCODING[0xF6 + i] == c + + def test_space(self): + assert GEN1_ENCODING[0x7F] == " " + + def test_terminator(self): + assert GEN1_ENCODING[0x50] == "" + + +# --------------------------------------------------------------------------- +# Name tables completeness +# --------------------------------------------------------------------------- + +class TestNameTables: + def test_all_151_pokemon(self): + for i in range(1, 152): + assert i in SPECIES_NAMES, f"Species {i} missing" + + def test_pikachu_is_25(self): + assert SPECIES_NAMES[25] == "Pikachu" + + def test_mew_is_151(self): + assert SPECIES_NAMES[151] == "Mew" + + def test_common_moves(self): + assert MOVE_NAMES[33] == "Tackle" + assert MOVE_NAMES[85] == "Thunderbolt" + assert MOVE_NAMES[57] == "Surf" + + def test_common_items(self): + assert ITEM_NAMES[4] == "Poke Ball" + assert ITEM_NAMES[40] == "Rare Candy" + assert ITEM_NAMES[1] == "Master Ball" + + def test_pallet_town(self): + assert MAP_NAMES[0] == "Pallet Town" + + def test_eight_badges(self): + assert len(BADGE_NAMES) == 8 + assert BADGE_NAMES[0] == "Boulder" + assert BADGE_NAMES[7] == "Earth" + + +# --------------------------------------------------------------------------- +# RedBlueMemoryReader with mock emulator +# --------------------------------------------------------------------------- + +class TestRedBlueMemoryReader: + @pytest.fixture + def setup(self): + emu = _make_mock_emu() + reader = RedBlueMemoryReader(emu) + return emu, reader + + # -- read_player -- + + def test_read_player_name(self, setup): + emu, reader = setup + _write_gen1_text(emu._ram, ADDR_PLAYER_NAME, "RED") + _write_gen1_text(emu._ram, ADDR_RIVAL_NAME, "BLUE") + _write_bcd(emu._ram, ADDR_MONEY, 3000, 3) + emu._ram[ADDR_BADGES] = 0b00000011 # Boulder + Cascade + emu._ram[ADDR_MAP_Y] = 5 + emu._ram[ADDR_MAP_X] = 3 + emu._ram[ADDR_FACING] = 0x00 # down + emu._ram[ADDR_PLAYTIME_H] = 10 + emu._ram[ADDR_PLAYTIME_H + 1] = 0 + emu._ram[ADDR_PLAYTIME_M] = 30 + emu._ram[ADDR_PLAYTIME_S] = 15 + + player = reader.read_player() + + assert player["name"] == "RED" + assert player["rival_name"] == "BLUE" + assert player["money"] == 3000 + assert player["badges"] == ["Boulder", "Cascade"] + assert player["badge_count"] == 2 + assert player["position"] == {"y": 5, "x": 3} + assert player["facing"] == "down" + + def test_read_player_no_badges(self, setup): + emu, reader = setup + _write_gen1_text(emu._ram, ADDR_PLAYER_NAME, "ASH") + _write_gen1_text(emu._ram, ADDR_RIVAL_NAME, "GARY") + emu._ram[ADDR_BADGES] = 0 + + player = reader.read_player() + assert player["badges"] == [] + assert player["badge_count"] == 0 + + def test_read_player_all_badges(self, setup): + emu, reader = setup + _write_gen1_text(emu._ram, ADDR_PLAYER_NAME, "RED") + _write_gen1_text(emu._ram, ADDR_RIVAL_NAME, "BLUE") + emu._ram[ADDR_BADGES] = 0xFF + + player = reader.read_player() + assert player["badge_count"] == 8 + assert player["badges"] == list(BADGE_NAMES) + + # -- read_party -- + + def test_read_party_one_pokemon(self, setup): + emu, reader = setup + emu._ram[ADDR_PARTY_COUNT] = 1 + + mon_data = _build_party_mon(species_id=25, level=20, hp=50, max_hp=55) + for i, b in enumerate(mon_data): + emu._ram[ADDR_PARTY_DATA + i] = b + + _write_gen1_text(emu._ram, ADDR_PARTY_NICKS, "PIKACHU") + + party = reader.read_party() + assert len(party) == 1 + assert party[0]["species"] == "Pikachu" + assert party[0]["level"] == 20 + assert party[0]["hp"] == 50 + assert party[0]["max_hp"] == 55 + assert party[0]["nickname"] == "PIKACHU" + assert party[0]["status"] == "OK" + + def test_read_party_capped_at_six(self, setup): + emu, reader = setup + emu._ram[ADDR_PARTY_COUNT] = 255 # corrupted value + party = reader.read_party() + assert len(party) <= 6 + + def test_read_party_empty(self, setup): + emu, reader = setup + emu._ram[ADDR_PARTY_COUNT] = 0 + party = reader.read_party() + assert party == [] + + # -- read_bag -- + + def test_read_bag_with_items(self, setup): + emu, reader = setup + emu._ram[ADDR_BAG_COUNT] = 2 + emu._ram[ADDR_BAG_ITEMS] = 4 # Poke Ball + emu._ram[ADDR_BAG_ITEMS + 1] = 10 # qty + emu._ram[ADDR_BAG_ITEMS + 2] = 20 # Potion + emu._ram[ADDR_BAG_ITEMS + 3] = 5 # qty + + bag = reader.read_bag() + assert len(bag) == 2 + assert bag[0]["item"] == "Poke Ball" + assert bag[0]["quantity"] == 10 + assert bag[1]["item"] == "Potion" + assert bag[1]["quantity"] == 5 + + def test_read_bag_empty(self, setup): + emu, reader = setup + emu._ram[ADDR_BAG_COUNT] = 0 + bag = reader.read_bag() + assert bag == [] + + def test_read_bag_terminator(self, setup): + emu, reader = setup + emu._ram[ADDR_BAG_COUNT] = 5 + emu._ram[ADDR_BAG_ITEMS] = 4 + emu._ram[ADDR_BAG_ITEMS + 1] = 3 + emu._ram[ADDR_BAG_ITEMS + 2] = 0xFF # terminator + bag = reader.read_bag() + assert len(bag) == 1 + + # -- read_battle -- + + def test_read_battle_not_in_battle(self, setup): + emu, reader = setup + emu._ram[ADDR_BATTLE_TYPE] = 0 + + battle = reader.read_battle() + assert battle["in_battle"] is False + assert battle["type"] == "none" + assert "enemy" not in battle + + def test_read_battle_wild(self, setup): + emu, reader = setup + emu._ram[ADDR_BATTLE_TYPE] = 1 # wild + from pokemon_agent.memory.red import ADDR_ENEMY_SPECIES, ADDR_ENEMY_DATA + emu._ram[ADDR_ENEMY_SPECIES] = 25 # Pikachu + + enemy_data = _build_party_mon(species_id=25, level=10, hp=30, max_hp=30) + for i, b in enumerate(enemy_data): + emu._ram[ADDR_ENEMY_DATA + i] = b + + battle = reader.read_battle() + assert battle["in_battle"] is True + assert battle["type"] == "wild" + assert battle["enemy"]["species"] == "Pikachu" + assert battle["enemy"]["level"] == 10 + + # -- read_dialog -- + + def test_read_dialog_inactive(self, setup): + emu, reader = setup + emu._ram[ADDR_TEXT_BOX_ID] = 0 + emu._ram[ADDR_JOY_IGNORE] = 0 + + dialog = reader.read_dialog() + assert dialog["active"] is False + + def test_read_dialog_active_textbox(self, setup): + emu, reader = setup + emu._ram[ADDR_TEXT_BOX_ID] = 1 + emu._ram[ADDR_JOY_IGNORE] = 0 + + dialog = reader.read_dialog() + assert dialog["active"] is True + + def test_read_dialog_active_joyignore(self, setup): + emu, reader = setup + emu._ram[ADDR_TEXT_BOX_ID] = 0 + emu._ram[ADDR_JOY_IGNORE] = 0x20 # bit 5 + + dialog = reader.read_dialog() + assert dialog["active"] is True + + # -- read_map_info -- + + def test_read_map_pallet_town(self, setup): + emu, reader = setup + emu._ram[ADDR_MAP_ID] = 0 + + map_info = reader.read_map_info() + assert map_info["map_id"] == 0 + assert map_info["map_name"] == "Pallet Town" + + def test_read_map_unknown(self, setup): + emu, reader = setup + emu._ram[ADDR_MAP_ID] = 254 # not in table + + map_info = reader.read_map_info() + assert "Unknown" in map_info["map_name"] or "254" in map_info["map_name"] + + # -- read_flags -- + + def test_read_flags_has_pokedex(self, setup): + emu, reader = setup + emu._ram[ADDR_POKEDEX_FLAG] = 0x20 # bit 5 + emu._ram[ADDR_OAK_PARCEL] = 0x02 # bit 1 + + flags = reader.read_flags() + assert flags["has_pokedex"] is True + assert flags["has_oaks_parcel"] is True + + def test_read_flags_no_pokedex(self, setup): + emu, reader = setup + emu._ram[ADDR_POKEDEX_FLAG] = 0 + emu._ram[ADDR_OAK_PARCEL] = 0 + + flags = reader.read_flags() + assert flags["has_pokedex"] is False + assert flags["has_oaks_parcel"] is False + + # -- status decoding -- + + def test_decode_status_ok(self, setup): + _, reader = setup + assert reader._decode_status(0) == "OK" + + def test_decode_status_poisoned(self, setup): + _, reader = setup + assert "PSN" in reader._decode_status(0x08) + + def test_decode_status_paralyzed(self, setup): + _, reader = setup + assert "PAR" in reader._decode_status(0x40) + + def test_decode_status_sleep(self, setup): + _, reader = setup + result = reader._decode_status(0x03) + assert "SLP" in result + + def test_decode_status_multiple(self, setup): + _, reader = setup + # poison + burn + result = reader._decode_status(0x08 | 0x10) + assert "PSN" in result + assert "BRN" in result + + # -- game_name -- + + def test_game_name(self, setup): + _, reader = setup + assert reader.game_name == "Pokemon Red/Blue (USA)" diff --git a/tests/test_pathfinding.py b/tests/test_pathfinding.py new file mode 100644 index 0000000..0ad71fb --- /dev/null +++ b/tests/test_pathfinding.py @@ -0,0 +1,173 @@ +"""Tests for pokemon_agent.pathfinding module.""" + +import pytest + +from pokemon_agent.pathfinding import ( + find_path, + navigate, + directions_to_actions, + neighbors, + manhattan, + path_length, +) + + +# --------------------------------------------------------------------------- +# manhattan distance +# --------------------------------------------------------------------------- + +class TestManhattan: + def test_same_point(self): + assert manhattan((0, 0), (0, 0)) == 0 + + def test_horizontal(self): + assert manhattan((0, 0), (5, 0)) == 5 + + def test_vertical(self): + assert manhattan((0, 0), (0, 3)) == 3 + + def test_diagonal(self): + assert manhattan((1, 1), (4, 5)) == 7 + + def test_negative_coords(self): + assert manhattan((-2, -3), (2, 3)) == 10 + + +# --------------------------------------------------------------------------- +# neighbors +# --------------------------------------------------------------------------- + +class TestNeighbors: + def test_no_collision_map_returns_four(self): + result = neighbors((5, 5), None) + assert len(result) == 4 + positions = {pos for pos, _ in result} + assert positions == {(5, 4), (5, 6), (4, 5), (6, 5)} + + def test_collision_map_blocks_walls(self): + cmap = {(1, 0): True, (0, 1): True, (-1, 0): False, (0, -1): False} + result = neighbors((0, 0), cmap) + positions = {pos for pos, _ in result} + assert (1, 0) in positions + assert (0, 1) in positions + assert (-1, 0) not in positions + assert (0, -1) not in positions + + def test_directions_are_correct(self): + result = {d: pos for pos, d in neighbors((0, 0), None)} + assert result["up"] == (0, -1) + assert result["down"] == (0, 1) + assert result["left"] == (-1, 0) + assert result["right"] == (1, 0) + + +# --------------------------------------------------------------------------- +# find_path (A*) +# --------------------------------------------------------------------------- + +class TestFindPath: + def test_same_start_and_goal(self): + assert find_path((0, 0), (0, 0)) == [] + + def test_straight_horizontal(self): + path = find_path((0, 0), (3, 0)) + assert path == ["right", "right", "right"] + + def test_straight_vertical(self): + path = find_path((0, 0), (0, 2)) + assert path == ["down", "down"] + + def test_diagonal_no_collision(self): + path = find_path((0, 0), (2, 2)) + assert len(path) == 4 # manhattan distance + assert path.count("right") == 2 + assert path.count("down") == 2 + + def test_with_collision_map(self): + # Simple corridor: only specific tiles are walkable + cmap = { + (0, 0): True, + (1, 0): True, + (2, 0): True, + (2, 1): True, + (2, 2): True, + } + path = find_path((0, 0), (2, 2), cmap) + assert path == ["right", "right", "down", "down"] + + def test_no_path_returns_empty(self): + # Start is isolated + cmap = {(0, 0): True} + path = find_path((0, 0), (5, 5), cmap) + assert path == [] + + def test_max_iterations_limit(self): + path = find_path((0, 0), (1000, 1000), max_iterations=10) + assert path == [] + + def test_path_around_wall(self): + # Wall blocks direct path, must go around + cmap = {} + for x in range(5): + for y in range(5): + cmap[(x, y)] = True + # Block the middle column + cmap[(2, 0)] = False + cmap[(2, 1)] = False + cmap[(2, 2)] = False + + path = find_path((0, 1), (4, 1), cmap) + assert len(path) > 0 + # Verify path doesn't go through walls + pos = (0, 1) + deltas = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0)} + for step in path: + dx, dy = deltas[step] + pos = (pos[0] + dx, pos[1] + dy) + assert cmap.get(pos, False), f"Path went through wall at {pos}" + assert pos == (4, 1) + + +# --------------------------------------------------------------------------- +# directions_to_actions +# --------------------------------------------------------------------------- + +class TestDirectionsToActions: + def test_conversion(self): + dirs = ["up", "down", "left", "right"] + assert directions_to_actions(dirs) == [ + "walk_up", "walk_down", "walk_left", "walk_right" + ] + + def test_empty(self): + assert directions_to_actions([]) == [] + + +# --------------------------------------------------------------------------- +# navigate (high-level helper) +# --------------------------------------------------------------------------- + +class TestNavigate: + def test_returns_walk_actions(self): + actions = navigate((0, 0), (2, 1)) + assert all(a.startswith("walk_") for a in actions) + assert len(actions) == 3 + + def test_same_position(self): + assert navigate((3, 3), (3, 3)) == [] + + +# --------------------------------------------------------------------------- +# path_length +# --------------------------------------------------------------------------- + +class TestPathLength: + def test_same_position(self): + assert path_length((0, 0), (0, 0)) == 0 + + def test_reachable(self): + assert path_length((0, 0), (3, 2)) == 5 + + def test_unreachable(self): + cmap = {(0, 0): True} + assert path_length((0, 0), (5, 5), cmap) == -1 diff --git a/tests/test_state_builder.py b/tests/test_state_builder.py new file mode 100644 index 0000000..be4ca75 --- /dev/null +++ b/tests/test_state_builder.py @@ -0,0 +1,262 @@ +"""Tests for pokemon_agent.state.builder module.""" + +import pytest +from unittest.mock import MagicMock + +from pokemon_agent.state.builder import build_game_state, build_state_summary + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_mock_reader(**overrides): + """Create a mock GameMemoryReader with configurable return values.""" + reader = MagicMock() + reader.game_name = "Pokemon Red/Blue (USA)" + + defaults = { + "read_player": { + "name": "RED", + "rival_name": "BLUE", + "money": 3000, + "badges": ["Boulder", "Cascade"], + "badge_count": 2, + "position": {"y": 5, "x": 3}, + "facing": "down", + "play_time": "10:30:15", + }, + "read_party": [ + { + "species_id": 25, + "species": "Pikachu", + "nickname": "PIKACHU", + "level": 20, + "hp": 50, + "max_hp": 55, + "status": "OK", + "types": ["Electric", "Electric"], + "moves": [ + {"id": 85, "name": "Thunderbolt", "pp": 15, "pp_up": 0}, + {"id": 98, "name": "Quick Attack", "pp": 30, "pp_up": 0}, + ], + "stats": {"attack": 40, "defense": 30, "speed": 60, "special": 45}, + "ot_id": 12345, + "experience": 8000, + } + ], + "read_bag": [ + {"id": 4, "item": "Poke Ball", "quantity": 10}, + {"id": 20, "item": "Potion", "quantity": 5}, + ], + "read_battle": {"in_battle": False, "type": "none"}, + "read_dialog": {"active": False, "text_box_id": 0}, + "read_map_info": {"map_id": 0, "map_name": "Pallet Town"}, + "read_flags": { + "has_pokedex": True, + "has_oaks_parcel": True, + "pokedex_owned": 10, + "pokedex_seen": 25, + "badges": ["Boulder", "Cascade"], + "badge_count": 2, + }, + } + + for method, value in defaults.items(): + if method in overrides: + getattr(reader, method).return_value = overrides[method] + else: + getattr(reader, method).return_value = value + + return reader + + +# --------------------------------------------------------------------------- +# build_game_state +# --------------------------------------------------------------------------- + +class TestBuildGameState: + def test_contains_all_sections(self): + reader = _make_mock_reader() + state = build_game_state(reader, frame_count=1000) + + assert "metadata" in state + assert "player" in state + assert "party" in state + assert "bag" in state + assert "battle" in state + assert "dialog" in state + assert "map" in state + assert "flags" in state + + def test_metadata(self): + reader = _make_mock_reader() + state = build_game_state(reader, frame_count=42) + + assert state["metadata"]["game"] == "Pokemon Red/Blue (USA)" + assert state["metadata"]["frame_count"] == 42 + assert "timestamp" in state["metadata"] + + def test_metadata_no_frame_count(self): + reader = _make_mock_reader() + state = build_game_state(reader) + assert state["metadata"]["frame_count"] is None + + def test_player_data(self): + reader = _make_mock_reader() + state = build_game_state(reader) + + assert state["player"]["name"] == "RED" + assert state["player"]["money"] == 3000 + + def test_party_data(self): + reader = _make_mock_reader() + state = build_game_state(reader) + + assert len(state["party"]) == 1 + assert state["party"][0]["species"] == "Pikachu" + + def test_section_error_handling(self): + reader = _make_mock_reader() + reader.read_party.side_effect = RuntimeError("RAM read failed") + + state = build_game_state(reader) + + assert state["party"] is None + assert "party_error" in state + assert "RuntimeError" in state["party_error"] + + def test_not_implemented_error(self): + reader = _make_mock_reader() + reader.read_flags.side_effect = NotImplementedError("flags not supported") + + state = build_game_state(reader) + + assert state["flags"] is None + assert "flags_error" in state + assert "flags not supported" in state["flags_error"] + + def test_partial_failure(self): + """One section failing shouldn't affect others.""" + reader = _make_mock_reader() + reader.read_bag.side_effect = ValueError("corrupt data") + + state = build_game_state(reader) + + # bag failed + assert state["bag"] is None + assert "bag_error" in state + # others still work + assert state["player"]["name"] == "RED" + assert state["party"][0]["species"] == "Pikachu" + assert state["map"]["map_name"] == "Pallet Town" + + +# --------------------------------------------------------------------------- +# build_state_summary +# --------------------------------------------------------------------------- + +class TestBuildStateSummary: + def test_contains_game_name(self): + reader = _make_mock_reader() + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "Pokemon Red/Blue (USA)" in summary + + def test_contains_player_info(self): + reader = _make_mock_reader() + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "RED" in summary + assert "BLUE" in summary + assert "3,000" in summary or "3000" in summary + + def test_contains_party_info(self): + reader = _make_mock_reader() + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "Pikachu" in summary + assert "Lv20" in summary + + def test_contains_location(self): + reader = _make_mock_reader() + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "Pallet Town" in summary + + def test_contains_bag_items(self): + reader = _make_mock_reader() + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "Poke Ball" in summary + assert "Potion" in summary + + def test_battle_not_shown_when_not_in_battle(self): + reader = _make_mock_reader() + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "Not in battle" in summary + + def test_battle_shown_when_in_battle(self): + reader = _make_mock_reader( + read_battle={ + "in_battle": True, + "type": "wild", + "enemy": { + "species": "Rattata", + "level": 5, + "hp": 15, + "max_hp": 15, + "status": "OK", + "moves": ["Tackle", "Tail Whip"], + }, + } + ) + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "BATTLE" in summary + assert "Rattata" in summary + assert "wild" in summary + + def test_dialog_shown_when_active(self): + reader = _make_mock_reader( + read_dialog={"active": True, "text_box_id": 1} + ) + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "DIALOG" in summary + + def test_error_section_shown(self): + reader = _make_mock_reader() + reader.read_party.side_effect = RuntimeError("corrupt") + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "error" in summary.lower() + + def test_flags_section(self): + reader = _make_mock_reader() + state = build_game_state(reader) + summary = build_state_summary(state) + + assert "FLAGS" in summary + assert "Pokedex" in summary + + def test_returns_string(self): + reader = _make_mock_reader() + state = build_game_state(reader) + summary = build_state_summary(state) + assert isinstance(summary, str) + assert len(summary) > 0 + + def test_empty_state_doesnt_crash(self): + summary = build_state_summary({"metadata": {}}) + assert isinstance(summary, str)