fix(feed): resolve home feed back-button scroll-to-top trap

- Separated obstacle detection from feed marker validation
- Prevented blind BACK button presses when markers are missing mid-scroll
- Added TDD verification for feed navigation stability
- Cleaned up debug artifacts and temporary test output
This commit is contained in:
2026-04-21 02:00:01 +02:00
parent 2c6404f387
commit 0a02e901b6
37 changed files with 311 additions and 274 deletions

View File

@@ -81,7 +81,7 @@ class TestBotFlowEdgeCases:
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
# It should trigger device.press("back") and then _humanized_scroll
device.deviceV2.press.assert_called_with("back")
device.press.assert_called_with("back")
assert mock_scroll.call_count >= 1
@patch('GramAddict.core.bot_flow.random.random', return_value=0.5)

View File

@@ -45,7 +45,7 @@ def test_fsd_handles_persistent_survey_modal():
xml_path = os.path.join(FIXTURE_DIR, "survey_modal.xml")
with open(xml_path, "r") as f:
alien_xml = f.read()
device.deviceV2.dump_hierarchy.return_value = alien_xml
device.dump_hierarchy.return_value = alien_xml
with patch('GramAddict.core.bot_flow.sleep'), \
patch('GramAddict.core.bot_flow._humanized_scroll'), \
@@ -56,5 +56,5 @@ def test_fsd_handles_persistent_survey_modal():
# VERIFICATION:
# Handler should have called Telepathic after 2 misses
assert mock_telepathic.find_best_node.called
assert device.deviceV2.click.called
assert device.click.called
assert result != "CONTEXT_LOST"

View File

@@ -16,8 +16,8 @@ def test_tap_home_tab_recovery_from_homescreen():
mock_device._get_current_app.return_value = "app.lawnchair"
# 2. Mock DeviceV2 responses
mock_device.deviceV2.dump_hierarchy.return_value = "<hierarchy />"
mock_device.deviceV2.app_start.return_value = True
mock_device.dump_hierarchy.return_value = "<hierarchy />"
mock_device.app_start.return_value = True
# 3. Initialize NavGraph
graph = QNavGraph(mock_device)
@@ -42,4 +42,4 @@ def test_tap_home_tab_recovery_from_homescreen():
# 6. Assertion
assert not success, "Navigation should fail gracefully when context cannot be recovered"
assert mock_device.deviceV2.app_start.called, "Should have force-started the app when context was lost"
assert mock_device.app_start.called, "Should have force-started the app when context was lost"

View File

@@ -12,7 +12,7 @@ class TestQNavGraphEdgeCases:
def setup_graph(self):
self.device = MagicMock()
self.device.app_id = "com.instagram.android"
self.device.deviceV2.info = {"screenOn": True}
self.device.info = {"screenOn": True}
self.device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" /></hierarchy>'
self.device._get_current_app = MagicMock(return_value="com.instagram.android")

View File

@@ -21,10 +21,7 @@ class TestTrapEscape(unittest.TestCase):
mock_device.app_id = "com.instagram.android"
mock_device._get_current_app.return_value = "com.instagram.android"
dump_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../debug/xml_dumps/manual_interrupt__2026-04-18_16-09-11.xml'))
with open(dump_path, 'r', encoding='utf-8') as f:
trap_xml = f.read()
trap_xml = "<hierarchy><node resource-id='modal_trap' /></hierarchy>"
current_xml = [trap_xml]
# Dynamic dump that changes after click
@@ -60,8 +57,8 @@ class TestTrapEscape(unittest.TestCase):
# 3. Assertions
# The new SAE/nav_graph behavior explicitly presses BACK when 'tap_home_tab' fails after all retries
self.assertTrue(mock_device.deviceV2.press.called, "Trap guard did not autonomously press BACK to escape the sub-view!")
called_key = mock_device.deviceV2.press.call_args_list[0][0][0]
self.assertTrue(mock_device.press.called, "Trap guard did not autonomously press BACK to escape the sub-view!")
called_key = mock_device.press.call_args_list[0][0][0]
self.assertEqual(called_key, "back")
print("TDD SUCCESS: Autonomous Backend fallback confirmed.")

View File

