fix(sae): stabilize navigation engine, fix container filtering, and negative reinforcement logic

This commit is contained in:
2026-04-25 13:09:12 +02:00
parent ad1af4edfe
commit 77e8251aa7
43 changed files with 4107 additions and 4075 deletions

View File

@@ -1,9 +1,10 @@
import pytest
import os
import time
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _wait_for_post_loaded, _run_zero_latency_feed_loop, FEED_MARKERS
from GramAddict.core.device_facade import DeviceFacade
import pytest
from GramAddict.core.bot_flow import FEED_MARKERS, _run_zero_latency_feed_loop, _wait_for_post_loaded
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DUMPS = {
@@ -12,14 +13,17 @@ DUMPS = {
}
FIXTURE_DIR = os.path.join(ROOT_DIR, "fixtures")
def mutate_xml_to_foreign(xml_content: str) -> str:
"""Removes meaningful text content to simulate a language failure or empty state."""
import re
# Strip text and content-desc
xml = re.sub(r'text="[^"]*"', 'text=""', xml_content)
xml = re.sub(r'content-desc="[^"]*"', 'content-desc=""', xml)
return xml
def mutate_xml_remove_feed_markers(xml_content: str) -> str:
"""Removes all feed markers to simulate a grid view or random popup."""
xml = xml_content
@@ -27,21 +31,26 @@ def mutate_xml_remove_feed_markers(xml_content: str) -> str:
xml = xml.replace(marker, "some_random_id")
return xml
class ConfigMock:
def __init__(self):
self.args = MagicMock()
self.args.interact_percentage = 0
self.args.comment_percentage = 0
@pytest.fixture
def test_dumps():
dumps = {}
with open(DUMPS["organic"], "r") as f:
dumps["post"] = f.read()
# Fake explore grid that lacks ALL feed markers
dumps["grid"] = '<?xml version="1.0"?><hierarchy><node resource-id="com.instagram.android:id/explore_grid_container" /></hierarchy>'
dumps["grid"] = (
'<?xml version="1.0"?><hierarchy><node resource-id="com.instagram.android:id/explore_grid_container" /></hierarchy>'
)
return dumps
def test_slow_loading_post_recovery(test_dumps):
"""
Test that _wait_for_post_loaded correctly handles a delay where the
@@ -50,33 +59,35 @@ def test_slow_loading_post_recovery(test_dumps):
device = MagicMock()
# Simulate: Grid -> Grid -> Error -> Post
device.dump_hierarchy.side_effect = [
test_dumps["grid"],
test_dumps["grid"],
test_dumps["grid"],
Exception("uiautomator2 temp failure"),
test_dumps["post"]
test_dumps["post"],
]
# We patch sleep to make the test super fast
with patch('GramAddict.core.bot_flow.sleep', return_value=None):
with patch("GramAddict.core.bot_flow.sleep", return_value=None):
start = time.time()
success = _wait_for_post_loaded(device, timeout=5)
# Should return true when it hits the 4th element
assert success is True
assert device.dump_hierarchy.call_count == 4
def test_wait_timeout_aborts_gracefully(test_dumps):
"""Test what happens if the network is so slow it times out entirely."""
device = MagicMock()
# Always return grid
device.dump_hierarchy.return_value = test_dumps["grid"]
# Patch time.time to simulate 6 seconds passing immediately
# We add sequence padding because python's logger internally uses time.time()
with patch('GramAddict.core.bot_flow.time.time', side_effect=[0, 1, 6, 6, 6, 6, 6, 6, 6, 6]):
with patch('GramAddict.core.bot_flow.sleep', return_value=None):
with patch("time.time", side_effect=[0, 1, 6, 6, 6, 6, 6, 6, 6, 6]):
with patch("GramAddict.core.bot_flow.sleep", return_value=None):
success = _wait_for_post_loaded(device, timeout=5)
assert success is False
def test_empty_content_extraction_guard(test_dumps):
"""
Test that if a post is loaded, but it has strange empty text (foreign language or bug),
@@ -85,38 +96,47 @@ def test_empty_content_extraction_guard(test_dumps):
device = MagicMock()
nav_graph = MagicMock()
configs = ConfigMock()
# We create a fake active inference engine to just break the loop after 1 iteration
ai = MagicMock()
# Dopamine engine controls loop exit
dopamine = MagicMock()
dopamine.is_app_session_over.side_effect = [False, True] # Run once, then exit
dopamine.is_app_session_over.side_effect = [False, True] # Run once, then exit
dopamine.wants_to_change_feed.return_value = False
dopamine.wants_to_doomscroll.return_value = False
cognitive_stack = {
"dopamine": dopamine,
"active_inference": ai,
"resonance": None, "growth_brain": None, "swarm": None, "darwin": None
"resonance": None,
"growth_brain": None,
"swarm": None,
"darwin": None,
}
# Mutate the post so it has NO text or description
broken_xml = mutate_xml_to_foreign(test_dumps["post"])
device.dump_hierarchy.return_value = broken_xml
from GramAddict.core.situational_awareness import SituationType
with patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
patch('GramAddict.core.bot_flow.sleep'), \
patch('GramAddict.core.situational_awareness.SituationalAwarenessEngine.perceive', return_value=SituationType.NORMAL):
with (
patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll,
patch("GramAddict.core.bot_flow.sleep"),
patch(
"GramAddict.core.situational_awareness.SituationalAwarenessEngine.perceive",
return_value=SituationType.NORMAL,
),
):
result = _run_zero_latency_feed_loop(device, None, nav_graph, configs, MagicMock(), "HomeFeed", cognitive_stack)
# Ensure scroll was called (the recovery mechanism)
assert mock_scroll.called
# Check that we never called resonance evaluation because we broke early
assert not ai.predict_state.called
assert result == "FEED_EXHAUSTED"
def test_missing_feed_markers_guard(test_dumps):
"""
Test that if the UI is completely foreign (e.g., a system popup),
@@ -124,23 +144,23 @@ def test_missing_feed_markers_guard(test_dumps):
"""
device = MagicMock()
configs = ConfigMock()
dopamine = MagicMock()
dopamine.is_app_session_over.side_effect = [False, True]
dopamine.wants_to_change_feed.return_value = False
dopamine.wants_to_doomscroll.return_value = False
cognitive_stack = {"dopamine": dopamine, "growth_brain": None, "active_inference": None}
# Mutate XML to remove all FEED MARKERS
alien_xml = mutate_xml_remove_feed_markers(test_dumps["post"])
device.dump_hierarchy.return_value = alien_xml
with patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
patch('GramAddict.core.bot_flow.sleep'):
with patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll, patch("GramAddict.core.bot_flow.sleep"):
_run_zero_latency_feed_loop(device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack)
@patch('GramAddict.core.device_facade.u2')
@patch("GramAddict.core.device_facade.u2")
def test_xpath_watcher_initialization(mock_u2):
"""
Test fixing the critical watcher API bug.
@@ -148,21 +168,22 @@ def test_xpath_watcher_initialization(mock_u2):
"""
mock_d = MagicMock()
mock_u2.connect.return_value = mock_d
# Setup mock chain: deviceV2.watcher("crash_dialog").when(...)
mock_watcher = MagicMock()
mock_d.watcher.return_value = mock_watcher
mock_when = MagicMock()
mock_watcher.when.return_value = mock_when
# Just init the facade
from GramAddict.core.device_facade import create_device
device = create_device("fake_serial", "com.fake.app", MagicMock())
# Verify exact API call structure for XPath
mock_d.watcher.assert_any_call("crash_dialog")
mock_d.watcher.assert_any_call("system_dialog")
# We can't perfectly assert the chained arguments natively without a bit of inspection,
# but we can verify it didn't crash and called start
assert mock_d.watcher.start.called

View File

@@ -0,0 +1,69 @@
from GramAddict.core.telepathic_engine import TelepathicEngine
class TestTelepathicGuards:
def setup_method(self):
self.engine = TelepathicEngine()
def test_strict_story_ring_guard(self):
"""
TDD: Story rings MUST be physically near the top of the screen (y < 30%).
Post profile headers that appear further down must be aggressively blocked
when the intent is 'tap story ring avatar'.
"""
intent = "tap story ring avatar"
screen_height = 2400
# Valid Story Ring (Top of screen, but below status bar)
valid_story = {"resource_id": "reel_ring", "y": 300, "area": 100}
assert self.engine._structural_sanity_check(valid_story, intent, screen_height) is True
# Invalid Story Ring (Hallucination: Post profile header in the feed)
invalid_story = {"resource_id": "row_feed_profile_header", "y": 800, "area": 100}
assert self.engine._structural_sanity_check(invalid_story, intent, screen_height) is False
def test_strict_button_guard(self):
"""
TDD: When explicitly looking for a 'button', nodes that declare themselves
as profiles (e.g. 'go to profile') must be blocked, to prevent accidental
profile visits when clicking 'like'.
"""
intent = "Heart like button for comment"
screen_height = 2400
# Valid Like Button
valid_btn = {"resource_id": "like_button", "semantic_string": "Like", "y": 1000, "area": 100}
assert self.engine._structural_sanity_check(valid_btn, intent, screen_height) is True
# Invalid Profile Link masquerading as a match due to string proximity
invalid_prof = {
"resource_id": "username",
"semantic_string": "Go to cayleighanddavid's profile",
"y": 1000,
"area": 100,
}
assert self.engine._structural_sanity_check(invalid_prof, intent, screen_height) is False
# However, if the intent *is* profile, it should pass
intent_prof = "go to profile"
assert self.engine._structural_sanity_check(invalid_prof, intent_prof, screen_height) is True
def test_like_semantic_verification(self):
"""
TDD: Verify that 'unlike' is treated as a successful 'Like' action,
because tapping 'Like' changes the state to 'Unlike' in English Instagram.
"""
# Testing the specific regex logic inside verify_success
import re
xml_dump_success = '<node class="android.widget.ImageView" content-desc="Unlike" />'
intent = "tap like button"
marker_found = re.search(r"\b(liked|unlike|gefällt mir nicht mehr|gefällt mir am)\b", xml_dump_success.lower())
assert marker_found is not None
xml_dump_fail = '<node class="android.widget.ImageView" content-desc="Like" />'
marker_found_fail = re.search(
r"\b(liked|unlike|gefällt mir nicht mehr|gefällt mir am)\b", xml_dump_fail.lower()
)
assert marker_found_fail is None

View File

@@ -1,130 +1,169 @@
import pytest
import logging
import os
from unittest.mock import MagicMock
import pytest
def pytest_addoption(parser):
parser.addoption(
"--live", action="store_true", default=False, help="run tests against a live ADB device (disable DeviceFacade mocks)"
"--live",
action="store_true",
default=False,
help="run tests against a live ADB device (disable DeviceFacade mocks)",
)
MagicMock.app_id = "com.instagram.android"
MagicMock._get_current_app = MagicMock(return_value="com.instagram.android")
class MockArgs:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class MockConfigs:
def __init__(self, args):
self.args = args
from unittest.mock import create_autospec, MagicMock
from unittest.mock import MagicMock, create_autospec
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.telepathic_engine import TelepathicEngine
def create_mock_device():
mock = create_autospec(DeviceFacade, instance=True)
mock.app_id = "com.instagram.android"
mock.device_id = "test_device"
mock.info = {"displayWidth": 1080, "displayHeight": 2400}
mock.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
mock.cm_to_pixels.side_effect = lambda cm: int(cm * 10)
mock.shell.return_value = "" # Ensure SendEventInjector detection gets a string
import uuid
mock.dump_hierarchy.side_effect = lambda: f"<hierarchy><node resource-id=\"com.instagram.android:id/row_feed_photo_profile_name\" bounds=\"[0,200][1080,260]\" text=\"testuser\" /><node resource-id=\"com.instagram.android:id/row_comment_imageview\" bounds=\"[10,10][20,20]\" content-desc=\"Story\" text=\"following\" /><node resource-id=\"com.instagram.android:id/button_like\" bounds=\"[50,50][60,60]\" /><node resource-id=\"com.instagram.android:id/reel_viewer\" /><node sid=\"{uuid.uuid4()}\" /></hierarchy>"
mock.dump_hierarchy.side_effect = (
lambda: f'<hierarchy><node resource-id="com.instagram.android:id/row_feed_photo_profile_name" bounds="[0,200][1080,260]" text="testuser" /><node resource-id="com.instagram.android:id/row_comment_imageview" bounds="[10,10][20,20]" content-desc="Story" text="following" /><node resource-id="com.instagram.android:id/button_like" bounds="[50,50][60,60]" /><node resource-id="com.instagram.android:id/reel_viewer" /><node sid="{uuid.uuid4()}" /></hierarchy>'
)
return mock
def create_mock_telepathic_engine():
mock = create_autospec(TelepathicEngine, instance=True)
mock.find_best_node.return_value = {"x": 500, "y": 500, "confidence": 0.9}
mock.evaluate_profile_vibe.return_value = {"quality_score": 8, "matches_niche": True, "reason": "Mocked positive vibe"}
mock.evaluate_grid_visuals.return_value = {"x": 500, "y": 500, "score": 0.99, "semantic": "Mocked matching grid cell", "source": "vlm_grid"}
mock._extract_semantic_nodes.return_value = [{"x": 500, "y": 500, "semantic_string": "dummy node"}]
mock.evaluate_profile_vibe.return_value = {
"quality_score": 8,
"matches_niche": True,
"reason": "Mocked positive vibe",
}
mock.evaluate_grid_visuals.return_value = {
"x": 500,
"y": 500,
"score": 0.99,
"semantic": "Mocked matching grid cell",
"source": "vlm_grid",
}
mock.find_best_node.return_value = {"x": 500, "y": 500, "semantic_string": "dummy node"}
return mock
@pytest.fixture
def mock_logger():
return logging.getLogger("test")
@pytest.fixture
def device(request):
if request.config.getoption("--live"):
from GramAddict.core.device_facade import create_device
import yaml
import os
import yaml
from GramAddict.core.device_facade import create_device
device_id = "emulator-5554"
app_id = "com.instagram.android"
config_path = "test_config.yml"
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
if config:
device_id = config.get("device", device_id)
app_id = config.get("app-id", app_id)
except Exception as e:
print(f"⚠️ Warning: Could not load {config_path}: {e}")
print(f"🚀 Connecting to live device: {device_id} (App: {app_id})")
return create_device(device_id, app_id)
return create_mock_device()
@pytest.fixture(autouse=True)
def reset_singletons():
"""Ensure all core engine singletons are fresh for each test."""
from GramAddict.core.telepathic_engine import TelepathicEngine
from GramAddict.core.goap import GoalExecutor
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
from GramAddict.core.qdrant_memory import QdrantBase
from GramAddict.core.behaviors import PluginRegistry
from GramAddict.core.goap import GoalExecutor
from GramAddict.core.physics.biomechanics import PhysicsBody
from GramAddict.core.physics.sendevent_injector import SendEventInjector
from GramAddict.core.qdrant_memory import QdrantBase
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
from GramAddict.core.telepathic_engine import TelepathicEngine
TelepathicEngine.reset()
GoalExecutor.reset()
SituationalAwarenessEngine.reset()
PluginRegistry.reset()
PhysicsBody.reset()
SendEventInjector.reset()
QdrantBase._connection_failed_logged = False
from GramAddict.core.dojo_engine import DojoEngine
if hasattr(DojoEngine, "reset"):
DojoEngine.reset()
else:
DojoEngine._instance = None
# Aggressively wipe on-disk session files to prevent state leakage in tests
for f in ["telepathic_memory.json", "telepathic_blacklist.json", "growth_brain_memory.json", "gramaddict_nav_map.json", "l2_channels_cache.json"]:
for f in [
"telepathic_memory.json",
"telepathic_blacklist.json",
"growth_brain_memory.json",
"gramaddict_nav_map.json",
"l2_channels_cache.json",
]:
if os.path.exists(f):
try:
os.remove(f)
except Exception:
pass
yield
# Post-test cleanup
PhysicsBody.reset()
SendEventInjector.reset()
@pytest.fixture(autouse=True)
def telepathic_mock(monkeypatch, request):
if request.config.getoption("--live"):
# TelepathicEngine is a singleton, allow it to run natively
return None
import GramAddict.core.telepathic_engine
engine = create_mock_telepathic_engine()
monkeypatch.setattr(GramAddict.core.telepathic_engine.TelepathicEngine, "get_instance", lambda: engine)
return engine
@pytest.fixture
def mock_cognitive_stack():
stack = {
@@ -138,7 +177,7 @@ def mock_cognitive_stack():
"nav_graph": MagicMock(),
"zero_engine": MagicMock(),
"crm": MagicMock(),
"telepathic": create_mock_telepathic_engine()
"telepathic": create_mock_telepathic_engine(),
}
stack["radome"].sanitize_xml.side_effect = lambda x: x
return stack

View File

@@ -1,11 +1,13 @@
import sys
import os
import pytest
import sys
import time
from unittest.mock import MagicMock
import pytest
from GramAddict.core import utils
# Force Qdrant mocking globally across ALL E2E tests so we never
# Force Qdrant mocking globally across ALL E2E tests so we never
# block on connection refused trying to hit localhost:6344
mock_qdrant = MagicMock()
@@ -16,11 +18,12 @@ mock_qdrant.get_collection.return_value = mock_collection
sys.modules["qdrant_client"].QdrantClient = MagicMock(return_value=mock_qdrant)
@pytest.fixture
def e2e_device_dump_injector(request):
"""
Provides a factory to mock device.dump_hierarchy using real XML files.
Will gracefully fail with a comprehensive assertion if the file is missing
Will gracefully fail with a comprehensive assertion if the file is missing
(per 'ECHTE DUMPS fehlen' reporting requirement).
"""
if request.config.getoption("--live"):
@@ -29,30 +32,36 @@ def e2e_device_dump_injector(request):
def _inject_dump(device_mock, xml_filename):
fix_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
xml_path = os.path.join(fix_dir, xml_filename)
if not os.path.exists(xml_path):
pytest.fail(f"MISSING REAL DUMP: required XML fixture '{xml_filename}' for full E2E workflow testing could not be found at {xml_path}. FAKE_NOTHING policy implies dropping this test execution until it is captured.", pytrace=False)
pytest.fail(
f"MISSING REAL DUMP: required XML fixture '{xml_filename}' for full E2E workflow testing could not be found at {xml_path}. FAKE_NOTHING policy implies dropping this test execution until it is captured.",
pytrace=False,
)
with open(xml_path, "r") as f:
real_xml = f.read()
device_mock.dump_hierarchy.return_value = real_xml
return real_xml
return _inject_dump
class VirtualClock:
def __init__(self):
self.time = 0.0
self.animation_target_time = 0.0
def sleep(self, seconds):
if hasattr(seconds, '__iter__'):
return # For edge case where something weird is passed
if hasattr(seconds, "__iter__"):
return # For edge case where something weird is passed
self.time += float(seconds)
clock = VirtualClock()
@pytest.fixture
def dynamic_e2e_dump_injector(monkeypatch, request):
"""
@@ -66,9 +75,9 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
def _inject(device_mock, state_map, initial_xml):
from GramAddict.core.q_nav_graph import QNavGraph
fix_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
def load_xml(filename):
path = os.path.join(fix_dir, filename)
if not os.path.exists(path):
@@ -79,47 +88,56 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
# History stack to allow "back" navigation
device_mock._xml_history = [load_xml(initial_xml)]
device_mock._current_active_xml = device_mock._xml_history[-1]
import uuid
def _dump_hierarchy_hook():
if clock.time < clock.animation_target_time:
pytest.fail(f"UI SYNCHRONIZATION FAILURE: dump_hierarchy() called mid-animation! "
f"Virtual Clock is at {clock.time:.1f}s but UI needs until {clock.animation_target_time:.1f}s to settle. "
f"Add a time.sleep() guard before interacting with the UI after a click.", pytrace=False)
pytest.fail(
f"UI SYNCHRONIZATION FAILURE: dump_hierarchy() called mid-animation! "
f"Virtual Clock is at {clock.time:.1f}s but UI needs until {clock.animation_target_time:.1f}s to settle. "
f"Add a time.sleep() guard before interacting with the UI after a click.",
pytrace=False,
)
xml = device_mock._current_active_xml
if xml and "</hierarchy>" in xml:
xml = xml.replace("</hierarchy>", f"<node sid=\"{uuid.uuid4()}\" /></hierarchy>")
xml = xml.replace("</hierarchy>", f'<node sid="{uuid.uuid4()}" /></hierarchy>')
return xml
device_mock.dump_hierarchy.side_effect = _dump_hierarchy_hook
def _press_hook(key, *args, **kwargs):
if key == "back" and len(device_mock._xml_history) > 1:
device_mock._xml_history.pop()
device_mock._current_active_xml = device_mock._xml_history[-1]
clock.animation_target_time = clock.time + 1.5
device_mock.press.side_effect = _press_hook
class DummyEngine:
def find_best_node(self, *args, **kwargs):
return {"x": 500, "y": 500, "skip": False, "score": 1.0, "source": "e2e_mock"}
def verify_success(self, *args, **kwargs):
return True
def confirm_click(self, *args, **kwargs):
pass
def reject_click(self, *args, **kwargs):
pass
original_execute = QNavGraph._execute_transition
from GramAddict.core.goap import GoalExecutor
original_goap_execute = GoalExecutor._execute_action
def _mock_execute_transition(nav_self, action, zero_engine=None, max_retries=2):
if action == 'tap_post_username':
if action == "tap_post_username":
return True
original_click = nav_self.device.click
def _click_hook(obj=None, *args, **kwargs):
original_click(obj, *args, **kwargs)
if action in state_map:
@@ -127,22 +145,24 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
device_mock._xml_history.append(new_xml)
device_mock._current_active_xml = new_xml
clock.animation_target_time = clock.time + 1.5
nav_self.device.click = _click_hook
try:
success = original_execute(nav_self, action, mock_semantic_engine=DummyEngine(), max_retries=max_retries)
success = original_execute(
nav_self, action, mock_semantic_engine=DummyEngine(), max_retries=max_retries
)
return success
finally:
nav_self.device.click = original_click
def _mock_execute_action(goap_self, action, goal=None):
action_key = action.replace(" ", "_")
if action_key == 'tap_post_username':
if action_key == "tap_post_username":
return True
original_click = goap_self.device.click
def _click_hook(obj=None, *args, **kwargs):
original_click(obj, *args, **kwargs)
if action_key in state_map:
@@ -155,20 +175,21 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
device_mock._xml_history.append(new_xml)
device_mock._current_active_xml = new_xml
clock.animation_target_time = clock.time + 1.5
goap_self.device.click = _click_hook
try:
success = original_goap_execute(goap_self, action, goal=goal)
return success
finally:
goap_self.device.click = original_click
monkeypatch.setattr(QNavGraph, "_execute_transition", _mock_execute_transition)
monkeypatch.setattr(GoalExecutor, "_execute_action", _mock_execute_action)
return _inject
@pytest.fixture(autouse=True)
def mock_all_delays(monkeypatch, request):
"""
@@ -180,49 +201,72 @@ def mock_all_delays(monkeypatch, request):
return
global clock
clock.time = 0.0 # reset for test
clock.time = 0.0 # reset for test
clock.animation_target_time = 0.0
def simulate_sleep(seconds):
clock.sleep(seconds)
money_sleep = lambda x: simulate_sleep(x)
random_sleep = lambda *args, **kwargs: simulate_sleep(1.0) # Assume 1.0 minimum for randoms
random_sleep = lambda a=1.0, b=2.0, *args, **kwargs: simulate_sleep(max(1.5, float(a)))
monkeypatch.setattr(time, "sleep", money_sleep)
monkeypatch.setattr(utils, "random_sleep", random_sleep)
monkeypatch.setattr(utils, "sleep", money_sleep)
# Needs to capture specific module sleeps depending on how they imported it
try:
from GramAddict.core import bot_flow
monkeypatch.setattr(bot_flow, "sleep", money_sleep)
monkeypatch.setattr(bot_flow.random, "uniform", lambda a, b: float(a)) # deterministic lower bound
monkeypatch.setattr(bot_flow.random, "uniform", lambda a, b: float(a)) # deterministic lower bound
if hasattr(bot_flow, "random_sleep"):
monkeypatch.setattr(bot_flow, "random_sleep", random_sleep)
from GramAddict.core import q_nav_graph
monkeypatch.setattr(q_nav_graph.random, "uniform", lambda a, b: float(a))
if hasattr(q_nav_graph, "random_sleep"):
monkeypatch.setattr(q_nav_graph, "random_sleep", random_sleep)
from GramAddict.core import goap
if hasattr(goap, "random"):
monkeypatch.setattr(goap.random, "uniform", lambda a, b: float(a))
if hasattr(goap, "random_sleep"):
monkeypatch.setattr(goap, "random_sleep", random_sleep)
monkeypatch.setattr(utils.random, "uniform", lambda a, b: float(a))
from GramAddict.core import device_facade
monkeypatch.setattr(device_facade, "sleep", money_sleep)
monkeypatch.setattr(device_facade.random, "uniform", lambda a, b: float(a))
except Exception:
pass
if hasattr(device_facade, "random_sleep"):
monkeypatch.setattr(device_facade, "random_sleep", random_sleep)
except Exception as e:
print(f"Mocking delays exception: {e}")
# Standardize DarwinEngine across tests to prevent mockup math errors on session end
try:
from GramAddict.core.darwin_engine import DarwinEngine
monkeypatch.setattr(DarwinEngine, "evaluate_session_end", lambda *args, **kwargs: None)
except ImportError:
pass
@pytest.fixture(autouse=True)
def mock_identity_guard(monkeypatch):
import GramAddict.core.bot_flow
monkeypatch.setattr(GramAddict.core.bot_flow, "verify_and_switch_account", lambda *args, **kwargs: True)
@pytest.fixture
def e2e_configs():
import argparse
configs = MagicMock()
configs.username = "testuser"
configs.args = argparse.Namespace(
@@ -254,6 +298,7 @@ def e2e_configs():
)
return configs
@pytest.fixture(autouse=True)
def mock_sae_perceive(request, monkeypatch):
"""
@@ -263,9 +308,15 @@ def mock_sae_perceive(request, monkeypatch):
"""
if "test_e2e_sae.py" in str(request.node.fspath):
return
if "test_e2e_real_llm_learning.py" in str(request.node.fspath):
return
if request.config.getoption("--live"):
return
import GramAddict.core.situational_awareness
monkeypatch.setattr(GramAddict.core.situational_awareness.SituationalAwarenessEngine, "perceive", lambda self, xml: GramAddict.core.situational_awareness.SituationType.NORMAL)
import GramAddict.core.situational_awareness
monkeypatch.setattr(
GramAddict.core.situational_awareness.SituationalAwarenessEngine,
"perceive",
lambda self, xml: GramAddict.core.situational_awareness.SituationType.NORMAL,
)

View File

@@ -0,0 +1,92 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
from GramAddict.core.session_state import SessionState
def test_feed_loop_respects_config_limits(device, mock_cognitive_stack):
"""
Testet, ob die Config (Ziele/Limits) beachtet wird:
Erreicht der Bot sein Ziel (z.B. total_likes_limit) und stoppt er dann?
"""
# 1. Simulate dopamine so we don't naturally exit early due to session time
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
mock_cognitive_stack[
"resonance"
].calculate_resonance.return_value = 0.75 # < 0.8 to avoid rabbit hole, but high enough to engage
# 2. Setup Config mimicking test_config.yml goals
configs = MagicMock()
configs.args.total_likes_limit = 2
configs.args.end_if_likes_limit_reached = True
configs.args.interact_percentage = 100
configs.args.likes_percentage = 100
configs.args.follow_percentage = 0
configs.args.comment_percentage = 0
configs.args.visual_vibe_check_percentage = 0
configs.args.profile_learning_percentage = 0
configs.args.repost_percentage = 0
# 3. Setup real SessionState to track limits correctly based on config
session_state = SessionState(configs)
session_state.set_limits_session()
# 4. Provide a UI dump that has content so the bot interacts
device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="test_user" />
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />
</hierarchy>"""
# Prevent radome from stripping our mock structure
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
mock_cognitive_stack["nav_graph"].do.return_value = True
with (
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.bot_flow._extract_post_content") as mock_extract,
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
patch("GramAddict.core.bot_flow._humanized_scroll"),
patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "test"}),
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
patch("GramAddict.core.bot_flow.sleep"),
patch("GramAddict.core.bot_flow.random.random", return_value=0.1),
): # Force pass probabilities
mock_extract.return_value = {"username": "test_user", "description": "test image", "caption": ""}
mock_instance = MockTelepathic.get_instance.return_value
# Nodes for standard flow
mock_instance._extract_semantic_nodes.return_value = [{"x": 1, "y": 2}]
# When finding the like button
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
mock_cognitive_stack["telepathic"] = mock_instance
# We'll patch `_humanized_click` to increment the like counter to simulate the interaction succeeding.
def mock_click_side_effect(*args, **kwargs):
session_state.totalLikes += 1
session_state.add_interaction("test_user", succeed=True, followed=False, scraped=False)
mock_click.side_effect = mock_click_side_effect
# Run the autonomous loop
result = _run_zero_latency_feed_loop(
device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
# 5. Verify expectations
# The loop should break when `totalLikes` reaches at least 2 (total_likes_limit)
assert session_state.totalLikes >= 2, f"Expected at least 2 likes, got {session_state.totalLikes}"
# Loop terminates cleanly because of limit
assert result == "FEED_EXHAUSTED", "Der Feed-Loop sollte durch das Limit-Breakout terminieren!"

View File

@@ -42,9 +42,11 @@ Expected Behaviour After Green Phase
3. ``TelepathicEngine.find_best_node()`` with a profile-grid intent returns ``None``
(or a ``{"blocked_by_dm_thread": True}`` sentinel) when the XML is a DM thread.
"""
import os
from unittest.mock import MagicMock
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
# ──────────────────────────────────────────────
# Fixture Helpers
@@ -65,14 +67,12 @@ def _load_fixture(filename: str) -> str:
return f.read()
# ──────────────────────────────────────────────
# Test 3: Structural Guard — TelepathicEngine must refuse to find
# profile-intent nodes inside a DM thread
# ──────────────────────────────────────────────
class TestTelepathicEngineDmForbiddenZone:
"""
RED: When the visible XML is a DM thread and the intent is profile-related
@@ -86,25 +86,15 @@ class TestTelepathicEngineDmForbiddenZone:
"""
def _make_engine(self):
with patch("GramAddict.core.telepathic_engine.QdrantBase") as MockQdrant, \
patch("GramAddict.core.telepathic_engine.query_telepathic_llm"), \
patch("GramAddict.core.telepathic_engine.dump_ui_state"):
from GramAddict.core.telepathic_engine import TelepathicEngine
e = TelepathicEngine.__new__(TelepathicEngine)
e._embedding_cache = {}
e._intent_cache = {}
e._blacklist = {}
e._memory = {}
e._cached_username = "testuser"
e._cached_app_id = "com.instagram.android"
# Mock embedding_helper so vector stage is a no-op (returns None → falls to VLM)
mock_helper = MagicMock()
mock_helper._get_embedding.return_value = None
e.embedding_helper = mock_helper
# Mock ui_memory so Qdrant Fast Paths don't crash
e.ui_memory = MagicMock()
e.ui_memory.retrieve_memory.return_value = None
# We only need a raw TelepathicEngine instance
from GramAddict.core.telepathic_engine import TelepathicEngine
TelepathicEngine._instance = None
e = TelepathicEngine()
# Mock the internal resolver's LLM call to prevent actual OLLAMA requests during fast-paths
e._resolver.resolve = MagicMock(return_value=None)
return e
def test_profile_intent_is_blocked_when_dm_thread_is_active(self):
@@ -128,10 +118,7 @@ class TestTelepathicEngineDmForbiddenZone:
]
for intent in profile_seeking_intents:
# Patch embedding to None so vector stage is a no-op; VLM path also mocked off
with patch.object(engine, "_get_cached_embedding", return_value=None), \
patch.object(engine, "_vision_cortex_fallback", return_value=None):
result = engine.find_best_node(dm_xml, intent, device=device)
result = engine.find_best_node(dm_xml, intent, device=device)
# The keyword fast-path WILL find nodes in the DM thread (e.g. the 'view_profile_button'
# has 'profile' in its resource-id, matching the intent). The guard must intercept
@@ -162,10 +149,7 @@ class TestTelepathicEngineDmForbiddenZone:
# This intent is used by dm_engine.py to find the message composer
dm_intent = "find the message input text field"
# Mock the embedding calls so we don't block on Qdrant during unit test
with patch.object(engine, "_get_cached_embedding", return_value=None), \
patch.object(engine, "_vision_cortex_fallback", return_value=None):
result = engine.find_best_node(dm_xml, dm_intent, device=device)
result = engine.find_best_node(dm_xml, dm_intent, device=device)
# Should NOT be blocked — DM intents are valid inside a DM thread
# (may be None if keyword/vector stage misses, but must NOT be blocked_by_dm_thread)
@@ -174,4 +158,3 @@ class TestTelepathicEngineDmForbiddenZone:
f"DM intent '{dm_intent}' was incorrectly blocked inside a DM thread. "
f"The structural guard must only block PROFILE-seeking intents."
)

