Hardening autonomous navigation: Implemented Modal Guards, Transient Drift Protection (WhatsApp fix), and Aggressive Recovery paths. Cleaned up diagnostic artifacts.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
67
tests/tdd/test_drift_hardening.py
Normal file
67
tests/tdd/test_drift_hardening.py
Normal 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", [])
|
||||
|
||||
69
tests/tdd/test_modal_vlm_fix.py
Normal file
69
tests/tdd/test_modal_vlm_fix.py
Normal 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."
|
||||
135
tests/tdd/test_reels_repost.py
Normal file
135
tests/tdd/test_reels_repost.py
Normal 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")
|
||||
68
tests/unit/test_anomaly_interruptions.py
Normal file
68
tests/unit/test_anomaly_interruptions.py
Normal 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
|
||||
51
tests/unit/test_app_perimeter_guard.py
Normal file
51
tests/unit/test_app_perimeter_guard.py
Normal 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.
|
||||
@@ -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."
|
||||
|
||||
@@ -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):
|
||||
|
||||
101
tests/unit/test_critical_anomaly_guards.py
Normal file
101
tests/unit/test_critical_anomaly_guards.py
Normal 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"
|
||||
83
tests/unit/test_grid_retry_diversity.py
Normal file
83
tests/unit/test_grid_retry_diversity.py
Normal 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
|
||||
@@ -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"}'
|
||||
|
||||
91
tests/unit/test_profile_interaction_edges.py
Normal file
91
tests/unit/test_profile_interaction_edges.py
Normal 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"
|
||||
@@ -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)
|
||||
|
||||
109
tests/unit/test_unfollow_engine.py
Normal file
109
tests/unit/test_unfollow_engine.py
Normal 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
|
||||
73
tests/unit/test_verify_success_reels.py
Normal file
73
tests/unit/test_verify_success_reels.py
Normal 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"
|
||||
Reference in New Issue
Block a user