diff --git a/GramAddict/core/bot_flow.py b/GramAddict/core/bot_flow.py index 5e78823..a009a43 100644 --- a/GramAddict/core/bot_flow.py +++ b/GramAddict/core/bot_flow.py @@ -785,15 +785,24 @@ def _run_zero_latency_feed_loop( if cognitive_stack.get("radome"): context_xml = cognitive_stack.get("radome").sanitize_xml(context_xml) - # ── PRE-EMPTIVE AD SKIP (Fast Path) ── + # ── PRE-EMPTIVE AD SKIP (3-Tier Escape Cascade) ── if is_ad(context_xml, cognitive_stack): consecutive_ads += 1 - if consecutive_ads >= 3: + if consecutive_ads >= 6: + logger.error( + "🚨 [Ad Trap] Stuck on ad for 6+ cycles! Force-navigating to HomeFeed to escape deadlock.", + extra={"color": f"{Fore.RED}"}, + ) + nav_graph.navigate_to("HomeFeed", zero_engine) + consecutive_ads = 0 + elif consecutive_ads >= 3: logger.warning( - "📺 [Anti-Stuck] Stuck on ad! Executing aggressive skip.", extra={"color": f"{Fore.RED}"} + "📺 [Anti-Stuck] Stuck on ad! Executing aggressive double-skip.", + extra={"color": f"{Fore.RED}"}, ) _humanized_scroll(device, is_skip=True) - sleep(2.0) + sleep(0.5) + _humanized_scroll(device, is_skip=True) else: logger.info("📺 fast-skipping ad (no AI needed)...") _humanized_scroll(device, is_skip=True) @@ -1136,16 +1145,23 @@ def _run_zero_latency_feed_loop( for n in nodes: res_id = n.get("resource_id", "").lower() text_lower = (n.get("text", "") or n.get("content_desc", "")).lower() - if target_user.lower() in text_lower and ( - "profile_name" in res_id or "title" in res_id or "username" in res_id or "avatar" in res_id - ): - if n.get("x") and n.get("y"): - logger.info( - f"⚡ [Targeted UX] Exact matched username '{target_user}' on screen. Tapping directly." - ) - device.click(n["x"], n["y"]) - nav_success = True - break + + # 🛡️ Hardened Targeted UX: Use strict equality or boundary checks if possible, or exact substring. + if target_user.lower() in text_lower.split() or target_user.lower() == text_lower: + if ( + "profile_name" in res_id + or "title" in res_id + or "username" in res_id + or "avatar" in res_id + or not res_id + ): + if n.get("x") and n.get("y"): + logger.info( + f"⚡ [Targeted UX] Exact matched username '{target_user}' on screen. Tapping directly." + ) + device.click(n["x"], n["y"]) + nav_success = True + break if not nav_success: logger.info( @@ -1173,8 +1189,15 @@ def _run_zero_latency_feed_loop( # Identify the actual profile we landed on (e.g., from top action bar) if not actual_username and t and len(t) > 2: - if "action_bar_title" in res_id or "profile_name" in res_id or "username" in res_id: - actual_username = t.split("•")[0].strip() + # 🛡️ Hardened Context Correction: Expand matching IDs for the profile action bar + if ( + "action_bar" in res_id + or "profile_name" in res_id + or "username" in res_id + or "title" in res_id + ): + if n.get("y", 999) < 300: # Must be at the top of the screen + actual_username = t.split("•")[0].strip() # Ignore small numbers, but keep bio/followers if t and t not in texts and len(t) > 1: diff --git a/GramAddict/core/goap.py b/GramAddict/core/goap.py index 1a14683..e1f9159 100644 --- a/GramAddict/core/goap.py +++ b/GramAddict/core/goap.py @@ -716,7 +716,13 @@ class GoalPlanner: if nav_action: return nav_action - return "press back" + # Final fallback: back-track, UNLESS back-tracking is a known trap on this screen! + if not self.knowledge.is_trap(screen_type, "press back"): + return "press back" + + # We are trapped! Can't go forward, can't go back! + logger.error(f"💀 [GOAP] Completely trapped on {screen_type.name}. Forcing Instagram restart.") + return "force start instagram" def _is_goal_achieved(self, goal: str, screen_type: ScreenType, context: dict) -> bool: """Check if the goal is already satisfied.""" @@ -965,6 +971,12 @@ class GoalExecutor: screen = self.perceive() screen_type = screen["screen_type"] + if last_screen_type and screen_type != last_screen_type: + logger.debug( + f"📍 [GOAP State] Screen transitioned from {last_screen_type.name} to {screen_type.name}. Clearing explored actions." + ) + explored_nav_actions.clear() + # ── Loop Prevention: Mask Failed Actions ── MAX_RETRIES = 2 original_available = screen.get("available_actions", []).copy() @@ -1129,6 +1141,8 @@ class GoalExecutor: "home": ScreenType.HOME_FEED, "profile": ScreenType.OWN_PROFILE, "reels": ScreenType.REELS_FEED, + "following": ScreenType.FOLLOW_LIST, + "followers": ScreenType.FOLLOW_LIST, } expected_screen = None for keyword, screen in goal_screen_map.items(): @@ -1151,8 +1165,19 @@ class GoalExecutor: logger.info(f"✅ [GOAP Step] Navigation '{action}' successful -> {post_screen_type.name}.") self.planner.knowledge.learn_screen_mapping(action, post_screen_type) else: - logger.warning(f"❌ [GOAP Step] No UI change detected after '{action}'.") - action_success = False + # Check if we're already on the target screen (no-op is OK) + goal_lower = (goal or "").lower() + already_there = self.planner._is_goal_achieved( + goal_lower, post_screen_type, post_screen.get("context", {}) + ) + if already_there: + logger.info( + f"✅ [GOAP Step] No UI change — but goal '{goal}' is already achieved on {post_screen_type.name}. Not punishing." + ) + action_success = True + else: + logger.warning(f"❌ [GOAP Step] No UI change detected after '{action}'.") + action_success = False else: # For interactions (like, follow) or unknown goals, use XML delta + semantic verify if ui_changed: @@ -1197,11 +1222,19 @@ class GoalExecutor: engine.reject_click(action) return False else: - # action_success is None + # action_success is None (INCONCLUSIVE) + # We decay the memory so it unlearns if it repeatedly fails to produce a definitive success. + engine.decay_click(action) return False def _execute_recalled_path(self, steps: List[Dict], goal: str) -> bool: """Execute a memorized path.""" + # Pre-check: Is the goal already met? Don't execute stale paths. + screen = self.perceive() + if self.planner.plan_next_step(goal, screen) is None: + logger.info(f"🎯 [GOAP Recall] Goal '{goal}' already achieved. Skipping recalled path.") + return True + for i, step in enumerate(steps): action = step.get("action", "") logger.info(f"🧠 [GOAP Recall Step {i + 1}/{len(steps)}] '{action}'") diff --git a/GramAddict/core/telepathic_engine.py b/GramAddict/core/telepathic_engine.py index f82f6dc..1c49602 100644 --- a/GramAddict/core/telepathic_engine.py +++ b/GramAddict/core/telepathic_engine.py @@ -23,6 +23,27 @@ NAV_BAR_ZONE = 0.90 # Bottom 10% = Android nav bar (consistent with tab enforce 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 = [ + "tab", + "navigation", + "reels tab", + "profile tab", + "home tab", + "explore tab", + "message tab", + "direct message", + "inbox", + "dm", + "notification", + "heart icon", + "following", + "follower", + "followers", +] + # Cache files MEMORY_FILE = "telepathic_memory.json" BLACKLIST_FILE = "telepathic_blacklist.json" @@ -389,18 +410,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 = [ - "tab", - "navigation", - "explore tab", - "reels tab", - "profile tab", - "home tab", - "message tab", - "following", - "follower", - "followers", - ] + nav_keywords = NAV_INTENT_KEYWORDS modal_keywords = ["dismiss", "ok", "cancel", "accept", "allow", "deny", "action", "obstacle", "popup"] low_intent = intent_description.lower() @@ -428,6 +438,13 @@ class TelepathicEngine: # Silently filter non-bottom elements for navigation 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). + # We reject any story ring hallucinated in the feed (e.g. post headers). + is_story_intent = "story ring" in low_intent or "story avatar" in low_intent + if is_story_intent and node.get("y", 0) > screen_height * 0.30: + return False + # 3.5. Reject Action Bars for Content Intents # If we are looking for user content (posts, grid items, media), never click an action bar or tab bar. is_content_intent = any(k in low_intent for k in ["post", "grid", "media", "photo", "video", "reel", "item"]) @@ -449,6 +466,18 @@ class TelepathicEngine: if bot_username and bot_username in semantic_lower: # Rejecting bot's own username to prevent clicking itself return False + + # STRICT BUTTON GUARD + # If the intent explicitly asks for a button (e.g. "like button"), + # we reject nodes that are explicitly user profile links or text descriptions + # that contain the word 'profile' or 'go to', UNLESS the intent also asks to go to a profile. + is_button_intent = "button" in low_intent + is_profile_intent = "profile" in low_intent or "user" in low_intent + + if is_button_intent and not is_profile_intent: + semantic_lower = node.get("semantic_string", "").lower() + if "profile" in semantic_lower or "go to" in semantic_lower: + return False # 5. Language-Agnostic Modal/Menu Guard # Prevent clicks on items inside dialogs, bottom sheets, or context menus # UNLESS the intent explicitly targets a menu or modal interaction. @@ -656,10 +685,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 ["tab", "navigation", "reels tab", "profile tab", "home tab", "explore tab", "message tab"] - ) + is_nav_intent = any(k in intent_lower for k in NAV_INTENT_KEYWORDS) if is_nav_intent: threshold = 1.0 else: @@ -913,10 +939,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 ["tab", "navigation", "reels tab", "profile tab", "home tab", "explore tab", "message tab"] - ) + is_nav_intent = any(k in intent_lower for k in NAV_INTENT_KEYWORDS) if is_nav_intent and device is not None: from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType @@ -1439,8 +1462,13 @@ class TelepathicEngine: grid_nodes = [] for n in viable_nodes: desc = n.get("content_desc", "").lower() + res_id = n.get("resource_id", "").lower() has_text = bool(n.get("text", "")) + # 🛡️ Defensive Guard: Block profile pictures, header items, and anything too high up. + if "profile" in res_id or n.get("y", 0) < 400: + continue + if not has_text and ("photo" in desc or "video" in desc or "image" in desc or "post" in desc or desc == ""): grid_nodes.append(n) @@ -1678,9 +1706,48 @@ class TelepathicEngine: else: self._save_json(MEMORY_FILE, self._memory) + # Purge Qdrant Memory as well + if hasattr(self, "ui_memory") and self.ui_memory and self.ui_memory.is_connected: + self.ui_memory.decay_confidence(intent=actual_intent, amount=0.60) # Massive penalty for outright failure + # CLEAR context after reduction TelepathicEngine._last_click_context = None + def decay_click(self, intent: str = None): + """ + Called when a click is INCONCLUSIVE (e.g. didn't navigate anywhere, but didn't explicitly fail). + We don't globally blacklist it, but we decay its confidence so it will eventually + be unlearned if it repeatedly fails to produce a definitive success. + """ + ctx = TelepathicEngine._last_click_context + if not ctx: + return + + actual_intent = intent or ctx["intent"] + sem = ctx["semantic_string"] + + if actual_intent in self._memory: + entry = self._memory[actual_intent] + if isinstance(entry, list): + self._memory[actual_intent] = {k: 1.0 for k in entry} + + entry = self._memory[actual_intent] + if sem in entry: + entry[sem] -= 0.15 # Light penalty for inconclusive + logger.debug( + f"📉 [Unlearning] Inconclusive action. Decayed JSON confidence for '{actual_intent}' to {entry[sem]:.2f}" + ) + if entry[sem] < 0.50: + del entry[sem] + logger.warning(f"🗑️ [Memory Purge] Repeatedly inconclusive. Deleted '{actual_intent}' → '{sem}'") + self._save_json(MEMORY_FILE, self._memory) + + # Decay Qdrant + if hasattr(self, "ui_memory") and self.ui_memory and self.ui_memory.is_connected: + self.ui_memory.decay_confidence(intent=actual_intent, amount=0.30) # Light penalty + + TelepathicEngine._last_click_context = None + def verify_success(self, intent: str, post_click_xml: str) -> bool: """ Hardened verification. Does NOT rely on raw XML changes. @@ -1713,7 +1780,7 @@ class TelepathicEngine: # Success markers for common actions if "like" in low_intent: # Check for "Liked" or "gefällt mir nicht mehr" in content-desc or text - marker_found = re.search(r"\b(liked|gefällt mir nicht mehr|gefällt mir am)\b", low_xml) + marker_found = re.search(r"\b(liked|unlike|gefällt mir nicht mehr|gefällt mir am)\b", low_xml) if marker_found: logger.debug("✅ [Semantic Verification] Success confirmed: 'Liked' state detected.") return True @@ -1722,9 +1789,28 @@ class TelepathicEngine: return False if "follow" in low_intent: - # Check if button changed to "Following" or "Requested" - marker_found = re.search(r"\b(following|requested|folgst du|angefragt)\b", low_xml) + # ── Anti-Poisoning Guard ── + # Tapping a follow button should NOT transition the app from the feed to a profile screen. + # If it did, we hallucinated and clicked a profile name instead of a follow button. + if "row_feed_photo_profile_name" in low_xml or "profile_header" in low_xml: + # If we were previously NOT on a profile, but now we are, it's a hallucination. + # Since we don't have perfect state here, we rely on the fact that tapping "Follow" + # on a profile keeps you on the same profile layout. If the layout radically changes, + # it's safer to mark as inconclusive than falsely succeed. + # For now, we strictly check for the following marker. + pass + + # Check if button changed to "Following" or "Requested" or "Abonniert" (German) + marker_found = re.search(r"\b(following|requested|folgst du|angefragt|abonniert)\b", low_xml) if marker_found: + # Extra Guard: Ensure we didn't just open someone's profile from the feed by mistake + # which also has "Following" on it. We check if the screen is still the same rough layout. + if _ctx and "row_feed" in _ctx["semantic_string"].lower() and "profile_header" in low_xml: + logger.warning( + "❌ [Semantic Verification] FAILED: Tapping 'Follow' navigated to a profile page! (Hallucination)" + ) + return False + logger.debug("✅ [Semantic Verification] Success confirmed: 'Following/Requested' state detected.") return True else: @@ -1994,20 +2080,7 @@ class TelepathicEngine: ) return None - is_nav_intent = any( - k in intent.lower() - for k in [ - "tab", - "navigation", - "reels tab", - "profile tab", - "home tab", - "message tab", - "following", - "follower", - "followers", - ] - ) + is_nav_intent = any(k in intent.lower() for k in NAV_INTENT_KEYWORDS) # NAVIGATION TAB ENFORCEMENT: # Real navigation tabs (Home, Search, Reels, Store, Profile) are ALWAYS in the bottom zone. diff --git a/scripts/wipe_qdrant.py b/scripts/wipe_qdrant.py new file mode 100644 index 0000000..387e24b --- /dev/null +++ b/scripts/wipe_qdrant.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import os +import sys + +# Add parent dir to path so we can import GramAddict +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from GramAddict.core.qdrant_memory import UIMemoryDB + + +def wipe_qdrant(): + print("🧹 Initializing Qdrant connection...") + memory = UIMemoryDB() + + if not memory.is_connected: + print("❌ Qdrant is not connected. Make sure the container is running.") + sys.exit(1) + + print(f"⚠️ Wiping collection: {memory.COLLECTION_NAME}") + + try: + memory.client.delete_collection(collection_name=memory.COLLECTION_NAME) + print("✅ Collection deleted.") + + # Reinitialize to recreate the schema + memory._init_collection() + print("✅ Collection recreated with clean schema.") + + print("🎉 Qdrant Memory is now completely clean and ready for fresh learning!") + except Exception as e: + print(f"❌ Failed to wipe Qdrant memory: {e}") + sys.exit(1) + + +if __name__ == "__main__": + wipe_qdrant() diff --git a/tests/unit/test_goap_false_unlearn.py b/tests/unit/test_goap_false_unlearn.py new file mode 100644 index 0000000..c4a9166 --- /dev/null +++ b/tests/unit/test_goap_false_unlearn.py @@ -0,0 +1,132 @@ +""" +🔴 RED TDD: GOAP False Unlearning Fix + +Reproduces Bug 3: The bot taps 'home tab' while ALREADY on home_feed, +detects 'no UI change', and destructively unlearns the correct mapping. + +These tests MUST FAIL before the fix and PASS after. +""" + +from unittest.mock import MagicMock, patch + +from GramAddict.core.goap import GoalExecutor, ScreenType + + +def _make_goap(screen_type=ScreenType.HOME_FEED, available_actions=None): + """Create a GoalExecutor with a mocked device that returns a fixed screen state.""" + device = MagicMock() + device.app_id = "com.instagram.android" + device._get_current_app.return_value = "com.instagram.android" + + goap = GoalExecutor(device, bot_username="testbot") + goap._sae = MagicMock() + goap._sae.ensure_clear_screen.return_value = True + + # Mock perceive to return a fixed screen state + if available_actions is None: + available_actions = ["tap profile tab", "tap explore tab", "scroll down", "press back", "tap home tab"] + + goap.perceive = MagicMock( + return_value={ + "screen_type": screen_type, + "available_actions": available_actions, + "context": {}, + "selected_tab": "feed_tab", + } + ) + + return goap + + +class TestGoapRecalledPathPreCheck: + """Bug 3 Part A: Recalled path should skip execution when goal is already achieved.""" + + def test_recalled_path_skips_when_goal_already_achieved(self): + """ + If the bot is already on HOME_FEED and the goal is 'open home feed', + _execute_recalled_path must return True WITHOUT executing any steps. + """ + goap = _make_goap(screen_type=ScreenType.HOME_FEED) + + steps = [{"action": "tap home tab"}] + + # Mock _execute_action to track if it was called + goap._execute_action = MagicMock(return_value=True) + + result = goap._execute_recalled_path(steps, "open home feed") + + assert result is True, "Recalled path should succeed immediately when goal is already achieved." + ( + goap._execute_action.assert_not_called(), + ("_execute_action should NOT have been called — goal was already achieved."), + ) + + +class TestGoapNoUnlearnWhenAlreadyOnTarget: + """Bug 3 Part B: Navigation to current screen should not trigger reject_click.""" + + def test_no_unlearn_when_tapping_home_on_home_feed(self): + """ + If the bot is on HOME_FEED and taps 'home tab', the XML won't change. + But since the goal 'open home feed' is already achieved, the action + should be treated as SUCCESS, not FAILURE. + """ + goap = _make_goap(screen_type=ScreenType.HOME_FEED) + + # Make dump_hierarchy return the same XML (no change) + static_xml = "" + goap.device.dump_hierarchy.return_value = static_xml + + # Mock the TelepathicEngine + mock_engine = MagicMock() + mock_engine.find_best_node.return_value = { + "x": 540, + "y": 2300, + "score": 0.95, + "semantic": "description: 'Home', id context: 'feed tab'", + "source": "qdrant_nav", + "original_attribs": {}, + } + + with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_engine): + result = goap._execute_action("tap home tab", goal="open home feed") + + # The action should succeed (we're already where we need to be) + assert result is True, ( + "GOAP incorrectly treated 'tap home tab' on home_feed as a failure. " + "This causes destructive unlearning of valid navigation knowledge." + ) + + # Critically: reject_click should NEVER have been called + ( + mock_engine.reject_click.assert_not_called(), + ("reject_click was called — this destroys the correct 'tap home tab' → 'feed tab' mapping!"), + ) + + def test_genuine_navigation_failure_still_triggers_reject(self): + """ + If the bot is on HOME_FEED and taps 'tap explore tab' but nothing changes, + that IS a genuine failure and reject_click SHOULD be called. + """ + goap = _make_goap(screen_type=ScreenType.HOME_FEED) + + static_xml = "" + goap.device.dump_hierarchy.return_value = static_xml + + mock_engine = MagicMock() + mock_engine.find_best_node.return_value = { + "x": 540, + "y": 2300, + "score": 0.95, + "semantic": "description: 'Explore', id context: 'explore_tab'", + "source": "qdrant_nav", + "original_attribs": {}, + } + + with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_engine): + result = goap._execute_action("tap explore tab", goal="open explore feed") + + # This should fail — we expected to go to explore but UI didn't change + assert result is False, ( + "GOAP should reject a navigation that produced no UI change " "when the goal is NOT already achieved." + ) diff --git a/tests/unit/test_nav_intent_classification.py b/tests/unit/test_nav_intent_classification.py new file mode 100644 index 0000000..e71b612 --- /dev/null +++ b/tests/unit/test_nav_intent_classification.py @@ -0,0 +1,101 @@ +""" +🔴 RED TDD: DM Structural Guard Self-Sabotage Fix + +Reproduces Bug 2: The intent 'tap direct message icon inbox' is NOT classified +as a nav intent, causing the Structural Guard to reject the correct VLM match +in the nav bar zone. + +These tests MUST FAIL before the fix and PASS after. +""" + +from GramAddict.core.telepathic_engine import TelepathicEngine + + +class TestNavIntentClassification: + """Verifies that all navigation-related intents are correctly classified.""" + + def test_dm_intent_is_classified_as_nav_intent(self): + """ + The intent 'tap direct message icon inbox' MUST be treated as a nav intent + so the structural guard allows clicking elements in the nav bar zone. + """ + engine = TelepathicEngine() + screen_height = 2400 + + # DM icon is in the nav bar zone (top right, but the 'direct tab' + # element is at the bottom nav bar on some Instagram layouts) + dm_node = { + "semantic_string": "description: 'Message', id context: 'direct tab'", + "y": int(screen_height * 0.95), # Bottom nav bar zone + "area": 3000, + "class_name": "android.widget.ImageView", + "resource_id": "direct_tab", + } + + intent = "tap direct message icon inbox" + + # The node should be viable — it's a nav intent targeting the nav bar + is_valid = engine._structural_sanity_check(dm_node, intent, screen_height) + + assert is_valid is True, ( + "Structural Guard rejected 'direct tab' for DM intent. " + "This is the exact bug: 'tap direct message icon inbox' is not classified as nav intent." + ) + + def test_inbox_intent_is_classified_as_nav_intent(self): + """Variant: 'tap inbox' should also be treated as navigation.""" + engine = TelepathicEngine() + screen_height = 2400 + + inbox_node = { + "semantic_string": "description: 'Inbox', id context: 'direct_inbox'", + "y": int(screen_height * 0.95), + "area": 2500, + "class_name": "android.widget.ImageView", + "resource_id": "direct_inbox", + } + + intent = "tap inbox" + + is_valid = engine._structural_sanity_check(inbox_node, intent, screen_height) + assert is_valid is True, "Structural Guard rejected inbox node for 'tap inbox' intent." + + def test_notification_intent_is_classified_as_nav_intent(self): + """'tap heart icon notifications' should also be treated as navigation.""" + engine = TelepathicEngine() + screen_height = 2400 + + notification_node = { + "semantic_string": "description: 'Activity', id context: 'notification_tab'", + "y": int(screen_height * 0.95), + "area": 2500, + "class_name": "android.widget.ImageView", + "resource_id": "notification_tab", + } + + intent = "tap heart icon notifications" + + is_valid = engine._structural_sanity_check(notification_node, intent, screen_height) + assert is_valid is True, "Structural Guard rejected notification node for heart icon intent." + + def test_regular_post_intent_still_blocked_in_nav_zone(self): + """ + Non-nav intents (like 'tap like button') targeting elements in the nav bar + zone must STILL be rejected. We're not weakening the guard. + """ + engine = TelepathicEngine() + screen_height = 2400 + + misplaced_like_node = { + "semantic_string": "description: 'Like', id context: 'some_like_button'", + "y": int(screen_height * 0.95), + "area": 2000, + "class_name": "android.widget.ImageView", + } + + intent = "tap like button" + + is_valid = engine._structural_sanity_check(misplaced_like_node, intent, screen_height) + assert is_valid is False, ( + "Structural Guard allowed a like button in the nav bar zone. " "Non-nav intents should still be blocked." + )