View File

@@ -0,0 +1,163 @@
"""
Real LLM + Qdrant Integration Test
Tests the extreme learning behavior of the autonomous engine by hitting
the real local Ollama instance and storing/retrieving from local Qdrant.
Requirements:
- Ollama must be running on localhost:11434
- llama3.2-vision must be available locally
- Qdrant must be running locally
"""
import time
import uuid
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.qdrant_memory import ScreenMemoryDB
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
# ─────────────────────────────────────────────────────
# Test Setup & Isolation
# ─────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def isolated_screen_memory():
"""Ensures we use a separate Qdrant collection for real LLM testing and clean it."""
# We patch __init__ so that any instantiation uses the test collection
original_init = ScreenMemoryDB.__init__
def test_init(self):
super(ScreenMemoryDB, self).__init__(collection_name="test_real_llm_screens")
ScreenMemoryDB.__init__ = test_init
db = ScreenMemoryDB()
if db.is_connected:
db.wipe_collection()
yield db
# Restore original
ScreenMemoryDB.__init__ = original_init
def make_mock_device(app_id="com.instagram.android"):
device = MagicMock(spec=DeviceFacade)
device.app_id = app_id
device.deviceV2 = MagicMock()
device.dump_hierarchy = MagicMock()
device.click = MagicMock()
device.press = MagicMock()
device.app_start = MagicMock()
device._trace_counter = 0
device._trace_dir = "/tmp/test_traces"
return device
# ─────────────────────────────────────────────────────
# Tests
# ─────────────────────────────────────────────────────
@pytest.mark.live_llm
def test_real_llm_learning_and_unlearning(isolated_screen_memory):
"""
Testet das echte Lernverhalten:
1. Pass: Unbekanntes XML -> LLM wird angefragt -> Speichert in Qdrant
2. Pass: Gleiches XML -> LLM wird NICHT angefragt -> Holt aus Qdrant
3. Pass (Unlearn): Wir löschen den State (Simulation Fehler) -> Gleiches XML -> LLM wird wieder angefragt
"""
# Check if Qdrant is connected. If not, we skip the test gracefully.
if not isolated_screen_memory.is_connected:
pytest.skip("Qdrant is not running locally. Skipping live integration test.")
# Generate completely unique XML so it's guaranteed NOT in any cache
random_id = f"com.instagram.android:id/chaos_{uuid.uuid4().hex[:8]}"
random_text = f"REAL_LLM_TEST_{uuid.uuid4().hex[:8]}"
# A simple modal to trigger perception
chaos_xml = f"""<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node text="" resource-id="{random_id}" class="android.widget.FrameLayout" package="com.instagram.android" clickable="false" bounds="[0,500][1080,2200]">
<node text="{random_text}" resource-id="" class="android.widget.TextView" package="com.instagram.android" clickable="false" bounds="[100,600][980,700]" />
<node text="Dismiss" resource-id="" class="android.widget.Button" package="com.instagram.android" clickable="true" bounds="[100,2000][540,2100]" />
</node>
</node>
</hierarchy>"""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
# We patch the underlying LLM call just to spy on it (wraps the original function)
from GramAddict.core.llm_provider import query_telepathic_llm
with patch("GramAddict.core.llm_provider.query_telepathic_llm", wraps=query_telepathic_llm) as spy_llm:
# ---------------------------------------------------------
# PASS 1: The Initial Encounter (Learn)
# ---------------------------------------------------------
print(f"\n--- PASS 1: Querying real LLM for '{random_text}' ---")
start_time = time.time()
# This will block and hit the real local Ollama
result_pass1 = sae.perceive(chaos_xml)
duration = time.time() - start_time
print(f"Pass 1 completed in {duration:.2f}s. Result: {result_pass1}")
# Assertions
assert spy_llm.call_count == 1, "LLM was not called on unknown XML!"
assert result_pass1 in [
SituationType.OBSTACLE_MODAL,
SituationType.NORMAL,
SituationType.OBSTACLE_FOREIGN_APP,
], "Invalid LLM perception result"
spy_llm.reset_mock()
# Give Qdrant a split second to index the new point
time.sleep(0.5)
# ---------------------------------------------------------
# PASS 2: The Recall (Cache Hit)
# ---------------------------------------------------------
print("\\n--- PASS 2: Recalling from Qdrant ---")
start_time = time.time()
result_pass2 = sae.perceive(chaos_xml)
duration = time.time() - start_time
print(f"Pass 2 completed in {duration:.2f}s. Result: {result_pass2}")
# Assertions
assert spy_llm.call_count == 0, "LLM was called again despite being in Qdrant!"
assert result_pass2 == result_pass1, "Qdrant cache returned a different result than the initial LLM call!"
assert duration < 1.0, f"Qdrant retrieval took too long ({duration:.2f}s). Should be sub-second."
# ---------------------------------------------------------
# PASS 3: The Unlearn (Mistake Recovery)
# ---------------------------------------------------------
print("\\n--- PASS 3: Unlearning and verifying re-query ---")
# We simulate that the bot decided this classification was wrong and unlearns it
sae.unlearn_current_state(chaos_xml)
# Give Qdrant a split second to process the deletion
time.sleep(0.5)
start_time = time.time()
result_pass3 = sae.perceive(chaos_xml)
duration = time.time() - start_time
print(f"Pass 3 completed in {duration:.2f}s. Result: {result_pass3}")
# Assertions
assert spy_llm.call_count == 1, "LLM was NOT called after unlearning! Qdrant deletion failed."
assert result_pass3 == result_pass1, "LLM returned different result on third pass."
print("\\n✅ Real LLM + Qdrant Learning/Unlearning cycle successfully validated!")

