Hardening autonomous navigation: Implemented Modal Guards, Transient Drift Protection (WhatsApp fix), and Aggressive Recovery paths. Cleaned up diagnostic artifacts.

This commit is contained in:
2026-04-17 12:57:12 +02:00
parent 89f14463c5
commit 0aeed11186
35 changed files with 1802 additions and 460 deletions

View File

@@ -23,6 +23,7 @@ class MockDeviceV2:
def click(self, x, y):
self.clicks.append((x, y))
self.xml_dump += f"<node new='true' x='{x}' y='{y}' />"
def shell(self, cmd):
self.shells.append(cmd)
@@ -40,12 +41,21 @@ class MockDeviceV2:
class MockDevice:
def __init__(self):
self.deviceV2 = MockDeviceV2()
self.app_id = "com.instagram.android"
def _get_current_app(self):
return "com.instagram.android"
def get_info(self):
return {"displayWidth": 1080, "displayHeight": 2400}
def cm_to_pixels(self, cm):
return cm * 10
def click(self, x=None, y=None, obj=None):
if obj:
x, y = obj.get("x", 0), obj.get("y", 0)
self.deviceV2.click(x, y)
class MockTelepathicEngine:
def find_best_node(self, xml, intent_description, device=None, **kwargs):
@@ -61,6 +71,9 @@ class MockTelepathicEngine:
def _extract_semantic_nodes(self, xml, intent=None, threshold=0.0):
return [{"x": 10, "y": 10}]
def verify_success(self, intent_description, post_click_xml, previous_state_xml=None):
return True
def confirm_click(self, *args, **kwargs):
pass

View File

@@ -0,0 +1,67 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.telepathic_engine import TelepathicEngine
@pytest.fixture
def mock_device():
device = MagicMock()
device.app_id = "com.instagram.android"
# Mock u2 app_current to simulate a notification flicker
# Hardened detection makes up to 3 calls in the 'still flicker' case
device.app_current.side_effect = [
{"package": "com.whatsapp", "activity": ".Main"},
{"package": "com.whatsapp", "activity": ".Main"},
{"package": "com.whatsapp", "activity": ".Main"}
]
return device
def test_drift_hardening_flicker_resolution(mock_device):
"""
Test that _get_current_app handles transient packages correctly.
"""
# DeviceFacade expects (device_id, app_id, args)
# We mock u2.connect to avoid actual connection attempts
with patch("GramAddict.core.device_facade.u2.connect") as mock_connect:
mock_connect.return_value = mock_device
facade = DeviceFacade("mock_serial", "com.instagram.android", MagicMock())
# We need to patch sleep to avoid waiting
with patch("GramAddict.core.device_facade.sleep"):
pkg = facade._get_current_app()
# It should return the app_id (inferred as still in Instagram)
# because it saw a transient package (whatsapp) twice but we
# hardened it to assume it's a notification overlay.
assert pkg == "com.instagram.android"
def test_structural_guard_prevention():
"""
Test that structural intents are NOT blacklisted even if drift is reported.
"""
# Reset singleton or use real instance
engine = TelepathicEngine.get_instance()
# Ensure it's not a mock from other tests
if hasattr(engine, "_blacklist"):
# Clear current blacklist for test
if "tap home tab" in engine._blacklist:
engine._blacklist["tap home tab"] = []
# Simulate a drift context
context = {
"intent": "tap home tab",
"semantic_string": "description: 'Home', id context: 'feed tab'",
"x": 100, "y": 2000
}
TelepathicEngine._last_click_context = context
# Trigger rejection
engine.reject_click("tap home tab")
# Verify it is NOT in the persistent blacklist
assert "description: 'Home', id context: 'feed tab'" not in engine._blacklist.get("tap home tab", [])

View File

@@ -0,0 +1,69 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.telepathic_engine import TelepathicEngine
import os
FAILED_XML_PATH = "/Volumes/Alpha SSD/Coding/bot/debug/xml_dumps/manual_interrupt__2026-04-17_12-35-23.xml"
def test_modal_guard_blocks_nav_intent_on_failed_xml():
"""
Test that the Modal Guard correctly identifies the bottom sheet in the failed XML
and prevents searching for the 'Home Tab'.
"""
if not os.path.exists(FAILED_XML_PATH):
pytest.skip("Failed XML dump not found for testing.")
with open(FAILED_XML_PATH, "r") as f:
xml_content = f.read()
engine = TelepathicEngine.get_instance()
# Intent that SHOULD be blocked because Home Tab is obscured by the comment sheet
intent = "tap home tab"
# We don't want to trigger actual LLM/VLM calls during the test
with patch("GramAddict.core.telepathic_engine.query_telepathic_llm") as mock_vlm:
result = engine.find_best_node(xml_content, intent)
# 1. Verify that no VLM call was even attempted because the Modal Guard should have caught it early
assert mock_vlm.called is False, "VLM should not be called when a modal obscures the target zone."
# 2. Result should be None (meaning 'Target blocked/missing')
assert result is None, "Modal Guard should return None for navigation intents when a sheet is open."
def test_zone_enforcement_blocks_mid_screen_tab_hallucination():
"""
Tests that even if VLM is triggered (e.g. no modal detected but low confidence),
any result for a 'tab' intent that is in the middle of the screen is rejected.
"""
engine = TelepathicEngine.get_instance()
# Minimal XML
xml = "<?xml version='1.0' ?><hierarchy><node index='0' text='Add comment' bounds='[42,2224][1038,2350]' visible-to-user='true' /></hierarchy>"
intent = "tap home tab"
# Mock VLM to return the 'Add comment' field which is at Y=2224 (0.917 of 2424)
# Our guard enforces Tabs MUST be in the bottom 10% (Y > 0.90 * Height).
# Wait, Y=2224 on H=2424 is 0.917. That IS in the bottom 10%.
# Let's mock a node at Y=1000 (middle of screen) to test the guard.
with patch("GramAddict.core.telepathic_engine.query_telepathic_llm") as mock_vlm:
# Mock VLM returning a middle-screen element (Index 0)
mock_vlm.return_value = '{"index": 0, "reason": "hallucination test"}'
# Injected node at middle screen
with patch.object(TelepathicEngine, "_extract_semantic_nodes") as mock_extract:
mock_extract.return_value = [{
"index": 0,
"x": 500, "y": 1000, "width": 100, "height": 100, "area": 10000,
"raw_bounds": "[450,950][550,1050]",
"semantic_string": "text: 'Fake Home', id context: 'fake_tab'"
}]
# We also need to patch _is_modal_active to False so it GETS to the VLM step
with patch.object(TelepathicEngine, "_is_modal_active", return_value=False):
result = engine.find_best_node(xml, intent)
# Should be rejected because navigation tabs should be in the nav bar zone
assert result is None, "Structural Guard should reject mid-screen navigation tab candidates."

