fix(sae): stabilize navigation engine, fix container filtering, and negative reinforcement logic
This commit is contained in:
@@ -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
|
||||
|
||||
69
tests/anomalies/test_telepathic_guards.py
Normal file
69
tests/anomalies/test_telepathic_guards.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
92
tests/e2e/test_e2e_config_goal_limits.py
Normal file
92
tests/e2e/test_e2e_config_goal_limits.py
Normal 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!"
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
163
tests/e2e/test_e2e_real_llm_learning.py
Normal file
163
tests/e2e/test_e2e_real_llm_learning.py
Normal 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!")
|
||||
@@ -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
|
||||
|
||||
217
tests/e2e/test_full_e2e_android_sim.py
Normal file
217
tests/e2e/test_full_e2e_android_sim.py
Normal 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!"
|
||||
@@ -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]}"
|
||||
|
||||
39
tests/unit/perception/test_action_memory.py
Normal file
39
tests/unit/perception/test_action_memory.py
Normal 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
|
||||
37
tests/unit/perception/test_intent_resolver.py
Normal file
37
tests/unit/perception/test_intent_resolver.py
Normal 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
|
||||
77
tests/unit/perception/test_spatial_parser.py
Normal file
77
tests/unit/perception/test_spatial_parser.py
Normal 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
|
||||
65
tests/unit/test_bot_flow_unlearn.py
Normal file
65
tests/unit/test_bot_flow_unlearn.py
Normal 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>")
|
||||
@@ -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"
|
||||
|
||||
62
tests/unit/test_goap_unlearn.py
Normal file
62
tests/unit/test_goap_unlearn.py
Normal 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")
|
||||
@@ -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"
|
||||
|
||||
79
tests/unit/test_sae_tesla_upgrade.py
Normal file
79
tests/unit/test_sae_tesla_upgrade.py
Normal 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()
|
||||
22
tests/unit/test_sae_unlearn.py
Normal file
22
tests/unit/test_sae_unlearn.py
Normal 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/>")
|
||||
50
tests/unit/test_self_healing_memory.py
Normal file
50
tests/unit/test_self_healing_memory.py
Normal 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")
|
||||
@@ -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"
|
||||
@@ -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']}"
|
||||
Reference in New Issue
Block a user