View File

@@ -4,89 +4,108 @@ Tests autonomous recovery from foreign apps, unknown modals, and learning.
Uses REAL XML dumps from production sessions.
"""
import pytest
import os
from unittest.mock import MagicMock, patch
from GramAddict.core.situational_awareness import (
SituationalAwarenessEngine, SituationType, EscapeAction, SituationEpisodeDB
)
from GramAddict.core.device_facade import DeviceFacade
import pytest
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.situational_awareness import (
EscapeAction,
SituationalAwarenessEngine,
SituationType,
)
# ─────────────────────────────────────────────────────
# Test Fixtures: Real-world XML scenarios
# ─────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def mock_screen_memory():
with patch("GramAddict.core.qdrant_memory.ScreenMemoryDB.get_screen_type", return_value=None), \
patch("GramAddict.core.qdrant_memory.ScreenMemoryDB.store_screen"):
with (
patch("GramAddict.core.qdrant_memory.ScreenMemoryDB.get_screen_type", return_value=None),
patch("GramAddict.core.qdrant_memory.ScreenMemoryDB.store_screen"),
):
yield
@pytest.fixture(autouse=True)
def mock_telepathic_classifier():
with patch("GramAddict.core.llm_provider.query_telepathic_llm") as mock_llm:
def side_effect(model, url, system_prompt, user_prompt, use_local_edge):
if "keyguard_status_view" in user_prompt or "lock_icon" in user_prompt:
return '{"situation": "OBSTACLE_LOCKED_SCREEN"}'
elif "permissioncontroller" in user_prompt:
return '{"situation": "OBSTACLE_SYSTEM"}'
# If it's a passive scaffold but no active modal markers, it's NORMAL
is_passive_only = "bottom_sheet_container_view" in user_prompt and "survey_overlay_container" not in user_prompt
if "survey_overlay_container" in user_prompt or "mystery_interstitial_container" in user_prompt or ("bottom_sheet_container" in user_prompt and not is_passive_only):
is_passive_only = (
"bottom_sheet_container_view" in user_prompt and "survey_overlay_container" not in user_prompt
)
if (
"survey_overlay_container" in user_prompt
or "mystery_interstitial_container" in user_prompt
or ("bottom_sheet_container" in user_prompt and not is_passive_only)
):
return '{"situation": "OBSTACLE_MODAL"}'
elif "feed_tab" in user_prompt:
return '{"situation": "NORMAL"}'
else:
return '{"situation": "OBSTACLE_FOREIGN_APP"}'
mock_llm.side_effect = side_effect
yield mock_llm
@pytest.fixture(autouse=True)
def mock_fallback_llm():
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
def side_effect(*args, **kwargs):
prompt = kwargs.get('prompt', args[2] if len(args) > 2 else "")
prompt = kwargs.get("prompt", args[2] if len(args) > 2 else "")
prompt_lower = prompt.lower()
if "obstacle_foreign_app" in prompt_lower:
return {"response": '{"action": "kill_foreign_apps", "x": 0, "y": 0, "reason": "Killing foreign app"}'}
elif "obstacle_locked_screen" in prompt_lower:
return {"response": '{"action": "unlock", "x": 0, "y": 0, "reason": "Unlocking device"}'}
elif "close_friends" in prompt_lower:
return {"response": '{"action": "back", "x": 0, "y": 0, "reason": "Safe fallback for follow sheet"}'}
# Simulate LLM preferring BACK first for modals/dialogs
if "back:0,0" not in prompt_lower:
return {"response": '{"action": "back", "x": 0, "y": 0, "reason": "Trying safe BACK first"}'}
if "not now" in prompt_lower or "später" in prompt_lower or "deny" in prompt_lower:
return {"response": '{"action": "click", "x": 320, "y": 1850, "reason": "Found dismiss button"}'}
return {"response": '{"action": "back", "x": 0, "y": 0, "reason": "Fallback to back"}'}
mock_llm.side_effect = side_effect
yield mock_llm
GOOGLE_SEARCH_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
GOOGLE_SEARCH_XML = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.google.android.googlequicksearchbox" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node index="0" text="" resource-id="com.google.android.googlequicksearchbox:id/search_box" class="android.widget.EditText" package="com.google.android.googlequicksearchbox" content-desc="Search" clickable="true" bounds="[50,200][1030,300]" />
<node index="1" text="Close" resource-id="com.google.android.googlequicksearchbox:id/close_button" class="android.widget.ImageButton" package="com.google.android.googlequicksearchbox" content-desc="Close" clickable="true" bounds="[980,200][1050,280]" />
</node>
</hierarchy>'''
</hierarchy>"""
INSTAGRAM_HOME_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
INSTAGRAM_HOME_XML = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node index="0" text="" resource-id="com.instagram.android:id/feed_tab" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="Home" clickable="true" selected="true" bounds="[0,2235][216,2361]" />
<node index="1" text="" resource-id="com.instagram.android:id/search_tab" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="Search and Explore" clickable="true" bounds="[216,2235][432,2361]" />
<node index="2" text="" resource-id="com.instagram.android:id/profile_tab" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="Profile" clickable="true" bounds="[864,2235][1080,2361]" />
</node>
</hierarchy>'''
</hierarchy>"""
INSTAGRAM_SURVEY_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
INSTAGRAM_SURVEY_XML = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node index="0" text="" resource-id="com.instagram.android:id/feed_tab" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="Home" clickable="true" bounds="[0,2235][216,2361]" />
@@ -96,9 +115,9 @@ INSTAGRAM_SURVEY_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes'
<node text="Take Survey" resource-id="com.instagram.android:id/button_positive" class="android.widget.Button" package="com.instagram.android" clickable="true" bounds="[540,1800][980,1900]" />
</node>
</node>
</hierarchy>'''
</hierarchy>"""
UNKNOWN_MODAL_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
UNKNOWN_MODAL_XML = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node index="0" text="" resource-id="com.instagram.android:id/feed_tab" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="Home" clickable="true" bounds="[0,2235][216,2361]" />
@@ -108,9 +127,9 @@ UNKNOWN_MODAL_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<node text="Jetzt ansehen" resource-id="" class="android.widget.Button" package="com.instagram.android" clickable="true" bounds="[540,2000][980,2100]" />
</node>
</node>
</hierarchy>'''
</hierarchy>"""
PERMISSION_DIALOG_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
PERMISSION_DIALOG_XML = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.android.permissioncontroller" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node text="" resource-id="com.android.permissioncontroller:id/grant_dialog" class="android.widget.LinearLayout" package="com.android.permissioncontroller" clickable="false" bounds="[100,800][980,1600]">
@@ -119,9 +138,9 @@ PERMISSION_DIALOG_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes'
<node text="Allow" resource-id="com.android.permissioncontroller:id/permission_allow_button" class="android.widget.Button" package="com.android.permissioncontroller" clickable="true" bounds="[550,1400][930,1500]" />
</node>
</node>
</hierarchy>'''
</hierarchy>"""
LOCK_SCREEN_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
LOCK_SCREEN_XML = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node index="0" text="" resource-id="com.android.systemui:id/keyguard_status_view" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
@@ -129,13 +148,14 @@ LOCK_SCREEN_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
</node>
<node index="1" text="" resource-id="com.android.systemui:id/lock_icon" class="android.widget.ImageView" package="com.android.systemui" content-desc="Lock icon" clickable="true" bounds="[490,2100][590,2200]" />
</node>
</hierarchy>'''
</hierarchy>"""
# ─────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────
def make_mock_device(app_id="com.instagram.android"):
device = MagicMock(spec=DeviceFacade)
device.app_id = app_id
@@ -154,6 +174,7 @@ def make_mock_device(app_id="com.instagram.android"):
# PERCEPTION TESTS
# ─────────────────────────────────────────────────────
class TestSAEPerception:
"""Tests that the SAE correctly classifies screen situations."""
@@ -171,6 +192,7 @@ class TestSAEPerception:
def test_perceive_notification_shade(self):
import os
dump_path = os.path.join(os.path.dirname(__file__), "..", "fixtures", "notification_shade.xml")
try:
with open(dump_path, "r") as f:
@@ -180,7 +202,7 @@ class TestSAEPerception:
result = sae.perceive(shade_xml)
assert result == SituationType.OBSTACLE_FOREIGN_APP
except FileNotFoundError:
pass # allow test format to compile if fixture accidentally not available
pass # allow test format to compile if fixture accidentally not available
def test_perceive_system_permission_dialog(self):
device = make_mock_device()
@@ -201,10 +223,52 @@ class TestSAEPerception:
result = sae.perceive(UNKNOWN_MODAL_XML)
assert result == SituationType.OBSTACLE_MODAL
def test_perceive_randomized_chaos_modal(self, mock_telepathic_classifier):
"""Generates completely random XML. Proves SAE passes dynamic state to VLM without hardcoded heuristics."""
import uuid
random_id = f"com.instagram.android:id/chaos_{uuid.uuid4().hex[:8]}"
random_text = f"Nonsense_Text_{uuid.uuid4().hex[:8]}"
random_button_text = f"Dismiss_{uuid.uuid4().hex[:8]}"
chaos_xml = f"""<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node text="" resource-id="{random_id}" class="android.widget.FrameLayout" package="com.instagram.android" clickable="false" bounds="[0,500][1080,2200]">
<node text="{random_text}" resource-id="" class="android.widget.TextView" package="com.instagram.android" clickable="false" bounds="[100,600][980,700]" />
<node text="{random_button_text}" resource-id="" class="android.widget.Button" package="com.instagram.android" clickable="true" bounds="[100,2000][540,2100]" />
</node>
</node>
</hierarchy>"""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
# Override the mock behavior locally for this test to return OBSTACLE_MODAL
def local_side_effect(model, url, system_prompt, user_prompt, use_local_edge):
if random_text in user_prompt:
return '{"situation": "OBSTACLE_MODAL"}'
return '{"situation": "NORMAL"}'
mock_telepathic_classifier.side_effect = local_side_effect
result = sae.perceive(chaos_xml)
assert result == SituationType.OBSTACLE_MODAL
# PROOF: The VLM was actually called, and the prompt contained our randomized strings!
mock_telepathic_classifier.assert_called_once()
_, kwargs = mock_telepathic_classifier.call_args
user_prompt = kwargs.get("user_prompt", "")
id_suffix = random_id.split("/")[-1]
assert id_suffix in user_prompt, "Bot did not pass the random ID to VLM!"
assert random_text in user_prompt, "Bot did not pass the random text to VLM!"
assert random_button_text in user_prompt, "Bot did not pass the random button text to VLM!"
def test_perceive_action_blocked(self):
blocked_xml = INSTAGRAM_HOME_XML.replace(
'text="" resource-id="com.instagram.android:id/feed_tab"',
'text="Try again later" resource-id="com.instagram.android:id/bottom_sheet_container"'
'text="Try again later" resource-id="com.instagram.android:id/bottom_sheet_container"',
)
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
@@ -227,13 +291,13 @@ class TestSAEPerception:
"""Passive scaffold containers (bottom_sheet_container_view, bottom_sheet_camera_container) must NOT be OBSTACLE_MODAL."""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
# XML containing navigation tabs + the passive scaffold container
passive_xml = INSTAGRAM_HOME_XML.replace(
'<node index="1" text="" resource-id="com.instagram.android:id/main_feed_container"',
'<node index="1" text="" resource-id="com.instagram.android:id/bottom_sheet_container_view" />\n'
'<node index="2" text="" resource-id="com.instagram.android:id/bottom_sheet_camera_container" />\n'
'<node index="3" text="" resource-id="com.instagram.android:id/main_feed_container"'
'<node index="3" text="" resource-id="com.instagram.android:id/main_feed_container"',
)
result = sae.perceive(passive_xml)
assert result == SituationType.NORMAL, f"Passive scaffold misclassified as {result}"
@@ -312,11 +376,11 @@ class TestSAERealFixturePerception:
assert result == SituationType.OBSTACLE_MODAL, f"Mystery interstitial misclassified as {result}"
# ─────────────────────────────────────────────────────
# FULL AUTONOMOUS RECOVERY TESTS
# ─────────────────────────────────────────────────────
class TestSAEAutonomousRecovery:
"""Tests the full perceive→plan→act→verify→learn loop."""
@@ -329,8 +393,7 @@ class TestSAEAutonomousRecovery:
]
sae = SituationalAwarenessEngine(device)
with patch.object(sae.episodes, 'recall', return_value=None), \
patch.object(sae.episodes, 'learn'):
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
@@ -339,13 +402,12 @@ class TestSAEAutonomousRecovery:
"""Lock screen detected → SAE triggers unlock() → Instagram returns."""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
LOCK_SCREEN_XML, # perceive: locked
INSTAGRAM_HOME_XML, # verify after unlock
LOCK_SCREEN_XML, # perceive: locked
INSTAGRAM_HOME_XML, # verify after unlock
]
sae = SituationalAwarenessEngine(device)
with patch.object(sae.episodes, 'recall', return_value=None), \
patch.object(sae.episodes, 'learn'):
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.unlock.assert_called_once()
@@ -358,12 +420,11 @@ class TestSAEAutonomousRecovery:
INSTAGRAM_SURVEY_XML, # perceive: modal
INSTAGRAM_SURVEY_XML, # verify after BACK (BACK failed — modal still there)
INSTAGRAM_SURVEY_XML, # perceive again: still modal
INSTAGRAM_HOME_XML, # verify after clicking 'Not Now' (worked!)
INSTAGRAM_HOME_XML, # verify after clicking 'Not Now' (worked!)
]
sae = SituationalAwarenessEngine(device)
with patch.object(sae.episodes, 'recall', return_value=None), \
patch.object(sae.episodes, 'learn'):
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# First action was BACK, second was click
@@ -378,30 +439,90 @@ class TestSAEAutonomousRecovery:
device = make_mock_device()
device.dump_hierarchy.side_effect = [
INSTAGRAM_SURVEY_XML, # perceive: modal
INSTAGRAM_HOME_XML, # verify after BACK (worked!)
INSTAGRAM_HOME_XML, # verify after BACK (worked!)
]
sae = SituationalAwarenessEngine(device)
with patch.object(sae.episodes, 'recall', return_value=None), \
patch.object(sae.episodes, 'learn'):
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.press.assert_called_with("back")
device.click.assert_not_called() # Never needed to click!
def test_recovers_from_unknown_modal_german(self):
"""German modal → BACK first → fails → finds 'Später' by TEXT → clicks."""
def test_recovers_from_randomized_chaos_modal(self, mock_telepathic_classifier, mock_fallback_llm):
"""Generates a totally random modal and verifies the LLM dictates the random coordinates to recover."""
import uuid
random_id = f"com.instagram.android:id/chaos_{uuid.uuid4().hex[:8]}"
random_text = f"Nonsense_Text_{uuid.uuid4().hex[:8]}"
random_button_text = f"Dismiss_{uuid.uuid4().hex[:8]}"
chaos_xml = f"""<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node text="" resource-id="{random_id}" class="android.widget.FrameLayout" package="com.instagram.android" clickable="false" bounds="[0,500][1080,2200]">
<node text="{random_text}" resource-id="" class="android.widget.TextView" package="com.instagram.android" clickable="false" bounds="[100,600][980,700]" />
<node text="{random_button_text}" resource-id="" class="android.widget.Button" package="com.instagram.android" clickable="true" bounds="[321,2001][541,2101]" />
</node>
</node>
</hierarchy>"""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
UNKNOWN_MODAL_XML, # perceive: modal
UNKNOWN_MODAL_XML, # verify after BACK (failed)
UNKNOWN_MODAL_XML, # perceive again
chaos_xml, # perceive: modal
chaos_xml, # verify after BACK (failed)
chaos_xml, # perceive again
INSTAGRAM_HOME_XML, # verify after clicking randomized coords
]
# VLM Classifier override
def local_classifier(model, url, system_prompt, user_prompt, use_local_edge):
if random_text in user_prompt:
return '{"situation": "OBSTACLE_MODAL"}'
return '{"situation": "NORMAL"}'
mock_telepathic_classifier.side_effect = local_classifier
# VLM Fallback override (Action Solver)
def local_solver(*args, **kwargs):
prompt = kwargs.get("prompt", args[2] if len(args) > 2 else "")
# Simulate real LLM: First it tries back, if 'back' is not in prompt
if "back:0,0" not in prompt.lower():
return {"response": '{"action": "back", "x": 0, "y": 0, "reason": "Try back first"}'}
# Next time it sees the prompt, it finds the random button
if random_button_text in prompt:
# The bounds of our random button are [321,2001][541,2101] -> center is 431, 2051
return {"response": '{"action": "click", "x": 431, "y": 2051, "reason": "Found chaos button"}'}
return {"response": '{"action": "back", "x": 0, "y": 0, "reason": "Fallback"}'}
mock_fallback_llm.side_effect = local_solver
sae = SituationalAwarenessEngine(device)
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# Proof that BACK was tried first
device.press.assert_called_with("back")
# Proof that the random coordinates were extracted and clicked
device.click.assert_called_once()
click_args = device.click.call_args
assert click_args[0] == (
431,
2051,
), f"Expected bot to click chaotic coordinates (431, 2051), but got {click_args[0]}"
def test_recovers_from_unknown_modal_german(self):
device = make_mock_device()
device.dump_hierarchy.side_effect = [
UNKNOWN_MODAL_XML, # perceive: modal
UNKNOWN_MODAL_XML, # verify after BACK (failed)
UNKNOWN_MODAL_XML, # perceive again
INSTAGRAM_HOME_XML, # verify after clicking 'Später'
]
sae = SituationalAwarenessEngine(device)
with patch.object(sae.episodes, 'recall', return_value=None), \
patch.object(sae.episodes, 'learn'):
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
device.click.assert_called_once()
@@ -409,7 +530,7 @@ class TestSAEAutonomousRecovery:
def test_never_clicks_close_friends_on_follow_sheet(self):
"""CRITICAL REAL-WORLD BUG: Follow sheet has 'close_friends' row.
SAE must NEVER click it — it adds the user to Close Friends!"""
follow_sheet_xml = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
follow_sheet_xml = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node package="com.instagram.android" bounds="[0,0][1080,2400]">
<node resource-id="com.instagram.android:id/bottom_sheet_container" package="com.instagram.android" bounds="[0,1400][1080,2400]">
@@ -418,16 +539,15 @@ class TestSAEAutonomousRecovery:
<node resource-id="com.instagram.android:id/follow_sheet_unfollow_row" text="Unfollow" package="com.instagram.android" clickable="true" bounds="[0,2193][1080,2335]" />
</node>
</node>
</hierarchy>'''
</hierarchy>"""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
follow_sheet_xml, # perceive: modal
follow_sheet_xml, # perceive: modal
INSTAGRAM_HOME_XML, # verify after BACK (worked!)
]
sae = SituationalAwarenessEngine(device)
with patch.object(sae.episodes, 'recall', return_value=None), \
patch.object(sae.episodes, 'learn'):
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# CRITICAL: Must use BACK, never click any follow sheet button
@@ -449,12 +569,12 @@ class TestSAEAutonomousRecovery:
GOOGLE_SEARCH_XML, # attempt 5: perceive
GOOGLE_SEARCH_XML, # attempt 5: verify (LLM failed)
GOOGLE_SEARCH_XML, # attempt 6: perceive (escalate to app_start)
INSTAGRAM_HOME_XML, # attempt 6: verify (app_start worked!)
INSTAGRAM_HOME_XML, # attempt 6: verify (app_start worked!)
]
sae = SituationalAwarenessEngine(device)
# Mock LLM to return back action (simulating LLM also failing)
with patch.object(sae, '_plan_escape_via_llm', return_value=EscapeAction("back", reason="LLM says back")):
with patch.object(sae, "_plan_escape_via_llm", return_value=EscapeAction("back", reason="LLM says back")):
result = sae.ensure_clear_screen(max_attempts=7)
assert result is True
device.app_start.assert_called()
@@ -474,9 +594,10 @@ class TestSAEAutonomousRecovery:
def test_action_blocked_raises_exception(self):
"""If Instagram blocks us, SAE must HALT — never try to dismiss."""
from GramAddict.core.exceptions import ActionBlockedError
blocked_xml = INSTAGRAM_HOME_XML.replace(
'text="" resource-id="com.instagram.android:id/feed_tab"',
'text="Try again later" resource-id="com.instagram.android:id/dialog_container"'
'text="Try again later" resource-id="com.instagram.android:id/dialog_container"',
)
device = make_mock_device()
device.dump_hierarchy.return_value = blocked_xml
@@ -490,6 +611,7 @@ class TestSAEAutonomousRecovery:
# LEARNING TESTS
# ─────────────────────────────────────────────────────
class TestSAELearning:
"""Tests that SAE learns from experience and never repeats failures."""
@@ -536,15 +658,17 @@ class TestSAELearning:
"""When LLM returns 'false_positive', SAE must overwrite Qdrant and return True."""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
device.dump_hierarchy.return_value = INSTAGRAM_HOME_XML
# Force the situation to be perceived as an OBSTACLE_MODAL initially
with patch.object(sae, 'perceive', return_value=SituationType.OBSTACLE_MODAL):
with patch.object(sae, "perceive", return_value=SituationType.OBSTACLE_MODAL):
# Mock LLM to return 'false_positive'
with patch.object(sae, '_plan_escape_via_llm', return_value=EscapeAction("false_positive", reason="No modal found")):
with patch.object(
sae, "_plan_escape_via_llm", return_value=EscapeAction("false_positive", reason="No modal found")
):
result = sae.ensure_clear_screen(max_attempts=1, initial_xml=INSTAGRAM_HOME_XML)
assert result is True
mock_store_screen.assert_called_once()
args, kwargs = mock_store_screen.call_args