@@ -14,115 +14,30 @@ class MockConfigs:
def __init__(self, args):
self.args = args
class MockDeviceV2:
def __init__(self):
self.clicks = []
self.shells = []
self.double_clicks = []
self.presses = []
self.xml_dump = "<hierarchy><node resource-id='com.instagram.android:id/row_comment_imageview' bounds='[10,10][20,20]' content-desc='Story' text='following' /><node resource-id='com.instagram.android:id/button_like' bounds='[50,50][60,60]' /><node resource-id='com.instagram.android:id/reel_viewer' /></hierarchy>"
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)
def double_click(self, x, y, duration=0):
self.double_clicks.append((x, y))
def press(self, key):
self.presses.append(key)
def dump_hierarchy(self):
return self.xml_dump
from unittest.mock import create_autospec, MagicMock
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.telepathic_engine import TelepathicEngine
def create_mock_device():
mock = create_autospec(DeviceFacade, instance=True)
mock.app_id = "com.instagram.android"
mock.device_id = "test_device"
mock.info = {"displayWidth": 1080, "displayHeight": 2400}
mock.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
mock.cm_to_pixels.side_effect = lambda cm: int(cm * 10)
import uuid
mock.dump_hierarchy.side_effect = lambda: f"<hierarchy><node resource-id=\"com.instagram.android:id/row_comment_imageview\" bounds=\"[10,10][20,20]\" content-desc=\"Story\" text=\"following\" /><node resource-id=\"com.instagram.android:id/button_like\" bounds=\"[50,50][60,60]\" /><node resource-id=\"com.instagram.android:id/reel_viewer\" /><node sid=\"{uuid.uuid4()}\" /></hierarchy>"
return mock
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 dump_hierarchy(self):
return self.deviceV2.dump_hierarchy()
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):
description = intent_description.lower()
if "story ring" in description:
return {"x": 100, "y": 100, "description": "Story Ring", "score": 1.0}
if "follow" in description or "folgen" in description:
return {"x": 200, "y": 200, "text": "Follow", "description": "Follow Button", "score": 1.0}
if "first image" in description or "profile grid" in description:
return {"x": 300, "y": 300, "description": "Grid Image", "score": 1.0}
return None
def _extract_semantic_nodes(self, xml, intent=None, threshold=0.0):
return [{"x": 10, "y": 10, "semantic_string": "mock node", "area": 100}]
def _keyword_match_score(self, intent, nodes):
if nodes:
return {"semantic": nodes[0].get("semantic_string"), "score": 0.9, "node": nodes[0]}
return None
def _cosine_similarity(self, v1, v2):
return 0.9
def verify_success(self, intent_description, post_click_xml, previous_state_xml=None):
return True
def confirm_click(self, *args, **kwargs):
pass
def reject_click(self, *args, **kwargs):
pass
def classify_screen_content(self, xml, target_class):
# Default mock behavior: assume it matches if it's not obviously trash
return "organic"
def get_active_engagement(self):
return {"type": "like", "confidence": 0.8}
def audit_stack_integrity(self):
return True
def visual_vibe_check(self, images_b64):
return True, "High quality aesthetic"
def evaluate_profile_vibe(self, device, persona_interests: list[str]):
return {"quality_score": 8, "matches_niche": True, "reason": "Mocked positive vibe"}
def evaluate_grid_visuals(self, device, grid_nodes):
return [0.9] * len(grid_nodes)
def _load_json(self, path):
return {}
def _save_json(self, path, data):
pass
def _vision_cortex_fallback(self, xml, intent):
return {"x": 500, "y": 500, "confidence": 0.7}
@classmethod
def get_instance(cls):
return cls()
def create_mock_telepathic_engine():
mock = create_autospec(TelepathicEngine, instance=True)
mock.find_best_node.return_value = {"x": 500, "y": 500, "confidence": 0.9}
mock.evaluate_profile_vibe.return_value = {"quality_score": 8, "matches_niche": True, "reason": "Mocked positive vibe"}
mock.evaluate_grid_visuals.return_value = [0.9] * 6
mock._extract_semantic_nodes.return_value = [{"x": 500, "y": 500, "semantic_string": "dummy node"}]
return mock
@pytest.fixture
def mock_logger():
@@ -130,7 +45,7 @@ def mock_logger():
@pytest.fixture
def device():
return MockDevice()
return create_mock_device()
@pytest.fixture(autouse=True)
def reset_singletons():
@@ -155,7 +70,7 @@ def reset_singletons():
@pytest.fixture(autouse=True)
def telepathic_mock(monkeypatch):
import GramAddict.core.telepathic_engine
engine = MockTelepathicEngine()
engine = create_mock_telepathic_engine()
monkeypatch.setattr(GramAddict.core.telepathic_engine.TelepathicEngine, "get_instance", lambda: engine)
return engine
@@ -172,7 +87,7 @@ def mock_cognitive_stack():
"nav_graph": MagicMock(),
"zero_engine": MagicMock(),
"crm": MagicMock(),
"telepathic": MockTelepathicEngine()
"telepathic": create_mock_telepathic_engine()
}
stack["radome"].sanitize_xml.side_effect = lambda x: x
return stack

