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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
86
tests/tdd/test_home_feed_back_button_trap.py
Normal file
86
tests/tdd/test_home_feed_back_button_trap.py
Normal 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")
|
||||
@@ -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>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user