View File

@@ -0,0 +1,217 @@
import xml.etree.ElementTree as ET
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.goap import GoalExecutor
from GramAddict.core.qdrant_memory import QdrantBase
from GramAddict.core.telepathic_engine import TelepathicEngine
class AndroidEnvironmentSimulator(DeviceFacade):
def __init__(self, device_id="sim", app_id="com.instagram.android", args=None):
self.device_id = device_id
self.app_id = app_id
self.args = args
self.deviceV2 = MagicMock()
self.deviceV2.info = {"displayWidth": 1080, "displayHeight": 2400, "screenOn": True}
self.state_stack = ["home_feed"]
self.state_files = {
"home_feed": "tests/fixtures/home_feed_with_ad.xml",
"explore_grid": "tests/fixtures/explore_feed_dump.xml",
"post_detail": "tests/fixtures/organic_post.xml",
"user_profile": "tests/fixtures/user_profile_dump.xml",
}
def _current_state(self):
return self.state_stack[-1]
def dump_hierarchy(self):
current = self._current_state()
filepath = self.state_files[current]
with open(filepath, "r", encoding="utf-8") as f:
data = f.read()
print(f"📱 [Simulator] dump_hierarchy returning state: {current} (length: {len(data)})")
return data
def _parse_bounds(self, bounds_str):
import re
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str)
if match:
return [int(x) for x in match.groups()]
return None
def human_click(self, x, y):
# Simulate click translation to next state
xml_data = self.dump_hierarchy()
root = ET.fromstring(xml_data)
clicked_nodes = []
for node in root.iter("node"):
bounds_str = node.attrib.get("bounds", "")
bounds = self._parse_bounds(bounds_str)
if bounds:
x1, y1, x2, y2 = bounds
if x1 <= x <= x2 and y1 <= y <= y2:
area = (x2 - x1) * (y2 - y1)
clicked_nodes.append((area, node))
if not clicked_nodes:
return
clicked_nodes.sort(key=lambda item: item[0])
for _, target in clicked_nodes:
content_desc = target.attrib.get("content-desc", "") or ""
res_id = target.attrib.get("resource-id", "") or ""
text = target.attrib.get("text", "") or ""
current = self._current_state()
if current == "home_feed":
if "Search and explore" in content_desc or "search_tab" in res_id:
print(f"📱 [Simulator] Clicked ({x}, {y}) on {res_id}. Transition: home_feed -> explore_grid")
self.state_stack.append("explore_grid")
return
elif current == "explore_grid":
# In explore, anything the VLM clicks that has an image or button is likely a post
if "image_button" in res_id or "container" in res_id or target.attrib.get("clickable") == "true":
print(f"📱 [Simulator] Clicked ({x}, {y}) on {res_id}. Transition: explore_grid -> post_detail")
self.state_stack.append("post_detail")
return
elif current == "post_detail":
# Allow clicking either the post author or the comment author (both go to user_profile)
if 100 < x < 800 and 300 < y < 900:
print(f"📱 [Simulator] Clicked ({x}, {y}) on {res_id}. Transition: post_detail -> user_profile")
self.state_stack.append("user_profile")
return
# If we get here, no transition happened
for _, target in clicked_nodes:
print(
f"📱 [Simulator] Click ({x}, {y}) fell through on: {target.attrib.get('resource-id')} / text={target.attrib.get('text')}"
)
if not clicked_nodes:
print(f"📱 [Simulator] Click ({x}, {y}) fell outside ALL elements!")
def click(self, x=None, y=None, obj=None):
if x is not None and y is not None:
self.human_click(x, y)
elif obj and isinstance(obj, dict) and "x" in obj:
self.human_click(obj["x"], obj["y"])
def press(self, key):
if key == "back":
if len(self.state_stack) > 1:
old_state = self.state_stack.pop()
print(f"📱 [Simulator] Back pressed. State Transition: {old_state} -> {self._current_state()}")
else:
print("📱 [Simulator] Back pressed at root state.")
def _get_current_app(self):
return self.app_id
def get_info(self):
return self.deviceV2.info
def wake_up(self):
pass
def unlock(self):
pass
def shell(self, cmd):
return ""
def swipe(self, sx, sy, ex, ey, duration=None):
print(f"📱 [Simulator] Swiped ({sx}, {sy}) -> ({ex}, {ey})")
def human_swipe(self, sx, sy, ex, ey, duration=None):
print(f"📱 [Simulator] Swiped ({sx}, {sy}) -> ({ex}, {ey})")
@property
def info(self):
return self.deviceV2.info
@pytest.fixture(autouse=True)
def setup_qdrant_isolation():
"""Prefix all Qdrant collections with test_sim_ so we don't pollute live data."""
original_init = QdrantBase.__init__
def mocked_init(self, collection_name, *args, **kwargs):
test_collection = f"test_sim_{collection_name}"
original_init(self, test_collection, *args, **kwargs)
with patch.object(QdrantBase, "__init__", new=mocked_init):
# We aggressively wipe these collections before running the test!
from GramAddict.core.qdrant_memory import NavigationMemoryDB
qb = NavigationMemoryDB()
try:
qb.wipe_collection()
except:
pass
yield
def test_full_autonomous_sim_loop(monkeypatch):
"""
This test runs the real GoalExecutor with the real TelepathicEngine (VLM)
and real Qdrant (sandboxed via prefix) against a simulated Android environment.
"""
import urllib.request
try:
urllib.request.urlopen("http://localhost:11434/", timeout=2)
except Exception:
pytest.skip("Ollama is not running. Live E2E sim requires LLM backend.")
# 1. Create Simulator
sim_device = AndroidEnvironmentSimulator()
# 2. Patch TelepathicEngine to NOT be mocked by conftest
engine = TelepathicEngine()
monkeypatch.setattr(TelepathicEngine, "get_instance", lambda: engine)
# 3. Create context and GoalExecutor
from GramAddict.core.config import Config
if not hasattr(Config(), "args"):
Config().args = MagicMock()
Config().args.use_nav_memory = True
Config().args.use_semantic_memory = True
executor = GoalExecutor(sim_device, bot_username="testbot")
# 4. Start an autonomous loop: We want to reach an organic post from the home feed
assert sim_device._current_state() == "home_feed"
success = executor.achieve("open post", max_steps=10)
assert success is True
# The VLM should have figured out:
# 1. Tap explore tab -> switches to "explore_grid"
# 2. Tap grid item -> switches to "post_detail"
assert sim_device._current_state() == "post_detail"
# 5. Let's do another intent: view the user profile
success = executor.achieve("open post author profile", max_steps=5)
assert success is True
assert sim_device._current_state() == "user_profile"
# 6. Now go back to the post
success = executor.achieve("open post", max_steps=5)
assert success is True
assert sim_device._current_state() == "post_detail"
# 7. Check Qdrant Memory is actually populated
# We should have stored the state transitions in the goap_paths collection
from GramAddict.core.goap import PathMemory
nav_db = PathMemory("testbot")
# verify at least some nodes exist
count = nav_db._db.client.count(nav_db._db.collection_name).count
assert count > 0, "Qdrant memory should have learned the paths!"