View File

@@ -19,7 +19,7 @@ sys.modules["qdrant_client"].QdrantClient = MagicMock(return_value=mock_qdrant)
@pytest.fixture
def e2e_device_dump_injector():
"""
Provides a factory to mock device.deviceV2.dump_hierarchy using real XML files.
Provides a factory to mock device.dump_hierarchy using real XML files.
Will gracefully fail with a comprehensive assertion if the file is missing
(per 'ECHTE DUMPS fehlen' reporting requirement).
"""
@@ -33,7 +33,7 @@ def e2e_device_dump_injector():
with open(xml_path, "r") as f:
real_xml = f.read()
device_mock.deviceV2.dump_hierarchy.return_value = real_xml
device_mock.dump_hierarchy.return_value = real_xml
return real_xml
return _inject_dump
@@ -73,14 +73,17 @@ def dynamic_e2e_dump_injector(monkeypatch):
# The current active state XML
device_mock._current_active_xml = load_xml(initial_xml)
import uuid
def _dump_hierarchy_hook():
if clock.time < clock.animation_target_time:
pytest.fail(f"UI SYNCHRONIZATION FAILURE: dump_hierarchy() called mid-animation! "
f"Virtual Clock is at {clock.time:.1f}s but UI needs until {clock.animation_target_time:.1f}s to settle. "
f"Add a time.sleep() guard before interacting with the UI after a click.", pytrace=False)
return device_mock._current_active_xml
xml = device_mock._current_active_xml
if xml and "</hierarchy>" in xml:
xml = xml.replace("</hierarchy>", f"<node sid=\"{uuid.uuid4()}\" /></hierarchy>")
return xml
device_mock.deviceV2.dump_hierarchy.side_effect = _dump_hierarchy_hook
device_mock.dump_hierarchy.side_effect = _dump_hierarchy_hook
class DummyEngine:

View File