View File

@@ -0,0 +1,135 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
from GramAddict.core.session_state import SessionState
@pytest.fixture
def mock_device():
device = MagicMock()
device.deviceV2 = MagicMock()
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.app_id = "com.instagram.android"
return device
def test_reels_loop_repost_execution(mock_device):
"""
TDD Test: Verifies that the bot attempts to repost a Reel if resonance is high
and repost_percentage allows it.
"""
# 1. Setup Cognitive Stack & Configs
mock_cognitive_stack = {
"dopamine": MagicMock(),
"resonance": MagicMock(),
"zero_engine": MagicMock(),
"nav_graph": MagicMock(),
"telepathic": MagicMock()
}
# Simulate a single post interaction then exit
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
# High resonance to trigger repost
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.95
configs = MagicMock()
configs.args.repost_percentage = 100
configs.args.interact_percentage = 100
configs.args.likes_percentage = 0
configs.args.comment_percentage = 0
configs.args.follow_percentage = 0
configs.args.visit_profiles = 0
session_state = MagicMock()
session_state.check_limit.return_value = (False, False, False, False)
# 2. Mock Reels UI
# Reels usually have 'clips_viewer' or similar in the hierarchy
reels_xml = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_profile_header" bounds="[0,200][1080,300]" />
<node resource-id="com.instagram.android:id/clips_author_username" text="test_user" />
<node resource-id="com.instagram.android:id/clips_media_component" content-desc="Check out this cool reel #repost #viral" />
<node resource-id="com.instagram.android:id/clips_video_container" />
<node resource-id="com.instagram.android:id/direct_share_button" content-desc="Share" />
</hierarchy>'''
mock_device.deviceV2.dump_hierarchy.return_value = reels_xml
mock_device.dump_hierarchy.return_value = reels_xml
# Repost Sheet XML
repost_sheet_xml = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/bottom_sheet_container">
<node text="Repost" content-desc="Repost interaction button with two arrows" />
</node>
</hierarchy>'''
# 3. Setup Telepathic Engine Mocks
mock_telepathic = mock_cognitive_stack["telepathic"]
# First call: find interaction buttons
mock_telepathic._extract_semantic_nodes.return_value = [{"x": 100, "y": 100, "semantic_string": "share button"}]
# Logic for finding the Repost button inside the share sheet
def mock_find_best_node(xml, intent, **kwargs):
if "Repost" in intent:
return {"x": 500, "y": 2000, "bounds": "[400,1950][600,2050]", "skip": False}
return {"x": 100, "y": 100, "bounds": "[90,90][110,110]", "skip": False}
mock_telepathic.find_best_node.side_effect = mock_find_best_node
# Simulate share button transition success
mock_cognitive_stack["nav_graph"]._execute_transition.return_value = True
# 4. Execute Feed Loop for Reels
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockEngine, \
patch('GramAddict.core.bot_flow._humanized_click') as mock_click, \
patch('GramAddict.core.bot_flow.sleep'):
MockEngine.get_instance.return_value = mock_telepathic
# Resilient state-based mock for dump_hierarchy
comment_sheet_xml = '''<?xml version='1.0' ?><hierarchy><node resource-id="com.instagram.android:id/layout_comment_thread" /><node resource-id="com.instagram.android:id/bottom_sheet_container" /></hierarchy>'''
state = {'current': reels_xml}
def side_effect_func(*args, **kwargs):
return state['current']
mock_device.dump_hierarchy.side_effect = side_effect_func
mock_device.deviceV2.dump_hierarchy.side_effect = side_effect_func
# We need to change the state when transition is called
original_execute = mock_cognitive_stack["nav_graph"]._execute_transition
def mocked_execute(transition_name):
if transition_name == "tap_comment_button":
state['current'] = comment_sheet_xml
elif transition_name == "tap_share_button":
state['current'] = repost_sheet_xml
return True
mock_cognitive_stack["nav_graph"]._execute_transition.side_effect = mocked_execute
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
_run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"ReelsFeed",
mock_cognitive_stack,
is_reels=True
)
# 5. Assertions
# Should click the share button (via transition) and the repost button (direct click)
assert mock_cognitive_stack["nav_graph"]._execute_transition.called_with("tap_share_button")
# Check if _humanized_click was called for the Repost button (x=500, y=2000)
click_args = [call.args for call in mock_click.call_args_list]
repost_clicked = any(args[1] == 500 and args[2] == 2000 for args in click_args)
assert repost_clicked, "Repost button was not clicked on Reels share sheet"
assert mock_telepathic.confirm_click.called_with("Repost interaction button with two arrows")

