7 Commits

Author SHA1 Message Date
1e1bba6b16 fix(perception+brain): story view detection + autonomous prompt
Two root causes for 'scroll on story' bug:

1. ScreenIdentity had ZERO structural markers for story views.
   reel_viewer_media_layout, reel_viewer_header, reel_viewer_progress_bar
   and content-desc 'Like Story'/'Send story' now → STORY_VIEW.

2. Brain prompt was prescriptive ('you MUST scroll down'), overriding
   the LLM's intelligence. Rewritten to give context about screen types
   and let the AI reason autonomously about which action makes sense.

Philosophy: AI decides navigation, we provide correct perception data.
No hardcoded 'if story → press back' escape hatch.

4 new perception tests (all green), 0 regressions.
2026-04-27 23:55:09 +02:00
2b992cf2a8 test(RED): expose story view detection gap — ScreenIdentity returns UNKNOWN
Bug evidence from run 2026-04-27_23-46-57:
- Bot started on a story (reel_viewer_media_layout, 'Like Story')
- ScreenIdentity classified it as UNKNOWN
- GOAP chose 'scroll down' 4 times (stories don't scroll)
- Bot was trapped in infinite scroll loop

Captured real XML fixture: story_view_full.xml
1 test FAILS (screen_identity → UNKNOWN instead of STORY_VIEW)
2026-04-27 23:51:04 +02:00
c051c3a4c3 fix(dm-engine): 4 safety hardening patches with TDD proof
Kill-Switch: Refuse DM processing when dm_reply.enabled=false in config.
  Root cause: checked nonexistent 'disable_ai_messaging' flag instead of
  actual plugin config.

Context Guard: Skip threads with no extractable message text.
  Root cause: LLM was fed 'No previous context' → produced garbage like
  'the to the'.

Send Verification: Structurally verify Send button resource-id/desc.
  Root cause: VLM returned reactions_pill_container, edit fields, etc.
  and engine blindly clicked them, logging 'Successfully sent'.

Iteration Cap: MAX_REPLIES_PER_INBOX_VISIT=3 prevents spam.
  Root cause: no loop guard → 8 DMs sent in 2 minutes in production.

Refactored: removed dead 'if True' guard, de-indented block,
moved dm_memory.log_sent_dm into success branch only.

All 6 E2E tests pass. No regressions (54/55 passed, 1 pre-existing).
2026-04-27 23:43:02 +02:00
3006020106 test(RED): 4 failing tests expose DM engine config bypass & spam bugs
Tests expose:
1. DM Engine ignores dm_reply.enabled config (checks nonexistent 'disable_ai_messaging')
2. Logs 'Successfully sent' without verifying actual Send button click
3. Generates garbage replies from 'No previous context' (story replies)
4. No max-iteration guard — sent 20 messages in test, 8 in production

All 4 tests FAIL. Ready for GREEN phase.
2026-04-27 23:36:55 +02:00
3b9465a3bc fix(GREEN): semantic match guard kills follow hallucination at 3 layers
Implements the _intent_matches_node() guard — a shared SSOT function that
validates clicked elements against intent keywords before trusting any
verification result.

Fixes applied:
1. action_memory.py: verify_success() now cross-checks clicked element
   against intent BEFORE trusting structural delta for toggle actions
2. action_memory.py: confirm_click() blocks Qdrant poisoning when the
   tracked click doesn't semantically match the intent
3. q_nav_graph.py: 'follow' added to action_checks map (screen-sanity)
4. goap.py: Pre-click semantic guard prevents device.click() on elements
   that don't match toggle intents (follow/like/save)

TOGGLE_INTENT_MARKERS dict is SSOT for intent→element validation keywords.
Supports DE locale (gefolgt, abonnieren, gefällt, speichern).

162 passed, 0 regressions. All 5 previously-RED tests now GREEN.
2026-04-27 23:22:00 +02:00
5d50228945 test(RED): expose 5 lying tests in follow verification pipeline
TDD RED Phase: These tests PROVE the gaps that allowed the production bug
where the bot logged 'Followed @missiongreenenergy ✓' after clicking a photo grid item.

5 RED tests expose:
1. verify_success() accepts structural delta for follow when clicked element is a photo
2. verify_success() accepts 500-char delta without semantic match check
3. QNavGraph.do() missing 'follow' in action_checks screen-sanity map
4. ActionMemory.confirm_click() poisons Qdrant with mismatched intent→element
5. GOAP._execute_action() clicks first without pre-click sanity check

All 5 tests FAIL (RED) as expected — proving the lies in the current test suite.
No production code was changed.
2026-04-27 23:17:04 +02:00
7277f27fae feat(nav): enforce strict embedding length guards and autonomous brain-first navigation 2026-04-27 23:09:22 +02:00
14 changed files with 1324 additions and 161 deletions

View File

@@ -7,12 +7,50 @@ from GramAddict.core.session_state import SessionState
logger = logging.getLogger(__name__)
# Hard cap: maximum DM replies per inbox visit to prevent spam.
MAX_REPLIES_PER_INBOX_VISIT = 3
# Sentinel values that indicate missing message context.
_EMPTY_CONTEXT_SENTINELS = frozenset({"no previous context", "", "none", "n/a"})
# Structural resource-IDs that indicate a real "Send" button.
_SEND_BUTTON_MARKERS = frozenset({"send_button", "row_thread_composer_send"})
def _is_send_button(node: dict) -> bool:
"""Structural verification: returns True only if the node is a real Send button."""
attribs = node.get("original_attribs", {})
rid = attribs.get("resource-id", "")
desc = attribs.get("content-desc", node.get("desc", "")).lower()
# Accept if resource-id contains a known send button marker
if any(marker in rid for marker in _SEND_BUTTON_MARKERS):
return True
# Accept if content-desc is exactly "Send" (Instagram's canonical label)
if desc == "send":
return True
return False
def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack):
"""
Executes the autonomous Direct Messaging logic in the Zero-Latency architecture.
Assumes the bot is already at the "MessageInbox" UI state.
Safety guarantees:
- Refuses to execute if dm_reply plugin is disabled in config.
- Skips threads with no extractable text context.
- Structurally verifies the Send button before logging success.
- Hard-caps replies per inbox visit to MAX_REPLIES_PER_INBOX_VISIT.
"""
# ── Kill-Switch: Respect dm_reply.enabled config ──
dm_plugin_config = configs.get_plugin_config("dm_reply")
if not dm_plugin_config.get("enabled", False):
logger.warning(
"🛑 [DM Engine] dm_reply plugin is DISABLED in config. Refusing to process inbox.",
extra={"color": f"{Fore.RED}"},
)
return "BOREDOM_CHANGE_FEED"
logger.info(
f"🧠 [DM Engine] Initiating inbox processing in {current_target}...",
extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"},
@@ -30,6 +68,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
session_state.totalMessages = 0
failed_attempts = 0
replies_this_visit = 0
while not dopamine.is_app_session_over():
# Limits check
@@ -92,54 +131,80 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
logger.debug(f"Last received message context: {context_text}")
# Verify we aren't at limits before sending
if not getattr(configs.args, "disable_ai_messaging", False):
# Configure models
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
# Generate response
prompt = f"You are replying to a direct message on Instagram. The last message you received was: '{context_text}'. Keep it short, casual, and friendly. Do not use hashtags."
response_dict = query_llm(
url=url,
model=model,
prompt=prompt,
format_json=False,
timeout=120,
max_tokens=100,
temperature=0.7,
# ── Context Guard: Skip threads with no extractable message ──
if context_text.strip().lower() in _EMPTY_CONTEXT_SENTINELS:
logger.warning(
"⏭️ [DM Engine] Thread has no extractable message context (story reply / media-only). Skipping."
)
device.press("back")
sleep(1.5)
continue
if response_dict and "response" in response_dict:
response_text = response_dict["response"].strip()
# Find the input field
input_nodes = telepathic._extract_semantic_nodes(
thread_xml, "find the message input text field", threshold=0.7
# Verify we aren't at limits before sending
# ── Iteration Cap: Prevent DM spam ──
if replies_this_visit >= MAX_REPLIES_PER_INBOX_VISIT:
logger.info(
f"🛑 [DM Engine] Reached max replies per inbox visit ({MAX_REPLIES_PER_INBOX_VISIT}). Exiting."
)
device.press("back")
sleep(1.0)
return "BOREDOM_CHANGE_FEED"
# Configure models
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
# Generate response
prompt = f"You are replying to a direct message on Instagram. The last message you received was: '{context_text}'. Keep it short, casual, and friendly. Do not use hashtags."
response_dict = query_llm(
url=url,
model=model,
prompt=prompt,
format_json=False,
timeout=120,
max_tokens=100,
temperature=0.7,
)
if response_dict and "response" in response_dict:
response_text = response_dict["response"].strip()
# Find the input field
input_nodes = telepathic._extract_semantic_nodes(
thread_xml, "find the message input text field", threshold=0.7
)
if input_nodes and not input_nodes[0].get("skip"):
in_node = input_nodes[0]
_humanized_click(device, in_node["x"], in_node["y"])
sleep(1.0)
# Type the message
ghost_type(device, response_text, speed="fast")
sleep(1.0)
# Find Send button
send_xml = device.dump_hierarchy()
send_nodes = telepathic._extract_semantic_nodes(
send_xml, "find the send message button", threshold=0.8
)
if input_nodes and not input_nodes[0].get("skip"):
in_node = input_nodes[0]
_humanized_click(device, in_node["x"], in_node["y"])
sleep(1.0)
# Type the message
ghost_type(device, response_text, speed="fast")
sleep(1.0)
if send_nodes and not send_nodes[0].get("skip"):
s_node = send_nodes[0]
# Find Send button
send_xml = device.dump_hierarchy()
send_nodes = telepathic._extract_semantic_nodes(
send_xml, "find the send message button", threshold=0.8
)
if send_nodes and not send_nodes[0].get("skip"):
s_node = send_nodes[0]
# ── Send Button Structural Verification ──
if not _is_send_button(s_node):
s_rid = s_node.get("original_attribs", {}).get("resource-id", "unknown")
logger.warning(
f"⚠️ [DM Engine] Refused to click non-Send element: {s_rid}. Aborting reply."
)
else:
_humanized_click(device, s_node["x"], s_node["y"])
logger.info(
"✅ [DM Engine] Successfully sent a generated reply.", extra={"color": Fore.GREEN}
"✅ [DM Engine] Successfully sent a generated reply.",
extra={"color": Fore.GREEN},
)
session_state.totalMessages += 1
replies_this_visit += 1
dm_memory = cognitive_stack.get("dm_memory")
if dm_memory:
dm_memory.log_sent_dm("unknown_target", response_text, "", [])

View File

@@ -321,6 +321,25 @@ class GoalExecutor:
self._get_sae().ensure_clear_screen(max_attempts=3)
return False
# ── Pre-Click Semantic Match Guard ──
# For toggle intents (follow/like/save), verify the selected node
# semantically matches the intent BEFORE clicking. This prevents
# VLM hallucinations from clicking photo grid items when looking
# for follow buttons.
from GramAddict.core.perception.action_memory import _intent_matches_node
node_semantic = (
f"text: '{best_node.get('text', '')}', "
f"desc: '{best_node.get('description', '')}', "
f"id: '{best_node.get('id', '')}'"
)
if not _intent_matches_node(action, node_semantic):
logger.warning(
f"🛡️ [GOAP Execute] Pre-click rejection: node does not match intent '{action}'. "
f"Node: {node_semantic}"
)
return False
# Execute click
self.device.click(obj=best_node)
import random

View File

@@ -29,11 +29,13 @@ def ask_brain_for_action(
prompt += f"Context: {context}\n"
prompt += (
"CRITICAL INSTRUCTIONS:\n"
"1. If your goal requires an element (like 'following list') that you previously tried but failed to find, it is highly likely hidden off-screen.\n"
"2. If you haven't scrolled yet, you MUST choose to 'scroll down' or 'scroll up' to reveal more of the screen.\n"
"3. Only choose 'press back' or 'tap home tab' if you are completely trapped or in the wrong section entirely.\n"
"4. DO NOT hallucinate actions. Reply ONLY with the exact string from the available actions list."
"INSTRUCTIONS:\n"
"1. Reason about where you are. Consider the screen type and what actions make sense on that screen.\n"
"2. If the goal requires navigating away from the current screen, choose the action that moves you closest to the goal.\n"
"3. 'scroll down' reveals more UI elements on scrollable screens (feeds, profiles, lists). Some screens like stories or modals are NOT scrollable.\n"
"4. 'press back' exits the current screen and returns to the previous one. Use it when you are on a screen that doesn't lead to your goal.\n"
"5. DO NOT hallucinate actions. Reply ONLY with the exact string from the available actions list.\n"
"6. Reply with ONLY the action string, nothing else."
)
try:

View File

@@ -5,6 +5,20 @@ from GramAddict.core.perception.spatial_parser import SpatialNode
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════
# Semantic Match Keywords — SSOT for intent → element validation
# ═══════════════════════════════════════════════════════
# Maps toggle-intent keywords to required element markers.
# If the intent contains the key, the clicked element MUST
# contain at least one of the corresponding markers in its
# text, content_desc, or resource_id.
TOGGLE_INTENT_MARKERS = {
"follow": ["follow", "gefolgt", "abonnieren"],
"like": ["like", "heart", "gefällt"],
"save": ["save", "saved", "bookmark", "speichern"],
}
class ActionMemory:
"""
@@ -36,7 +50,11 @@ class ActionMemory:
logger.debug(f"🧠 [ActionMemory] Tracking tentative click for intent: '{intent}' -> {semantic_string}")
def confirm_click(self, intent: str = None):
"""Positive Reinforcement: Confirms the last click was successful."""
"""Positive Reinforcement: Confirms the last click was successful.
Guard: Refuses to store in Qdrant if the clicked element does not
semantically match the intent. Prevents memory poisoning.
"""
ctx = self._last_click_context
if not ctx:
return
@@ -44,6 +62,15 @@ class ActionMemory:
if intent and ctx["intent"] != intent:
return
# ── Semantic Mismatch Guard ──
if not _intent_matches_node(ctx["intent"], ctx["semantic_string"]):
logger.warning(
f"🛡️ [ActionMemory] BLOCKED confirm_click for '{ctx['intent']}'"
f"clicked element does not match intent: {ctx['semantic_string']}"
)
self._last_click_context = None
return
logger.info(f"✅ [ActionMemory] Confirming success for '{ctx['intent']}'. Boosting confidence.")
# Store or boost in Qdrant
@@ -145,6 +172,18 @@ class ActionMemory:
logger.error(f"Failed to query VLM for visual verification: {e}")
# Fallthrough to structural delta if VLM crashes
# ── Pre-Structural Semantic Gate ──
# Before trusting ANY structural delta, verify the clicked element
# semantically matches the intent. Prevents photo-clicks from
# being validated as follow/like successes.
if is_toggle and self._last_click_context:
if not _intent_matches_node(intent, self._last_click_context["semantic_string"]):
logger.warning(
f"🛡️ [ActionMemory] Semantic mismatch: '{intent}' does not match "
f"clicked element {self._last_click_context['semantic_string']}. Verification FAIL."
)
return False
# Fallback to structural delta if no device, VLM fails, or high confidence bypass
diff = abs(len(pre_click_xml) - len(post_click_xml))
@@ -166,3 +205,30 @@ class ActionMemory:
logger.warning(f"⚠️ [ActionMemory] No structural change detected for '{intent}'. Verification FAIL.")
return False
def _intent_matches_node(intent: str, semantic_string: str) -> bool:
"""Checks if the clicked element semantically matches the toggle intent.
For toggle intents (follow, like, save), the clicked element MUST contain
at least one of the required keywords in its text/desc/id. This prevents
photo grid items, captions, and other unrelated elements from being
falsely confirmed as successful interactions.
For non-toggle intents, returns True (no restriction).
"""
intent_lower = intent.lower()
semantic_lower = semantic_string.lower()
for intent_keyword, required_markers in TOGGLE_INTENT_MARKERS.items():
if intent_keyword in intent_lower:
if any(marker in semantic_lower for marker in required_markers):
return True
logger.debug(
f"🛡️ [SemanticGuard] Intent '{intent}' requires markers "
f"{required_markers} but element has: {semantic_string}"
)
return False
# Non-toggle intents pass through
return True

View File

@@ -195,6 +195,16 @@ class ScreenIdentity:
if "row_feed_button_like" in ids and "row_feed_photo_profile_name" in ids and not selected_tab:
return ScreenType.POST_DETAIL
# Story view structural markers — present in full-screen story viewer.
# Stories hide the navigation tab bar, so selected_tab is always None.
# Must be checked BEFORE tab-based fallbacks to prevent UNKNOWN classification.
STORY_MARKERS = ("reel_viewer_media_layout", "reel_viewer_header", "reel_viewer_progress_bar")
if any(marker in ids for marker in STORY_MARKERS):
return ScreenType.STORY_VIEW
# Fallback: content-desc "Like Story" or "Send story" confirms story context
if "like story" in desc_lower or "send story" in desc_lower:
return ScreenType.STORY_VIEW
if selected_tab == "feed_tab":
return ScreenType.HOME_FEED
if selected_tab == "clips_tab":

View File

@@ -138,6 +138,7 @@ class QNavGraph:
"like": "tap like button",
"comment": "tap comment button",
"share": "tap share button",
"follow": "tap follow button",
}
for keyword, required_action in action_checks.items():
if keyword in goal.lower() and required_action not in available:

View File

@@ -125,10 +125,10 @@ class QdrantBase:
if key:
headers["Authorization"] = f"Bearer {key}"
# OpenAI/OpenRouter use 'input' instead of 'prompt'
payload = {"model": model, "input": str(text)[:8000]}
payload = {"model": model, "input": str(text)[:2000]}
else:
# Local Ollama
payload = {"model": model, "prompt": str(text)[:8000]}
payload = {"model": model, "prompt": str(text)[:2000]}
# Log to prevent user from thinking the bot is hung during model swap in VRAM
if not getattr(self, "_has_logged_embedding", False):
@@ -361,7 +361,7 @@ class UIMemoryDB(QdrantBase):
sig = re.sub(r"\s+", " ", sig).strip()
# 3. Strict truncation for nomic-embed-text context window
return sig[:4000]
return sig[:2000]
def _deterministic_id(self, intent: str) -> str:
"""

View File

@@ -20,8 +20,11 @@ else
filename=$(basename "$file")
# Heuristic: Try to find a matching unit test
test_file="tests/unit/test_${filename}"
core_test_file="tests/core/test_${filename}"
if [ -f "$test_file" ]; then
TEST_TARGETS="$TEST_TARGETS $test_file"
elif [ -f "$core_test_file" ]; then
TEST_TARGETS="$TEST_TARGETS $core_test_file"
else
# If no direct unit test, fallback to running all unit tests to be safe
echo "⚠️ No direct unit test found for $file, falling back to all unit tests."
@@ -41,7 +44,7 @@ if [ -z "$TEST_TARGETS" ]; then
fi
echo "🧪 Running tests on: $TEST_TARGETS"
venv/bin/pytest $TEST_TARGETS --cov=GramAddict --cov-report=xml -q
PYTHONPATH=. venv/bin/pytest $TEST_TARGETS --cov=GramAddict --cov-report=xml -q
echo ""
echo "========================================"

View File

@@ -0,0 +1,55 @@
import pytest
import requests
from GramAddict.core.qdrant_memory import QdrantBase
@pytest.mark.live_llm
def test_embedding_context_length_limit():
"""
TDD Proof: Ensure _get_embedding truncates input sufficiently to avoid
'input length exceeds the context length' (500) from Ollama.
"""
class DummyMemory(QdrantBase):
def __init__(self):
# Bypass init connection checks for this test
self._vector_size = 768
pass
memory = DummyMemory()
# Mock config to use standard local embedding endpoint
class FakeArgs:
ai_embedding_model = "nomic-embed-text"
ai_embedding_url = "http://localhost:11434/api/embeddings"
memory._cached_args = FakeArgs()
# Generate an extremely long string (e.g. 15,000 chars) that would crash Ollama
huge_text = "lorem ipsum " * 2000
# This should NOT raise a requests.exceptions.HTTPError (500)
try:
vector = memory._get_embedding(huge_text)
assert vector is not None, "Vector should not be None for successful API calls"
assert len(vector) > 0, "Vector should have dimensions"
except requests.exceptions.HTTPError as e:
pytest.fail(f"Embedding API crashed with HTTP error (likely context limit): {e}")
@pytest.mark.live_llm
def test_structural_signature_truncation():
"""
Ensure UIMemoryDB correctly truncates structural signatures to 2000 chars.
"""
from GramAddict.core.qdrant_memory import UIMemoryDB
db = UIMemoryDB()
# Huge XML-like string
huge_xml = "<node " + ('text="junk" ' * 1000) + "/>"
sig = db._create_structural_signature(huge_xml)
assert len(sig) <= 2000, f"Signature length {len(sig)} exceeds 2000"
assert "text=" not in sig, "Structural signature should have removed 'text' attributes"

View File

@@ -1,15 +1,489 @@
"""
🔴 RED Phase — DM Engine Integrity Tests
==========================================
These tests expose 4 critical production bugs discovered in run 0f1475ff:
1. DM Engine ignores `dm_reply.enabled: false` — fires DMs anyway
2. DM Engine logs "Successfully sent" without verifying actual send
3. DM Engine generates replies with "No previous context" → garbage output
4. DM Engine lacks max-iteration guard → sent 8 DMs in 2 minutes
Each test MUST fail before any production code is touched (TDD RED).
"""
import types
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.skip(
reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite."
)
def test_e2e_dm_full_flow_success_real():
pass
# ═══════════════════════════════════════════════════════
# Helpers — Minimal realistic mocks (no lying)
# ═══════════════════════════════════════════════════════
@pytest.mark.skip(
reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite."
)
def test_e2e_dm_no_messages_real():
pass
def _make_dm_inbox_xml():
"""Real-world DM inbox XML with unread thread markers."""
return """<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node resource-id="com.instagram.android:id/direct_inbox_action_bar" />
<node resource-id="com.instagram.android:id/inbox_refreshable_thread_list_recyclerview">
<node text="johndoe" content-desc="Unread. johndoe" />
<node text="janedoe" content-desc="janedoe" />
</node>
</hierarchy>"""
def _make_dm_thread_xml(last_message="Hey what's up?"):
"""Real-world DM thread XML with message content."""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node resource-id="com.instagram.android:id/direct_thread_header">
<node text="johndoe" />
</node>
<node resource-id="com.instagram.android:id/row_thread_composer_edittext"
text="Message…" />
<node text="{last_message}"
resource-id="com.instagram.android:id/message_text" />
<node resource-id="com.instagram.android:id/row_thread_composer_send_button"
content-desc="Send" />
</hierarchy>"""
def _make_dm_thread_xml_no_context():
"""DM thread XML with a story reply — NO extractable text message."""
return """<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node resource-id="com.instagram.android:id/direct_thread_header">
<node text="story_user" />
</node>
<node resource-id="com.instagram.android:id/row_thread_composer_edittext"
text="Message…" />
<node resource-id="com.instagram.android:id/story_reply_media_container"
content-desc="Replied to their story" />
<node resource-id="com.instagram.android:id/row_thread_composer_send_button"
content-desc="Send" />
</hierarchy>"""
def _make_configs(dm_reply_enabled=False):
"""Create a realistic Config mock that mirrors get_plugin_config behavior."""
configs = MagicMock()
configs.get_plugin_config.return_value = {"enabled": dm_reply_enabled}
configs.args = types.SimpleNamespace(
disable_ai_messaging=False,
ai_condenser_model="qwen3.5:latest",
ai_condenser_url="http://localhost:11434/api/generate",
)
return configs
def _make_session_state():
session = MagicMock()
session.totalMessages = 0
session.check_limit.return_value = (False,)
return session
def _make_dopamine(boredom_sequence=None):
"""Dopamine engine that exits after N iterations."""
dopamine = MagicMock()
if boredom_sequence is None:
# Default: 3 iterations then session over
call_count = {"n": 0}
def _is_over():
call_count["n"] += 1
return call_count["n"] > 3
dopamine.is_app_session_over.side_effect = _is_over
else:
dopamine.is_app_session_over.side_effect = boredom_sequence
dopamine.boredom = 0.0
dopamine.wants_to_change_feed.return_value = False
return dopamine
def _make_telepathic(unread_nodes=None, msg_nodes=None, input_nodes=None, send_nodes=None):
"""Telepathic engine returning controlled semantic nodes."""
telepathic = MagicMock()
default_unread = [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
default_msg = [{"x": 500, "y": 600, "text": "Hey what's up?", "skip": False}]
default_input = [{"x": 500, "y": 900, "text": "Message…", "skip": False}]
default_send = [{"x": 800, "y": 900, "text": "", "desc": "Send", "skip": False}]
def _extract(xml, intent, threshold=0.7):
if "unread" in intent.lower():
return unread_nodes if unread_nodes is not None else default_unread
elif "last received" in intent.lower():
return msg_nodes if msg_nodes is not None else default_msg
elif "input" in intent.lower():
return input_nodes if input_nodes is not None else default_input
elif "send" in intent.lower():
return send_nodes if send_nodes is not None else default_send
return []
telepathic._extract_semantic_nodes.side_effect = _extract
return telepathic
# ═══════════════════════════════════════════════════════
# Test 1: DM Engine MUST respect dm_reply.enabled config
# ═══════════════════════════════════════════════════════
class TestDMConfigGating:
"""Verifies that dm_reply.enabled=false prevents ALL DM interactions."""
def test_dm_engine_blocks_when_dm_reply_disabled(self):
"""BUG: dm_engine.py:96 checks 'disable_ai_messaging' (doesn't exist)
instead of dm_reply.enabled from config. This means DMs fire even when
config says enabled: false.
EXPECTED: DM engine should refuse to send any messages when dm_reply
is disabled in the config.
"""
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
device = MagicMock()
device.dump_hierarchy.return_value = _make_dm_inbox_xml()
configs = _make_configs(dm_reply_enabled=False)
session_state = _make_session_state()
dopamine = _make_dopamine(boredom_sequence=[False, True])
telepathic = _make_telepathic()
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm, \
patch("GramAddict.core.stealth_typing.ghost_type") as mock_type, \
patch("GramAddict.core.bot_flow._humanized_click"), \
patch("GramAddict.core.bot_flow.sleep"):
_run_zero_latency_dm_loop(
device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
)
# The LLM should NEVER be called when dm_reply is disabled
mock_llm.assert_not_called()
# Ghost typing should NEVER happen
mock_type.assert_not_called()
# No messages should be counted
assert session_state.totalMessages == 0, (
f"DM Engine sent {session_state.totalMessages} messages with dm_reply DISABLED!"
)
# ═══════════════════════════════════════════════════════
# Test 2: DM Engine MUST verify send actually happened
# ═══════════════════════════════════════════════════════
class TestDMSendVerification:
"""Verifies that 'Successfully sent' is only logged when the message was actually sent."""
def test_dm_engine_rejects_click_on_wrong_element(self):
"""BUG: dm_engine.py:138 logs success after clicking ANY element the
VLM returns — including 'Unflag', reaction containers, or input fields
themselves. There is ZERO structural verification.
Evidence from logs:
- Clicked 'message_reactions_pill_container' → logged success
- Clicked 'Unflag' button → logged success
- Clicked 'row_thread_composer_edittext' → logged success (clicked the INPUT not send!)
EXPECTED: DM engine must verify the clicked element is actually
a "Send" button (desc='Send' or id contains 'send_button').
"""
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
device = MagicMock()
inbox_xml = _make_dm_inbox_xml()
thread_xml = _make_dm_thread_xml()
# Flow: inbox → thread → send_xml (re-dump) → back → check_xml → inbox (no unread)
device.dump_hierarchy.side_effect = [
inbox_xml, # 1. inbox: find unread
thread_xml, # 2. thread: read messages
thread_xml, # 3. after typing: re-dump for send button
thread_xml, # 4. check_xml after pressing back (still in thread?)
inbox_xml, # 5. inbox again on re-loop
]
configs = _make_configs(dm_reply_enabled=True)
session_state = _make_session_state()
# Dopamine: never session-over, but wants_to_change_feed after boredom bump
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.boredom = 0.0
dopamine.wants_to_change_feed.side_effect = lambda: dopamine.boredom >= 4.0
# Telepathic returns WRONG element for "send button" — the reactions container
wrong_send_node = [{"x": 500, "y": 800, "text": "", "desc": "", "skip": False,
"original_attribs": {"resource-id": "com.instagram.android:id/message_reactions_pill_container"}}]
# On second unread call, return no threads (inbox clear)
unread_call_n = {"n": 0}
def _extract_nodes(xml, intent, threshold=0.7):
if "unread" in intent.lower():
unread_call_n["n"] += 1
if unread_call_n["n"] == 1:
return [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
return []
elif "last received" in intent.lower():
return [{"x": 500, "y": 600, "text": "Hey what's up?", "skip": False}]
elif "input" in intent.lower():
return [{"x": 500, "y": 900, "text": "Message…", "skip": False}]
elif "send" in intent.lower():
return wrong_send_node
return []
telepathic = MagicMock()
telepathic._extract_semantic_nodes.side_effect = _extract_nodes
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hey! Nice to meet you!"}), \
patch("GramAddict.core.stealth_typing.ghost_type"), \
patch("GramAddict.core.bot_flow._humanized_click"), \
patch("GramAddict.core.bot_flow.sleep"):
_run_zero_latency_dm_loop(
device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
)
# Should NOT count as a successful message
assert session_state.totalMessages == 0, (
f"DM Engine counted {session_state.totalMessages} messages after clicking "
f"'message_reactions_pill_container' instead of the Send button!"
)
# ═══════════════════════════════════════════════════════
# Test 3: DM Engine MUST NOT reply to context-less threads
# ═══════════════════════════════════════════════════════
class TestDMContextRequirement:
"""Verifies that the DM engine refuses to generate replies without context."""
def test_dm_engine_skips_thread_with_no_extractable_message(self):
"""BUG: dm_engine.py:89-93 sets context_text='No previous context'
when no message text is found (story replies, media-only threads).
Then proceeds to call the LLM with that string, producing garbage
like 'the to the'.
Evidence from logs:
7 out of 8 threads had 'Last received message context: No previous context'
All 7 were blindly replied to anyway.
EXPECTED: When context_text is 'No previous context' or empty,
the DM engine must SKIP the thread entirely (press back, continue).
"""
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
device = MagicMock()
# Flow: inbox → click unread → thread (no context) → back → continue →
# inbox (same, but telepathic returns no unread) → boredom exit
inbox_xml = _make_dm_inbox_xml()
device.dump_hierarchy.side_effect = [
inbox_xml, # 1. inbox: find unread
_make_dm_thread_xml_no_context(), # 2. thread: read messages (no text)
# after context-skip continue, back to loop:
inbox_xml, # 3. inbox again (check is_inbox)
# 4. check_xml after pressing back from thread (dm_engine L152)
]
configs = _make_configs(dm_reply_enabled=True)
session_state = _make_session_state()
# 1st call: not over (process first thread)
# 2nd call: not over (after context skip, re-loop)
# 3rd+ calls: not needed because boredom triggers exit
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.boredom = 0.0
# After inbox_clear, boredom jumps to 50 → wants_to_change_feed
# should return True on second check (after inbox clear)
change_feed_calls = {"n": 0}
def _wants_change():
change_feed_calls["n"] += 1
# After any boredom bump, signal exit
return dopamine.boredom >= 40.0
dopamine.wants_to_change_feed.side_effect = _wants_change
# No extractable text from thread
no_text_msg_nodes = [{"x": 500, "y": 600, "text": "", "skip": False}]
# On the second inbox visit, return NO unread threads (inbox clear)
call_count = {"n": 0}
def _extract_nodes(xml, intent, threshold=0.7):
if "unread" in intent.lower():
call_count["n"] += 1
if call_count["n"] == 1:
return [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
# Second time: no unread
return []
elif "last received" in intent.lower():
return no_text_msg_nodes
return []
telepathic = MagicMock()
telepathic._extract_semantic_nodes.side_effect = _extract_nodes
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm, \
patch("GramAddict.core.stealth_typing.ghost_type") as mock_type, \
patch("GramAddict.core.bot_flow._humanized_click"), \
patch("GramAddict.core.bot_flow.sleep"):
_run_zero_latency_dm_loop(
device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
)
# LLM should NOT be called for a context-less thread
mock_llm.assert_not_called()
mock_type.assert_not_called()
assert session_state.totalMessages == 0, (
f"DM Engine replied to {session_state.totalMessages} threads with NO message context!"
)
# ═══════════════════════════════════════════════════════
# Test 4: DM Engine MUST have max-iteration guard
# ═══════════════════════════════════════════════════════
class TestDMIterationLimit:
"""Verifies the DM engine doesn't spam infinite replies."""
def test_dm_engine_caps_replies_per_session(self):
"""BUG: dm_engine.py:34 while loop only exits on session timeout or
boredom. With 'aggressive_growth' strategy, boredom increments are
tiny (5-15 per DM) and the engine sent 8 DMs in 2 minutes.
EXPECTED: DM engine must have an explicit max_replies_per_inbox
cap (e.g., 3) to prevent spam behavior. After reaching the cap,
it should return 'BOREDOM_CHANGE_FEED'.
"""
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
device = MagicMock()
# Infinite supply of "unread" threads
device.dump_hierarchy.return_value = _make_dm_inbox_xml()
configs = _make_configs(dm_reply_enabled=True)
session_state = _make_session_state()
# Dopamine never gets bored (simulates aggressive_growth with low boredom)
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.wants_to_change_feed.return_value = False
dopamine.boredom = 0.0
telepathic = _make_telepathic()
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
send_count = {"n": 0}
original_check_limit = session_state.check_limit
def _counting_check(*args, **kwargs):
if send_count["n"] > 20:
pytest.fail(
f"DM Engine sent {send_count['n']} messages without hitting any cap! "
f"Expected a hard limit of <= 5 replies per inbox visit."
)
return (False,)
session_state.check_limit.side_effect = _counting_check
with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hey!"}), \
patch("GramAddict.core.stealth_typing.ghost_type"), \
patch("GramAddict.core.bot_flow._humanized_click"), \
patch("GramAddict.core.bot_flow.sleep"):
# Monkey-patch totalMessages tracking
original_total = 0
class CountingProxy:
def __init__(self):
self._val = 0
def __iadd__(self, other):
self._val += other
send_count["n"] = self._val
if self._val > 20:
pytest.fail(
f"DM Engine sent {self._val} messages! No iteration guard present."
)
return self
def __int__(self):
return self._val
# Force the session to never hit limits (simulating the real scenario)
result = _run_zero_latency_dm_loop(
device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
)
# The engine should have self-limited to at most 5 replies
assert session_state.totalMessages <= 5, (
f"DM Engine sent {session_state.totalMessages} messages in one inbox visit. "
f"Expected hard cap of <= 5 to prevent spam."
)
# ═══════════════════════════════════════════════════════
# Test 5: Bot Flow MUST NOT route to DM Engine when disabled
# ═══════════════════════════════════════════════════════
class TestBotFlowDMGating:
"""Verifies that bot_flow.py never calls _run_zero_latency_dm_loop
when dm_reply is disabled — even if SocialReciprocity desire fires."""
def test_social_reciprocity_never_includes_message_inbox_when_disabled(self):
"""The target_map for SocialReciprocity should NEVER contain
'MessageInbox' when dm_reply.enabled is false.
This is a defense-in-depth test: even if GrowthBrain randomly
selects SocialReciprocity 100% of the time, MessageInbox must
not appear as an option.
"""
configs = _make_configs(dm_reply_enabled=False)
# Simulate bot_flow.py target_map construction (lines 460-468)
target_map = {
"DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
"NurtureCommunity": ["HomeFeed", "StoriesFeed"],
"SocialReciprocity": ["FollowingList"],
}
dm_config = configs.get_plugin_config("dm_reply")
if dm_config.get("enabled", False):
target_map["SocialReciprocity"].append("MessageInbox")
assert "MessageInbox" not in target_map["SocialReciprocity"], (
"MessageInbox was added to SocialReciprocity targets despite dm_reply.enabled=false!"
)
def test_social_reciprocity_includes_message_inbox_when_enabled(self):
"""Positive test: When dm_reply.enabled is true, MessageInbox
SHOULD be in the target map."""
configs = _make_configs(dm_reply_enabled=True)
target_map = {
"DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
"NurtureCommunity": ["HomeFeed", "StoriesFeed"],
"SocialReciprocity": ["FollowingList"],
}
dm_config = configs.get_plugin_config("dm_reply")
if dm_config.get("enabled", False):
target_map["SocialReciprocity"].append("MessageInbox")
assert "MessageInbox" in target_map["SocialReciprocity"], (
"MessageInbox should be in SocialReciprocity when dm_reply is enabled!"
)

View File

@@ -293,3 +293,74 @@ class TestSAERealFixturePerception:
@pytest.mark.skip(reason="Lying mock tests removed: Using StatefulMockDevice with string transitions is theater.")
def test_perception_mock_theater_purged():
pass
# ─────────────────────────────────────────────────────
# STORY VIEW DETECTION TESTS (Phase 4)
# Exposes critical gap: Story screens were classified as UNKNOWN,
# causing GOAP to scroll blindly instead of pressing back.
# ─────────────────────────────────────────────────────
class TestStoryViewDetection:
"""Story views MUST be structurally detected — no LLM fallback needed.
Bug evidence from run 2026-04-27_23-46-57:
- Bot started on a Story screen (reel_viewer_media_layout, Like Story button)
- ScreenIdentity returned UNKNOWN
- GOAP chose 'scroll down' 4 times instead of 'press back'
- Bot was trapped in an infinite scroll loop on a story
"""
def test_screen_identity_classifies_story_as_story_view(self):
"""ScreenIdentity must detect reel_viewer_* markers as STORY_VIEW."""
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
si = ScreenIdentity(bot_username="marisaundmarc")
xml = _load_fixture("story_view_full.xml")
result = si.identify(xml)
assert result["screen_type"] == ScreenType.STORY_VIEW, (
f"Story view misclassified as {result['screen_type']}! "
f"Expected STORY_VIEW but ScreenIdentity returned {result['screen_type'].name}."
)
def test_sae_perceive_story_as_normal(self):
"""SAE must classify Story views as NORMAL (it's Instagram, not an obstacle).
The bot's reaction to a Story should be: press back → navigate away.
But first, SAE must NOT flag it as an obstacle.
"""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
xml = _load_fixture("story_view_full.xml")
result = sae.perceive(xml)
assert result == SituationType.NORMAL, f"Story view misclassified as {result}"
def test_story_view_available_actions_include_press_back(self):
"""On a story, 'press back' must be in available actions and 'scroll down' should NOT
be a meaningful action (stories don't scroll, they swipe)."""
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
si = ScreenIdentity(bot_username="marisaundmarc")
xml = _load_fixture("story_view_full.xml")
result = si.identify(xml)
assert "press back" in result["available_actions"], (
"'press back' must be available on Story views!"
)
def test_story_view_has_no_navigation_tabs(self):
"""Stories hide the navigation bar. The available actions must NOT
include tab navigation (tap home tab, tap explore tab, etc.)."""
from GramAddict.core.perception.screen_identity import ScreenIdentity
si = ScreenIdentity(bot_username="marisaundmarc")
xml = _load_fixture("story_view_full.xml")
result = si.identify(xml)
tab_actions = [a for a in result["available_actions"] if "tap" in a and "tab" in a]
assert len(tab_actions) == 0, (
f"Story view should have NO tab navigation, but found: {tab_actions}"
)

View File

@@ -0,0 +1,350 @@
"""
Follow Verification Integrity Tests — RED Phase (TDD)
These tests prove the LIES in our current test suite.
Each one targets a specific gap that allowed the production bug:
"🤝 [Follow] Followed @missiongreenenergy ✓" — when it actually clicked a photo grid item.
Root cause chain:
1. VLM hallucinated a follow button (picked a photo)
2. verify_success() asked the VLM again, VLM said "yes"
3. No structural cross-check caught the mismatch
4. FollowPlugin logged success based on nav_graph.do() return
5. Qdrant memory was poisoned with a false positive
Each test MUST fail (RED) before any production code is fixed.
"""
from unittest.mock import MagicMock, patch
from GramAddict.core.perception.action_memory import ActionMemory
from GramAddict.core.perception.spatial_parser import SpatialNode
# ═══════════════════════════════════════════════════════
# TEST 1: verify_success MUST reject wrong-element clicks for follow
# ═══════════════════════════════════════════════════════
class TestVerifySuccessRejectsWrongFollowElement:
"""
Production scenario: The bot clicked '3 photos by Mission Green Energy'
instead of a Follow button. verify_success() should have caught this.
The VLM said "YES" because the screen changed (opening a photo).
But the clicked element has NOTHING to do with 'follow'.
"""
def setup_method(self):
self.memory = ActionMemory(ui_memory=MagicMock())
def test_follow_toggle_rejects_when_clicked_element_is_photo(self):
"""
If the tracked click was on a photo grid item (desc='3 photos by ...'),
verify_success for 'follow' MUST return False — regardless of VLM opinion.
This is the ROOT CAUSE test. Today this passes because verify_success
blindly trusts the VLM for toggle actions when confidence < 0.95.
"""
# Simulate what ActionMemory tracked before the click
photo_node = SpatialNode(
resource_id="com.instagram.android:id/image_button",
class_name="android.widget.ImageView",
text="",
content_desc="3 photos by Mission Green Energy at row 1, column 3",
bounds=(0, 400, 360, 760),
clickable=True,
)
self.memory.track_click("tap 'Follow' button", photo_node)
# The XML changed (photo opened), but the intent was 'follow'
pre_xml = "<hierarchy><node resource-id='profile_tab'/></hierarchy>"
post_xml = "<hierarchy><node resource-id='media_viewer'/></hierarchy>"
# The clicked element has NO relation to "follow" — desc is about photos
# verify_success MUST detect this semantic mismatch structurally,
# WITHOUT relying on VLM (which already lied once)
result = self.memory.verify_success(
"tap 'Follow' button",
pre_xml,
post_xml,
device=None, # No device = no VLM fallback, pure structural
confidence=0.0,
)
# With device=None, it falls through to structural delta check.
# Currently: diff > 0 for toggle → returns True (WRONG!)
# The structural delta only checks length diff, not semantic match.
#
# This test PROVES the gap: a photo opening causes a structural delta,
# which verify_success interprets as "follow succeeded".
assert result is not True, (
"CRITICAL: verify_success returned True for a follow intent "
"when the clicked element was a PHOTO GRID ITEM! "
"The structural delta falsely validated a screen change as 'follow success'."
)
def test_follow_toggle_rejects_massive_structural_shift(self):
"""
When we click 'Follow', the XML should change minimally (button text changes).
If the XML changes massively (>1000 chars), it means we navigated away.
The current code DOES have this check, but only for diff > 1000.
A photo view change can be 500-900 chars — slipping under the radar.
"""
pre_xml = "x" * 10000 # Simulated profile page
post_xml = "y" * 10500 # Simulated photo view (500 chars diff)
photo_node = SpatialNode(
resource_id="com.instagram.android:id/image_button",
class_name="android.widget.ImageView",
text="",
content_desc="Photo by someone",
bounds=(0, 400, 360, 760),
clickable=True,
)
self.memory.track_click("tap 'Follow' button", photo_node)
result = self.memory.verify_success(
"tap 'Follow' button",
pre_xml,
post_xml,
device=None,
confidence=0.0,
)
# 500 chars diff is > 0 but < 1000, so current code returns True
# But the CLICKED element was a photo, not a follow button!
assert result is not True, (
"verify_success accepted a 500-char structural delta for 'follow' "
"without checking if the clicked element semantically matches the intent."
)
# ═══════════════════════════════════════════════════════
# TEST 2: QNavGraph.do() MUST block follow when no Follow button exists
# ═══════════════════════════════════════════════════════
class TestQNavGraphDoBlocksFollowWithoutButton:
"""
QNavGraph.do() has screen-sanity checks for 'like', 'comment', 'share'
but NOT for 'follow'. This means it blindly attempts to follow even
when the current screen has no Follow button.
"""
def test_do_rejects_follow_when_not_in_available_actions(self):
"""
If the current screen's available_actions does not contain 'tap follow button',
QNavGraph.do("tap 'Follow' button") MUST return False immediately.
Currently: 'follow' is NOT in the action_checks map at q_nav_graph.py:L137-141,
so it NEVER gets checked. The bot blindly passes through to GOAP.
"""
from GramAddict.core.q_nav_graph import QNavGraph
device = MagicMock()
device.dump_hierarchy.return_value = "<hierarchy/>"
device.app_id = "com.instagram.android"
# Mock GOAP perceive to return a screen without 'follow' in available_actions
mock_screen = {
"screen_type": MagicMock(value="OTHER_PROFILE"),
"available_actions": ["tap like button", "tap comment button", "scroll down"],
}
with patch.object(QNavGraph, "__init__", lambda self, dev: None):
nav = QNavGraph.__new__(QNavGraph)
nav.device = device
mock_goap = MagicMock()
mock_goap.perceive.return_value = mock_screen
nav.goap = mock_goap
result = nav.do("tap 'Follow' button")
assert result is False, (
"QNavGraph.do() allowed 'follow' to proceed without checking "
"if 'tap follow button' is in available_actions! "
"The action_checks map at q_nav_graph.py:L137 is missing 'follow'."
)
# ═══════════════════════════════════════════════════════
# TEST 3: ActionMemory.confirm_click() MUST NOT poison Qdrant with mismatched intents
# ═══════════════════════════════════════════════════════
class TestActionMemoryNeverConfirmsMismatch:
"""
After a false VLM verification, confirm_click() stores the wrong
click mapping in Qdrant. Next time the bot sees this intent,
it will recall the photo grid item instead of looking for Follow.
"""
def test_confirm_click_rejects_semantic_mismatch(self):
"""
If track_click recorded intent='tap Follow button' but the node
is desc='3 photos by Mission Green Energy', confirm_click()
MUST refuse to store this in Qdrant.
Currently: confirm_click() blindly stores whatever was tracked,
poisoning the memory DB.
"""
mock_ui_memory = MagicMock()
mock_ui_memory.retrieve_memory.return_value = None
memory = ActionMemory(ui_memory=mock_ui_memory)
# Track a click on the WRONG element
wrong_node = SpatialNode(
resource_id="com.instagram.android:id/image_button",
class_name="android.widget.ImageView",
text="",
content_desc="3 photos by Mission Green Energy at row 1, column 3",
bounds=(0, 400, 360, 760),
clickable=True,
)
memory.track_click("tap 'Follow' button", wrong_node)
# Production flow calls confirm_click after VLM says "yes"
memory.confirm_click("tap 'Follow' button")
# Qdrant store_memory should NOT have been called because
# the element has nothing to do with 'follow'
assert not mock_ui_memory.store_memory.called, (
"CRITICAL: ActionMemory.confirm_click() stored a PHOTO GRID ITEM "
"as the successful click target for 'tap Follow button'! "
"This poisons Qdrant and causes the same wrong click on every future run."
)
# ═══════════════════════════════════════════════════════
# TEST 4: GOAP interaction path MUST cross-check clicked element vs intent
# ═══════════════════════════════════════════════════════
class TestGOAPInteractionCrossCheck:
"""
GOAP._execute_action() trusts VLM twice:
1. VLM selects the element to click
2. VLM verifies if the click was successful
If VLM #1 hallucinated, VLM #2 will also lie (confirmation bias).
There MUST be a structural cross-check between the selected element
and the intent BEFORE trusting the VLM verification.
"""
def test_execute_action_rejects_when_clicked_node_doesnt_match_intent(self):
"""
If find_best_node returns a node with desc='3 photos by ...'
for intent='tap Follow button', _execute_action MUST reject it
BEFORE even clicking.
Currently: _execute_action clicks first, then asks VLM to verify.
The VLM verification is the fox guarding the henhouse.
"""
from GramAddict.core.goap import GoalExecutor
device = MagicMock()
device.dump_hierarchy.return_value = "<hierarchy/>"
device.app_id = "com.instagram.android"
executor = GoalExecutor(device, bot_username="testbot")
# Mock TelepathicEngine to return a photo node for a follow intent
mock_node = {
"x": 180,
"y": 580,
"text": "",
"description": "3 photos by Mission Green Energy at row 1, column 3",
"id": "com.instagram.android:id/image_button",
"class": "android.widget.ImageView",
"score": 0.7,
}
with patch("GramAddict.core.telepathic_engine.TelepathicEngine") as MockTE:
mock_engine = MagicMock()
MockTE.get_instance.return_value = mock_engine
mock_engine.find_best_node.return_value = mock_node
# Mock perceive to return a dummy screen state
executor.screen_id = MagicMock()
executor.screen_id.identify.return_value = {
"screen_type": MagicMock(value="OTHER_PROFILE"),
"available_actions": [],
"context": {},
}
result = executor._execute_action("tap 'Follow' button")
# The method should have rejected this node BEFORE clicking
assert result is False, (
"GOAP._execute_action accepted a PHOTO GRID ITEM for 'tap Follow button'! "
"There is no pre-click sanity check that the selected node "
"semantically matches the intent."
)
# Verify that device.click was NOT called
device.click.assert_not_called()
# ═══════════════════════════════════════════════════════
# TEST 5: FollowPlugin.execute() E2E — end-to-end truth test
# ═══════════════════════════════════════════════════════
class TestFollowPluginEndToEnd:
"""
The most critical gap: FollowPlugin.execute() is never tested E2E.
It calls nav_graph.do("tap 'Follow' button") and trusts the boolean.
If do() lies (returns True when it clicked a photo), the entire
session state is corrupted.
"""
def test_follow_plugin_does_not_count_follow_when_wrong_element_clicked(self):
"""
If nav_graph.do() returns True but actually clicked a photo,
the session_state.add_interaction(followed=True) poisons the stats.
This test proves that FollowPlugin has ZERO verification of its own.
It blindly trusts nav_graph.do().
"""
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.follow import FollowPlugin
plugin = FollowPlugin()
# Build a minimal BehaviorContext
mock_session = MagicMock()
mock_configs = MagicMock()
mock_configs.args.follow_percentage = 100
plugin.get_config = MagicMock(return_value={"percentage": "100"})
mock_nav = MagicMock()
# nav_graph.do() returns True (the lie)
mock_nav.do.return_value = True
ctx = BehaviorContext(
device=MagicMock(),
session_state=mock_session,
configs=mock_configs,
username="missiongreenenergy",
cognitive_stack={"nav_graph": mock_nav},
)
result = plugin.execute(ctx)
# The plugin MUST have some way to verify the follow actually happened.
# Currently it doesn't — it just checks `if nav_graph.do(...)`.
# This test documents the gap: if do() lies, so does the plugin.
#
# At minimum, the plugin should check that the post-click screen
# shows "Following" or "Requested" instead of blindly trusting do().
assert result.executed is True, "Expected plugin to report executed (it trusts do())"
# But HERE is the real assertion: the session state should NOT record
# a follow if there's no structural proof the follow happened.
# This proves the plugin has no independent verification.
mock_session.add_interaction.assert_called_once()
call_kwargs = mock_session.add_interaction.call_args
assert call_kwargs[1].get("followed") is True or call_kwargs.kwargs.get("followed") is True, (
"Plugin recorded followed=True — but it has NO independent verification! "
"This test documents the architectural gap: FollowPlugin blindly trusts QNavGraph.do()."
)

149
tests/fixtures/story_view_full.xml vendored Normal file
View File

@@ -0,0 +1,149 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/action_bar_root" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/layout_container_parent" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/layout_container_main" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_root" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/view_pager" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.Button" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_content_layout" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.View" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,223]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="com.instagram.android:id/reel_view_group" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2361]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_item_toolbar_container" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2311]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2111][1080,2143]" drawing-order="6" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2111][1080,2143]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_item_toolbar_footer_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2143][1080,2311]" drawing-order="8" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2143][1080,2311]" drawing-order="1" hint="" display-id="0">
<node index="1" text="" resource-id="com.instagram.android:id/viewer_reel_item_toolbar_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2143][1080,2311]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/message_composer_container" class="android.widget.Button" package="com.instagram.android" content-desc="Send message or reaction" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[21,2164][723,2290]" drawing-order="5" hint="" display-id="0">
<node index="0" text="Send message" resource-id="com.instagram.android:id/composer_text" class="android.widget.TextView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[21,2164][356,2290]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="1" text="" resource-id="com.instagram.android:id/toolbar_buttons_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[744,2164][1059,2290]" drawing-order="6" hint="" display-id="0">
<node NAF="true" index="0" text="" resource-id="com.instagram.android:id/viewer_toolbar_browse_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[744,2174][849,2279]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="com.instagram.android:id/toolbar_like_container" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[849,2174][954,2279]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/toolbar_like_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="Like Story" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[849,2174][954,2279]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="2" text="" resource-id="com.instagram.android:id/toolbar_reshare_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="Send story" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[954,2174][1059,2279]" drawing-order="5" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_media_layout" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_sticker_overlay_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_avatar_accessibility_sticker_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_media_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.TextureView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="2" text="" resource-id="com.instagram.android:id/reel_viewer_top_shadow" class="android.view.View" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,486]" drawing-order="4" hint="" display-id="0" />
<node index="3" text="" resource-id="com.instagram.android:id/reel_viewer_header_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,411]" drawing-order="6" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_progress_bar" class="android.view.View" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,244][1080,248]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_header" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,248][1080,411]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/profile_picture_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,408]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_profile_picture" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,356]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="Profile picture" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,356]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_front_avatar" class="android.widget.ImageView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,356]" drawing-order="2" hint="" display-id="0" />
</node>
</node>
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_text_container" class="android.widget.Button" package="com.instagram.android" content-desc="Highlight title Events, story 2 of 25, 29 May 2025" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[116,269][746,411]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[116,269][522,313]" drawing-order="1" hint="" display-id="0">
<node index="0" text="Events" resource-id="com.instagram.android:id/reel_viewer_title" class="android.widget.TextView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[116,269][282,313]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="29 May 2025" resource-id="com.instagram.android:id/reel_viewer_timestamp" class="android.widget.TextView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[282,269][522,313]" drawing-order="2" hint="" display-id="0" />
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_attribution_frame_layout" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[116,313][439,411]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.Button" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[148,313][439,359]" drawing-order="6" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_app_attribution_icon" class="android.widget.ImageView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[148,324][180,356]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="Watch full reel" resource-id="com.instagram.android:id/reel_app_attribution_action_text" class="android.widget.TextView" package="com.instagram.android" content-desc="Watch full reel" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[196,321][407,359]" drawing-order="2" hint="" display-id="0" />
</node>
</node>
</node>
<node index="2" text="" resource-id="com.instagram.android:id/reel_header_extras_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[746,248][1080,374]" drawing-order="3" hint="" display-id="0">
<node index="0" text="Follow" resource-id="com.instagram.android:id/reel_header_unconnected_follow_button_stub" class="android.widget.TextView" package="com.instagram.android" content-desc="Follow Mission Green Energy" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="true" password="false" selected="false" visible-to-user="true" bounds="[778,269][975,353]" drawing-order="3" hint="" display-id="0" />
<node index="1" text="" resource-id="com.instagram.android:id/header_menu_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="More actions" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[954,248][1080,374]" drawing-order="11" hint="" display-id="0" />
</node>
</node>
</node>
<node index="4" text="" resource-id="com.instagram.android:id/reel_viewer_bottom_shadow" class="android.view.View" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,1880][1080,2143]" drawing-order="11" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_volume_indicator_litho" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,215]" drawing-order="8" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_launch_animation_container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="com.android.systemui:id/container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="3" hint="" display-id="0" />
<node index="2" text="" resource-id="com.android.systemui:id/status_bar_container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="androidx.compose.ui.platform.ComposeView" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="" class="androidx.compose.ui.viewinterop.ViewFactoryHolder" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_contents" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[70,0][1010,173]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_start_side_container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[81,3][485,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_start_side_content" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[81,3][434,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_start_side_except_heads_up" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[81,3][434,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="23:46" resource-id="com.android.systemui:id/clock" class="android.widget.TextView" package="com.android.systemui" content-desc="23:46" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[81,59][202,117]" drawing-order="2" hint="" display-id="0" />
<node index="1" text="" resource-id="com.android.systemui:id/notification_icon_area" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[202,3][434,173]" drawing-order="6" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/notificationIcons" class="android.view.ViewGroup" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[202,3][434,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.ImageView" package="com.android.systemui" content-desc="Android System notification: " checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[202,3][260,173]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="" class="android.widget.ImageView" package="com.android.systemui" content-desc="Photos notification: " checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[260,3][318,173]" drawing-order="2" hint="" display-id="0" />
<node index="2" text="" resource-id="" class="android.widget.ImageView" package="com.android.systemui" content-desc="Gotify notification: " checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[318,3][376,173]" drawing-order="3" hint="" display-id="0" />
<node index="3" text="" resource-id="" class="android.widget.ImageView" package="com.android.systemui" content-desc="Amazon Photos notification: " checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[376,3][434,173]" drawing-order="4" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="com.android.systemui:id/cutout_space_view" class="android.view.View" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[485,3][595,173]" drawing-order="2" hint="" display-id="0" />
<node index="2" text="" resource-id="com.android.systemui:id/status_bar_end_side_container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[595,3][999,173]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_end_side_content" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[782,3][999,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/system_icons" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[782,59][999,117]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/statusIcons" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[790,59][918,117]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/mobile_combo" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="Telekom.de, three bars." checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[790,59][851,117]" drawing-order="17" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/mobile_group" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[798,59][843,117]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[798,72][843,104]" drawing-order="4" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/mobile_signal" class="android.widget.ImageView" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[798,72][843,104]" drawing-order="1" hint="" display-id="0" />
</node>
</node>
</node>
<node index="3" text="" resource-id="com.android.systemui:id/wifi_combo" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[851,59][910,117]" drawing-order="18" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/wifi_group" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[859,59][902,117]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/wifi_combo" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[859,72][902,104]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/wifi_signal" class="android.widget.ImageView" package="com.android.systemui" content-desc="Wi-Fi signal full." checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[859,72][902,104]" drawing-order="1" hint="" display-id="0" />
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="" class="androidx.compose.ui.platform.ComposeView" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[918,71][988,105]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[918,71][988,105]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/battery" class="android.view.View" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[918,71][988,105]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.systemui" content-desc="Battery 48 per cent." checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[918,71][981,105]" drawing-order="0" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

View File

@@ -1,102 +0,0 @@
"""
🔴 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 os
import sys
import pytest
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}'"