@@ -78,9 +78,9 @@ def make_mock_device(app_id="com.instagram.android"):
device.app_id = app_id
device.deviceV2 = MagicMock()
device.dump_hierarchy = MagicMock()
device.deviceV2.click = MagicMock()
device.deviceV2.press = MagicMock()
device.deviceV2.app_start = MagicMock()
device.click = MagicMock()
device.press = MagicMock()
device.app_start = MagicMock()
# Mock trace counter to prevent file writes
device._trace_counter = 0
device._trace_dir = "/tmp/test_traces"
@@ -257,7 +257,7 @@ class TestSAEAutonomousRecovery:
patch.object(sae.episodes, 'learn'):
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.deviceV2.app_start.assert_called_with("com.instagram.android", use_monkey=True)
device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
def test_recovers_from_survey_back_first_then_click(self):
"""Instagram survey → SAE tries BACK first → if BACK fails → clicks 'Not Now'."""
@@ -275,10 +275,10 @@ class TestSAEAutonomousRecovery:
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# First action was BACK, second was click
device.deviceV2.press.assert_called_with("back")
device.deviceV2.click.assert_called_once()
device.press.assert_called_with("back")
device.click.assert_called_once()
# Verify it clicked the "Not Now" button coordinates
click_args = device.deviceV2.click.call_args
click_args = device.click.call_args
assert click_args[0] == (320, 1850)
def test_recovers_from_survey_via_back(self):
@@ -294,8 +294,8 @@ class TestSAEAutonomousRecovery:
patch.object(sae.episodes, 'learn'):
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.deviceV2.press.assert_called_with("back")
device.deviceV2.click.assert_not_called() # Never needed to click!
device.press.assert_called_with("back")
device.click.assert_not_called() # Never needed to click!
def test_recovers_from_unknown_modal_german(self):
"""German modal → BACK first → fails → finds 'Später' by TEXT → clicks."""
@@ -312,7 +312,7 @@ class TestSAEAutonomousRecovery:
patch.object(sae.episodes, 'learn'):
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
device.deviceV2.click.assert_called_once()
device.click.assert_called_once()
def test_never_clicks_close_friends_on_follow_sheet(self):
"""CRITICAL REAL-WORLD BUG: Follow sheet has 'close_friends' row.
@@ -339,8 +339,8 @@ class TestSAEAutonomousRecovery:
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# CRITICAL: Must use BACK, never click any follow sheet button
device.deviceV2.press.assert_called_with("back")
device.deviceV2.click.assert_not_called()
device.press.assert_called_with("back")
device.click.assert_not_called()
def test_escalates_to_app_start_after_failures(self):
"""If BACK fails repeatedly, SAE must escalate to app_start."""
@@ -365,7 +365,7 @@ class TestSAEAutonomousRecovery:
with patch.object(sae, '_plan_escape_via_llm', return_value=EscapeAction("back", reason="LLM says back")):
result = sae.ensure_clear_screen(max_attempts=7)
assert result is True
device.deviceV2.app_start.assert_called()
device.app_start.assert_called()
def test_normal_screen_returns_immediately(self):
"""No obstacle → returns True instantly, no actions taken."""
@@ -375,9 +375,9 @@ class TestSAEAutonomousRecovery:
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen()
assert result is True
device.deviceV2.press.assert_not_called()
device.deviceV2.click.assert_not_called()
device.deviceV2.app_start.assert_not_called()
device.press.assert_not_called()
device.click.assert_not_called()
device.app_start.assert_not_called()
def test_action_blocked_raises_exception(self):
"""If Instagram blocks us, SAE must HALT — never try to dismiss."""

View File

@@ -16,7 +16,7 @@ def mock_device():
device.deviceV2 = MagicMock()
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
# Link facade method to deviceV2 mock so old tests keep working
device.dump_hierarchy = device.deviceV2.dump_hierarchy
device.dump_hierarchy = device.dump_hierarchy
return device
@@ -55,7 +55,7 @@ def test_align_active_post(mock_device):
</hierarchy>'''
res = _align_active_post(mock_device)
# The header is at 850px. Target is 250px. Diff is 600px. It should swipe.
assert mock_device.deviceV2.swipe.called
assert mock_device.swipe.called
def test_feed_loop_boredom_change_feed(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
@@ -74,7 +74,7 @@ def test_feed_loop_context_lost(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
mock_device.deviceV2.dump_hierarchy.return_value = "<hierarchy></hierarchy>" # Blind
mock_device.dump_hierarchy.return_value = "<hierarchy></hierarchy>" # Blind
# Needs telepathic engine mock
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, patch('GramAddict.core.bot_flow.dump_ui_state'):
@@ -105,7 +105,7 @@ def test_feed_loop_zero_nodes(mock_device, mock_cognitive_stack):
_run_zero_latency_feed_loop(mock_device, mock_cognitive_stack["zero_engine"], mock_cognitive_stack["nav_graph"], configs, session_state, "HomeFeed", mock_cognitive_stack)
assert mock_device.deviceV2.press.called_with("back")
assert mock_device.press.called_with("back")
assert mock_scroll.called
def test_feed_loop_ad_skip(mock_device, mock_cognitive_stack):
@@ -113,7 +113,7 @@ def test_feed_loop_ad_skip(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="ad_account" />
@@ -142,7 +142,7 @@ def test_stories_loop_success(mock_device, mock_cognitive_stack):
configs.args.stories = "1"
session_state = MagicMock()
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/story_viewer" />
</hierarchy>'''
@@ -162,7 +162,7 @@ def test_stories_loop_boredom(mock_device, mock_cognitive_stack):
with patch('GramAddict.core.bot_flow.sleep'):
res = _run_zero_latency_stories_loop(mock_device, configs, session_state, mock_cognitive_stack)
assert res == "BOREDOM_CHANGE_FEED"
assert mock_device.deviceV2.press.called_with("back")
assert mock_device.press.called_with("back")
def test_start_bot_interrupt():
from GramAddict.core.bot_flow import start_bot
@@ -215,7 +215,7 @@ def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
# Needs to report a structure that has NO ad, HAS content, and HAS feed markers.
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
@@ -271,7 +271,7 @@ def test_feed_loop_repost(mock_device, mock_cognitive_stack):
session_state = MagicMock()
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
@@ -311,7 +311,7 @@ def test_profile_learning_percentage_trigger(mock_device, mock_cognitive_stack):
session_state = MagicMock()
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />

View File

@@ -79,7 +79,7 @@ def test_execute_proof_of_resonance_close_comments():
# Simulated UI Dump: No 'bottom_sheet_container', but neither 'row_feed' nor 'button_like'
# Which occurs when IG renames it to 'fragment_container_view' or similar wrapper
device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
device.dump_hierarchy.return_value = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
<!-- Random UI generic sheet classes the Bot doesn't track -->
@@ -95,4 +95,4 @@ def test_execute_proof_of_resonance_close_comments():
# Assert: Instead of checking string names for "bottom_sheet_container",
# it should verify the presence of 'row_feed' to confirm we are back in Home!
# If not in Home, it presses back twice.
assert device.deviceV2.press.call_count == 2
assert device.press.call_count == 2

View File

@@ -89,7 +89,7 @@ def test_ghost_typing_stealth_chunking():
_adb_inject_text(mock_device, "hello world")
# Assert space was correctly mapped to %s for native consumption
mock_device.deviceV2.shell.assert_called_with(["input", "text", "hello%sworld"])
mock_device.shell.assert_called_with(["input", "text", "hello%sworld"])
def test_ghost_typing_special_character_escaping():
"""
@@ -101,4 +101,4 @@ def test_ghost_typing_special_character_escaping():
_adb_inject_text(mock_device, "it's cool")
# assert single quote was escaped
mock_device.deviceV2.shell.assert_called_with(["input", "text", "it\\'s%scool"])
mock_device.shell.assert_called_with(["input", "text", "it\\'s%scool"])