View File

@@ -0,0 +1,68 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.q_nav_graph import QNavGraph
class TestAnomalyInterruptions:
def setup_method(self):
self.mock_device = MagicMock()
self.mock_device.deviceV2 = MagicMock()
self.mock_device.app_id = "com.instagram.android"
self.mock_device._get_current_app.return_value = "com.instagram.android"
self.nav_graph = QNavGraph(self.mock_device)
def test_os_permission_dialog_denial(self):
"""
Z-Depth Guard must explicitly attempt to find a 'Deny' / 'Don't allow' button
when encountering the Android permission modal instead of just pressing BACK.
"""
# We simulate a dump showing the Android permission grant dialogue
self.mock_device.deviceV2.dump_hierarchy.return_value = '''
<hierarchy>
<node resource-id="com.android.permissioncontroller:id/grant_dialog">
<node text="Allow Instagram to access your location?" />
<node text="While using the app" resource-id="com.android.permissioncontroller:id/permission_allow_button" bounds="[100,500][900,600]" />
<node text="Don't allow" resource-id="com.android.permissioncontroller:id/permission_deny_button" bounds="[100,650][900,750]" />
</node>
</hierarchy>
'''
# When checking for obstacles, it should clear it by clicking deny
cleared = self.nav_graph._clear_anomaly_obstacles()
assert cleared is True, "Z-Depth Guard failed to clear the OS permission modal"
assert self.mock_device.deviceV2.shell.call_count >= 1
# Verify it called shell with input swipe
args, _ = self.mock_device.deviceV2.shell.call_args
assert "input swipe" in args[0]
# Ensure it looks like "input swipe 493 1008 495 1008 87" natively or similar.
# We don't perform rigid string match because human_swipe adds high coordinate variance.
assert len(args[0].split()) >= 5
def test_instagram_survey_dismissal(self):
"""
Instagram can prompt surveys mid-run. We must dismiss them gracefully
(e.g., clicking Not Now or Cancel) instead of pressing BACK repeatedly.
"""
self.mock_device.deviceV2.dump_hierarchy.return_value = '''
<hierarchy>
<node resource-id="com.instagram.android:id/survey_container">
<node text="How are we doing?" resource-id="com.instagram.android:id/survey_title" />
<node text="Take Survey" resource-id="com.instagram.android:id/take_survey_btn" bounds="[200,800][800,900]" />
<node text="Not Now" resource-id="com.instagram.android:id/button_negative" bounds="[200,950][800,1050]" />
</node>
</hierarchy>
'''
cleared = self.nav_graph._clear_anomaly_obstacles()
assert cleared is True, "Z-Depth Guard failed to dismiss the Instagram Survey"
assert self.mock_device.deviceV2.shell.call_count >= 1
# Verify it called shell with input swipe
args, _ = self.mock_device.deviceV2.shell.call_args
assert "input swipe" in args[0]
# Ensure it looks like "input swipe 493 1008 495 1008 87" natively or similar.
# We don't perform rigid string match because human_swipe adds high coordinate variance.
assert len(args[0].split()) >= 5

View File

@@ -0,0 +1,51 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.q_nav_graph import QNavGraph
def test_app_perimeter_guard_after_click():
"""
Simulates a catastrophic VLM hallucination where clicking an element
(e.g., an ad) causes the OS to switch to another app (e.g., Google Play Store).
The QNavGraph MUST detect this post-click, reject the telemetry,
and return CONTEXT_LOST immediately to prevent memory poisoning.
"""
mock_device = MagicMock()
# Initial state is Instagram
mock_device._get_current_app.side_effect = [
"com.android.vending", # POST-CLICK verification at line 361 (App drifted!)
"com.android.vending", # double check after BACK press in recovery
"com.android.vending" # fallback start check if needed
]
mock_device.app_id = "com.instagram.android"
# UI XML pre/post click
mock_device.deviceV2.dump_hierarchy.side_effect = [
"<hierarchy><node resource-id='ad' /></hierarchy>", # initial context (line 293)
"<hierarchy><node resource-id='ad' /></hierarchy>", # anomaly guard check (line 191)
"<hierarchy><node resource-id='play_store_ui' /></hierarchy>" # post-click check (line 358)
]
# Mock Telepathic Engine
mock_engine = MagicMock()
mock_engine.find_best_node.return_value = {"x": 50, "y": 50, "semantic_string": "fake profile link", "source": "vlm"}
# Even if verify_success blindly returns True because the UI changed, the Perimeter Guard MUST intercept it.
mock_engine.verify_success.return_value = True
nav_graph = QNavGraph(mock_device)
# Execute the transition
result = nav_graph._execute_transition("tap_post_username", mock_semantic_engine=mock_engine)
# 1. It must return CONTEXT_LOST without saving to memory
assert result == "CONTEXT_LOST", "Did not return CONTEXT_LOST after app drifted to Play Store!"
# 2. It MUST NOT confirm the click and poison telemetry!
mock_engine.confirm_click.assert_not_called()
# 3. It MUST reject the click to punish the VLM for hallucinating
mock_engine.reject_click.assert_called_once()
# 4. It MUST press BACK to attempt to leave the Play Store, or at least we should expect it.
# Actually, CONTEXT_LOST relies on the caller (bot_flow or navigate_to) to app_start(), but doing a BACK
# to close play store is even cleaner before returning CONTEXT_LOST.

