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