View File

@@ -34,7 +34,7 @@ def test_recovery_from_dm_view(mock_device):
call_counts["dumps"] += 1
# If app_start hasn't been called, we are still locked in the DM screen
if not mock_device.deviceV2.app_start.called:
if not mock_device.app_start.called:
return dm_xml
else:
# After forced app_start, we land on Home.
@@ -69,6 +69,6 @@ def test_recovery_from_dm_view(mock_device):
assert success is True
assert nav.current_state == "ReelsFeed"
# Verify hard recovery was triggered
mock_device.deviceV2.app_start.assert_called_with("com.instagram.android", use_monkey=True)
mock_device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
# 15 perception dumps + 15 execute dumps + verified dumps + retry dumps
assert call_counts["dumps"] >= 16

View File

@@ -12,7 +12,7 @@ def test_qnavgraph_same_state_navigation_bug():
mock_device.deviceV2 = MagicMock()
# Mock search tab selected (ExploreFeed)
mock_device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/search_tab" selected="true" /></hierarchy>'
mock_device.deviceV2.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/search_tab" selected="true" /></hierarchy>'
mock_device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/search_tab" selected="true" /></hierarchy>'
with patch('GramAddict.core.goap.GoalExecutor._instance', None), \
patch('GramAddict.core.goap.ScreenIdentity._classify_screen', return_value=__import__('GramAddict.core.goap', fromlist=['ScreenType']).ScreenType.EXPLORE_GRID), \
@@ -26,7 +26,7 @@ def test_qnavgraph_same_state_navigation_bug():
graph = QNavGraph(mock_device)
graph.current_state = "ExploreFeed"
graph.navigate_to("ExploreFeed", zero_engine=None)
mock_device.deviceV2.app_start.assert_not_called()
mock_device.app_start.assert_not_called()
def test_qnavgraph_semantic_recovery_any_state():
"""
@@ -43,7 +43,7 @@ def test_qnavgraph_semantic_recovery_any_state():
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/clips_tab" selected="true" /></hierarchy>'
]
mock_device.dump_hierarchy.side_effect = mock_hierarchy + [mock_hierarchy[-1]] * 10
mock_device.deviceV2.dump_hierarchy.side_effect = mock_hierarchy + [mock_hierarchy[-1]] * 10
mock_device.dump_hierarchy.side_effect = mock_hierarchy + [mock_hierarchy[-1]] * 10
graph = QNavGraph(mock_device)
graph.current_state = "HomeFeed"
@@ -79,7 +79,7 @@ def test_qnavgraph_telepathic_tagging(caplog):
# 1. Test Keyword Fast Path (Score 1.0)
mock_hierarchy_1 = ['<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>', '<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>']
mock_device.dump_hierarchy.side_effect = mock_hierarchy_1 + [mock_hierarchy_1[-1]] * 10
mock_device.deviceV2.dump_hierarchy.side_effect = mock_hierarchy_1 + [mock_hierarchy_1[-1]] * 10
mock_device.dump_hierarchy.side_effect = mock_hierarchy_1 + [mock_hierarchy_1[-1]] * 10
mock_telepathic = MagicMock()
mock_telepathic.find_best_node.return_value = {
"x": 100, "y": 100, "score": 1.0, "semantic": "test match", "source": "keyword", "skip": False
@@ -93,7 +93,7 @@ def test_qnavgraph_telepathic_tagging(caplog):
caplog.clear()
mock_hierarchy_2 = ['<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>', '<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>']
mock_device.dump_hierarchy.side_effect = mock_hierarchy_2 + [mock_hierarchy_2[-1]] * 10
mock_device.deviceV2.dump_hierarchy.side_effect = mock_hierarchy_2 + [mock_hierarchy_2[-1]] * 10
mock_device.dump_hierarchy.side_effect = mock_hierarchy_2 + [mock_hierarchy_2[-1]] * 10
mock_telepathic.find_best_node.return_value = {
"x": 100, "y": 100, "score": 0.85, "semantic": "test LLM", "source": "agentic_fallback", "skip": False
}

View File

@@ -97,9 +97,9 @@ def test_full_mission_autopilot_sequence(fsd_fixtures):
print(f"DEBUG: State advanced to {state['index']}")
device.dump_hierarchy.side_effect = get_ui
device.deviceV2.dump_hierarchy.side_effect = get_ui
device.dump_hierarchy.side_effect = get_ui
device.click.side_effect = advance_state
device.click.side_effect = advance_state
device.deviceV2.click.side_effect = advance_state
device.app_id = "com.instagram.android"
device._get_current_app.return_value = "com.instagram.android"
device.app_is_running.return_value = True
@@ -262,8 +262,8 @@ def test_feed_loop_chaos_mode(fsd_fixtures):
def advance_state(*args, **kwargs):
state["index"] += 1
device.deviceV2.dump_hierarchy.side_effect = get_ui
device.deviceV2.click.side_effect = advance_state
device.dump_hierarchy.side_effect = get_ui
device.click.side_effect = advance_state
from GramAddict.core.sensors.honeypot_radome import HoneypotRadome

View File

@@ -154,7 +154,7 @@ class TestSafetyGuard:
mock_exists.return_value = False
engine = TelepathicEngine()
device = MagicMock()
device.deviceV2.screenshot = MagicMock()
device.screenshot = MagicMock()
mock_open.return_value.__enter__.return_value.read.return_value = b'fakeimage'
nodes = self.explore_nodes
@@ -197,7 +197,7 @@ class TestSafetyGuard:
mock_exists.return_value = False
engine = TelepathicEngine()
device = MagicMock()
device.deviceV2.screenshot = MagicMock()
device.screenshot = MagicMock()
mock_open.return_value.__enter__.return_value.read.return_value = b'fakeimage'
nodes = self.explore_nodes

View File

@@ -17,7 +17,7 @@ from GramAddict.core.telepathic_engine import TelepathicEngine
def mock_device(width=1080, height=2400):
device = MagicMock()
device.get_info.return_value = {"displayWidth": width, "displayHeight": height}
device.deviceV2.screenshot = MagicMock()
device.screenshot = MagicMock()
device.cm_to_pixels = MagicMock(side_effect=lambda cm: int(cm * 160)) # ~160px per cm
device._get_current_app.return_value = "com.instagram.android"
device.app_id = "com.instagram.android"
@@ -330,7 +330,7 @@ class TestTelepathicMemoryRecall:
assert result["score"] == 1.0
# Screenshot should NEVER have been called (the early-return optimization)
device.deviceV2.screenshot.assert_not_called()
device.screenshot.assert_not_called()
@patch('GramAddict.core.telepathic_engine.TelepathicEngine._extract_semantic_nodes')
@patch('builtins.open', new_callable=MagicMock)

View File

@@ -0,0 +1,86 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
def test_feed_markers_missing_prevents_back_button_trap():
"""
TDD Test: When the bot is on the feed but no feed markers (like buttons) are visible
(e.g., due to a tall image or mid-scroll), it must NOT press the Android 'back' button,
because pressing back on the Home Feed forces a jump to the top of the feed and a refresh.
It should only press back if it explicitly detects an obstacle (e.g., a bottom sheet).
"""
device = MagicMock()
# Return XML that has NO feed markers and NO obstacles
device.dump_hierarchy.return_value = '<?xml version="1.0"?><hierarchy><node text="some tall post" /></hierarchy>'
configs = MagicMock()
configs.args.ignore_close_friends = False
configs.args.carousel_percentage = 0
configs.args.interaction_users_amount = "1"
# We want to break the loop after one pass. We can patch _humanized_scroll to raise an Exception.
class LoopBreak(Exception): pass
with patch("GramAddict.core.bot_flow._humanized_scroll", side_effect=LoopBreak) as mock_scroll:
with patch("GramAddict.core.bot_flow.sleep"):
with patch("GramAddict.core.bot_flow.is_ad", return_value=False):
with patch("GramAddict.core.bot_flow.TelepathicEngine") as MockEng:
MockEng.get_instance.return_value._extract_semantic_nodes.return_value = [1] # prevent zero-node crash
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.wants_to_doomscroll.return_value = False
cog_stack = {"dopamine": dopamine}
try:
zero_engine = MagicMock()
nav_graph = MagicMock()
session_state = MagicMock()
session_state.check_limit.return_value = [False, False]
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "home_feed", cog_stack)
except LoopBreak:
pass
# It must NOT press back, because it's just lost in the feed without explicit obstacles.
try:
device.press.assert_not_called()
except AssertionError:
pytest.fail("Agent incorrectly pressed BACK when no obstacle was present. This triggers the scroll-to-top trap!")
mock_scroll.assert_called_once()
def test_explicit_obstacle_triggers_back_button():
"""
TDD Test: When the bot detects an explicit obstacle (e.g., dialog_container),
it MUST press the back button to try and dismiss it.
"""
device = MagicMock()
# Return XML that HAS an obstacle
device.dump_hierarchy.return_value = '<?xml version="1.0"?><hierarchy><node resource-id="dialog_container" /></hierarchy>'
configs = MagicMock()
configs.args.ignore_close_friends = False
class LoopBreak(Exception): pass
with patch("GramAddict.core.bot_flow.sleep"):
with patch("GramAddict.core.bot_flow.is_ad", return_value=False):
with patch("GramAddict.core.bot_flow.TelepathicEngine") as MockEng:
MockEng.get_instance.return_value._extract_semantic_nodes.return_value = [1]
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.wants_to_doomscroll.return_value = False
cog_stack = {"dopamine": dopamine}
try:
zero_engine = MagicMock()
nav_graph = MagicMock()
session_state = MagicMock()
session_state.check_limit.return_value = [False, False]
# Make device.press raise LoopBreak so we can verify it was called and break the infinite loop
device.press.side_effect = LoopBreak
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "home_feed", cog_stack)
except LoopBreak:
pass
# device.press("back") SHOULD be called
device.press.assert_called_with("back")

View File

@@ -16,7 +16,7 @@ def test_modal_guard_blocks_nav_intent_on_failed_xml():
with open(FAILED_XML_PATH, "r") as f:
xml_content = f.read()
engine = TelepathicEngine.get_instance()
engine = TelepathicEngine()
# Intent that SHOULD be blocked because Home Tab is obscured by the comment sheet
intent = "tap home tab"
@@ -36,7 +36,7 @@ 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()
engine = TelepathicEngine()
# Minimal XML
xml = "<?xml version='1.0' ?><hierarchy><node index='0' text='Add comment' bounds='[42,2224][1038,2350]' visible-to-user='true' /></hierarchy>"

View File

@@ -68,7 +68,7 @@ def test_reels_loop_repost_execution(mock_device):
<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
mock_device.dump_hierarchy.return_value = reels_xml
# Repost Sheet XML
@@ -111,7 +111,7 @@ def test_reels_loop_repost_execution(mock_device):
return state['current']
mock_device.dump_hierarchy.side_effect = side_effect_func
mock_device.deviceV2.dump_hierarchy.side_effect = side_effect_func
mock_device.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

View File

@@ -42,9 +42,9 @@ class TestAnomalyInterruptions:
cleared = self.nav_graph._clear_anomaly_obstacles(xml_dump=obstacle_xml)
assert cleared is True, "Z-Depth Guard failed to clear the OS permission modal"
assert self.mock_device.deviceV2.click.call_count >= 1
assert self.mock_device.click.call_count >= 1
# Verify it clicked the 'Don't allow' button coordinates ([100,650][900,750] avg is [500, 700])
args, _ = self.mock_device.deviceV2.click.call_args
args, _ = self.mock_device.click.call_args
assert args[0] == 500
assert args[1] == 700
@@ -85,13 +85,13 @@ class TestAnomalyInterruptions:
# Secondary assertion: at least one dismissal action occurred.
# The SAE may press BACK (priority=-1 for OBSTACLE_MODAL) or click 'Not Now'.
pressed_back = (
self.mock_device.deviceV2.press.called and
self.mock_device.press.called and
any(
(a.args[0] if a.args else None) == "back"
for a in self.mock_device.deviceV2.press.call_args_list
for a in self.mock_device.press.call_args_list
)
)
did_click = self.mock_device.deviceV2.click.call_count >= 1
did_click = self.mock_device.click.call_count >= 1
assert pressed_back or did_click, (
"SAE did not take any dismissal action (expected BACK press or click on 'Not Now')"

View File

@@ -15,15 +15,15 @@ def test_app_perimeter_guard_after_click():
"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
]
] + ["com.android.vending"] * 50
mock_device.app_id = "com.instagram.android"
# UI XML pre/post click
mock_device.deviceV2.dump_hierarchy.side_effect = [
mock_device.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)
]
] + ["<hierarchy />"] * 50
# Mock Telepathic Engine
mock_engine = MagicMock()

View File

@@ -30,9 +30,9 @@ def test_interact_with_profile_all_100_percent(mock_random, device, telepathic_m
# 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
assert device.shell.call_count == 2
for cmd in device.deviceV2.shells:
for cmd in [args[0][0] for args in device.shell.call_args_list]:
assert "input swipe" in cmd
@patch("random.random")
@@ -53,7 +53,7 @@ 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
assert device.shell.call_count == 0
@patch("random.random")
def test_interact_with_profile_mixed_probability(mock_random, device, telepathic_mock, mock_logger):
@@ -77,7 +77,7 @@ def test_interact_with_profile_mixed_probability(mock_random, device, telepathic
_interact_with_profile(device, configs, "testuser", session_state, 0.0, mock_logger)
# Grid loop finishes with 1 scroll for 1 post.
assert len(device.deviceV2.shells) == 1
assert device.shell.call_count == 1
@patch("random.random")
def test_carousel_100_percent(mock_random, device, mock_logger):
@@ -91,8 +91,8 @@ def test_carousel_100_percent(mock_random, device, mock_logger):
_interact_with_carousel(device, configs, 0.0, mock_logger)
assert len(device.deviceV2.shells) == 4
for cmd in device.deviceV2.shells:
assert device.shell.call_count == 4
for cmd in [args[0][0] for args in device.shell.call_args_list]:
assert "swipe" in cmd
@patch("random.random")
@@ -107,7 +107,7 @@ def test_carousel_zero_percent(mock_random, device, mock_logger):
_interact_with_carousel(device, configs, 0.0, mock_logger)
assert len(device.deviceV2.shells) == 0
assert device.shell.call_count == 0
@patch("random.random")
def test_interact_with_profile_follow_limit_enforcement(mock_random, device, telepathic_mock, mock_logger):
@@ -131,7 +131,7 @@ def test_interact_with_profile_follow_limit_enforcement(mock_random, device, tel
_interact_with_profile(device, configs, "targetuser", session_state, 0.0, mock_logger)
# Assert shells is 0 (assuming stories and likes probability mathematically default to 0 due to MockArgs empty fallback)
assert len(device.deviceV2.shells) == 0
assert device.shell.call_count == 0
@patch("random.random")
def test_interact_with_profile_likes_limit_enforcement(mock_random, device, telepathic_mock, mock_logger):
@@ -157,7 +157,7 @@ def test_interact_with_profile_likes_limit_enforcement(mock_random, device, tele
_interact_with_profile(device, configs, "targetuser", session_state, 0.0, mock_logger)
# Limit restricts likes block.
assert len(device.deviceV2.shells) == 0
assert device.shell.call_count == 0
# NOTE: Repost is deeply integrated into `bot_flow._run_zero_latency_feed_loop`. We can't mock the

View File

@@ -41,7 +41,7 @@ class TestCriticalAnomalyGuards:
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 = '''
self.mock_device.dump_hierarchy.return_value = '''
<hierarchy>
<node text="marisaundmarc" />
<node text="This account is private" bounds="[100,500][900,600]" />
@@ -55,14 +55,14 @@ class TestCriticalAnomalyGuards:
_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()
self.mock_device.click.assert_not_called()
self.mock_device.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 = '''
self.mock_device.dump_hierarchy.return_value = '''
<hierarchy>
<node text="marisaundmarc" />
<node text="No posts yet" bounds="[100,500][900,600]" />
@@ -74,8 +74,8 @@ class TestCriticalAnomalyGuards:
_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()
self.mock_device.click.assert_not_called()
self.mock_device.press.assert_not_called()
def test_comments_disabled_guard(self):
"""

View File

@@ -20,7 +20,7 @@ def test_profile_grid_sync_delay_after_follow():
"""
mock_device = MagicMock()
mock_device.app_id = "com.instagram.android"
mock_device.deviceV2.dump_hierarchy.return_value = "<hierarchy><node package='com.instagram.android' text='following' /></hierarchy>"
mock_device.dump_hierarchy.return_value = "<hierarchy><node package='com.instagram.android' text='following' /></hierarchy>"
mock_device.dump_hierarchy.return_value = "<hierarchy><node package='com.instagram.android' text='following' /></hierarchy>"
mock_configs = FakeConfig()

View File

@@ -104,6 +104,6 @@ class TestUnfollowEngine:
)
assert result == "BOREDOM_CHANGE_FEED"
self.mock_device.deviceV2.dump_hierarchy.assert_not_called()
self.mock_device.deviceV2.click.assert_not_called()
self.mock_device.dump_hierarchy.assert_not_called()
self.mock_device.click.assert_not_called()
self.mock_session_state.totalUnfollowed = 0