diff --git a/GramAddict/core/goap.py b/GramAddict/core/goap.py index 3e3c745..8f7cae1 100644 --- a/GramAddict/core/goap.py +++ b/GramAddict/core/goap.py @@ -758,7 +758,14 @@ class GoalPlanner: selected_tab: Optional[str] = None, explored_nav_actions: set = None, ) -> Optional[str]: - """If we're on the wrong screen, figure out how to navigate.""" + """If we're on the wrong screen, figure out how to navigate. + + Strategy (priority order): + 1. HD Map (ScreenTopology BFS) β€” deterministic, pre-computed routes + 2. Learned Knowledge (Qdrant) β€” dynamic discovery from past sessions + 3. Autonomous Discovery β€” linguistic matching + VLM intent + """ + from GramAddict.core.screen_topology import ScreenTopology # 0. Aversive Filter: Remove known traps from available actions safe_available = [] @@ -769,10 +776,30 @@ class GoalPlanner: logger.debug(f"πŸ›‘οΈ [Aversive Filter] Masking trapped action: '{action}'") available = safe_available - # 1. Get required screens for this goal from knowledge + # ── 1. HD Map Routing (Primary Strategy) ── + target_screen = ScreenTopology.goal_to_target_screen(goal) + if target_screen and target_screen != screen_type: + route = ScreenTopology.find_route(screen_type, target_screen) + if route: + next_action, next_screen = route[0] + # Verify action isn't explored/trapped + if next_action not in (explored_nav_actions or set()): + if not self.knowledge.is_trap(screen_type, next_action): + route_desc = " β†’ ".join(s.name for _, s in route) + logger.info( + f"πŸ—ΊοΈ [HD Map] Route: {screen_type.name} β†’ {route_desc}. " + f"Next action: '{next_action}'" + ) + return next_action + else: + logger.warning(f"πŸ›‘οΈ [HD Map] Route action '{next_action}' is trapped. Falling back.") + else: + logger.debug(f"πŸ›‘οΈ [HD Map] Route action '{next_action}' already explored. Falling back.") + + # ── 2. Learned Knowledge (Qdrant) ── required_screens = self.knowledge.get_requirements(goal) - # 2. Blank Start Discovery (if knowledge is empty) + # ── 3. Autonomous Discovery (Blank Start fallback) ── if not required_screens: logger.info(f"🧠 [Nav Discovery] No known requirements for '{goal}'. Will attempt autonomous discovery.") @@ -784,15 +811,12 @@ class GoalPlanner: for action in available: if any(w in action.lower() for w in goal_words): - # Ensure it doesn't lead to a completely contradictory known state (if we knew the target). - # But since we are in a blank start, any linguistic match is a highly probable path! logger.info( f"🎯 [Nav Discovery] Linguistic match on available action! '{action}' aligns with '{goal}'" ) return action - # We don't know the required screen or path. Let the TelepathicEngine figure out - # what button to press based on the pure goal text! + # Return raw intent for TelepathicEngine discovery if explored_nav_actions and goal in explored_nav_actions: logger.info( f"πŸ›‘ [Nav Discovery] Autonomous intent '{goal}' already tried and failed/trapped. Yielding to back-tracking." @@ -801,37 +825,31 @@ class GoalPlanner: else: return goal - # 3. If we're already on an acceptable screen, no navigation needed + # 4. If we're already on an acceptable screen, no navigation needed if screen_type in required_screens: return None - # 4. Find the action we need to take + # 5. Find the action we need to take (from learned knowledge) for target_screen in required_screens: known_action = self.knowledge.get_action_for_screen(target_screen) if not known_action: logger.info(f"🧭 [Nav Discovery] Don't know action to reach {target_screen.name}. Asking VLM...") - # Check ALL available actions if one linguistically aligns with the target screen - # Or just let VLM figure it out by returning an intention. screen_friendly_name = target_screen.name.replace("_", " ").lower() - - # Semantic Heuristic Match on dynamically perceived actions goal_words = [w.rstrip("s") for w in screen_friendly_name.split() if len(w) > 3] for action in available: if any(w in action.lower() for w in goal_words): - # Verify we don't know this leads somewhere else known_target = self.knowledge.get_screen_for_action(action) if known_target and known_target != target_screen: - continue # Trap prevention + continue logger.info( f"🎯 [Nav Discovery] Linguistic match on available action! '{action}' aligns with '{screen_friendly_name}'" ) return action - # If no perceived action matches, return intent for TelepathicEngine return f"navigate to {screen_friendly_name}" else: if known_action in available: @@ -840,7 +858,6 @@ class GoalPlanner: # If no targeted navigation works, try going back first if "press back" in available: - # Only press back if we aren't currently on the required screen (handled in step 3) return "press back" return None diff --git a/GramAddict/core/q_nav_graph.py b/GramAddict/core/q_nav_graph.py index 7cb96bf..bc680fe 100644 --- a/GramAddict/core/q_nav_graph.py +++ b/GramAddict/core/q_nav_graph.py @@ -9,6 +9,7 @@ from GramAddict.core.compiler_engine import VLMCompilerEngine from GramAddict.core.qdrant_memory import NavigationMemoryDB from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType from GramAddict.core.goap import GoalExecutor, ScreenType +from GramAddict.core.screen_topology import ScreenTopology logger = logging.getLogger(__name__) @@ -37,18 +38,34 @@ class QNavGraph: def _load_graph(self): - """Loads the topological map from Qdrant. Merges with core seeds to guarantee baseline navigation.""" + """Loads the topological map from Qdrant. Merges with core seeds from ScreenTopology (SSOT).""" logger.debug("🌐 [NavGraph] Syncing topological map with Qdrant...") self.nodes = self.nav_memory.get_all_transitions() - - core_nodes = { - "HomeFeed": {"transitions": {"tap_explore_tab": "ExploreFeed", "tap_profile_tab": "OwnProfile", "tap_message_icon": "MessageInbox"}}, - "ExploreFeed": {"transitions": {"tap_home_tab": "HomeFeed"}}, - "OwnProfile": {"transitions": {"tap_home_tab": "HomeFeed", "tap_following_list": "FollowingList"}}, - "MessageInbox": {"transitions": {"tap_back": "HomeFeed"}}, - "FollowingList": {"transitions": {"tap_back": "OwnProfile"}}, - "UNKNOWN": {"transitions": {"tap_home_tab": "HomeFeed"}} - } + + # Generate core_nodes from ScreenTopology (single source of truth) + core_nodes = {} + for screen_type, transitions in ScreenTopology.TRANSITIONS.items(): + # Map ScreenType enum names to QNavGraph string names + screen_name_map = { + ScreenType.HOME_FEED: "HomeFeed", + ScreenType.EXPLORE_GRID: "ExploreFeed", + ScreenType.REELS_FEED: "ReelsFeed", + ScreenType.OWN_PROFILE: "OwnProfile", + ScreenType.DM_INBOX: "MessageInbox", + ScreenType.FOLLOW_LIST: "FollowingList", + ScreenType.OTHER_PROFILE: "OtherProfile", + ScreenType.UNKNOWN: "UNKNOWN", + } + node_name = screen_name_map.get(screen_type) + if not node_name: + continue + node_transitions = {} + for action, target_screen in transitions.items(): + # Convert action format: "tap profile tab" β†’ "tap_profile_tab" + action_key = action.replace(" ", "_") + target_name = screen_name_map.get(target_screen, target_screen.name) + node_transitions[action_key] = target_name + core_nodes[node_name] = {"transitions": node_transitions} # Merge core nodes into loaded nodes for node, data in core_nodes.items(): @@ -135,25 +152,29 @@ class QNavGraph: return self.goap._execute_action(goal) def _find_path(self, start: str, end: str): - if start == end: return [] - if start not in self.nodes: return None - - queue = [(start, [])] - visited = set() - - while queue: - current, path = queue.pop(0) - if current == end: - return path - - visited.add(current) - transitions = self.nodes.get(current, {}).get("transitions", {}) - - for action, next_state in transitions.items(): - if next_state not in visited: - queue.append((next_state, path + [action])) - - return None + """Delegates to ScreenTopology for BFS pathfinding (SSOT).""" + # Map string names to ScreenType for ScreenTopology + name_to_screen = { + "HomeFeed": ScreenType.HOME_FEED, + "ExploreFeed": ScreenType.EXPLORE_GRID, + "ReelsFeed": ScreenType.REELS_FEED, + "OwnProfile": ScreenType.OWN_PROFILE, + "MessageInbox": ScreenType.DM_INBOX, + "FollowingList": ScreenType.FOLLOW_LIST, + "OtherProfile": ScreenType.OTHER_PROFILE, + "UNKNOWN": ScreenType.UNKNOWN, + } + from_screen = name_to_screen.get(start) + to_screen = name_to_screen.get(end) + if not from_screen or not to_screen: + return None + + route = ScreenTopology.find_route(from_screen, to_screen) + if route is None: + return None + + # Convert back to QNavGraph action format: "tap profile tab" β†’ "tap_profile_tab" + return [action.replace(" ", "_") for action, _ in route] def _clear_anomaly_obstacles(self, max_attempts=2, xml_dump: str = None) -> bool: """ diff --git a/GramAddict/core/screen_topology.py b/GramAddict/core/screen_topology.py new file mode 100644 index 0000000..04debcd --- /dev/null +++ b/GramAddict/core/screen_topology.py @@ -0,0 +1,135 @@ +""" +ScreenTopology β€” The Instagram HD Map + +Pure-data BFS pathfinding between Instagram screen states. +Zero dependencies on device, VLM, Qdrant, or any runtime state. + +This is the bot's GPS: it knows HOW to get from screen A to screen B +before the bot starts moving. The GOAP planner consults this map +as its primary routing strategy. +""" +from collections import deque +from enum import Enum +from typing import Dict, List, Optional, Tuple + +from GramAddict.core.goap import ScreenType + + +class ScreenTopology: + """ + Topological HD Map of Instagram's screen graph. + + Provides BFS pathfinding between any two ScreenTypes. + All transitions use the same action string format as + the TelepathicEngine intent system β€” no translation needed. + """ + + # ── The Map: ScreenType β†’ {action_string β†’ ScreenType} ── + # These are structural facts about Instagram's UI, not learned behavior. + # They survive blank_start because they describe the app's architecture. + TRANSITIONS: Dict[ScreenType, Dict[str, ScreenType]] = { + ScreenType.HOME_FEED: { + "tap explore tab": ScreenType.EXPLORE_GRID, + "tap profile tab": ScreenType.OWN_PROFILE, + "tap reels tab": ScreenType.REELS_FEED, + "tap messages tab": ScreenType.DM_INBOX, + }, + ScreenType.EXPLORE_GRID: { + "tap home tab": ScreenType.HOME_FEED, + "tap profile tab": ScreenType.OWN_PROFILE, + "tap reels tab": ScreenType.REELS_FEED, + }, + ScreenType.REELS_FEED: { + "tap home tab": ScreenType.HOME_FEED, + "tap explore tab": ScreenType.EXPLORE_GRID, + "tap profile tab": ScreenType.OWN_PROFILE, + }, + ScreenType.OWN_PROFILE: { + "tap home tab": ScreenType.HOME_FEED, + "tap explore tab": ScreenType.EXPLORE_GRID, + "tap reels tab": ScreenType.REELS_FEED, + "tap following list": ScreenType.FOLLOW_LIST, + }, + ScreenType.DM_INBOX: { + "press back": ScreenType.HOME_FEED, + }, + ScreenType.FOLLOW_LIST: { + "press back": ScreenType.OWN_PROFILE, + }, + ScreenType.OTHER_PROFILE: { + "press back": ScreenType.HOME_FEED, + "tap home tab": ScreenType.HOME_FEED, + }, + ScreenType.UNKNOWN: { + "tap home tab": ScreenType.HOME_FEED, + }, + } + + # ── Goal β†’ ScreenType mapping ── + # Maps natural-language goals to their target screen. + _GOAL_MAP: Dict[str, ScreenType] = { + "open home feed": ScreenType.HOME_FEED, + "open home": ScreenType.HOME_FEED, + "open explore feed": ScreenType.EXPLORE_GRID, + "open explore": 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, + "open followers list": ScreenType.FOLLOW_LIST, + } + + @classmethod + def find_route( + cls, from_screen: ScreenType, to_screen: ScreenType + ) -> Optional[List[Tuple[str, ScreenType]]]: + """ + BFS shortest path from from_screen to to_screen. + + Returns: + [] if already there, + [(action, resulting_screen), ...] for a path, + None if unreachable. + """ + if from_screen == to_screen: + return [] + + queue: deque = deque() + queue.append((from_screen, [])) + visited = {from_screen} + + while queue: + current, path = queue.popleft() + transitions = cls.TRANSITIONS.get(current, {}) + + for action, next_screen in transitions.items(): + if next_screen == to_screen: + return path + [(action, next_screen)] + + if next_screen not in visited: + visited.add(next_screen) + queue.append((next_screen, path + [(action, next_screen)])) + + return None # Unreachable + + @classmethod + def get_transitions(cls, screen: ScreenType) -> Dict[str, ScreenType]: + """Get all known transitions from a screen.""" + return dict(cls.TRANSITIONS.get(screen, {})) + + @classmethod + def goal_to_target_screen(cls, goal: str) -> Optional[ScreenType]: + """Map a goal string to its target ScreenType. Returns None for non-navigation goals.""" + goal_lower = goal.lower().strip() + + # Exact match first + if goal_lower in cls._GOAL_MAP: + return cls._GOAL_MAP[goal_lower] + + # Substring match for flexibility + for key, screen in cls._GOAL_MAP.items(): + if key in goal_lower: + return screen + + return None diff --git a/GramAddict/core/telepathic_engine.py b/GramAddict/core/telepathic_engine.py index 4dfd90b..476472b 100644 --- a/GramAddict/core/telepathic_engine.py +++ b/GramAddict/core/telepathic_engine.py @@ -24,9 +24,17 @@ MAX_BUTTON_AREA = 150000 # Buttons/icons should be smaller than this (pxΒ²) MAX_CONTAINER_AREA = 500000 # Anything above this is a full-screen container # ── Nav Intent Keywords (SSOT β€” used by all structural guards) ── -# Any intent containing these keywords is classified as "navigation", -# allowing the structural guard to accept elements in the nav bar zone. -NAV_INTENT_KEYWORDS = [ +# +# TWO purpose-specific sets prevent the Guard Civil War: +# +# NAV_ZONE_BYPASS_KEYWORDS: Intents ALLOWED to target elements in the +# bottom nav bar zone. Broad β€” includes tabs, DMs, profile stats. +# +# NAV_TAB_KEYWORDS: Intents that REQUIRE their target to be at the bottom. +# Narrow β€” only actual navigation tabs. "following"/"follower" are profile +# stats at the TOP of the screen, not tabs. + +NAV_ZONE_BYPASS_KEYWORDS = [ "tab", "navigation", "reels tab", @@ -44,6 +52,14 @@ NAV_INTENT_KEYWORDS = [ "followers", ] +NAV_TAB_KEYWORDS = [ + "reels tab", + "profile tab", + "home tab", + "explore tab", + "message tab", +] + # Cache files MEMORY_FILE = "telepathic_memory.json" BLACKLIST_FILE = "telepathic_blacklist.json" @@ -410,7 +426,7 @@ class TelepathicEngine: # 3. Reject nodes in the Navigation Bar zone (bottom 6% - adjusted for accuracy) # UNLESS the intent is explicitly about navigation tabs, profile stats, OR popup modals - nav_keywords = NAV_INTENT_KEYWORDS + nav_keywords = NAV_ZONE_BYPASS_KEYWORDS modal_keywords = ["dismiss", "ok", "cancel", "accept", "allow", "deny", "action", "obstacle", "popup"] low_intent = intent_description.lower() @@ -431,12 +447,11 @@ class TelepathicEngine: # STRICT NAVIGATION TAB ENFORCEMENT # Navigation tabs MUST be at the bottom (>= 0.92 of screen height). # We reject any navigation tab hallucinated in the middle of the screen. - if is_nav_intent and node.get("y", 0) < screen_height * 0.92: - # We must be careful: intent could be "navigate to profile" which means click username - # So ONLY block if the intent explicitly says "tab". - if "tab" in low_intent: - # Silently filter non-bottom elements for navigation intents to prevent log spam - return False + # STRICT TAB ENFORCEMENT: Only actual tab intents must be at the bottom. + is_tab_intent = any(k in low_intent for k in NAV_TAB_KEYWORDS) + if is_tab_intent and node.get("y", 0) < screen_height * 0.92: + # Silently filter non-bottom elements for tab intents to prevent log spam + return False # STRICT STORY TRAY ENFORCEMENT # Story rings MUST be at the top of the screen (y < 30% of screen height). @@ -685,7 +700,7 @@ class TelepathicEngine: # - Navigation intents: Require 100% exact match to avoid feed-cross-talk # - Short intents (1-2 words): Require at least 50% hit (0.45) # - Longer intents: Require 75% to avoid false matches on noisy screens. - is_nav_intent = any(k in intent_lower for k in NAV_INTENT_KEYWORDS) + is_nav_intent = any(k in intent_lower for k in NAV_ZONE_BYPASS_KEYWORDS) if is_nav_intent: threshold = 1.0 else: @@ -939,7 +954,7 @@ class TelepathicEngine: # If a bottom sheet or dialog is active, it likely obscures the main navigation tabs. # We rely strictly on the SAE (Situational Awareness Engine) for 100% autonomous detection # (via Qdrant cache or LLM reasoning) instead of hardcoded resource IDs. - is_nav_intent = any(k in intent_lower for k in NAV_INTENT_KEYWORDS) + is_nav_intent = any(k in intent_lower for k in NAV_ZONE_BYPASS_KEYWORDS) if is_nav_intent and device is not None: from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType @@ -2080,15 +2095,14 @@ class TelepathicEngine: ) return None - is_nav_intent = any(k in intent.lower() for k in NAV_INTENT_KEYWORDS) + is_nav_intent = any(k in intent.lower() for k in NAV_ZONE_BYPASS_KEYWORDS) + is_tab_intent = any(k in intent.lower() for k in NAV_TAB_KEYWORDS) # NAVIGATION TAB ENFORCEMENT: # Real navigation tabs (Home, Search, Reels, Store, Profile) are ALWAYS in the bottom zone. - # If the bot is looking for a tab, forbid results that are too high up (likely hallucinations on comments/feed). - # IMPORTANT: Only enforce for intents explicitly containing "tab". - # "following"/"follower" are profile stats at the TOP of the screen, not tabs. - # This aligns with _structural_sanity_check which has the same "tab" guard. - if is_nav_intent and "tab" in intent.lower(): + # If the bot is looking for a tab, forbid results that are too high up. + # "following"/"follower" are profile stats at the TOP β€” not tabs. + if is_tab_intent: if match.get("y", 0) < screen_height * 0.92: logger.warning( f"πŸ›‘οΈ [Structural Guard] VLM hallucinated a navigation tab '{intent}' " diff --git a/tests/unit/test_goap_bootstrap.py b/tests/unit/test_goap_bootstrap.py index 0871d03..e1fdfa9 100644 --- a/tests/unit/test_goap_bootstrap.py +++ b/tests/unit/test_goap_bootstrap.py @@ -3,10 +3,12 @@ from unittest.mock import MagicMock from GramAddict.core.goap import GoalPlanner, ScreenType -def test_goal_planner_linguistic_match_blank_start(): +def test_goal_planner_uses_hd_map_over_linguistic_match(): """ - Tests that the GoalPlanner correctly matches a linguistic target - during Blank Start discovery without being blocked by a known target check. + Tests that the GoalPlanner uses the HD Map (ScreenTopology) as its + primary routing strategy, even during Blank Start discovery. + The HD Map returns canonical action intents that the TelepathicEngine + interprets β€” not just available_actions from the XML dump. """ planner = GoalPlanner("test_user") @@ -26,4 +28,8 @@ def test_goal_planner_linguistic_match_blank_start(): result = planner.plan_next_step(goal, screen, explored_nav_actions=set()) - assert result == "tap explore grid", "Failed to linguistically match explore during Blank Start" + # HD Map routes HOME_FEED β†’ EXPLORE_GRID via "tap explore tab" + assert result == "tap explore tab", ( + f"Expected HD Map to route via 'tap explore tab', got '{result}'. " + "The HD Map should override linguistic matching as the primary strategy." + ) diff --git a/tests/unit/test_goap_graph_routing.py b/tests/unit/test_goap_graph_routing.py new file mode 100644 index 0000000..d73c534 --- /dev/null +++ b/tests/unit/test_goap_graph_routing.py @@ -0,0 +1,103 @@ +""" +πŸ”΄ RED TDD: GOAP Graph-Aware Routing + +The GoalPlanner must use the ScreenTopology HD Map as its PRIMARY +routing strategy. When asked to reach FollowingList from HomeFeed, +it should return "tap profile tab" (first step of the BFS route), +NOT "open following list" (impossible direct action). +""" +import pytest +import sys +import os +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from GramAddict.core.goap import GoalPlanner, ScreenType + + +class TestGoapGraphRouting: + """GOAP planner must use the HD Map for multi-step navigation.""" + + @pytest.fixture + def planner(self): + p = GoalPlanner("testbot") + # Ensure blank start (no learned knowledge) + p.knowledge._goal_requirements = {} + p.knowledge._learned_screen_mappings = {} + p.knowledge._learned_traps = set() + return p + + def test_planner_routes_to_profile_first_for_following_list(self, planner): + """ + From HOME_FEED, goal 'open following list' should return + 'tap profile tab' (navigate to OwnProfile first), + NOT 'open following list' as a raw intent. + """ + screen = { + "screen_type": ScreenType.HOME_FEED, + "available_actions": [ + "tap explore tab", + "tap home tab", + "tap messages tab", + "tap reels tab", + "press back", + ], + "context": {}, + "selected_tab": "feed_tab", + } + action = planner.plan_next_step("open following list", screen) + assert action == "tap profile tab", ( + f"Planner returned '{action}' instead of 'tap profile tab'. " + "It should use the HD Map to route HOME_FEED β†’ OWN_PROFILE β†’ FOLLOW_LIST." + ) + + def test_planner_returns_final_action_on_intermediate_screen(self, planner): + """ + From OWN_PROFILE, goal 'open following list' should return + 'tap following list' directly (we're already on the right screen). + """ + screen = { + "screen_type": ScreenType.OWN_PROFILE, + "available_actions": [ + "tap explore tab", + "tap home tab", + "tap reels tab", + "tap following list", + "press back", + ], + "context": {}, + "selected_tab": "profile_tab", + } + action = planner.plan_next_step("open following list", screen) + assert action == "tap following list", ( + f"Planner returned '{action}' instead of 'tap following list'. " + "On OWN_PROFILE, it should directly execute the final action." + ) + + def test_planner_detects_goal_already_achieved(self, planner): + """On FOLLOW_LIST, goal 'open following list' should return None (achieved).""" + screen = { + "screen_type": ScreenType.FOLLOW_LIST, + "available_actions": ["press back"], + "context": {}, + } + action = planner.plan_next_step("open following list", screen) + assert action is None, "Goal is already achieved β€” planner should return None." + + def test_planner_routes_explore_to_following_list(self, planner): + """From EXPLORE_GRID, route should be: Explore β†’ Profile β†’ FollowList.""" + screen = { + "screen_type": ScreenType.EXPLORE_GRID, + "available_actions": [ + "tap home tab", + "tap profile tab", + "tap reels tab", + ], + "context": {}, + "selected_tab": "explore_tab", + } + action = planner.plan_next_step("open following list", screen) + assert action == "tap profile tab", ( + f"From EXPLORE_GRID, planner should route via OWN_PROFILE, got '{action}'" + ) diff --git a/tests/unit/test_screen_topology.py b/tests/unit/test_screen_topology.py new file mode 100644 index 0000000..0e401cb --- /dev/null +++ b/tests/unit/test_screen_topology.py @@ -0,0 +1,98 @@ +""" +πŸ”΄ RED TDD: ScreenTopology β€” The Instagram HD Map + +Tests for BFS pathfinding between Instagram screens. +The killer test: HOME_FEED β†’ OWN_PROFILE β†’ FOLLOW_LIST must be a 2-step route. +""" +import pytest +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from GramAddict.core.goap import ScreenType +from GramAddict.core.screen_topology import ScreenTopology + + +class TestScreenTopologyRouting: + """BFS pathfinding must compute correct multi-step routes.""" + + def test_route_home_to_following_list(self): + """THE killer test: HomeFeed β†’ OwnProfile β†’ FollowList (2 hops).""" + route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.FOLLOW_LIST) + assert route is not None, "Route from HOME_FEED to FOLLOW_LIST must exist" + assert len(route) == 2, f"Expected 2 hops, got {len(route)}: {route}" + assert route[0] == ("tap profile tab", ScreenType.OWN_PROFILE) + assert route[1] == ("tap following list", ScreenType.FOLLOW_LIST) + + def test_route_already_there(self): + """Same screen = empty route (no steps needed).""" + route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.HOME_FEED) + assert route == [] + + def test_route_single_hop(self): + """Direct neighbor = 1 step.""" + 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_route_reverse_direction(self): + """FollowList back to HomeFeed: FollowList β†’ OwnProfile β†’ HomeFeed (2 hops).""" + route = ScreenTopology.find_route(ScreenType.FOLLOW_LIST, ScreenType.HOME_FEED) + assert route is not None + assert len(route) == 2 + assert route[0][1] == ScreenType.OWN_PROFILE + assert route[1][1] == ScreenType.HOME_FEED + + def test_no_route_from_unreachable(self): + """FOREIGN_APP has no outbound transitions β€” route should be None.""" + route = ScreenTopology.find_route(ScreenType.FOREIGN_APP, ScreenType.FOLLOW_LIST) + assert route is None + + +class TestGoalToTargetScreen: + """Goal string β†’ target ScreenType mapping.""" + + def test_following_list_goal(self): + assert ScreenTopology.goal_to_target_screen("open following list") == ScreenType.FOLLOW_LIST + + def test_followers_list_goal(self): + assert ScreenTopology.goal_to_target_screen("open followers list") == ScreenType.FOLLOW_LIST + + def test_profile_goal(self): + assert ScreenTopology.goal_to_target_screen("open profile") == ScreenType.OWN_PROFILE + + def test_home_feed_goal(self): + assert ScreenTopology.goal_to_target_screen("open home feed") == ScreenType.HOME_FEED + + def test_explore_goal(self): + assert ScreenTopology.goal_to_target_screen("open explore feed") == ScreenType.EXPLORE_GRID + + def test_messages_goal(self): + assert ScreenTopology.goal_to_target_screen("open messages") == ScreenType.DM_INBOX + + def test_interaction_goal_returns_none(self): + """Non-navigation goals should return None.""" + assert ScreenTopology.goal_to_target_screen("like this post") is None + + def test_unknown_goal_returns_none(self): + assert ScreenTopology.goal_to_target_screen("do something random") is None + + +class TestGetTransitions: + """Get available transitions from a screen.""" + + def test_home_feed_has_profile_tab(self): + transitions = ScreenTopology.get_transitions(ScreenType.HOME_FEED) + assert "tap profile tab" in transitions + assert transitions["tap profile tab"] == ScreenType.OWN_PROFILE + + def test_own_profile_has_following_list(self): + transitions = ScreenTopology.get_transitions(ScreenType.OWN_PROFILE) + assert "tap following list" in transitions + assert transitions["tap following list"] == ScreenType.FOLLOW_LIST + + def test_unknown_screen_returns_empty(self): + transitions = ScreenTopology.get_transitions(ScreenType.FOREIGN_APP) + assert transitions == {}