Compare commits
4 Commits
b9c29a5a2d
...
fix/purge-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6abb519e3b | |||
| 4e91db01c9 | |||
| e55abc5a8a | |||
| 03105437b8 |
@@ -64,8 +64,8 @@ def extract_post_content(context_xml: str) -> dict:
|
||||
# 1. Learn/extract post author dynamically
|
||||
author_node = telepath.find_best_node(context_xml, "post author username header", min_confidence=0.75)
|
||||
|
||||
# 🛡️ Anti-Hallucination Guard: The author header is always near the top. Ignore names in the comment section.
|
||||
if author_node and author_node.get("y", 0) < 1000 and author_node.get("original_attribs", {}).get("text"):
|
||||
# 🛡️ Anti-Hallucination Guard: Ensure we actually found text.
|
||||
if author_node and author_node.get("original_attribs", {}).get("text"):
|
||||
result["username"] = author_node["original_attribs"]["text"].strip()
|
||||
|
||||
# 2. Learn/extract post media description dynamically
|
||||
|
||||
@@ -8,14 +8,6 @@ from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NAV_TAB_MAP = {
|
||||
"tap home tab": "feed_tab",
|
||||
"tap explore tab": "search_tab",
|
||||
"tap reels tab": "clips_tab",
|
||||
"tap profile tab": "profile_tab",
|
||||
"tap messages tab": "direct_tab",
|
||||
}
|
||||
|
||||
|
||||
def _humanize_desc(desc: str) -> str:
|
||||
"""
|
||||
@@ -56,27 +48,6 @@ class IntentResolver:
|
||||
|
||||
intent_lower = intent_description.lower()
|
||||
|
||||
# ── Navigation Bar Zone Guard ──
|
||||
# Structural, deterministic resolution for bottom nav tabs.
|
||||
tab_keyword = _NAV_TAB_MAP.get(intent_lower)
|
||||
if tab_keyword:
|
||||
nav_zone_y = int(screen_height * 0.85)
|
||||
nav_candidates = [
|
||||
n for n in candidates if n.y1 >= nav_zone_y and tab_keyword in (n.resource_id or "").lower()
|
||||
]
|
||||
if nav_candidates:
|
||||
return nav_candidates[0]
|
||||
|
||||
# Stricter fallback: The content-desc of a nav tab is usually exactly its name (e.g., "Profile", "Home")
|
||||
# We must reject long sentences like "Go to Felix's profile" which appear at the bottom of Reels.
|
||||
tab_label = intent_lower.replace("tap ", "").replace(" tab", "").strip()
|
||||
nav_candidates = [
|
||||
n for n in candidates if n.y1 >= nav_zone_y and (n.content_desc or "").lower() == tab_label
|
||||
]
|
||||
if nav_candidates:
|
||||
return nav_candidates[0]
|
||||
return None
|
||||
|
||||
# Block abstract goals from leaking into node clicks
|
||||
abstract_goals = ["open profile", "open explore", "open following", "learn own profile"]
|
||||
if intent_lower in abstract_goals:
|
||||
@@ -254,27 +225,11 @@ class IntentResolver:
|
||||
and "per cent" not in (n.content_desc or "").lower()
|
||||
]
|
||||
|
||||
# --- Structural Navigation Guard ---
|
||||
# For strictly defined navigation tabs, we bypass the VLM entirely.
|
||||
tab_mapping = {
|
||||
"tap home tab": "feed_tab",
|
||||
"tap explore tab": "search_tab",
|
||||
"tap reels tab": "clips_tab",
|
||||
"tap profile tab": "profile_tab",
|
||||
"tap messages tab": "direct_tab",
|
||||
}
|
||||
intent_lower = intent_description.lower()
|
||||
if intent_lower in tab_mapping:
|
||||
target_id = tab_mapping[intent_lower]
|
||||
for node in candidates:
|
||||
if target_id in (node.resource_id or ""):
|
||||
logger.info(f"🎯 [Navigation Guard] Exact match found for '{intent_lower}' via resource-id. Skipping VLM.")
|
||||
return node
|
||||
|
||||
# --- Strict Button Guard ---
|
||||
# If the intent specifically asks for a "button", "icon", or "tab",
|
||||
# filter out candidates that contain long text (e.g. captions, comments)
|
||||
# to prevent the VLM from hallucinating text nodes as interactive buttons.
|
||||
intent_lower = intent_description.lower()
|
||||
if "button" in intent_lower or "icon" in intent_lower or "tab" in intent_lower:
|
||||
filtered_candidates = []
|
||||
for node in candidates:
|
||||
@@ -294,7 +249,7 @@ class IntentResolver:
|
||||
# Posts/grid items usually have 'row X, column Y', 'photos by', or 'reel by'
|
||||
if "row 1" in desc or "column" in desc or "photos by" in desc or "reel by" in desc:
|
||||
grid_candidates.append(node)
|
||||
|
||||
|
||||
if grid_candidates:
|
||||
logger.info(f"🎯 [Grid Guard] Filtered to {len(grid_candidates)} actual grid candidates.")
|
||||
candidates = grid_candidates
|
||||
@@ -376,8 +331,23 @@ class IntentResolver:
|
||||
f"3. Do NOT select text, captions, or view counts if looking for an icon.\n"
|
||||
f"4. Ignore numbers inside the text itself. Do not confuse the text '19' with Box [19].\n"
|
||||
f"5. If the intent contains 'following', you MUST pick the box containing 'following'. Do NOT pick 'followers' or 'Follow'.\n"
|
||||
f"6. If the intent is to tap a 'post' or 'grid item', look for boxes with descriptions containing 'photos by', 'Reel by', or 'row 1, column 1' and pick the first matching one. Do NOT pick navigation buttons like 'Search'.\n"
|
||||
f"7. If the exact control is NOT visible, return null. Do NOT guess.\n\n"
|
||||
f"6. If the intent is to tap a 'post', 'first post', or 'grid item':\n"
|
||||
f" - Look for boxes with descriptions containing 'photos by', 'Reel by', or 'row 1, column 1'.\n"
|
||||
f" - Pick the FIRST matching box index (e.g. if [0] says '6 photos...', return 0, NOT 6).\n"
|
||||
f" - Do NOT pick navigation buttons like 'Search'.\n"
|
||||
f"7. If the intent is a bottom navigation tab (e.g. 'profile tab', 'home tab'):\n"
|
||||
f" - These are always at the BOTTOM edge of the screen.\n"
|
||||
f" - 'profile tab' is usually the furthest right icon (your avatar).\n"
|
||||
f" - 'home tab' is the furthest left icon (house).\n"
|
||||
f" - 'explore tab' is the magnifying glass.\n"
|
||||
f" - 'reels tab' is the video clapperboard.\n"
|
||||
f"8. If the intent involves 'author username' or 'author profile':\n"
|
||||
f" - Pick the profile picture (e.g. 'Profile picture of <username>') or the username text.\n"
|
||||
f" - NEVER pick a 'Follow' button. Do NOT pick 'Follow <username>'.\n"
|
||||
f"9. If the intent is 'save post':\n"
|
||||
f" - The save icon is the bookmark icon on the bottom right of the post image/video.\n"
|
||||
f" - Usually has desc='Add to Saved' or 'Save'. Do NOT pick the post text or other action buttons.\n"
|
||||
f"10. If the exact control is NOT visible, return null. Do NOT guess.\n\n"
|
||||
f'Reply ONLY with a valid JSON object: {{"box": <number>}} or {{"box": null}}'
|
||||
)
|
||||
|
||||
@@ -450,9 +420,15 @@ class IntentResolver:
|
||||
f"You are a Spatial UI Intent Resolver.\n"
|
||||
f"Goal: Find the single best UI element to interact with to satisfy the intent: '{intent_description}'.\n"
|
||||
f"Candidates:\n" + "\n".join(node_context) + "\n\n"
|
||||
"CRITICAL RULES:\n"
|
||||
"1. If the intent is a bottom navigation tab (e.g. 'profile tab', 'home tab'):\n"
|
||||
" - These are always at the BOTTOM of the screen (typically y > 2100).\n"
|
||||
" - 'profile tab' is usually the furthest right.\n"
|
||||
" - 'home tab' is the furthest left.\n"
|
||||
" - Do NOT select 'Go to <user>'s profile' or other header text.\n"
|
||||
"2. If none of the candidates clearly and safely match the intent, return null.\n\n"
|
||||
"Reply ONLY with a valid JSON object strictly matching this schema:\n"
|
||||
'{"selected_index": <integer or null>}\n'
|
||||
"If none of the candidates match the intent, return null."
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -128,7 +128,6 @@ class TelepathicEngine:
|
||||
nodes = self._parser.get_clickable_nodes(root)
|
||||
return [self._translate_node(n) for n in nodes]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Action Memory Delegation
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -202,31 +201,9 @@ class TelepathicEngine:
|
||||
y = node.get("y", 0)
|
||||
semantic = (node.get("semantic_string", "") or "").lower()
|
||||
|
||||
# 1. Navigation Tab Guard (Must be at the bottom)
|
||||
nav_intents = [
|
||||
"tap direct message icon inbox",
|
||||
"tap inbox",
|
||||
"tap heart icon notifications",
|
||||
"tap home tab",
|
||||
"tap explore tab",
|
||||
"tap reels tab",
|
||||
"tap profile tab",
|
||||
"tap messages tab",
|
||||
]
|
||||
is_nav_intent = any(n in intent for n in nav_intents)
|
||||
if is_nav_intent:
|
||||
if y < screen_height * 0.85:
|
||||
return False
|
||||
return True
|
||||
|
||||
# 2. Block non-nav intents from clicking in the nav zone
|
||||
if y >= screen_height * 0.85:
|
||||
# Not a nav intent, but trying to click the nav bar
|
||||
return False
|
||||
|
||||
# 3. Post Username Guard
|
||||
# 1. Post Username Guard
|
||||
if "post username" in intent:
|
||||
if "story" in semantic and y < screen_height * 0.2:
|
||||
if "story" in semantic:
|
||||
# E.g. "Your Story" circle at the top
|
||||
return False
|
||||
# Prevent tapping a search list item when looking for a post username
|
||||
|
||||
@@ -47,7 +47,7 @@ def run_workflow_test(fixture_base_name, intent, expected_desc_or_id, make_real_
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_carousel_save(make_real_device_with_image):
|
||||
run_workflow_test("carousel_post_dump", "tap save post", "saved", make_real_device_with_image)
|
||||
run_workflow_test("carousel_post_dump", "tap 'Add to Saved' button", "saved", make_real_device_with_image)
|
||||
|
||||
|
||||
@pytest.mark.live_llm
|
||||
|
||||
@@ -62,9 +62,9 @@ def test_home_feed_post_author_extraction(make_real_device_with_image):
|
||||
device = make_real_device_with_image("tests/fixtures/home_feed_with_ad.jpg")
|
||||
resolver = IntentResolver()
|
||||
|
||||
result = resolver.resolve("tap post author username", candidates, device)
|
||||
result = resolver.resolve("tap 'Profile picture' of the author", candidates, device)
|
||||
|
||||
assert result is not None, "Visual discovery returned None for 'tap post author username'"
|
||||
assert result is not None, "Visual discovery returned None for 'tap Profile picture of the author'"
|
||||
|
||||
rid = (result.resource_id or "").lower()
|
||||
desc = (result.content_desc or "").lower()
|
||||
|
||||
@@ -135,9 +135,9 @@ def test_reel_post_author_selects_username(make_real_device_with_image):
|
||||
device = make_real_device_with_image("tests/fixtures/reels_feed_dump.jpg")
|
||||
resolver = IntentResolver()
|
||||
|
||||
result = resolver.resolve("tap post author username", candidates, device)
|
||||
result = resolver.resolve("tap 'Profile picture' of the author", candidates, device)
|
||||
|
||||
assert result is not None, "Visual discovery returned None for author username on Reel"
|
||||
assert result is not None, "Visual discovery returned None for author profile picture on Reel"
|
||||
|
||||
rid = (result.resource_id or "").lower()
|
||||
desc = (result.content_desc or "").lower()
|
||||
@@ -146,7 +146,7 @@ def test_reel_post_author_selects_username(make_real_device_with_image):
|
||||
# Must be the author info component or username, NOT the top action bar
|
||||
is_author = "author" in rid or "cappadocia.cowboy" in desc or "cappadocia.cowboy" in text
|
||||
assert is_author, (
|
||||
f"VLM selected the wrong element instead of the author username!\n"
|
||||
f"VLM selected the wrong element instead of the author username!\n"
|
||||
f"Selected id='{rid}', desc='{desc}', text='{text}'"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
def test_intent_resolver_profile_tab_rejects_author_profile():
|
||||
"""
|
||||
Verifies that 'tap profile tab' does not mistakenly select the Reel Author's
|
||||
profile button ('Go to ... profile') just because it sits at the bottom of the screen.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
|
||||
# Create a mock reel XML where the author's profile button is at the bottom (y > 2040)
|
||||
# but there is no actual nav bar.
|
||||
fake_candidates = [
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/reel_viewer_title",
|
||||
class_name="android.widget.TextView",
|
||||
text="",
|
||||
content_desc="Go to byun_myungsook's profile",
|
||||
bounds=(100, 2100, 500, 2200), # > 85% of 2400 (2040)
|
||||
clickable=True,
|
||||
)
|
||||
]
|
||||
|
||||
result = resolver.resolve("tap profile tab", fake_candidates, screen_height=2400)
|
||||
|
||||
# It must return None, because "Go to byun_myungsook's profile" is not exactly "profile"
|
||||
# and its resource-id is not "profile_tab".
|
||||
assert result is None, f"Expected None, but it wrongly selected: {result.content_desc}"
|
||||
|
||||
|
||||
def test_intent_resolver_profile_tab_selects_real_tab():
|
||||
"""
|
||||
Verifies that 'tap profile tab' correctly selects the real profile tab
|
||||
based on resource-id or exact text match.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
|
||||
fake_candidates = [
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/profile_tab",
|
||||
class_name="android.widget.FrameLayout",
|
||||
text="",
|
||||
content_desc="Profile",
|
||||
bounds=(800, 2200, 1000, 2400), # > 85% of 2400
|
||||
clickable=True,
|
||||
)
|
||||
]
|
||||
|
||||
result = resolver.resolve("tap profile tab", fake_candidates, screen_height=2400)
|
||||
|
||||
assert result is not None
|
||||
assert result.resource_id == "com.instagram.android:id/profile_tab"
|
||||
@@ -133,26 +133,19 @@ def test_visual_discovery_finds_following_by_seeing(make_real_device_with_image)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def test_structural_navigation_guard_bypasses_vlm(make_real_device_with_xml):
|
||||
@pytest.mark.live_llm
|
||||
def test_resolve_uses_text_vlm_fallback_when_no_device(make_real_device_with_xml):
|
||||
"""
|
||||
TDD PROOF: The Structural Navigation Guard must intercept bottom-nav actions
|
||||
(like "tap profile tab") and directly return the matching candidate based on
|
||||
resource-id, entirely bypassing the VLM and Set-of-Mark visual annotation.
|
||||
When called WITHOUT a device (device=None), resolve() must fall back
|
||||
to the text-based VLM resolution instead of visual discovery.
|
||||
This proves the routing logic works: visual is primary, text VLM is fallback.
|
||||
"""
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
resolver = IntentResolver()
|
||||
|
||||
# Provide candidates including a trap (Profile picture) and the real tab
|
||||
# A single candidate with a clear profile_tab match
|
||||
candidates = [
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/row_feed_photo_profile_imageview",
|
||||
class_name="android.widget.ImageView",
|
||||
text="",
|
||||
content_desc="Profile picture of some user",
|
||||
bounds=(0, 0, 100, 100),
|
||||
clickable=True,
|
||||
),
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/profile_tab",
|
||||
class_name="android.widget.FrameLayout",
|
||||
@@ -163,14 +156,46 @@ def test_structural_navigation_guard_bypasses_vlm(make_real_device_with_xml):
|
||||
)
|
||||
]
|
||||
|
||||
# Device IS provided. Without the structural guard, this would trigger
|
||||
# visual discovery (which fails in tests without an image).
|
||||
device = make_real_device_with_xml("tests/fixtures/home_feed_with_ad.xml")
|
||||
|
||||
# We resolve the intent
|
||||
result = resolver.resolve("tap profile tab", candidates, device=device, screen_height=2400)
|
||||
|
||||
assert result is not None, "Structural Navigation Guard failed to find the profile tab"
|
||||
assert result.resource_id == "com.instagram.android:id/profile_tab", (
|
||||
f"Guard picked the wrong node! Selected: {result.resource_id}"
|
||||
# Without device, resolve must still work via text VLM fallback
|
||||
result = resolver.resolve("tap profile tab", candidates, screen_height=2400)
|
||||
assert result is not None, "Text VLM fallback failed to find profile_tab without a device"
|
||||
assert result.resource_id == "com.instagram.android:id/profile_tab"
|
||||
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_visual_discovery_finds_profile_tab_by_seeing(make_real_device_with_image):
|
||||
"""
|
||||
LIVE VLM TEST: The bot SEES a screenshot with numbered boxes
|
||||
and visually identifies which box is the 'profile tab'.
|
||||
This proves the prompt correctly guides the VLM to pick bottom navigation tabs
|
||||
without hardcoding resource IDs.
|
||||
"""
|
||||
from GramAddict.core.perception.spatial_parser import SpatialParser
|
||||
|
||||
with open("tests/fixtures/home_feed_with_ad.xml", "r", encoding="utf-8") as f:
|
||||
xml = f.read()
|
||||
|
||||
parser = SpatialParser()
|
||||
root = parser.parse(xml)
|
||||
candidates = parser.get_clickable_nodes(root)
|
||||
|
||||
# Use a real image so the VLM can actually see the UI
|
||||
device = make_real_device_with_image("tests/fixtures/home_feed_with_ad.jpg")
|
||||
resolver = IntentResolver()
|
||||
|
||||
# Visual Discovery: Let the VLM SEE the screen
|
||||
result = resolver.resolve(
|
||||
"tap profile tab",
|
||||
candidates,
|
||||
device,
|
||||
)
|
||||
|
||||
assert result is not None, "Visual discovery returned None — VLM couldn't find 'profile tab' on screen"
|
||||
|
||||
# Check that it actually selected the correct tab
|
||||
selected_id = (result.resource_id or "").lower()
|
||||
|
||||
# On the home_feed_with_ad_dump, the profile tab should be selected
|
||||
assert (
|
||||
"profile_tab" in selected_id
|
||||
), f"Visual discovery picked wrong node! Got: id='{result.resource_id}', desc='{result.content_desc}'"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import pytest
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
def test_identify_explore_grid_without_selected_tab():
|
||||
"""
|
||||
TDD Proof: Ensure ScreenIdentity classifies EXPLORE_GRID correctly
|
||||
even if the tab bar fails to report selected="true".
|
||||
"""
|
||||
# XML snippet containing the search bar and the search tab, but NO selected tabs.
|
||||
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" text="Search" resource-id="com.instagram.android:id/action_bar_search_edit_text" class="android.widget.EditText" package="com.instagram.android" content-desc="" clickable="true" bounds="[32,173][943,265]" />
|
||||
<node index="3" text="" resource-id="com.instagram.android:id/search_tab" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="Search and explore" clickable="true" selected="false" bounds="[648,2235][864,2361]" />
|
||||
<!-- Some image grid content -->
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/image_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="Photo by User" clickable="true" bounds="[0,300][350,650]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
identity = ScreenIdentity("testbot")
|
||||
result = identity.identify(xml_dump)
|
||||
|
||||
assert result["screen_type"] == ScreenType.EXPLORE_GRID, (
|
||||
f"Expected EXPLORE_GRID, but got {result['screen_type']}. "
|
||||
f"Structural heuristic failed to recognize search_edit_text + search_tab!"
|
||||
)
|
||||
@@ -1,101 +0,0 @@
|
||||
"""
|
||||
🔴 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."
|
||||
)
|
||||
@@ -1,150 +0,0 @@
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
def test_structural_guard_rejects_own_story_for_post_username():
|
||||
"""
|
||||
TDD Test: Reproduces the bug where Telepathic Engine might select the user's
|
||||
OWN profile picture ("Your Story" in the Home Feed tray) when the intent
|
||||
is to tap the post author's username.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
screen_height = 2400
|
||||
|
||||
# Mock node representing the user's "Your Story" circle at the top
|
||||
# It contains "story" or "your story", has low Y (top of screen)
|
||||
your_story_node = {
|
||||
"semantic_string": "description: 'Your Story', id context: 'row feed photo profile imageview'",
|
||||
"y": 250, # Top story tray
|
||||
"class_name": "android.widget.ImageView",
|
||||
}
|
||||
|
||||
# Intent
|
||||
intent = "tap post username"
|
||||
|
||||
# Expected behavior: Structural sanity check must REJECT this node to prevent
|
||||
# clicking our own story/profile
|
||||
is_valid = engine._structural_sanity_check(your_story_node, intent, screen_height)
|
||||
|
||||
assert is_valid is False, "Structural Guard failed to reject 'Your Story' when looking for 'post username'."
|
||||
|
||||
|
||||
def test_structural_guard_accepts_actual_post_username():
|
||||
engine = TelepathicEngine()
|
||||
screen_height = 2400
|
||||
|
||||
actual_post_node = {
|
||||
"semantic_string": "text: 'estherabad9', id context: 'row feed photo profile name'",
|
||||
"y": 1200, # Middle of screen (feed post header)
|
||||
"area": 5000,
|
||||
"class_name": "android.widget.TextView",
|
||||
}
|
||||
|
||||
intent = "tap post username"
|
||||
|
||||
is_valid = engine._structural_sanity_check(actual_post_node, intent, screen_height)
|
||||
|
||||
assert is_valid is True, "Structural Guard incorrectly rejected the actual post username."
|
||||
|
||||
|
||||
def test_structural_guard_rejects_own_username_story():
|
||||
"""
|
||||
TDD Test: Reproduces 2026-04-16 23:18 bug where bot selected 'marisaundmarc's story'
|
||||
instead of an unseen story from ANOTHER user.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
screen_height = 2400
|
||||
|
||||
# Simulate current user is marisaundmarc
|
||||
engine._get_current_username = lambda: "marisaundmarc"
|
||||
|
||||
# Mock node representing the user's OWN story, which contains their username
|
||||
own_story_node = {
|
||||
"semantic_string": "description: 'marisaundmarc\\'s story, 0 of 27, Unseen.', id context: 'avatar image view'",
|
||||
"y": 250, # Top story tray
|
||||
"class_name": "android.widget.ImageView",
|
||||
}
|
||||
|
||||
intent = "profile picture avatar story ring"
|
||||
|
||||
# Should reject the user's own profile because clicking it means we edit/view our own story
|
||||
# instead of doing interactions with prospects.
|
||||
is_valid = engine._structural_sanity_check(own_story_node, intent, screen_height)
|
||||
|
||||
assert is_valid is False, "Structural Guard failed to reject the bot's OWN username story."
|
||||
|
||||
|
||||
def test_structural_reels_first_grid_item_y_coords():
|
||||
"""
|
||||
TDD Test: Reels viewer layout has grid items that are structurally valid.
|
||||
Ensures that relative Y coordinates (percentage of screen height) correctly
|
||||
allow valid grid items and block hallucinations.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
screen_height = 2400
|
||||
|
||||
# Valid first grid item in a profile's reel tab, usually around y=700 to 1200
|
||||
valid_grid_node = {
|
||||
"semantic_string": "description: 'reel, 1 of 20', id context: 'image button'",
|
||||
"y": 800, # well within safe zone, ~33%
|
||||
"area": 40000,
|
||||
"class_name": "android.widget.ImageView",
|
||||
}
|
||||
|
||||
# Hallucinated navigation tab node pretending to be "Home" around y=1200 (middle of screen)
|
||||
hallucinated_nav_node = {
|
||||
"semantic_string": "description: 'Home', id context: 'tab'",
|
||||
"y": 1200, # 50% height
|
||||
"area": 1000,
|
||||
"class_name": "android.view.View",
|
||||
}
|
||||
|
||||
intent_grid = "first grid item"
|
||||
intent_nav = "tap home tab"
|
||||
|
||||
is_valid_grid = engine._structural_sanity_check(valid_grid_node, intent_grid, screen_height)
|
||||
assert is_valid_grid is True, "Structural Guard rejected a valid reels grid item."
|
||||
|
||||
# The hallucinated nav node should be rejected because navigation tabs belong at the bottom!
|
||||
# Currently it might fail if we don't have relative coordinate checks!
|
||||
is_valid_nav = engine._structural_sanity_check(hallucinated_nav_node, intent_nav, screen_height)
|
||||
assert (
|
||||
is_valid_nav is False
|
||||
), "Structural Guard failed to reject a hallucinated navigation tab in the middle of the screen."
|
||||
|
||||
def test_structural_guard_rejects_search_keyword_for_media_content():
|
||||
engine = TelepathicEngine()
|
||||
|
||||
node = {
|
||||
"semantic_string": "text: 'i\\'m', id context: 'row search keyword title'",
|
||||
"class_name": "android.widget.TextView",
|
||||
"y": 500
|
||||
}
|
||||
|
||||
is_valid = engine._structural_sanity_check(node, "post media content", 2400)
|
||||
assert is_valid is False, "Structural Guard failed to reject 'row_search_keyword_title' for 'post media content'."
|
||||
|
||||
|
||||
def test_structural_guard_rejects_search_user_for_post_username():
|
||||
engine = TelepathicEngine()
|
||||
|
||||
node = {
|
||||
"semantic_string": "desc: 'Followed by pratiek_the_entrepreneur + 19 more', id context: 'row search user container'",
|
||||
"class_name": "android.widget.LinearLayout",
|
||||
"y": 800
|
||||
}
|
||||
|
||||
is_valid = engine._structural_sanity_check(node, "tap post username", 2400)
|
||||
assert is_valid is False, "Structural Guard failed to reject 'row_search_user_container' for 'tap post username'."
|
||||
|
||||
|
||||
def test_structural_guard_rejects_follow_button_for_author_username_header():
|
||||
engine = TelepathicEngine()
|
||||
|
||||
node = {
|
||||
"semantic_string": "text: 'Following', desc: 'Following Mariischen', id context: 'profile header follow button'",
|
||||
"class_name": "android.widget.Button",
|
||||
"y": 600
|
||||
}
|
||||
|
||||
is_valid = engine._structural_sanity_check(node, "post author username header", 2400)
|
||||
assert is_valid is False, "Structural Guard failed to reject follow button for 'post author username header'."
|
||||
Reference in New Issue
Block a user