View File

@@ -1,14 +1,15 @@
import pytest
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import (
_align_active_post,
_extract_post_content,
_run_zero_latency_feed_loop,
_run_zero_latency_stories_loop,
_extract_post_content,
is_ad,
_align_active_post
)
from GramAddict.core.session_state import SessionState
@pytest.fixture
def mock_device():
@@ -20,12 +21,12 @@ def mock_device():
return device
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
def test_extract_post_content(mock_get_telepathic):
mock_engine = MagicMock()
mock_engine.find_best_node.side_effect = [
{"original_attribs": {"text": "test_user"}},
{"original_attribs": {"desc": "test description of image with more than 10 chars"}}
{"original_attribs": {"desc": "test description of image with more than 10 chars"}},
]
mock_get_telepathic.return_value = mock_engine
xml = "<xml/>"
@@ -33,19 +34,21 @@ def test_extract_post_content(mock_get_telepathic):
assert res["username"] == "test_user"
assert "test description" in res["description"]
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
def test_extract_post_content_fallback_caption(mock_get_telepathic):
mock_engine = MagicMock()
mock_engine.find_best_node.side_effect = [{"original_attribs": {"text": "other_user"}}, None]
mock_get_telepathic.return_value = mock_engine
xml = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
xml = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy>
<node resource-id="" text="other_user this is a very long caption text that we want to extract as fallback" />
</hierarchy>'''
</hierarchy>"""
res = _extract_post_content(xml)
assert res["username"] == "other_user"
assert "this is a very long caption" in res["caption"]
def testis_ad():
assert is_ad('<node resource-id="com.instagram.android:id/ad_cta_button" />') == True
assert is_ad('<node resource-id="com.instagram.android:id/clips_single_image_ads_media_content" />') == True
@@ -53,7 +56,8 @@ def testis_ad():
assert is_ad('<node resource-id="com.instagram.android:id/secondary_label" text="regular post" />') == False
assert is_ad('<node resource-id="com.instagram.android:id/normal_post" />') == False
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
def test_align_active_post(mock_get_telepathic, mock_device):
# Test snapping when post is far from ideal coordinates
mock_engine = MagicMock()
@@ -64,127 +68,176 @@ def test_align_active_post(mock_get_telepathic, mock_device):
# The header is at 850px. Target is 250px. Diff is 600px. It should swipe.
assert mock_device.swipe.called
def test_feed_loop_boredom_change_feed(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
mock_cognitive_stack["growth_brain"].evaluate_governance.return_value = "SHIFT_CONTEXT"
configs = MagicMock()
session_state = MagicMock()
session_state.check_limit.return_value = (False, False, False, False)
res = _run_zero_latency_feed_loop(mock_device, mock_cognitive_stack["zero_engine"], mock_cognitive_stack["nav_graph"], configs, session_state, "HomeFeed", mock_cognitive_stack)
res = _run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
assert res == "BOREDOM_CHANGE_FEED"
def test_feed_loop_context_lost(mock_device, mock_cognitive_stack):
# Simulate not having any feed markers 3 times
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, False, False, True]
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
mock_device.dump_hierarchy.return_value = "<hierarchy></hierarchy>" # Blind
mock_device.dump_hierarchy.return_value = "<hierarchy></hierarchy>" # Blind
# Needs telepathic engine mock
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, patch('GramAddict.core.bot_flow.dump_ui_state'):
with (
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.bot_flow.dump_ui_state"),
):
mock_instance = MockTelepathic.get_instance.return_value
mock_instance._extract_semantic_nodes.return_value = [{"x": 1, "y": 2}] # Pretend we have nodes so it doesn't trigger zero-node immediately
mock_instance._extract_semantic_nodes.return_value = [
{"x": 1, "y": 2}
] # Pretend we have nodes so it doesn't trigger zero-node immediately
configs = MagicMock()
session_state = MagicMock()
session_state.check_limit.return_value = (False, False, False, False)
res = _run_zero_latency_feed_loop(mock_device, mock_cognitive_stack["zero_engine"], mock_cognitive_stack["nav_graph"], configs, session_state, "HomeFeed", mock_cognitive_stack)
res = _run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
assert res == "CONTEXT_LOST"
def test_feed_loop_zero_nodes(mock_device, mock_cognitive_stack):
# Tests the Zero-Node recovery anomaly handler
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
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, \
patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll:
with (
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll,
):
mock_instance = MockTelepathic.get_instance.return_value
mock_instance._extract_semantic_nodes.return_value = [] # Zero interactive nodes
mock_instance._extract_semantic_nodes.return_value = [] # Zero interactive nodes
configs = MagicMock()
session_state = MagicMock()
session_state.check_limit.return_value = (False, False, False, False)
_run_zero_latency_feed_loop(mock_device, mock_cognitive_stack["zero_engine"], mock_cognitive_stack["nav_graph"], configs, session_state, "HomeFeed", mock_cognitive_stack)
_run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
assert mock_device.press.called_with("back")
assert mock_scroll.called
def test_feed_loop_ad_skip(mock_device, mock_cognitive_stack):
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
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="ad_account" />
<node resource-id="com.instagram.android:id/ad_cta_button" />
</hierarchy>'''
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, \
patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
patch('GramAddict.core.bot_flow._align_active_post') as mock_align:
</hierarchy>"""
with (
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll,
patch("GramAddict.core.bot_flow._align_active_post") as mock_align,
):
mock_instance = MockTelepathic.get_instance.return_value
mock_instance._extract_semantic_nodes.return_value = [{"x": 1}]
mock_align.return_value = False
configs = MagicMock()
session_state = MagicMock()
session_state.check_limit.return_value = (False, False, False, False)
_run_zero_latency_feed_loop(mock_device, mock_cognitive_stack["zero_engine"], mock_cognitive_stack["nav_graph"], configs, session_state, "HomeFeed", mock_cognitive_stack)
_run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
assert mock_scroll.called
def test_stories_loop_success(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
configs = MagicMock()
configs.args.stories = "1"
session_state = MagicMock()
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/story_viewer" />
</hierarchy>'''
with patch('GramAddict.core.bot_flow._humanized_click') as mock_click, patch('GramAddict.core.bot_flow.sleep'):
</hierarchy>"""
with patch("GramAddict.core.bot_flow._humanized_click") as mock_click, patch("GramAddict.core.bot_flow.sleep"):
res = _run_zero_latency_stories_loop(mock_device, configs, session_state, mock_cognitive_stack)
assert res == "FEED_EXHAUSTED"
assert mock_click.called
def test_stories_loop_boredom(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = True
configs = MagicMock()
session_state = MagicMock()
with patch('GramAddict.core.bot_flow.sleep'):
with patch("GramAddict.core.bot_flow.sleep"):
res = _run_zero_latency_stories_loop(mock_device, configs, session_state, mock_cognitive_stack)
assert res == "BOREDOM_CHANGE_FEED"
assert mock_device.press.called_with("back")
def test_start_bot_interrupt():
from GramAddict.core.bot_flow import start_bot
# Mock all the heavy initialization
with patch('GramAddict.core.bot_flow.Config') as MockConfig, \
patch('GramAddict.core.bot_flow.configure_logger'), \
patch('GramAddict.core.bot_flow.check_if_updated'), \
patch('GramAddict.core.benchmark_guard.check_model_benchmarks'), \
patch('GramAddict.core.llm_provider.log_openrouter_burn'), \
patch('GramAddict.core.bot_flow.create_device') as mock_create_device, \
patch('GramAddict.core.bot_flow.set_time_delta') as mock_time_delta, \
patch('GramAddict.core.bot_flow.SessionState') as MockSession, \
patch('GramAddict.core.bot_flow.open_instagram', side_effect=KeyboardInterrupt()):
# Mock all the heavy initialization
with (
patch("GramAddict.core.bot_flow.Config") as MockConfig,
patch("GramAddict.core.bot_flow.configure_logger"),
patch("GramAddict.core.bot_flow.check_if_updated"),
patch("GramAddict.core.benchmark_guard.check_model_benchmarks"),
patch("GramAddict.core.llm_provider.log_openrouter_burn"),
patch("GramAddict.core.bot_flow.create_device") as mock_create_device,
patch("GramAddict.core.bot_flow.set_time_delta") as mock_time_delta,
patch("GramAddict.core.bot_flow.SessionState") as MockSession,
patch("GramAddict.core.bot_flow.open_instagram", side_effect=KeyboardInterrupt()),
):
MockConfig.return_value.args.feed = True
MockConfig.return_value.args.explore = False
MockConfig.return_value.args.reels = False
@@ -197,19 +250,20 @@ def test_start_bot_interrupt():
MockConfig.return_value.args.ai_embedding_url = "http://localhost:11434/api/chat"
MockConfig.return_value.args.ai_embedding_model = "llama3"
MockConfig.return_value.args.agent_strategy = "conservative"
MockSession.inside_working_hours.return_value = (True, 0)
with pytest.raises(KeyboardInterrupt):
start_bot(username="test_user", device_id="123")
def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
# This test hits the core interaction (Lines 900 - 1300)
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
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.85
configs = MagicMock()
configs.args.likes_percentage = 100
configs.args.follow_percentage = 100
@@ -218,13 +272,15 @@ def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
configs.args.ai_condenser_model = "test-model"
configs.args.ai_condenser_url = "test-url"
configs.args.dry_run_comments = False
session_state = MagicMock()
# If checking ALL, return a tuple of Falses. If specific limit like LIKES/COMMENTS, return False bool.
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
session_state.check_limit.side_effect = (
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
)
# Needs to report a structure that has NO ad, HAS content, and HAS feed markers.
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
@@ -234,142 +290,182 @@ def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
<node resource-id="com.instagram.android:id/row_comment_textview_comment" text="This is a fantastic picture!" />
<node resource-id="com.instagram.android:id/row_comment_button_like" bounds="[10,10][20,20]" />
</node>
</hierarchy>'''
</hierarchy>"""
# Ensure radome doesn't destroy our XML string
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
# Simulate that nav_graph transitions work
mock_cognitive_stack["nav_graph"].do.return_value = True
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, \
patch('GramAddict.core.bot_flow._extract_post_content') as mock_extract, \
patch('GramAddict.core.llm_provider.query_llm') as mock_llm, \
patch('GramAddict.core.bot_flow.random.random', return_value=0.11), \
patch('GramAddict.core.bot_flow.random.uniform', return_value=1.5), \
patch('GramAddict.core.bot_flow.random.randint', return_value=1), \
patch('GramAddict.core.bot_flow._align_active_post', return_value=False), \
patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
patch('GramAddict.core.bot_flow._humanized_click') as mock_click, \
patch('GramAddict.core.stealth_typing.ghost_type') as mock_type:
with (
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.bot_flow._extract_post_content") as mock_extract,
patch("GramAddict.core.llm_provider.query_llm") as mock_llm,
patch("GramAddict.core.bot_flow.random.random", return_value=0.11),
patch("GramAddict.core.bot_flow.random.uniform", return_value=1.5),
patch("GramAddict.core.bot_flow.random.randint", return_value=1),
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll,
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
patch("GramAddict.core.stealth_typing.ghost_type") as mock_type,
):
mock_extract.return_value = {"username": "legit_user", "description": "test image", "caption": ""}
mock_instance = MockTelepathic.get_instance.return_value
mock_instance._extract_semantic_nodes.return_value = [{"x": 1, "y": 2, "original_attribs": {"text": "This is a fantastic picture!"}}]
mock_instance._extract_semantic_nodes.return_value = [
{"x": 1, "y": 2, "original_attribs": {"text": "This is a fantastic picture!"}}
]
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
mock_llm.return_value = {"response": "Great shot!"}
mock_cognitive_stack["telepathic"] = mock_instance
# We need to ensure that the configs allow interacting!
configs.args.interact_percentage = 100
_run_zero_latency_feed_loop(mock_device, mock_cognitive_stack["zero_engine"], mock_cognitive_stack["nav_graph"], configs, session_state, "HomeFeed", mock_cognitive_stack)
_run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
assert mock_click.called
assert mock_type.called
def test_feed_loop_repost(mock_device, mock_cognitive_stack):
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
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.85
configs = MagicMock()
configs.args.repost_percentage = 100
configs.args.likes_percentage = 0
configs.args.comment_percentage = 0
session_state = MagicMock()
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
session_state.check_limit.side_effect = (
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
)
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />
</hierarchy>'''
</hierarchy>"""
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
mock_cognitive_stack["nav_graph"].do.return_value = True
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, \
patch('GramAddict.core.bot_flow.random.random', return_value=0.11), \
patch('GramAddict.core.bot_flow._align_active_post', return_value=False), \
patch('GramAddict.core.bot_flow._humanized_scroll'), \
patch('GramAddict.core.bot_flow._humanized_click') as mock_click:
with (
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.bot_flow.random.random", return_value=0.11),
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
patch("GramAddict.core.bot_flow._humanized_scroll"),
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
):
mock_instance = MockTelepathic.get_instance.return_value
mock_instance._extract_semantic_nodes.return_value = [{"x": 1, "y": 2}]
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
mock_cognitive_stack["telepathic"] = mock_instance
configs.args.interact_percentage = 100
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, "HomeFeed", mock_cognitive_stack)
_run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
def test_profile_learning_percentage_trigger(mock_device, mock_cognitive_stack):
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
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.50 # Not high enough to trigger default
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.50 # Not high enough to trigger default
configs = MagicMock()
configs.args.profile_learning_percentage = 100 # Should force visit
configs.args.profile_learning_percentage = 100 # Should force visit
configs.args.likes_percentage = 0
configs.args.comment_percentage = 0
configs.args.follow_percentage = 0 # Won't trigger by follow chance either
configs.args.follow_percentage = 0 # Won't trigger by follow chance either
session_state = MagicMock()
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
session_state.check_limit.side_effect = (
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
)
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />
</hierarchy>'''
</hierarchy>"""
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
mock_cognitive_stack["nav_graph"].do.return_value = True
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, \
patch('GramAddict.core.bot_flow._extract_post_content') as mock_extract, \
patch('GramAddict.core.bot_flow.random.random', return_value=0.5), \
patch('GramAddict.core.bot_flow._align_active_post', return_value=False), \
patch('GramAddict.core.bot_flow._humanized_scroll'), \
patch('GramAddict.core.bot_flow._interact_with_profile') as mock_interact:
with (
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.bot_flow._extract_post_content") as mock_extract,
patch("GramAddict.core.bot_flow.random.random", return_value=0.5),
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
patch("GramAddict.core.bot_flow._humanized_scroll"),
patch("GramAddict.core.bot_flow._interact_with_profile") as mock_interact,
):
mock_extract.return_value = {"username": "legit_user", "description": "test image", "caption": ""}
mock_instance = MockTelepathic.get_instance.return_value
mock_instance._extract_semantic_nodes.return_value = [{"x": 1, "y": 2, "original_attribs": {"text": "dummy"}}]
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
mock_cognitive_stack["telepathic"] = mock_instance
configs.args.interact_percentage = 100
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, "HomeFeed", mock_cognitive_stack)
_run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
assert mock_interact.called
def test_ai_learn_own_profile_triggers_goap():
with patch('GramAddict.core.bot_flow.Config') as MockConfig, \
patch('GramAddict.core.bot_flow.configure_logger'), \
patch('GramAddict.core.bot_flow.check_if_updated'), \
patch('GramAddict.core.benchmark_guard.check_model_benchmarks'), \
patch('GramAddict.core.llm_provider.log_openrouter_burn'), \
patch('GramAddict.core.llm_provider.prewarm_ollama_models'), \
patch('GramAddict.core.bot_flow.create_device') as mock_create_device, \
patch('GramAddict.core.bot_flow.set_time_delta'), \
patch('GramAddict.core.bot_flow.SessionState') as MockSession, \
patch('GramAddict.core.bot_flow.open_instagram', return_value=True), \
patch('GramAddict.core.bot_flow.verify_and_switch_account', return_value=True), \
patch('GramAddict.core.bot_flow.get_instagram_version', return_value="1.0"), \
patch('GramAddict.core.goap.GoalExecutor') as MockGoalExecutor, \
patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, \
patch('GramAddict.core.llm_provider.query_llm') as mock_query, \
patch('GramAddict.core.bot_flow.DojoEngine'), \
patch('GramAddict.core.bot_flow.sleep'):
with (
patch("GramAddict.core.bot_flow.Config") as MockConfig,
patch("GramAddict.core.bot_flow.configure_logger"),
patch("GramAddict.core.bot_flow.check_if_updated"),
patch("GramAddict.core.benchmark_guard.check_model_benchmarks"),
patch("GramAddict.core.llm_provider.log_openrouter_burn"),
patch("GramAddict.core.llm_provider.prewarm_ollama_models"),
patch("GramAddict.core.bot_flow.create_device") as mock_create_device,
patch("GramAddict.core.bot_flow.set_time_delta"),
patch("GramAddict.core.bot_flow.SessionState") as MockSession,
patch("GramAddict.core.bot_flow.open_instagram", return_value=True),
patch("GramAddict.core.bot_flow.verify_and_switch_account", return_value=True),
patch("GramAddict.core.bot_flow.get_instagram_version", return_value="1.0"),
patch("GramAddict.core.goap.GoalExecutor") as MockGoalExecutor,
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.llm_provider.query_llm") as mock_query,
patch("GramAddict.core.bot_flow.DojoEngine"),
patch("GramAddict.core.bot_flow.sleep"),
):
MockConfig.return_value.args.ai_learn_own_profile = True
MockConfig.return_value.args.agent_strategy = "aggressive_growth"
MockConfig.return_value.args.capture_e2e_dumps = False
@@ -379,26 +475,25 @@ def test_ai_learn_own_profile_triggers_goap():
MockConfig.return_value.args.stories = False
MockConfig.return_value.args.working_hours = [10, 20]
MockConfig.return_value.args.time_delta_session = 30
MockSession.inside_working_hours.return_value = (True, 0)
mock_goap = MockGoalExecutor.get_instance.return_value
mock_goap.achieve.return_value = True
mock_telepathic = MockTelepathic.return_value # the class constructor is called inside start_bot
mock_telepathic._extract_semantic_nodes.return_value = [
{"original_attribs": {"text": "my cool bio"}}
]
mock_telepathic = MockTelepathic.return_value # the class constructor is called inside start_bot
mock_telepathic._extract_semantic_nodes.return_value = [{"original_attribs": {"text": "my cool bio"}}]
mock_query.return_value = {"persona": "cool dev", "vibe": "chill"}
from GramAddict.core.bot_flow import start_bot
try:
with patch('GramAddict.core.bot_flow.random_sleep', side_effect=KeyboardInterrupt()):
with patch("GramAddict.core.bot_flow.random_sleep", side_effect=KeyboardInterrupt()):
start_bot(username="testuser", device_id="123")
except KeyboardInterrupt:
pass
mock_goap.achieve.assert_any_call("learn own profile")
# resonance is created internally, so we can't easily assert on update_identity unless we patch ResonanceEngine too.
# It's sufficient to know the GOAP goal was triggered.
@@ -408,58 +503,75 @@ def test_profile_mismatch_recovery(mock_device, mock_cognitive_stack):
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
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.50
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.50
configs = MagicMock()
configs.args.profile_learning_percentage = 100 # Should force visit
configs.args.profile_learning_percentage = 100 # Should force visit
configs.args.likes_percentage = 0
configs.args.comment_percentage = 0
configs.args.follow_percentage = 0
configs.args.follow_percentage = 0
session_state = MagicMock()
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
feed_xml = '''<?xml version='1.0' ?>
session_state.check_limit.side_effect = (
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
)
feed_xml = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="amorextravel" />
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />
</hierarchy>'''
profile_xml = '''<?xml version='1.0' ?>
</hierarchy>"""
profile_xml = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/action_bar_title" text="ryanresatka" />
<node resource-id="com.instagram.android:id/row_profile_header_textview_biography" text="cool bio" />
</hierarchy>'''
</hierarchy>"""
call_count = [0]
def dump_hierarchy_mock(*args, **kwargs):
call_count[0] += 1
return feed_xml if call_count[0] == 1 else profile_xml
mock_device.dump_hierarchy.side_effect = dump_hierarchy_mock
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
mock_cognitive_stack["nav_graph"].do.return_value = True
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, \
patch('GramAddict.core.bot_flow._extract_post_content') as mock_extract, \
patch('GramAddict.core.bot_flow.random.random', return_value=0.5), \
patch('GramAddict.core.bot_flow._align_active_post', return_value=False), \
patch('GramAddict.core.bot_flow._humanized_scroll'), \
patch('GramAddict.core.bot_flow._interact_with_profile') as mock_interact:
with (
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
patch("GramAddict.core.bot_flow._extract_post_content") as mock_extract,
patch("GramAddict.core.bot_flow.random.random", return_value=0.5),
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
patch("GramAddict.core.bot_flow._humanized_scroll"),
patch("GramAddict.core.bot_flow._interact_with_profile") as mock_interact,
):
mock_extract.return_value = {"username": "amorextravel", "description": "test image", "caption": ""}
mock_instance = MockTelepathic.get_instance.return_value
mock_instance._extract_semantic_nodes.side_effect = [
[{"x": 1, "y": 2, "original_attribs": {"text": "amorextravel"}}], # 1st call at top of loop
[{"x": 1, "y": 2, "original_attribs": {"text": "amorextravel"}}], # 2nd call before Targeted UX
[{"x": 1, "y": 2, "text": "ryanresatka", "resource_id": "com.instagram.android:id/action_bar_title"}] # 3rd call on profile
[{"x": 1, "y": 2, "original_attribs": {"text": "amorextravel"}}], # 1st call at top of loop
[{"x": 1, "y": 2, "original_attribs": {"text": "amorextravel"}}], # 2nd call before Targeted UX
[
{"x": 1, "y": 2, "text": "ryanresatka", "resource_id": "com.instagram.android:id/action_bar_title"}
], # 3rd call on profile
]
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
mock_cognitive_stack["telepathic"] = mock_instance
configs.args.interact_percentage = 100
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, "HomeFeed", mock_cognitive_stack)
assert mock_interact.call_args[0][2] == "ryanresatka", f"Expected ryanresatka but got {mock_interact.call_args[0][2]}"
_run_zero_latency_feed_loop(
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"HomeFeed",
mock_cognitive_stack,
)
assert (
mock_interact.call_args[0][2] == "ryanresatka"
), f"Expected ryanresatka but got {mock_interact.call_args[0][2]}"

View File

@@ -0,0 +1,39 @@
from unittest.mock import patch
import pytest
from GramAddict.core.perception.action_memory import ActionMemory
from GramAddict.core.perception.spatial_parser import SpatialNode
@pytest.fixture
def memory():
with patch("GramAddict.core.qdrant_memory.UIMemoryDB") as MockDB:
mock_db = MockDB.return_value
yield ActionMemory(ui_memory=mock_db)
def test_track_click_stores_memory(memory):
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
memory.track_click("tap test", node)
assert memory._last_click_context is not None
assert memory._last_click_context["intent"] == "tap test"
def test_confirm_click_boosts_confidence(memory):
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
memory.track_click("tap test", node)
memory.confirm_click()
memory.ui_memory.boost_confidence.assert_called_once()
assert memory._last_click_context is None
def test_reject_click_decays_confidence(memory):
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
memory.track_click("tap test", node)
memory.reject_click()
memory.ui_memory.decay_confidence.assert_called_once()
assert memory._last_click_context is None

View File

@@ -0,0 +1,37 @@
from GramAddict.core.perception.intent_resolver import IntentResolver
from GramAddict.core.perception.spatial_parser import SpatialNode
def test_intent_resolver_finds_bottom_tab():
resolver = IntentResolver()
# A tab at the top
top_tab = SpatialNode(bounds=(0, 0, 100, 100), content_desc="Explore Tab", clickable=True)
# A tab at the bottom
bottom_tab = SpatialNode(bounds=(0, 2200, 100, 2300), content_desc="Explore Tab", clickable=True)
# Intent resolver should prefer the one that geometrically matches the bottom navigation area
best_match = resolver.resolve("tap explore tab", [top_tab, bottom_tab])
assert best_match == bottom_tab
def test_intent_resolver_finds_button_by_text():
resolver = IntentResolver()
btn1 = SpatialNode(bounds=(0, 0, 100, 100), text="Follow", clickable=True)
btn2 = SpatialNode(bounds=(200, 200, 300, 300), text="Message", clickable=True)
best_match = resolver.resolve("tap follow button", [btn1, btn2])
assert best_match == btn1
def test_intent_resolver_returns_none_if_no_match():
resolver = IntentResolver()
btn = SpatialNode(bounds=(0, 0, 100, 100), text="Like", clickable=True)
best_match = resolver.resolve("tap follow button", [btn])
assert best_match is None

View File

@@ -0,0 +1,77 @@
from GramAddict.core.perception.spatial_parser import SpatialNode, SpatialParser
XML_FIXTURE = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" bounds="[0,0][1080,2400]" class="android.widget.FrameLayout">
<node index="0" bounds="[0,2200][1080,2400]" class="android.view.ViewGroup" content-desc="Bottom Navigation">
<node index="0" bounds="[50,2250][150,2350]" class="android.widget.ImageView" content-desc="Home Tab" clickable="true"/>
<node index="1" bounds="[250,2250][350,2350]" class="android.widget.ImageView" content-desc="Explore Tab" clickable="true"/>
</node>
<node index="1" bounds="[0,100][1080,2200]" class="androidx.recyclerview.widget.RecyclerView">
<node index="0" bounds="[50,150][1030,800]" class="android.widget.FrameLayout" content-desc="Post 1">
<node index="0" bounds="[100,700][200,750]" class="android.widget.Button" text="Like" clickable="true"/>
</node>
</node>
</node>
</hierarchy>
"""
class TestSpatialParser:
def test_parses_xml_into_spatial_nodes(self):
parser = SpatialParser()
root = parser.parse(XML_FIXTURE)
assert root is not None
assert isinstance(root, SpatialNode)
assert root.bounds == (0, 0, 1080, 2400)
assert len(root.children) == 2 # The Bottom Nav and the Recycler View
def test_extracts_all_clickable_nodes(self):
parser = SpatialParser()
root = parser.parse(XML_FIXTURE)
clickables = parser.get_clickable_nodes(root)
assert len(clickables) == 3
descriptions = [n.content_desc or n.text for n in clickables]
assert "Home Tab" in descriptions
assert "Explore Tab" in descriptions
assert "Like" in descriptions
def test_spatial_containment(self):
parser = SpatialParser()
root = parser.parse(XML_FIXTURE)
# Get the first post
post_nodes = [n for n in parser.get_all_nodes(root) if n.content_desc == "Post 1"]
assert len(post_nodes) == 1
post = post_nodes[0]
# Get the Like button
like_nodes = [n for n in parser.get_all_nodes(root) if n.text == "Like"]
assert len(like_nodes) == 1
like_btn = like_nodes[0]
# Spatial Containment: The Like button should be mathematically inside the Post
assert post.contains(like_btn) is True
# The Like button should NOT contain the Post
assert like_btn.contains(post) is False
# The Home Tab should NOT be in the Post
home_tabs = [n for n in parser.get_all_nodes(root) if n.content_desc == "Home Tab"]
assert post.contains(home_tabs[0]) is False
def test_spatial_intersection(self):
parser = SpatialParser()
# Node 1: Left side
n1 = SpatialNode(bounds=(0, 0, 100, 100))
# Node 2: Overlapping Right side
n2 = SpatialNode(bounds=(50, 50, 150, 150))
# Node 3: Far away
n3 = SpatialNode(bounds=(200, 200, 300, 300))
assert n1.intersects(n2) is True
assert n2.intersects(n1) is True
assert n1.intersects(n3) is False

