Hardening autonomous navigation: Implemented Modal Guards, Transient Drift Protection (WhatsApp fix), and Aggressive Recovery paths. Cleaned up diagnostic artifacts.
This commit is contained in:
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