diff --git a/tests/e2e/test_system_goap_navigation.py b/tests/e2e/test_system_goap_navigation.py
new file mode 100644
index 0000000..5e29dd6
--- /dev/null
+++ b/tests/e2e/test_system_goap_navigation.py
@@ -0,0 +1,555 @@
+"""
+E2E: GOAP Navigation & Learning Engine
+========================================
+Tests the autonomous navigation brain WITHOUT a live device.
+
+These tests validate the GOAP decision-making pipeline:
+- Screen identification from XML structural markers
+- Action failure counting & masking
+- HD Map BFS routing with masked edges
+- Trap detection & forced restart logic
+- Goal achievement detection
+- Recalled path execution logic
+
+WHY THIS MATTERS (from 2026-05-02 production log):
+ Bot tried 'tap reels tab' but VLM clicked wrong element.
+ Screen identity reported EXPLORE_GRID instead of REELS_FEED.
+ After 2 failures the action was masked → HD Map said 'unreachable'
+ → GOAP declared 'completely trapped' → forced Instagram restart.
+ All tests were green because NONE of this logic was tested.
+"""
+
+import pytest
+
+from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
+from GramAddict.core.screen_topology import ScreenTopology
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Helpers
+# ═══════════════════════════════════════════════════════════════════════
+
+def _xml_with_ids(*resource_ids, selected_tab=None, texts=None, descs=None):
+ """Build a minimal XML dump with given resource IDs."""
+ nodes = []
+ for rid in resource_ids:
+ selected = "true" if selected_tab and rid.endswith(selected_tab) else "false"
+ nodes.append(
+ f''
+ )
+ for text in (texts or []):
+ nodes.append(
+ f''
+ )
+ for desc in (descs or []):
+ nodes.append(
+ f''
+ )
+ return f'{"".join(nodes)}'
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CONTRACT 1: Screen Identification from XML
+# ═══════════════════════════════════════════════════════════════════════
+#
+# If screen identification is wrong, EVERY downstream decision is wrong:
+# routing, goal checking, action masking — everything.
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestScreenIdentification:
+ """Tests ScreenIdentity._classify_screen with real XML patterns."""
+
+ def setup_method(self):
+ self.si = ScreenIdentity(bot_username="testbot")
+
+ def test_home_feed_from_selected_tab(self):
+ xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
+ selected_tab="feed_tab")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.HOME_FEED
+
+ def test_explore_grid_from_selected_tab(self):
+ xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
+ selected_tab="search_tab")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.EXPLORE_GRID
+
+ def test_reels_feed_from_selected_tab(self):
+ xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
+ selected_tab="clips_tab")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.REELS_FEED
+
+ def test_reels_feed_from_structural_markers(self):
+ """Reels in full-screen mode hides the tab bar → no selected_tab."""
+ xml = _xml_with_ids("clips_viewer_container", "root_clips_layout")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.REELS_FEED
+
+ def test_own_profile_from_selected_tab(self):
+ xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
+ "profile_header_container",
+ selected_tab="profile_tab")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.OWN_PROFILE
+
+ def test_other_profile_from_header_without_tab(self):
+ """Other profile has header but profile_tab is NOT selected."""
+ xml = _xml_with_ids("profile_header_container", "feed_tab",
+ selected_tab="feed_tab")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.OTHER_PROFILE
+
+ def test_follow_list(self):
+ xml = _xml_with_ids("unified_follow_list_tab_layout")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.FOLLOW_LIST
+
+ def test_dm_inbox_from_tab(self):
+ xml = _xml_with_ids("direct_tab", selected_tab="direct_tab")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.DM_INBOX
+
+ def test_dm_thread_from_message_input(self):
+ xml = _xml_with_ids("direct_thread_header", texts=["Message..."])
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.DM_THREAD
+
+ def test_story_view_from_markers(self):
+ xml = _xml_with_ids("reel_viewer_media_layout", "reel_viewer_header")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.STORY_VIEW
+
+ def test_modal_from_creation_flow(self):
+ xml = _xml_with_ids("creation_flow_container", "gallery_cancel_button")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.MODAL
+
+ def test_foreign_app_when_no_instagram_package(self):
+ xml = ''
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.FOREIGN_APP
+
+ def test_post_detail_has_feed_markers_but_no_action_bar(self):
+ """POST_DETAIL has row_feed_button_like but NO main_feed_action_bar."""
+ xml = _xml_with_ids("row_feed_button_like", "row_feed_photo_profile_name")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.POST_DETAIL
+
+ def test_home_feed_has_feed_markers_with_action_bar(self):
+ """HOME_FEED has row_feed_* markers AND main_feed_action_bar."""
+ xml = _xml_with_ids("row_feed_button_like", "row_feed_photo_profile_name",
+ "main_feed_action_bar", "feed_tab",
+ selected_tab="feed_tab")
+ result = self.si.identify(xml)
+ assert result["screen_type"] == ScreenType.HOME_FEED
+
+ def test_empty_xml_returns_foreign_app(self):
+ result = self.si.identify("")
+ assert result["screen_type"] == ScreenType.FOREIGN_APP
+
+ def test_garbage_xml_returns_foreign_app(self):
+ result = self.si.identify("not xml at all!")
+ assert result["screen_type"] == ScreenType.FOREIGN_APP
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CONTRACT 2: Available Actions Extraction
+# ═══════════════════════════════════════════════════════════════════════
+#
+# If available_actions is wrong, the planner masks the wrong things
+# or can't find valid actions to execute.
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestAvailableActions:
+ """Tests that screen identification extracts correct available actions."""
+
+ def setup_method(self):
+ self.si = ScreenIdentity(bot_username="testbot")
+
+ def test_home_feed_has_all_tabs(self):
+ xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
+ "direct_tab", selected_tab="feed_tab")
+ result = self.si.identify(xml)
+ actions = result["available_actions"]
+ assert "tap home tab" in actions
+ assert "tap explore tab" in actions
+ assert "tap reels tab" in actions
+ assert "tap profile tab" in actions
+ assert "tap messages tab" in actions
+
+ def test_explore_grid_has_first_post(self):
+ xml = _xml_with_ids("search_tab", selected_tab="search_tab")
+ result = self.si.identify(xml)
+ actions = result["available_actions"]
+ assert "tap first post" in actions
+
+ def test_profile_has_following_list(self):
+ xml = _xml_with_ids("profile_header_container", "profile_tab",
+ selected_tab="profile_tab",
+ descs=["following"])
+ result = self.si.identify(xml)
+ actions = result["available_actions"]
+ assert "tap following list" in actions
+
+ def test_always_has_scroll_and_back(self):
+ xml = _xml_with_ids("feed_tab", selected_tab="feed_tab")
+ result = self.si.identify(xml)
+ actions = result["available_actions"]
+ assert "scroll down" in actions
+ assert "press back" in actions
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CONTRACT 3: HD Map BFS Routing
+# ═══════════════════════════════════════════════════════════════════════
+#
+# The HD Map is the bot's GPS. If BFS routing breaks, the bot
+# can't navigate between screens and gets stuck.
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestHDMapRouting:
+ """Tests ScreenTopology.find_route BFS pathfinding."""
+
+ def test_direct_route_home_to_explore(self):
+ route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID)
+ assert route is not None
+ assert len(route) == 1
+ assert route[0] == ("tap explore tab", ScreenType.EXPLORE_GRID)
+
+ def test_direct_route_home_to_reels(self):
+ route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.REELS_FEED)
+ assert route is not None
+ assert len(route) == 1
+ assert route[0] == ("tap reels tab", ScreenType.REELS_FEED)
+
+ def test_multi_step_route_home_to_follow_list(self):
+ """HOME → OWN_PROFILE → FOLLOW_LIST (2 steps)."""
+ route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.FOLLOW_LIST)
+ assert route is not None
+ assert len(route) == 2
+ assert route[0][1] == ScreenType.OWN_PROFILE
+ assert route[1][1] == ScreenType.FOLLOW_LIST
+
+ def test_already_there_returns_empty(self):
+ route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.HOME_FEED)
+ assert route == []
+
+ def test_unreachable_returns_none(self):
+ """POST_DETAIL has no outgoing edges → can't reach other screens."""
+ route = ScreenTopology.find_route(ScreenType.POST_DETAIL, ScreenType.DM_INBOX)
+ assert route is None
+
+ def test_masked_edge_makes_route_none(self):
+ """
+ Production bug 2026-05-02: 'tap reels tab' was masked after 2 failures.
+ HD Map must return None when the only edge to REELS_FEED is masked.
+ """
+ route = ScreenTopology.find_route(
+ ScreenType.HOME_FEED,
+ ScreenType.REELS_FEED,
+ avoid_actions={"tap reels tab"},
+ )
+ # Should find alternative route via EXPLORE_GRID or OWN_PROFILE
+ if route is not None:
+ # If there IS an alternative route, it must not use the masked action
+ for action, _ in route:
+ assert action != "tap reels tab"
+ # If None, the target is unreachable (which matches production behavior)
+
+ def test_masked_all_edges_returns_none(self):
+ """When ALL edges to a target are masked, route must be None."""
+ # Mask ALL routes that could reach REELS_FEED
+ all_reels_actions = set()
+ for screen, transitions in ScreenTopology.TRANSITIONS.items():
+ for action, target in transitions.items():
+ if target == ScreenType.REELS_FEED:
+ all_reels_actions.add(action)
+
+ route = ScreenTopology.find_route(
+ ScreenType.HOME_FEED,
+ ScreenType.REELS_FEED,
+ avoid_actions=all_reels_actions,
+ )
+ assert route is None, (
+ f"Expected None (unreachable) but got route: {route}"
+ )
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CONTRACT 4: Goal → Target Screen Mapping
+# ═══════════════════════════════════════════════════════════════════════
+#
+# If the goal map is wrong, the bot navigates to the wrong screen
+# and thinks it failed when it succeeded (or vice versa).
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestGoalMapping:
+ """Tests ScreenTopology.goal_to_target_screen."""
+
+ @pytest.mark.parametrize(
+ "goal,expected_screen",
+ [
+ ("open home feed", ScreenType.HOME_FEED),
+ ("open home", ScreenType.HOME_FEED),
+ ("open explore", ScreenType.EXPLORE_GRID),
+ ("open explore feed", ScreenType.EXPLORE_GRID),
+ ("open reels", ScreenType.REELS_FEED),
+ ("open profile", ScreenType.OWN_PROFILE),
+ ("learn own profile", ScreenType.OWN_PROFILE),
+ ("open messages", ScreenType.DM_INBOX),
+ ("open following list", ScreenType.FOLLOW_LIST),
+ ("view a post", ScreenType.POST_DETAIL),
+ ("open post author profile", ScreenType.OTHER_PROFILE),
+ # Non-navigation goals
+ ("like this post", None),
+ ("follow the user", None),
+ ("comment on the post", None),
+ ],
+ ids=[
+ "home_feed", "home_short", "explore", "explore_full",
+ "reels", "profile", "learn_profile", "messages",
+ "following_list", "post", "other_profile",
+ "like_non_nav", "follow_non_nav", "comment_non_nav",
+ ],
+ )
+ def test_goal_mapping(self, goal, expected_screen):
+ result = ScreenTopology.goal_to_target_screen(goal)
+ assert result == expected_screen
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CONTRACT 5: Action Failure Masking Logic
+# ═══════════════════════════════════════════════════════════════════════
+#
+# Production bug 2026-05-02: After 2 failures of 'tap reels tab',
+# the action was masked. But GOAP continued trying to route through
+# it, causing an infinite restart loop.
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestActionMasking:
+ """Tests the action failure tracking and masking logic in GoalExecutor."""
+
+ def test_action_masked_after_max_retries(self):
+ """After 2 failures, the action must be excluded from available_actions."""
+ action_failures = {"tap reels tab": 2}
+ original_actions = ["tap home tab", "tap reels tab", "tap profile tab"]
+ MAX_RETRIES = 2
+
+ masked = [a for a in original_actions if action_failures.get(a, 0) < MAX_RETRIES]
+ assert "tap reels tab" not in masked
+ assert "tap home tab" in masked
+ assert "tap profile tab" in masked
+
+ def test_action_not_masked_under_threshold(self):
+ """1 failure is not enough to mask."""
+ action_failures = {"tap reels tab": 1}
+ original_actions = ["tap reels tab", "tap profile tab"]
+ MAX_RETRIES = 2
+
+ masked = [a for a in original_actions if action_failures.get(a, 0) < MAX_RETRIES]
+ assert "tap reels tab" in masked
+
+ def test_success_resets_failure_count(self):
+ """A successful execution must reset the failure counter."""
+ action_failures = {"tap reels tab": 1}
+ # Simulate success
+ action_failures["tap reels tab"] = 0
+ assert action_failures["tap reels tab"] == 0
+
+ def test_hd_map_unreachable_with_masked_actions(self):
+ """
+ Production chain 2026-05-02:
+ 1. 'tap reels tab' fails 2x → masked
+ 2. HD Map can't find route → returns None
+ 3. Planner says 'unreachable' → trapped → restart
+
+ This test reproduces the exact chain.
+ """
+ avoid_actions = {"tap reels tab"}
+
+ # Step 1: Check if route exists without masking
+ route_clean = ScreenTopology.find_route(
+ ScreenType.HOME_FEED, ScreenType.REELS_FEED
+ )
+ assert route_clean is not None, "Route should exist without masking"
+
+ # Step 2: Check if route exists WITH masking
+ route_masked = ScreenTopology.find_route(
+ ScreenType.HOME_FEED, ScreenType.REELS_FEED,
+ avoid_actions=avoid_actions,
+ )
+
+ # Step 3: The planner's HD Map pre-check logic
+ # If route_masked is None BUT route_clean exists → "unreachable due to masked edges"
+ if route_masked is None:
+ # This is exactly the production scenario
+ is_unreachable_due_to_masking = route_clean is not None
+ assert is_unreachable_due_to_masking is True
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CONTRACT 6: Goal Achievement Detection
+# ═══════════════════════════════════════════════════════════════════════
+#
+# If the goal detection is wrong, the bot either:
+# - Stops too early (thinks it's done but isn't)
+# - Loops forever (can't detect when it's actually done)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestGoalAchievement:
+ """Tests GoalPlanner._is_goal_achieved logic."""
+
+ def setup_method(self):
+ from GramAddict.core.navigation.planner import GoalPlanner
+ self.planner = GoalPlanner(username="testbot")
+
+ def test_open_explore_achieved_on_explore(self):
+ assert self.planner._is_goal_achieved(
+ "open explore", ScreenType.EXPLORE_GRID, {}
+ ) is True
+
+ def test_open_explore_not_achieved_on_home(self):
+ assert self.planner._is_goal_achieved(
+ "open explore", ScreenType.HOME_FEED, {}
+ ) is False
+
+ def test_open_reels_achieved_on_reels(self):
+ assert self.planner._is_goal_achieved(
+ "open reels", ScreenType.REELS_FEED, {}
+ ) is True
+
+ def test_open_reels_not_achieved_on_explore(self):
+ """
+ Production bug 2026-05-02: Bot tapped 'reels tab' but
+ landed on EXPLORE_GRID. Goal must NOT be achieved.
+ """
+ assert self.planner._is_goal_achieved(
+ "open reels", ScreenType.EXPLORE_GRID, {}
+ ) is False
+
+ def test_like_achieved_when_context_is_liked(self):
+ assert self.planner._is_goal_achieved(
+ "like a post", ScreenType.POST_DETAIL, {"is_liked": True}
+ ) is True
+
+ def test_like_not_achieved_when_not_liked(self):
+ assert self.planner._is_goal_achieved(
+ "like a post", ScreenType.POST_DETAIL, {"is_liked": False}
+ ) is False
+
+ def test_view_profile_achieved_on_own_profile(self):
+ assert self.planner._is_goal_achieved(
+ "view profile", ScreenType.OWN_PROFILE, {}
+ ) is True
+
+ def test_view_profile_achieved_on_other_profile(self):
+ assert self.planner._is_goal_achieved(
+ "view profile", ScreenType.OTHER_PROFILE, {}
+ ) is True
+
+ def test_non_navigation_goal_not_achieved_by_screen(self):
+ """Goals like 'follow the user' are NOT achieved by just being on a screen."""
+ assert self.planner._is_goal_achieved(
+ "follow the user", ScreenType.OTHER_PROFILE, {}
+ ) is False
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CONTRACT 7: Structural Action Protection
+# ═══════════════════════════════════════════════════════════════════════
+#
+# Structural actions (from the HD Map) must NEVER be permanently
+# learned as traps. The VLM may fail to click them, but the route
+# itself is architecturally valid.
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestStructuralActionProtection:
+ """Tests that HD Map actions are never permanently poisoned."""
+
+ def test_tap_reels_tab_is_structural_on_home(self):
+ assert ScreenTopology.is_structural_action(
+ ScreenType.HOME_FEED, "tap reels tab"
+ ) is True
+
+ def test_tap_profile_tab_is_structural_on_explore(self):
+ assert ScreenTopology.is_structural_action(
+ ScreenType.EXPLORE_GRID, "tap profile tab"
+ ) is True
+
+ def test_random_action_is_not_structural(self):
+ assert ScreenTopology.is_structural_action(
+ ScreenType.HOME_FEED, "like this post"
+ ) is False
+
+ def test_structural_on_wrong_screen_is_false(self):
+ """'tap following list' is structural on OWN_PROFILE, not on HOME_FEED."""
+ assert ScreenTopology.is_structural_action(
+ ScreenType.HOME_FEED, "tap following list"
+ ) is False
+ assert ScreenTopology.is_structural_action(
+ ScreenType.OWN_PROFILE, "tap following list"
+ ) is True
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CONTRACT 8: Step-Aware Navigation Validation
+# ═══════════════════════════════════════════════════════════════════════
+#
+# After executing an action, the GOAP checks if the resulting screen
+# matches what the HD Map expected. If this is wrong, correct actions
+# get rejected and the bot enters a failure loop.
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestStepValidation:
+ """Tests expected_screen_for_action validation."""
+
+ def test_tap_reels_tab_from_home_expects_reels(self):
+ expected = ScreenTopology.expected_screen_for_action(
+ "tap reels tab", ScreenType.HOME_FEED
+ )
+ assert expected == ScreenType.REELS_FEED
+
+ def test_tap_explore_tab_from_home_expects_explore(self):
+ expected = ScreenTopology.expected_screen_for_action(
+ "tap explore tab", ScreenType.HOME_FEED
+ )
+ assert expected == ScreenType.EXPLORE_GRID
+
+ def test_tap_reels_tab_landing_on_explore_is_failure(self):
+ """
+ Production bug 2026-05-02: Bot tapped 'reels tab' but
+ landed on EXPLORE_GRID. This must be detected as a failure.
+ """
+ expected = ScreenTopology.expected_screen_for_action(
+ "tap reels tab", ScreenType.HOME_FEED
+ )
+ actual = ScreenType.EXPLORE_GRID
+ assert expected != actual, "Reels tab landing on Explore must be a mismatch!"
+
+ def test_unknown_action_returns_none(self):
+ expected = ScreenTopology.expected_screen_for_action(
+ "like this post", ScreenType.HOME_FEED
+ )
+ assert expected is None
+
+ def test_press_back_from_follow_list_expects_profile(self):
+ expected = ScreenTopology.expected_screen_for_action(
+ "press back", ScreenType.FOLLOW_LIST
+ )
+ assert expected == ScreenType.OWN_PROFILE