View File

@@ -0,0 +1,65 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
def test_bot_flow_unlearns_on_context_loss():
"""Prove that bot_flow calls unlearn_current_state when context is completely lost (3 misses)."""
device = MagicMock()
# Provide a dummy dump hierarchy
device.dump_hierarchy.return_value = "<hierarchy></hierarchy>"
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
session_state = MagicMock()
# We will patch SituationalAwarenessEngine
with patch("GramAddict.core.situational_awareness.SituationalAwarenessEngine") as MockSAE:
# We need mock SAE to return OBSTACLE_MODAL to trigger the first condition
# Wait, the code has two paths: `has_obstacle` or `not has_feed_markers`.
# If we return `False` for has_feed_markers, it hits the second path.
mock_sae_instance = MockSAE.return_value
# perceive needs to return something that is not OBSTACLE_MODAL so we hit the feed markers path
mock_sae_instance.perceive.return_value = "EXPLORE_GRID"
# Act: _run_zero_latency_feed_loop runs a loop.
# Since has_feed_markers is always False, it will increment misses 3 times and return "CONTEXT_LOST".
# We also need to mock TelepathicEngine so it doesn't crash on misses == 2.
with (
patch("GramAddict.core.bot_flow.TelepathicEngine") as MockTelepathic,
patch("GramAddict.core.bot_flow.dump_ui_state"),
):
mock_telepathic_instance = MockTelepathic.get_instance.return_value
mock_telepathic_instance.find_best_node.return_value = None
mock_telepathic_instance._extract_semantic_nodes.return_value = [MagicMock()]
mock_cognitive_stack = MagicMock()
dopamine_mock = MagicMock()
dopamine_mock.is_app_session_over.return_value = False
dopamine_mock.wants_to_doomscroll.return_value = False
def stack_get(key):
if key == "radome":
return None
elif key == "dopamine":
return dopamine_mock
return MagicMock()
mock_cognitive_stack.get.side_effect = stack_get
with patch("GramAddict.core.bot_flow.is_ad", return_value=False):
result = _run_zero_latency_feed_loop(
device=device,
zero_engine=MagicMock(),
nav_graph=MagicMock(),
configs=MagicMock(),
session_state=session_state,
job_target="test_feed",
cognitive_stack=mock_cognitive_stack,
)
# Assert (RED)
assert result == "CONTEXT_LOST"
# SAE should have been told to unlearn the current state because of context loss
mock_sae_instance.unlearn_current_state.assert_called_with("<hierarchy></hierarchy>")