View File

@@ -9,11 +9,16 @@ def test_autonomous_retry_on_ambiguity_failure():
it should press BACK, blacklist the node, and retry automatically.
"""
mock_device = MagicMock()
mock_device._get_current_app.side_effect = ["com.instagram.android"] * 10
mock_device.app_id = "com.instagram.android"
mock_device.deviceV2.dump_hierarchy.side_effect = [
"initial_ui", # Before click 1
"changed_ui_wrong", # After click 1 (wrong menu opened)
"initial_ui", # After pressing BACK (UI restored)
"changed_ui_correct" # After click 2 (correct view opened)
"initial_ui", # Attempt 1 Start (line 293)
"initial_ui", # Anomaly Guard (line 191)
"changed_ui_wrong", # Post-Click 1 (line 358)
"initial_ui", # Attempt 2 Start (line 293)
"initial_ui", # Anomaly Guard (line 191)
"changed_ui_correct", # Post-Click 2 (line 358)
"changed_ui_correct" # Extra Buffer
]
mock_engine = MagicMock()
@@ -29,7 +34,7 @@ def test_autonomous_retry_on_ambiguity_failure():
nav_graph = QNavGraph(mock_device)
with patch("time.sleep"), patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_engine): # disable actual sleeping in the test
result = nav_graph._execute_transition("tap_grid_first_post", mock_engine)
result = nav_graph._execute_transition("tap_grid_first_post", mock_semantic_engine=mock_engine)
# The transition should ultimately succeed because attempt 2 passes
assert result is True, "Autonomous retry loop failed to return True."

View File

@@ -28,18 +28,10 @@ def test_interact_with_profile_all_100_percent(mock_random, device, telepathic_m
_interact_with_profile(device, configs, "testuser", session_state, 0.0, mock_logger)
# 1 target story click + 2 right-side skip clicks + 1 follow + 1 grid open + 2 post likes (double taps) + 2 scrolls
# 3 story (3 shell commands)
# 1 follow (1 shell command)
# 1 grid tap (1 shell config)
# 2 likes (Double tap = 2 shell commands each = 4 total)
# 2 scrolls (2 shell commands)
# Total shells expected: 3 + 1 + 1 + 4 + 2 = 11
# 2 scrolls (2 shell commands) via _humanized_scroll
# story, follow, grid, like all use QNavGraph click transitions because 'reel_viewer' is in MockDeviceV2 XML.
assert len(device.deviceV2.shells) == 2
# Check total shells
assert len(device.deviceV2.shells) == 11
# We no longer check explicit clicks/double_clicks array because we humanized them into shell commands.
for cmd in device.deviceV2.shells:
assert "input swipe" in cmd
@@ -60,25 +52,20 @@ def test_interact_with_profile_zero_percent(mock_random, device, telepathic_mock
_interact_with_profile(device, configs, "testuser", session_state, 0.0, mock_logger)
# No interaction blocks run, so no shells.
assert len(device.deviceV2.shells) == 0
@patch("random.random")
def test_interact_with_profile_mixed_probability(mock_random, device, telepathic_mock, mock_logger):
# This simulates passing the Follow and Like percentage, but failing the Story percentage.
# It ensures there are no UnboundLocalErrors when certain blocks are skipped.
def mock_random_side_effect():
# Let's say random.random() returns a predictable sequence or just use a generator:
# 1st call: story probability (fail, e.g. 0.99 < 0.0)
# 2nd call: follow probability (pass, e.g. 0.0 < 1.0)
# 3rd call: likes probability (pass, e.g. 0.0 < 1.0)
return 0.5
mock_random.return_value = 0.5
args = MockArgs(
stories_percentage=0, # Fails (0.5 < 0)
follow_percentage=100, # Passes (0.5 < 1)
likes_percentage=100, # Passes (0.5 < 1)
stories_percentage=0,
follow_percentage=100,
likes_percentage=100,
likes_count="1-1"
)
configs = MockConfigs(args)
@@ -89,9 +76,8 @@ def test_interact_with_profile_mixed_probability(mock_random, device, telepathic
# Should not throw any exception
_interact_with_profile(device, configs, "testuser", session_state, 0.0, mock_logger)
# 0 stories, 1 follow, 1 like block (1 grid open + 2 double tap shells + 1 scroll)
# total shells = 1 (follow) + 1 (grid click) + 2 (1 double tap) + 1 (scroll) = 5
assert len(device.deviceV2.shells) == 5
# Grid loop finishes with 1 scroll for 1 post.
assert len(device.deviceV2.shells) == 1
@patch("random.random")
def test_carousel_100_percent(mock_random, device, mock_logger):

View File

@@ -0,0 +1,101 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.exceptions import ActionBlockedError
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.bot_flow import _interact_with_profile
import logging
class TestCriticalAnomalyGuards:
def setup_method(self):
self.mock_device = MagicMock()
self.mock_device.deviceV2 = MagicMock()
self.mock_device.app_id = "com.instagram.android"
self.mock_device._get_current_app.return_value = "com.instagram.android"
self.mock_device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
self.nav_graph = QNavGraph(self.mock_device)
self.logger = logging.getLogger("test")
def test_action_blocked_dialog_raises_exception(self):
"""
If the OS/App shows a 'Try Again Later' block, we must explicitly crash the bot
by throwing an ActionBlockedError to prevent spamming and risking permanent bans.
"""
self.mock_device.deviceV2.dump_hierarchy.return_value = '''
<hierarchy>
<node resource-id="com.instagram.android:id/dialog_container">
<node text="Try Again Later" resource-id="com.instagram.android:id/dialog_title" />
<node text="We restrict certain activity to protect our community." />
<node text="OK" resource-id="com.instagram.android:id/button_positive" />
</node>
</hierarchy>
'''
with pytest.raises(ActionBlockedError, match="Action Block Dialog Detected|Instagram soft-banned"):
self.nav_graph._clear_anomaly_obstacles()
def test_interact_with_private_profile_aborts(self):
"""
If a user account is private, _interact_with_profile must skip everything
and return without doing ANY interactions.
"""
self.mock_device.deviceV2.dump_hierarchy.return_value = '''
<hierarchy>
<node text="marisaundmarc" />
<node text="This account is private" bounds="[100,500][900,600]" />
<node text="Follow to see their photos and videos." />
</hierarchy>
'''
configs = MagicMock()
session_state = MagicMock()
_interact_with_profile(self.mock_device, configs, "test_private", session_state, sleep_mod=0.0, logger=self.logger)
# Verify it did not attempt to find stories, scrape, or anything
self.mock_device.deviceV2.click.assert_not_called()
self.mock_device.deviceV2.press.assert_not_called()
def test_interact_with_empty_profile_aborts(self):
"""
If a user account has 0 posts, we must skip.
"""
self.mock_device.deviceV2.dump_hierarchy.return_value = '''
<hierarchy>
<node text="marisaundmarc" />
<node text="No posts yet" bounds="[100,500][900,600]" />
</hierarchy>
'''
configs = MagicMock()
session_state = MagicMock()
_interact_with_profile(self.mock_device, configs, "test_empty", session_state, sleep_mod=0.0, logger=self.logger)
self.mock_device.deviceV2.click.assert_not_called()
self.mock_device.deviceV2.press.assert_not_called()
def test_comments_disabled_guard(self):
"""
If the user has disabled comments, 'tap_comment_button' must abort and return skip=True
instead of defaulting to the VLM fallback which hallucinates clicks on random UI elements.
"""
from GramAddict.core.telepathic_engine import TelepathicEngine
engine = TelepathicEngine() # Bypass singleton mock
xml_dump = '''
<hierarchy>
<node text="marisaundmarc" />
<node text="comments are turned off." bounds="[100,500][900,600]" />
<node text="Following" />
</hierarchy>
'''
# TelepathicEngine should instantly return a "skip" object without invoking memory or vectors
result = engine.find_best_node(xml_dump, "tap comment button", device=self.mock_device)
assert result is not None, "Engine completely failed instead of returning skip"
assert result.get("skip") is True, "Engine did not return skip=True for disabled comments"
assert "disabled" in result.get("semantic", ""), "Semantic tag should reflect disabled comments"

View File

@@ -0,0 +1,83 @@
import pytest
from GramAddict.core.telepathic_engine import TelepathicEngine
class TestGridRetryDiversity:
"""
TDD Tests: Reproduces Bug 2 from the 2026-04-17 09:56 run.
The Grid Fast-Path always selects the exact same node on every retry
because it sorts by (y, x) and picks index 0. When a click fails,
the retry should skip previously-failed positions.
"""
def setup_method(self):
self.engine = TelepathicEngine()
def _make_grid_nodes(self):
"""Create 6 realistic explore grid nodes (2 rows × 3 cols)."""
return [
{"semantic_string": "id context: 'image button'", "x": 178, "y": 558,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 540, "y": 558,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 902, "y": 558,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 178, "y": 1040,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 540, "y": 1040,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 902, "y": 1040,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
]
def test_first_call_returns_topmost_leftmost(self):
"""Without skip_positions, Grid Fast-Path returns (178, 558)."""
nodes = self._make_grid_nodes()
result = self.engine._grid_fast_path("first image in explore grid", nodes)
assert result is not None
assert result["x"] == 178
assert result["y"] == 558
def test_retry_skips_failed_position(self):
"""With skip_positions={(178, 558)}, the next node (540, 558) is returned."""
nodes = self._make_grid_nodes()
result = self.engine._grid_fast_path(
"first image in explore grid", nodes,
skip_positions={(178, 558)}
)
assert result is not None
assert (result["x"], result["y"]) == (540, 558), \
f"Expected (540, 558) but got ({result['x']}, {result['y']})"
def test_skip_multiple_positions(self):
"""Skipping 2 positions returns the 3rd grid item."""
nodes = self._make_grid_nodes()
result = self.engine._grid_fast_path(
"first image in explore grid", nodes,
skip_positions={(178, 558), (540, 558)}
)
assert result is not None
assert (result["x"], result["y"]) == (902, 558)
def test_all_positions_skipped_returns_none(self):
"""If every grid node is skipped, return None to trigger VLM fallback."""
nodes = self._make_grid_nodes()
all_positions = {(n["x"], n["y"]) for n in nodes}
result = self.engine._grid_fast_path(
"first image in explore grid", nodes,
skip_positions=all_positions
)
assert result is None

View File

@@ -1,67 +1,83 @@
import pytest
from unittest.mock import patch, MagicMock
from GramAddict.core.llm_provider import get_model_pricing, query_llm
import GramAddict.core.llm_provider
from unittest.mock import MagicMock
from GramAddict.core.llm_provider import query_telepathic_llm
import requests
def test_get_model_pricing_success():
# Reset cache
GramAddict.core.llm_provider._MODEL_PRICING_CACHE = None
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{"id": "google/gemini-3.1-flash-lite-preview", "pricing": {"prompt": "0.00000025", "completion": "0.0000015"}},
{"id": "qwen3.5-32b", "pricing": {"prompt": "0.0000001", "completion": "0.0000002"}}
]
}
with patch("GramAddict.core.llm_provider.requests.get", return_value=mock_response):
pricing = get_model_pricing("google/gemini-3.1-flash-lite-preview")
assert pricing["prompt"] == "0.00000025"
assert pricing["completion"] == "0.0000015"
# Test caching
pricing2 = get_model_pricing("google/gemini-3.1-flash-lite-preview")
# Should NOT make another request
assert mock_response.json.call_count == 1
class TestLLMProvider:
def test_get_model_pricing_partial_match():
# Reset cache
GramAddict.core.llm_provider._MODEL_PRICING_CACHE = {"google/gemini-pro": {"prompt": "0.1", "completion": "0.2"}}
# Should match via substring
pricing = get_model_pricing("gemini-pro")
assert pricing["prompt"] == "0.1"
def test_get_model_pricing_failure():
GramAddict.core.llm_provider._MODEL_PRICING_CACHE = None
with patch("GramAddict.core.llm_provider.requests.get", side_effect=Exception("Network error")):
pricing = get_model_pricing("some-model")
assert pricing == {}
def test_vlm_timeout_aborts_fast_connections(self, monkeypatch):
"""
OpenRouter/Cloud connections must timeout around ~45s.
If a timeout exception is raised by requests, query_telepathic_llm should gracefully catch it
and return empty "{}" JSON.
"""
def mock_post(*args, **kwargs):
timeout = kwargs.get("timeout")
assert timeout == 45, "Expected 45s strict timeout for openrouter"
raise requests.exceptions.ReadTimeout("Mocked read timeout")
monkeypatch.setattr(requests, "post", mock_post)
def test_query_llm_cost_calculation():
# Set cache directly
GramAddict.core.llm_provider._MODEL_PRICING_CACHE = {
"test-model": {"prompt": "1.0", "completion": "2.0"}
}
mock_post_response = MagicMock()
mock_post_response.status_code = 200
mock_post_response.json.return_value = {
"choices": [{"message": {"content": "response text"}}],
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}
}
with patch("GramAddict.core.llm_provider.requests.post", return_value=mock_post_response) as mock_post:
with patch("GramAddict.core.llm_provider.logger.info") as mock_logger:
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test_key"}):
res = query_llm("https://openrouter.ai/api/v1/chat/completions", "test-model", "hello", format_json=False)
assert res["response"] == "response text"
# Check that logging included the calculated cost
# prompt = 5 * 1.0 = 5.0
# completion = 10 * 2.0 = 20.0
# total = 25.0
mock_logger.assert_called_with("🪙 [LLM Burn] test-model -> In: 5 | Out: 10 | Total: 15 | 💸 Cost: $25.000000", extra={"color": "\x1b[38;5;208m\x1b[1m"})
# Test Cloud URL
result = query_telepathic_llm(
model="openrouter/qwen3.5:latest",
url="https://openrouter.ai/api/v1/chat/completions",
system_prompt="sys",
user_prompt="user",
use_local_edge=False
)
assert result == "{}"
def test_vlm_timeout_allows_local_processing(self, monkeypatch):
"""
Localhost (Ollama) connections must have a significantly higher timeout (180s)
so cold starts loading into VRAM don't drop.
"""
def mock_post(*args, **kwargs):
url = kwargs.get("url") or args[0]
timeout = kwargs.get("timeout")
assert timeout == 180, f"Expected 180s extended timeout for localhost, got {timeout}"
mock_resp = MagicMock()
mock_resp.status_code = 200
# For ollama/vllm mimic structure
mock_resp.json.return_value = {"response": '{"clicked": "true"}'}
return mock_resp
monkeypatch.setattr(requests, "post", mock_post)
# Test Local URL
result = query_telepathic_llm(
model="qwen3.5:latest",
url="http://localhost:11434/api/generate",
system_prompt="sys",
user_prompt="user",
use_local_edge=False
)
assert result == '{"clicked": "true"}'
def test_local_edge_override_applies_timeout(self, monkeypatch):
"""
If use_local_edge=True is set, it overrides the URL to localhost and MUST apply 180s.
"""
def mock_post(*args, **kwargs):
timeout = kwargs.get("timeout")
assert timeout == 180, "Expected 180s extended timeout when forced to local edge"
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"response": '{"edge": "true"}'}
return mock_resp
monkeypatch.setattr(requests, "post", mock_post)
result = query_telepathic_llm(
model="openrouter",
url="https://openrouter...",
system_prompt="sys",
user_prompt="user",
use_local_edge=True # Force local
)
assert result == '{"edge": "true"}'

View File

@@ -0,0 +1,91 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.telepathic_engine import TelepathicEngine
from GramAddict.core.q_nav_graph import QNavGraph
class TestProfileInteractionSync:
"""
TDD Tests: Reproduces the 'Already Followed -> Favorites' and 'No Story Ring'
bugs from 2026-04-17 live run.
"""
def setup_method(self):
self.engine = TelepathicEngine()
self.mock_device = MagicMock()
self.mock_device.deviceV2 = MagicMock()
self.mock_device.app_id = "com.instagram.android"
self.mock_device._get_current_app.return_value = "com.instagram.android"
self.nav_graph = QNavGraph(self.mock_device)
def test_prevent_tapping_following_button(self):
"""
If the intent is to follow, but the matching node says 'following' or 'gefolgt',
the engine must skip the click to prevent opening the Favorites/Mute bottom sheet.
"""
# Simulate a profile where the user is already followed
viable_nodes = [{
"semantic_string": "id context: 'profile header user action follow button', text: 'Following'",
"x": 500, "y": 600,
"width": 100, "height": 50,
"area": 5000,
"class_name": "android.widget.Button",
"resource_id": "com.instagram.android:id/button",
"original_attribs": {"text": "Following"}
}]
# Test vector-based matching fallback
self.engine._blacklist = {}
# Mock the extraction to avoid needing valid complex XML
self.engine._extract_semantic_nodes = MagicMock(return_value=viable_nodes)
self.engine._structural_sanity_check = MagicMock(return_value=True)
self.engine._is_instagram_context = MagicMock(return_value=True)
result = self.engine.find_best_node("<mock></mock>", "tap follow button on profile", device=self.mock_device)
# We must intercept it in TelepathicEngine before VLM is called
# Wait, find_best_node falls back to VLM if vector score is low.
# But if we inject it into memory, it triggers stage 1
self.engine._memory = {
"tap follow button on profile": ["id context: 'profile header user action follow button', text: 'Following'"]
}
TelepathicEngine._instance = self.engine
result = self.engine.find_best_node("<mock></mock>", "tap follow button on profile", device=self.mock_device)
assert result is not None, "Engine should return a skip result, not None"
assert result.get("skip") is True, "Must return skip: True to prevent Favorites menu from opening"
assert result.get("semantic") == "already_followed"
def test_story_ring_not_present_skips_click(self):
"""
If no story ring is explicitly in the XML, bot_flow should not execute
the transition (simulated here by checking our XML evaluation logic).
"""
xml_without_story = '''
<hierarchy>
<node resource-id="com.instagram.android:id/row_profile_header_imageview" content-desc="Profile picture" />
<node text="marisaundmarc" />
</hierarchy>
'''
has_story = "reel_ring" in xml_without_story or "unseen story" in xml_without_story.lower() or "story von" in xml_without_story.lower()
assert has_story is False, "Logic falsely identified a story when there is only a generic profile picture"
def test_story_ring_present_allows_click(self):
"""
If a story ring is present, the logic should allow the interaction.
"""
xml_with_story = '''
<hierarchy>
<node resource-id="com.instagram.android:id/reel_ring" />
<node resource-id="com.instagram.android:id/row_profile_header_imageview" content-desc="mercedesbenz_de's unseen story" />
</hierarchy>
'''
has_story = "reel_ring" in xml_with_story or "unseen story" in xml_with_story.lower() or "story von" in xml_with_story.lower()
assert has_story is True, "Logic failed to identify active story ring"

View File

@@ -19,6 +19,7 @@ def test_profile_grid_sync_delay_after_follow():
It now tracks the autonomous QNavGraph calls.
"""
mock_device = MagicMock()
mock_device.deviceV2.dump_hierarchy.return_value = "dummy hierarchy"
mock_configs = FakeConfig()
mock_session_state = MagicMock(spec=SessionState)

View File

@@ -0,0 +1,109 @@
import pytest
from unittest.mock import MagicMock, call
from GramAddict.core.unfollow_engine import _run_zero_latency_unfollow_loop
import logging
class TestUnfollowEngine:
def setup_method(self):
self.mock_device = MagicMock()
self.mock_device.deviceV2 = MagicMock()
self.mock_device.app_id = "com.instagram.android"
self.mock_device._get_current_app.return_value = "com.instagram.android"
self.mock_device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
self.mock_telepathic = MagicMock()
self.mock_dopamine = MagicMock()
self.mock_dopamine.is_app_session_over.return_value = False
self.mock_dopamine.wants_to_change_feed.return_value = False
# default boredom
self.mock_dopamine.boredom = 0.0
self.cognitive_stack = {
"telepathic": self.mock_telepathic,
"dopamine": self.mock_dopamine,
}
self.mock_configs = MagicMock()
self.mock_configs.args.total_unfollows_limit = 50
self.mock_session_state = MagicMock()
self.mock_session_state.totalUnfollowed = 0
self.mock_session_state.check_limit.return_value = False
self.logger = logging.getLogger("test")
def test_unfollow_loop_success(self, monkeypatch):
"""
Happy path: Finds 'Following' button -> Clicks it -> Finds 'Unfollow' confirmation -> Clicks it -> Increments totalUnfollowed.
Then dopamine signals change of feed.
"""
def fake_extract_semantic_nodes(xml, intent, **kwargs):
if "Unfollow" in intent:
return [{"semantic_string": "Unfollow Confirmation", "x": 500, "y": 1500, "bounds": "[100,200]", "skip": False}]
else:
return [{"semantic_string": "Following Button", "x": 900, "y": 600, "bounds": "[100,200]", "skip": False}]
self.mock_telepathic._extract_semantic_nodes.side_effect = fake_extract_semantic_nodes
self.mock_dopamine.wants_to_change_feed.side_effect = [True]
# Patch local imports inside the bot_flow namespace since they are locally imported
mock_sleep = MagicMock()
mock_click = MagicMock()
import GramAddict.core.bot_flow
monkeypatch.setattr(GramAddict.core.bot_flow, "sleep", mock_sleep)
monkeypatch.setattr(GramAddict.core.bot_flow, "_humanized_click", mock_click)
result = _run_zero_latency_unfollow_loop(
self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack
)
# Verify result and clicks
assert result == "BOREDOM_CHANGE_FEED"
assert self.mock_session_state.totalUnfollowed == 1
# Assert humanized clicks were logged correctly
assert mock_click.call_count == 2
mock_click.assert_has_calls([
call(self.mock_device, 900, 600),
call(self.mock_device, 500, 1500)
])
def test_unfollow_loop_scrolls_if_empty(self, monkeypatch):
"""
End of list path: If no following buttons are found, it should scroll _humanized_scroll_down.
After 5 failed scrolls, it should gracefully return BOREDOM_CHANGE_FEED without crashing.
"""
# Always return empty nodes
self.mock_telepathic._extract_semantic_nodes.return_value = []
# Patch the imported _humanized_scroll_down so we can track it
mock_scroll = MagicMock()
import GramAddict.core.unfollow_engine
monkeypatch.setattr(GramAddict.core.unfollow_engine, "_humanized_scroll_down", mock_scroll)
result = _run_zero_latency_unfollow_loop(
self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack
)
assert result == "BOREDOM_CHANGE_FEED"
# It should scroll exactly 6 times (failed_scrolls > 5)
assert mock_scroll.call_count == 6
assert self.mock_session_state.totalUnfollowed == 0
def test_unfollow_loop_respects_limits(self):
"""
Limit protection: If session limit is reached at the start, abort immediately.
No clicks or UI dumps should occur.
"""
# Tell session_state that UNFOLLOWS limit is hit
self.mock_session_state.check_limit.return_value = (True, "Unfollow limit reached")
result = _run_zero_latency_unfollow_loop(
self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack
)
assert result == "BOREDOM_CHANGE_FEED"
self.mock_device.deviceV2.dump_hierarchy.assert_not_called()
self.mock_device.deviceV2.click.assert_not_called()
self.mock_session_state.totalUnfollowed = 0