View File

@@ -8,67 +8,91 @@ Reproduces the exact production failure from 2026-04-16 22:59 where the bot:
These tests MUST fail before the fix and pass after.
"""
import pytest
import re
from GramAddict.core.telepathic_engine import TelepathicEngine
import re
from GramAddict.core.telepathic_engine import TelepathicEngine
# ── Realistic node fixtures extracted from live XML dump 2026-04-16_22-59-53 ──
def make_node(x, y, bounds, semantic, res_id="com.instagram.android:id/dummy", text="", desc=""):
m = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
l, t, r, b = map(int, m.groups()) if m else (0, 0, 0, 0)
return {
"x": x, "y": y,
"width": r - l, "height": b - t, "area": (r - l) * (b - t),
"x": x,
"y": y,
"width": r - l,
"height": b - t,
"area": (r - l) * (b - t),
"raw_bounds": bounds,
"semantic_string": semantic,
"resource_id": res_id,
"class_name": "android.widget.FrameLayout",
"selected": False,
"original_attribs": {"text": text, "desc": desc}
"original_attribs": {"text": text, "desc": desc},
}
EXPLORE_GRID_NODES = [
# Row 1, Col 1 — this is what "first image" should match
make_node(178, 559, "[0,321][356,797]",
"description: '4 photos by Patsy Weingart at row 1, column 1', id context: 'grid card layout container'",
res_id="com.instagram.android:id/grid_card_layout_container",
desc="4 photos by Patsy Weingart at row 1, column 1"),
make_node(
178,
559,
"[0,321][356,797]",
"description: '4 photos by Patsy Weingart at row 1, column 1', id context: 'grid card layout container'",
res_id="com.instagram.android:id/grid_card_layout_container",
desc="4 photos by Patsy Weingart at row 1, column 1",
),
# Row 1, Col 1 — child image_button (same area, no semantic info)
make_node(178, 558, "[0,321][356,796]",
"id context: 'image button'",
res_id="com.instagram.android:id/image_button"),
make_node(
178, 558, "[0,321][356,796]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
),
# Row 1, Col 2
make_node(540, 559, "[362,321][718,797]",
"description: 'Photo by Barbara at Row 1, Column 2', id context: 'grid card layout container'",
res_id="com.instagram.android:id/grid_card_layout_container",
desc="Photo by Barbara at Row 1, Column 2"),
make_node(540, 558, "[362,321][718,796]",
"id context: 'image button'",
res_id="com.instagram.android:id/image_button"),
make_node(
540,
559,
"[362,321][718,797]",
"description: 'Photo by Barbara at Row 1, Column 2', id context: 'grid card layout container'",
res_id="com.instagram.android:id/grid_card_layout_container",
desc="Photo by Barbara at Row 1, Column 2",
),
make_node(
540, 558, "[362,321][718,796]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
),
# Row 2, Col 2
make_node(540, 1041, "[362,803][718,1279]",
"description: 'Photo by Garima Bhaskar at Row 2, Column 2', id context: 'grid card layout container'",
res_id="com.instagram.android:id/grid_card_layout_container",
desc="Photo by Garima Bhaskar at Row 2, Column 2"),
make_node(540, 1040, "[362,803][718,1278]",
"id context: 'image button'",
res_id="com.instagram.android:id/image_button"),
make_node(
540,
1041,
"[362,803][718,1279]",
"description: 'Photo by Garima Bhaskar at Row 2, Column 2', id context: 'grid card layout container'",
res_id="com.instagram.android:id/grid_card_layout_container",
desc="Photo by Garima Bhaskar at Row 2, Column 2",
),
make_node(
540, 1040, "[362,803][718,1278]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
),
# Row 3, Col 1 — this is what the VLM wrongly picked
make_node(178, 1523, "[0,1285][356,1761]",
"description: '4 photos by Soul Of Nature Photography at row 3, column 1', id context: 'grid card layout container'",
res_id="com.instagram.android:id/grid_card_layout_container",
desc="4 photos by Soul Of Nature Photography at row 3, column 1"),
make_node(178, 1522, "[0,1285][356,1760]",
"id context: 'image button'",
res_id="com.instagram.android:id/image_button"),
make_node(
178,
1523,
"[0,1285][356,1761]",
"description: '4 photos by Soul Of Nature Photography at row 3, column 1', id context: 'grid card layout container'",
res_id="com.instagram.android:id/grid_card_layout_container",
desc="4 photos by Soul Of Nature Photography at row 3, column 1",
),
make_node(
178, 1522, "[0,1285][356,1760]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
),
# Search bar
make_node(487, 219, "[32,173][943,265]",
"text: 'Search', id context: 'action bar search edit text'",
res_id="com.instagram.android:id/action_bar_search_edit_text",
text="Search"),
make_node(
487,
219,
"[32,173][943,265]",
"text: 'Search', id context: 'action bar search edit text'",
res_id="com.instagram.android:id/action_bar_search_edit_text",
text="Search",
),
]
@@ -87,17 +111,18 @@ class TestBlacklistPoisoning:
engine = TelepathicEngine()
# Clear any persisted state to test pure logic
engine._blacklist = {}
# Simulate the flow: the engine "clicked" an image_button and it failed
TelepathicEngine._last_click_context = {
"intent": "first image in explore grid",
"semantic_string": "id context: 'image button'",
"x": 178, "y": 558,
"timestamp": 1000
"x": 178,
"y": 558,
"timestamp": 1000,
}
engine.reject_click("first image in explore grid")
# The blacklist should NOT contain this generic entry
blacklisted = engine._blacklist.get("first image in explore grid", [])
assert "id context: 'image button'" not in blacklisted, (
@@ -123,18 +148,20 @@ class TestExploreGridFastPath:
# Build a minimal XML that the engine can parse — but we test the fast-path
# directly by calling find_best_node with mocked extraction
from unittest.mock import patch
with patch.object(engine, '_extract_semantic_nodes', return_value=EXPLORE_GRID_NODES):
with patch.object(engine, '_is_instagram_context', return_value=True):
result = engine.find_best_node("<fake>", "first image in explore grid", min_confidence=0.82, device=None)
with patch.object(engine, "_extract_semantic_nodes", return_value=EXPLORE_GRID_NODES):
with patch.object(engine, "_is_instagram_context", return_value=True):
result = engine.find_best_node(
"<fake>", "first image in explore grid", min_confidence=0.82, device=None
)
assert result is not None, (
"Grid Fast-Path returned None for 'first image in explore grid'. "
"This forces every explore grid tap to use the expensive VLM fallback."
)
assert any(k in result.get("semantic", "").lower() for k in ["image button", "grid card layout container"]), (
f"Grid Fast-Path selected wrong node type: {result.get('semantic')}"
)
assert any(
k in result.get("semantic_string", "").lower() for k in ["image button", "grid card layout container"]
), f"Grid Fast-Path selected wrong node type: {result.get('semantic_string')}"
def test_grid_fastpath_prefers_topmost_row(self):
"""
@@ -142,13 +169,15 @@ class TestExploreGridFastPath:
topmost one (smallest Y = row 1) since the intent says 'first'.
"""
engine = TelepathicEngine()
from unittest.mock import patch
with patch.object(engine, '_extract_semantic_nodes', return_value=EXPLORE_GRID_NODES):
with patch.object(engine, '_is_instagram_context', return_value=True):
result = engine.find_best_node("<fake>", "first image in explore grid", min_confidence=0.82, device=None)
with patch.object(engine, "_extract_semantic_nodes", return_value=EXPLORE_GRID_NODES):
with patch.object(engine, "_is_instagram_context", return_value=True):
result = engine.find_best_node(
"<fake>", "first image in explore grid", min_confidence=0.82, device=None
)
if result is not None:
# Row 1 items have y ≈ 559, Row 3 items have y ≈ 1523
assert result["y"] < 800, (
@@ -172,25 +201,26 @@ class TestVerifySuccessExploreGrid:
(no feed markers), verify_success must return None (Inconclusive).
"""
engine = TelepathicEngine()
# Simulate that we just clicked a grid item
TelepathicEngine._last_click_context = {
"intent": "first image in explore grid",
"semantic_string": "description: '4 photos by Patsy Weingart at row 1, column 1'",
"x": 178, "y": 559,
"timestamp": 1000
"x": 178,
"y": 559,
"timestamp": 1000,
}
# Post-click XML still shows the explore grid (no feed markers)
still_on_grid_xml = """
<node class="android.widget.FrameLayout">
<node resource-id="com.instagram.android:id/explore_action_bar" />
<node resource-id="com.instagram.android:id/grid_card_layout_container"
<node resource-id="com.instagram.android:id/grid_card_layout_container"
content-desc="4 photos by Patsy Weingart at row 1, column 1" />
<node resource-id="com.instagram.android:id/image_button" />
</node>
"""
result = engine.verify_success("first image in explore grid", still_on_grid_xml)
assert result is None, "verify_success should be inconclusive (None) when still on explore grid"
@@ -200,14 +230,15 @@ class TestVerifySuccessExploreGrid:
verify_success must return True.
"""
engine = TelepathicEngine()
TelepathicEngine._last_click_context = {
"intent": "first image in explore grid",
"semantic_string": "description: '4 photos by Patsy Weingart at row 1, column 1'",
"x": 178, "y": 559,
"timestamp": 1000
"x": 178,
"y": 559,
"timestamp": 1000,
}
# Post-click XML shows a feed post (has feed markers)
post_view_xml = """
<node class="android.widget.FrameLayout">
@@ -217,6 +248,6 @@ class TestVerifySuccessExploreGrid:
<node resource-id="com.instagram.android:id/row_feed_button_share" />
</node>
"""
result = engine.verify_success("first image in explore grid", post_view_xml)
assert result is True, "verify_success should pass when post view is visible"

View File

@@ -0,0 +1,62 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.goap import GoalExecutor
from GramAddict.core.screen_topology import ScreenType
def test_goap_unlearns_transition_on_back_press_trap():
"""Prove that GOAP forgets a specific topological transition when it gets trapped and spams 'back'."""
device = MagicMock()
orchestrator = GoalExecutor(device, "testuser")
# Mocking internal state
start_screen = ScreenType.HOME_FEED
goal = "open messages"
steps_taken = [
{"action": "tap explore tab"},
{"action": "press back"},
{"action": "press back"},
{"action": "press back"},
]
def mock_exec(*args, **kwargs):
print("EXECUTING:", args, kwargs)
return True
orchestrator._execute_action = MagicMock(side_effect=mock_exec) # "press back" succeeds
orchestrator.path_memory = MagicMock()
orchestrator.path_memory.recall_path.return_value = None
# To test the back-press circuit breaker, we just need to feed it 3 "press back" actions.
# Since achieve is complex, let's just test that the required logic
# exists inside it. The circuit breaker is in the "EXECUTE" block of achieve.
# We will mock the planner to return "press back" 3 times.
orchestrator.planner.plan_next_step = MagicMock(side_effect=[["press back"], ["press back"], ["press back"], []])
orchestrator.perceive = MagicMock(
return_value={"screen_type": ScreenType.EXPLORE_GRID, "available_actions": ["mock"]}
)
with patch("GramAddict.core.qdrant_memory.NavigationMemoryDB") as MockNavDB:
mock_nav_instance = MockNavDB.return_value
# We need to simulate that `steps_taken` already had "tap explore tab"
# However, achieve starts with an empty `steps_taken`.
# So we mock the internal variables if possible, but they are local.
# Alternatively, we make the planner return "tap explore tab", then 3 "press back"s.
orchestrator.planner.plan_next_step = MagicMock(
side_effect=["tap explore tab", "press back", "press back", "press back", "press back", None]
)
# Act
result = orchestrator.achieve(goal, max_steps=10)
# Assert (RED)
assert result is False
# Did it forget the path? (learn_path with success=False)
assert orchestrator.path_memory.learn_path.called
# Did it unlearn the transition?
print("MockNavDB calls:", MockNavDB.mock_calls)
print("NavInstance calls:", mock_nav_instance.mock_calls)
mock_nav_instance.unlearn_transition.assert_called_once_with(ScreenType.EXPLORE_GRID.value, "tap explore tab")

View File

@@ -1,12 +1,12 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.telepathic_engine import TelepathicEngine
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.telepathic_engine import TelepathicEngine
class TestProfileInteractionSync:
"""
TDD Tests: Reproduces the 'Already Followed -> Favorites' and 'No Story Ring'
TDD Tests: Reproduces the 'Already Followed -> Favorites' and 'No Story Ring'
bugs from 2026-04-17 live run.
"""
@@ -24,68 +24,84 @@ class TestProfileInteractionSync:
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"}
}]
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)
# Mock the parsing instead of old extraction
self.engine._parser = MagicMock()
self.engine._parser.parse.return_value = MagicMock()
# We must return SpatialNode objects for the new architecture
from GramAddict.core.perception.spatial_parser import SpatialNode
mock_node = SpatialNode(MagicMock())
mock_node.resource_id = "com.instagram.android:id/button"
mock_node.text = "Following"
mock_node.bounds = [500, 600, 600, 650]
self.engine._parser.get_clickable_nodes.return_value = [mock_node]
self.engine._structural_sanity_check = MagicMock(return_value=True)
self.engine._is_instagram_context = MagicMock(return_value=True)
# Mock resolver to return our mock node
self.engine._resolver = MagicMock()
self.engine._resolver.resolve.return_value = mock_node
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
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 = '''
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()
"""
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 = '''
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()
"""
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"

View File

@@ -0,0 +1,79 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.situational_awareness import EscapeAction, SituationalAwarenessEngine, SituationEpisodeDB
class TestSAETeslaUpgrade:
def setup_method(self):
self.device_mock = MagicMock()
self.sae = SituationalAwarenessEngine(self.device_mock)
def test_sae_foreground_extraction(self):
"""
Ensures that modals/popups located at the END of the XML document
(highest Z-index) are prioritized in the compressed signature.
The old system truncated at elements[:50], missing the actual popup.
"""
# Create an XML with 60 background nodes, and 1 dialog node at the end
xml_dump = "<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>\n<hierarchy rotation='0'>\n"
for i in range(60):
xml_dump += f' <node package="com.instagram.android" resource-id="id/feed_item_{i}" text="Feed {i}" bounds="[0,0][100,100]" />\n'
# The critical modal at the end
xml_dump += ' <node package="com.instagram.android" resource-id="id/dialog_container" text="Not Now" bounds="[100,100][200,200]" />\n'
xml_dump += "</hierarchy>"
compressed = self.sae._compress_xml(xml_dump)
# The compressed string MUST contain the dialog container
assert "dialog_container" in compressed, "Foreground extraction failed: modal was truncated!"
assert "Not Now" in compressed, "Foreground extraction failed: modal text was truncated!"
# It should prioritize the END of the document, so feed_item_0 should ideally be gone if capped at 50
assert "feed_item_0" not in compressed, "Background elements are still being prioritized over foreground!"
def test_sae_structural_generalization(self):
"""
Ensures that dynamic user content is stripped to allow cross-post modal generalization,
while short, static UI text (like "OK", "Cancel") is preserved.
"""
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation='0'>
<node package="com.instagram.android" resource-id="id/comment" text="This is a very long user comment that changes every time we see this modal so it should be stripped!" />
<node package="com.instagram.android" resource-id="id/button" text="OK" />
</hierarchy>
"""
compressed = self.sae._compress_xml(xml_dump)
# Long dynamic text should be stripped or truncated to not pollute the vector space
assert "This is a very long user comment" not in compressed, "Dynamic text > 20 chars was not stripped!"
assert "text='<STRIPPED_DYNAMIC>'" in compressed or "This is a very lo" not in compressed
# Short static text should be kept
assert "OK" in compressed, "Short static UI text was incorrectly stripped!"
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new=True)
def test_sae_negative_reinforcement(self):
"""
Ensures that failed escapes decay the confidence of the vector,
and eventually purge it, instead of just storing a useless 0.0 vector alongside it.
"""
db = SituationEpisodeDB()
# We need to mock db._db.client directly since it's an instance property
mock_client = MagicMock()
db._db._client = mock_client
db._db.client = mock_client
with patch.object(db._db, "upsert_point") as mock_upsert:
# Mock retrieve to return an existing point with confidence 0.4
mock_payload = {"confidence": 0.4, "action": {"action_type": "back", "x": 0, "y": 0, "reason": ""}}
mock_client.retrieve.return_value = [MagicMock(payload=mock_payload)]
# Simulate a FAILURE
action = EscapeAction("back")
db.learn("some_signature", action, success=False)
# Verify that it fetched the current confidence and updated it, or deleted it if < 0.1
# If confidence was 0.4 and delta is -0.5, it drops to -0.1 -> DELETED
mock_client.delete.assert_called_once()

View File

@@ -0,0 +1,22 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
def test_sae_has_unlearn_current_state():
"""Prove that SituationalAwarenessEngine exposes unlearn_current_state to heal from poisoned context."""
device = MagicMock()
sae = SituationalAwarenessEngine(device)
# Mocking internal compression and the screen_memory dependency
sae._compress_xml = MagicMock(return_value="<compressed_feed/>")
with patch("GramAddict.core.qdrant_memory.ScreenMemoryDB") as MockScreenMemoryDB:
mock_db_instance = MockScreenMemoryDB.return_value
# If unlearn_current_state does not exist, AttributeError (RED)
sae.unlearn_current_state("<full_feed_xml/>")
# Verify it compresses and delegates to purge_screen
sae._compress_xml.assert_called_once_with("<full_feed_xml/>")
mock_db_instance.purge_screen.assert_called_once_with("<compressed_feed/>")

View File

@@ -0,0 +1,50 @@
from unittest.mock import MagicMock, PropertyMock, patch
from GramAddict.core.qdrant_memory import NavigationMemoryDB, QdrantBase, ScreenMemoryDB
class DummyQdrantBase(QdrantBase):
def __init__(self):
self.client = MagicMock()
self.collection_name = "test_collection"
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
def test_qdrant_base_has_delete_point(mock_is_connected):
"""Prove that QdrantBase implements delete_point for autonomous unlearning."""
mock_is_connected.return_value = True
db = DummyQdrantBase()
# If delete_point does not exist, this will raise AttributeError (RED)
db.generate_uuid = MagicMock(return_value="test-uuid-1234")
result = db.delete_point("test-seed")
# Assert
db.client.delete.assert_called_once_with(collection_name="test_collection", points_selector=["test-uuid-1234"])
assert result is True
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
def test_screen_memory_has_purge_screen(mock_is_connected):
"""Prove that ScreenMemoryDB exposes purge_screen to heal poisoned classifications."""
mock_is_connected.return_value = True
db = ScreenMemoryDB()
db.client = MagicMock()
db.delete_point = MagicMock()
# If purge_screen does not exist, AttributeError (RED)
db.purge_screen("<node class='feed' />")
db.delete_point.assert_called_once_with("<node class='feed' />")
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
def test_navigation_memory_has_unlearn_transition(mock_is_connected):
"""Prove that NavigationMemoryDB exposes unlearn_transition to destroy trap paths."""
mock_is_connected.return_value = True
db = NavigationMemoryDB()
db.client = MagicMock()
db.delete_point = MagicMock()
# If unlearn_transition does not exist, AttributeError (RED)
db.unlearn_transition("HOME_FEED", "tap explore tab")
db.delete_point.assert_called_once_with("HOME_FEED_tap explore tab")

View File

@@ -1,38 +0,0 @@
from GramAddict.core.telepathic_engine import TelepathicEngine
def test_brevity_bonus_prioritizes_short_labels():
"""
Tests that the brevity bonus correctly prioritizes short, exact matches
over longer matches that contain the same keywords.
"""
engine = TelepathicEngine()
# A short, precise button
short_node = {
"x": 100,
"y": 200,
"area": 500,
"semantic_string": "text: 'Profile', id context: 'tab bar profile'",
"resource_id": "tab_bar_profile",
"original_attribs": {"desc": "", "text": "Profile"},
}
# A long, descriptive text that happens to contain "Profile"
long_node = {
"x": 100,
"y": 300,
"area": 5000,
"semantic_string": "text: 'Visit my profile to see more photos', id context: 'feed post text'",
"resource_id": "feed_post_text",
"original_attribs": {"desc": "", "text": "Visit my profile to see more photos"},
}
nodes = [long_node, short_node]
# "profile" is the intent
result = engine._keyword_match_score("profile", nodes)
assert result is not None, "Failed to extract node via fast path"
# The short node should win because of the brevity bonus (0.2)
assert "tab bar profile" in result["semantic"], "Brevity bonus failed to prioritize the shorter label"

View File

@@ -1,65 +0,0 @@
import pytest
from GramAddict.core.telepathic_engine import TelepathicEngine
def test_extract_post_author_confidence():
"""
Tests that the TelepathicEngine can confidently extract the post author
header node from a standard feed XML dump, even if it falls back to the
fast path or embeddings.
"""
engine = TelepathicEngine()
# A generic Feed post author node
author_node = {
"x": 100, "y": 200, "area": 500,
"semantic_string": "description: 'fiona.dawson', id context: 'row feed photo profile name'",
"resource_id": "row_feed_photo_profile_name",
"original_attribs": {"desc": "fiona.dawson", "text": "fiona.dawson"}
}
# A generic Feed post image node
image_node = {
"x": 100, "y": 300, "area": 5000,
"semantic_string": "description: 'Post image', id context: 'row feed photo imageview'",
"resource_id": "row_feed_photo_imageview",
"original_attribs": {"desc": "Post image", "text": ""}
}
nodes = [author_node, image_node]
# The exact string used by _extract_post_content
result = engine._keyword_match_score("post author username header", nodes)
assert result is not None, "Failed to extract author node via fast path"
assert "fiona.dawson" in result["semantic"], "Extracted wrong node for author"
assert result["score"] >= 0.35, f"Confidence score too low: {result['score']}"
def test_extract_post_description_confidence():
"""
Tests that the TelepathicEngine can confidently extract the post description
node from a standard feed XML dump.
"""
engine = TelepathicEngine()
author_node = {
"x": 100, "y": 200, "area": 500,
"semantic_string": "description: 'fiona.dawson', id context: 'row feed photo profile name'",
"resource_id": "row_feed_photo_profile_name",
"original_attribs": {"desc": "fiona.dawson", "text": "fiona.dawson"}
}
image_node = {
"x": 100, "y": 300, "area": 5000,
"semantic_string": "description: 'Post image', id context: 'row feed photo imageview'",
"resource_id": "row_feed_photo_imageview",
"original_attribs": {"desc": "Post image", "text": ""}
}
nodes = [author_node, image_node]
# The exact string used by _extract_post_content
result = engine._keyword_match_score("post image video media content description", nodes)
assert result is not None, "Failed to extract image/media node via fast path"
assert "imageview" in result["semantic"], "Extracted wrong node for media"
assert result["score"] >= 0.35, f"Confidence score too low: {result['score']}"