View File

@@ -0,0 +1,73 @@
import pytest
from GramAddict.core.telepathic_engine import TelepathicEngine
class TestVerifySuccessGridReels:
"""
TDD Tests: Reproduces Bug 1 from the 2026-04-17 09:56 run.
The Grid Fast-Path correctly clicks an explore grid item, the UI changes
(a Reel opens), but verify_success() returns False because it only looks
for row_feed_* markers which don't exist in Reel views.
"""
def setup_method(self):
self.engine = TelepathicEngine()
# Simulate a click context so verify_success has something to check against
TelepathicEngine._last_click_context = {
"intent": "first image in explore grid",
"semantic_string": "id context: 'image button'",
"x": 178, "y": 558,
"timestamp": 0
}
def test_reel_view_accepted_as_valid_grid_result(self):
"""A Reel opening after a grid tap must be accepted as success."""
# Simulate post-click XML containing Reel markers but NO feed markers
reel_xml = """
<hierarchy>
<node resource-id="com.instagram.android:id/clips_viewer_view_pager" class="ViewPager" />
<node resource-id="com.instagram.android:id/clips_media_component" class="FrameLayout" />
<node content-desc="Like" resource-id="com.instagram.android:id/clips_like_button" />
<node content-desc="Comment" resource-id="com.instagram.android:id/clips_comment_button" />
</hierarchy>
"""
result = self.engine.verify_success("first image in explore grid", reel_xml)
assert result is True, "verify_success rejected a valid Reel view opened from grid tap"
def test_normal_feed_post_still_accepted(self):
"""A normal feed post opening after a grid tap must still be accepted."""
feed_xml = """
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" content-desc="Like" />
<node resource-id="com.instagram.android:id/row_feed_button_comment" content-desc="Comment" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="@testuser" />
</hierarchy>
"""
result = self.engine.verify_success("first image in explore grid", feed_xml)
assert result is True, "verify_success rejected a valid feed post opened from grid tap"
def test_explore_grid_still_visible_is_failure(self):
"""If the grid is still showing (no post opened), verify must fail."""
explore_xml = """
<hierarchy>
<node resource-id="com.instagram.android:id/recycler_view" />
<node resource-id="com.instagram.android:id/image_button" />
<node resource-id="com.instagram.android:id/explore_action_bar" />
<node text="Search" resource-id="com.instagram.android:id/action_bar_search_edit_text" />
</hierarchy>
"""
result = self.engine.verify_success("first image in explore grid", explore_xml)
assert result is False, "verify_success accepted the explore grid as a post view"
def test_profile_grid_reel_accepted(self):
"""Profile grid → Reel must also be accepted."""
TelepathicEngine._last_click_context["intent"] = "first image post in profile grid"
reel_xml = """
<hierarchy>
<node resource-id="com.instagram.android:id/clips_viewer_view_pager" />
<node resource-id="com.instagram.android:id/reel_viewer_subtitle" text="Audio" />
</hierarchy>
"""
result = self.engine.verify_success("first image post in profile grid", reel_xml)
assert result is True, "verify_success rejected a Reel opened from profile grid"