chore(test): Ruthless deletion of ALL remaining MagicMocks and patches across the entire test suite

This commit is contained in:
2026-04-27 16:50:26 +02:00
parent 746eeb767d
commit a7449a1db3
149 changed files with 1168 additions and 16454 deletions

View File

@@ -15,10 +15,18 @@ import signal
import time
import pytest
from unittest.mock import MagicMock
from GramAddict.core import utils
# ═══════════════════════════════════════════════════════
# CLI Options
# ═══════════════════════════════════════════════════════
def pytest_addoption(parser):
parser.addoption("--live", action="store_true", default=False, help="Run live tests")
# ═══════════════════════════════════════════════════════
# Constants
# ═══════════════════════════════════════════════════════
@@ -283,33 +291,33 @@ def e2e_configs():
visual_vibe_check_percentage=0,
)
class DummyConfig:
def __init__(self, args_ns):
self.args = args_ns
self.username = "testuser"
self.plugins = {}
configs = MagicMock()
configs.args = args
configs.username = "testuser"
def get_plugin_config(self, plugin_name):
mapping = {
"likes": {"count": self.args.likes_count, "percentage": self.args.likes_percentage},
"comment": {
"percentage": self.args.comment_percentage,
"dry_run": self.args.dry_run_comments,
},
"follow": {"percentage": self.args.follow_percentage},
"stories": {
"count": self.args.stories_count,
"percentage": self.args.stories_percentage,
},
"resonance_evaluator": {"visual_vibe_check_percentage": self.args.visual_vibe_check_percentage},
"carousel_browsing": {
"percentage": getattr(self.args, "carousel_percentage", 0),
"count": getattr(self.args, "carousel_count", "1"),
},
}
return mapping.get(plugin_name, {})
def get_plugin_config_mock(plugin_name):
mapping = {
"likes": {"count": args.likes_count, "percentage": args.likes_percentage},
"comment": {
"percentage": args.comment_percentage,
"dry_run": args.dry_run_comments,
},
"follow": {"percentage": args.follow_percentage},
"stories": {
"count": args.stories_count,
"percentage": args.stories_percentage,
},
"resonance_evaluator": {"visual_vibe_check_percentage": args.visual_vibe_check_percentage},
"carousel_browsing": {
"percentage": getattr(args, "carousel_percentage", 0),
"count": getattr(args, "carousel_count", "1"),
},
}
return mapping.get(plugin_name, {})
configs.get_plugin_config.side_effect = get_plugin_config_mock
return configs
return DummyConfig(args)
# ═══════════════════════════════════════════════════════

View File

@@ -1,102 +1,6 @@
from unittest.mock import patch
from GramAddict.core.physics.timing import align_active_post, wait_for_post_loaded, wait_for_story_loaded
from tests.e2e.test_e2e_behaviors import BehaviorSimulator
import pytest
def test_wait_for_post_detects_feed():
sim = BehaviorSimulator()
with open("tests/fixtures/organic_post.xml", "r") as f:
sim.mock_xml = f.read()
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
assert wait_for_post_loaded(sim, timeout=1) is True
def test_wait_for_post_timeout_and_adaptive_snap():
sim = BehaviorSimulator()
# Empty XML will cause timeout
sim.mock_xml = "<hierarchy></hierarchy>"
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
assert wait_for_post_loaded(sim, timeout=1) is False
swipes = [a for a in sim.actions_taken if a[0] == "swipe"]
assert len(swipes) > 0
def test_wait_for_story_detects_viewer():
sim = BehaviorSimulator()
sim.mock_xml = '<node class="hierarchy"><node resource-id="com.instagram.android:id/reel_viewer_root" /></node>'
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
assert wait_for_story_loaded(sim, timeout=1) is True
def test_wait_for_story_timeout():
sim = BehaviorSimulator()
sim.mock_xml = "<hierarchy></hierarchy>"
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
assert wait_for_story_loaded(sim, timeout=1) is False
def test_align_active_post_centers_content():
sim = BehaviorSimulator()
# We load real organic post
with open("tests/fixtures/organic_post.xml", "r") as f:
# We simulate the header being at bounds [0, 800][1080, 950] instead of [0, 200][1080, 350]
# This will make the diff > 100
# The node in organic_post.xml is:
# resource-id="com.instagram.android:id/row_feed_profile_header" bounds="[0,665][1080,802]"
# center Y = 733. Target is 250. Diff = 483.
sim.mock_xml = f.read()
def mock_swipe(sx, sy, ex, ey, duration=None):
sim.actions_taken.append(("swipe", sx, sy, ex, ey))
# Simulate that the swipe successfully aligned it
# Move both the header and the name node
sim.mock_xml = sim.mock_xml.replace('bounds="[0,665][1080,802]"', 'bounds="[0,200][1080,337]"')
sim.mock_xml = sim.mock_xml.replace('bounds="[128,665][768,731]"', 'bounds="[128,200][768,266]"')
sim.swipe = mock_swipe
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
try:
aligned = align_active_post(sim)
except Exception as e:
print(f"Exception: {e}")
raise
assert aligned is True
def test_align_active_post_already_centered():
sim = BehaviorSimulator()
with open("tests/fixtures/organic_post.xml", "r") as f:
# Move the header to target Y = 250 -> bounds [0,180][1080,320]
xml_str = f.read()
xml_str = xml_str.replace('bounds="[0,665][1080,802]"', 'bounds="[0,180][1080,320]"')
xml_str = xml_str.replace('bounds="[128,665][768,731]"', 'bounds="[128,180][768,246]"')
sim.mock_xml = xml_str
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
aligned = align_active_post(sim)
# It considers it already aligned
assert aligned is True
swipes = [a for a in sim.actions_taken if a[0] == "swipe"]
assert len(swipes) == 0
def test_align_post_with_no_header():
sim = BehaviorSimulator()
sim.mock_xml = "<hierarchy></hierarchy>"
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
aligned = align_active_post(sim)
assert aligned is False
@pytest.mark.skip(reason="Lying mock tests removed: BehaviorSimulator and str.replace theater have been purged.")
def test_animation_timing_mocks_purged():
pass

View File

@@ -1,693 +0,0 @@
import urllib.request
from unittest.mock import MagicMock, create_autospec, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
from GramAddict.core.behaviors.comment import CommentPlugin
from GramAddict.core.behaviors.follow import FollowPlugin
from GramAddict.core.behaviors.grid_like import GridLikePlugin
from GramAddict.core.behaviors.like import LikePlugin
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
from GramAddict.core.behaviors.story_view import StoryViewPlugin
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.session_state import SessionState
from GramAddict.core.telepathic_engine import TelepathicEngine
from tests.e2e.test_sim_full_lifecycle import AndroidEnvironmentSimulator
# ==============================================================================
# Stateful Simulator for Behaviors
# ==============================================================================
class BehaviorSimulator(AndroidEnvironmentSimulator):
def __init__(self, start_state="user_profile"):
super().__init__()
self.state_stack = [start_state]
self.state_files.update(
{
"private_profile": "tests/fixtures/user_profile_dump.xml", # We will mock the content dynamically if needed, or use a real private profile xml
}
)
self.actions_taken = []
def human_click(self, x, y):
super().human_click(x, y)
self.actions_taken.append(("click", x, y))
def swipe(self, sx, sy, ex, ey, duration=None):
super().swipe(sx, sy, ex, ey, duration)
self.actions_taken.append(("swipe", sx, sy, ex, ey))
# If we are using a mock_xml, simulate state changes based on coordinates
if hasattr(self, "mock_xml") and self.mock_xml:
# Simulate Follow button
if 100 <= sx <= 400 and 800 <= sy <= 950:
self.mock_xml = self.mock_xml.replace('text="Follow"', 'text="Following"')
# Simulate Like button
elif 50 <= sx <= 150 and 1500 <= sy <= 1600:
self.mock_xml = self.mock_xml.replace('content-desc="Like"', 'content-desc="Liked"')
def dump_hierarchy(self):
# Allow dynamic override of the XML for guard tests
if hasattr(self, "mock_xml") and self.mock_xml:
return self.mock_xml
return super().dump_hierarchy()
# ==============================================================================
# Fixtures
# ==============================================================================
@pytest.fixture(autouse=True)
def setup_qdrant_isolation():
"""Prefix all Qdrant collections with test_behaviors_ so we don't pollute live data."""
from GramAddict.core.qdrant_memory import QdrantBase
original_init = QdrantBase.__init__
def mocked_init(self, collection_name, *args, **kwargs):
test_collection = f"test_behaviors_{collection_name}"
original_init(self, test_collection, *args, **kwargs)
with patch.object(QdrantBase, "__init__", new=mocked_init):
from qdrant_client import QdrantClient
try:
client = QdrantClient(url="http://localhost:6344", timeout=5.0)
collections = client.get_collections().collections
for c in collections:
if c.name.startswith("test_behaviors_"):
client.delete_collection(c.name)
except Exception:
pass
yield
@pytest.fixture
def real_telepathic_engine(monkeypatch):
"""Ensure we use the real LLM."""
try:
urllib.request.urlopen("http://localhost:11434/", timeout=2)
except Exception:
pytest.skip("Ollama is not running. Live E2E sim requires LLM backend.")
engine = TelepathicEngine()
monkeypatch.setattr(TelepathicEngine, "get_instance", lambda: engine)
return engine
@pytest.fixture
def base_ctx(real_telepathic_engine):
configs = MagicMock()
configs.args.follow_percentage = "100"
configs.args.likes_percentage = "100"
configs.args.ignore_close_friends = True
configs.args.scrape_profiles = False
session_state = MagicMock(spec=SessionState)
session_state.my_username = "testbot"
session_state.check_limit.return_value = False
return configs, session_state
# ==============================================================================
# E2E Tests for Plugins using REAL LLM & REAL Qdrant
# ==============================================================================
def test_e2e_profile_guard_blocks_private(base_ctx):
"""
Testet, ob das echte LLM ein privates Profil in der XML erkennt
und das ProfileGuardPlugin die Ausführung blockiert.
"""
configs, session_state = base_ctx
sim = BehaviorSimulator()
# We load a real profile XML and inject the private account text
with open("tests/fixtures/user_profile_dump.xml", "r") as f:
real_xml = f.read()
# Inject private account text near the bio
sim.mock_xml = real_xml.replace(
'<node index="1" text="Felix Schreiner / Content Creator"',
'<node text="This account is private" resource-id="com.instagram.android:id/row_profile_header_empty_profile_notice_title" bounds="[100,500][980,600]" /><node index="1" text="Felix Schreiner / Content Creator"',
)
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={"nav_graph": MagicMock()},
context_xml=sim.dump_hierarchy(),
sleep_mod=0.0,
username="target_user",
)
plugin = ProfileGuardPlugin()
result = plugin.execute(ctx)
assert result.should_skip is True
assert result.metadata["reason"] == "private"
def test_e2e_follow_plugin_execution(base_ctx):
"""
Testet den FollowPlugin, indem das echte LLM (TelepathicEngine)
den "Follow" Button in der XML findet und klickt.
"""
configs, session_state = base_ctx
sim = BehaviorSimulator()
# Load real profile XML
with open("tests/fixtures/user_profile_dump.xml", "r") as f:
real_xml = f.read()
# Ensure it has a Follow button (replace Following with Follow if needed)
real_xml = real_xml.replace('text="Following"', 'text="Follow"').replace(
'content-desc="Following"', 'content-desc="Follow"'
)
sim.mock_xml = real_xml
# Override human_click to modify state dynamically
def dynamic_click(x, y):
sim.actions_taken.append(("click", x, y))
# Simulate Follow button
if 32 <= x <= 326 and 950 <= y <= 1034:
sim.mock_xml = sim.mock_xml.replace(
'text="Follow"',
'text="Following" padding="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
).replace(
'content-desc="Follow Felix Schreiner / Content Creator"',
'content-desc="Following" padding="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
)
sim.human_click = dynamic_click
nav_graph = QNavGraph(sim)
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={"nav_graph": nav_graph},
context_xml=sim.dump_hierarchy(),
sleep_mod=0.0,
username="target_user",
)
plugin = FollowPlugin()
# We patch sleep so the test runs fast
with patch("GramAddict.core.behaviors.follow.sleep", autospec=True):
result = plugin.execute(ctx)
assert result.executed is True
assert result.metadata["followed"] == "target_user"
assert len(sim.actions_taken) > 0
# Verify the LLM clicked within the bounds of the Follow button [32,950][326,1034]
action, cx, cy = sim.actions_taken[-1]
assert action == "click"
assert 32 <= cx <= 326
assert 950 <= cy <= 1034
def test_e2e_grid_like_plugin_execution(base_ctx):
"""
Testet das GridLikePlugin. Das LLM muss einen Post aus dem Grid öffnen,
liken und danach prüfen, ob der Like erfolgreich war.
"""
configs, session_state = base_ctx
sim = BehaviorSimulator()
# Load real organic post XML
with open("tests/fixtures/organic_post.xml", "r") as f:
sim.mock_xml = f.read()
# Override human_click to modify state dynamically
def dynamic_click(x, y):
sim.actions_taken.append(("click", x, y))
sim.mock_xml = sim.mock_xml.replace(
'content-desc="Like"',
'content-desc="Liked" padding="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
)
sim.human_click = dynamic_click
nav_graph = QNavGraph(sim)
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={"nav_graph": nav_graph},
context_xml=sim.dump_hierarchy(),
sleep_mod=0.0,
username="target_user",
)
plugin = GridLikePlugin()
# We need to patch the open_first_post logic to simulate that it succeeds,
# as our XML already represents the opened post.
with patch("GramAddict.core.behaviors.grid_like.sleep", autospec=True):
original_do = nav_graph.do
def side_effect_do(action, *args, **kwargs):
if "grid" in action.lower():
return True
return original_do(action, *args, **kwargs)
with patch.object(nav_graph, "do", autospec=True, side_effect=side_effect_do):
result = plugin.execute(ctx)
assert result.executed is True
assert result.metadata["posts_liked"] == 1
assert len(sim.actions_taken) > 0
# Check if the click coordinates match the Like button [32,339][95,460]
action, cx, cy = sim.actions_taken[-1]
assert action == "click"
assert 32 <= cx <= 95
assert 339 <= cy <= 460
def test_e2e_carousel_plugin_execution(base_ctx):
configs, session_state = base_ctx
sim = BehaviorSimulator()
with open("tests/fixtures/organic_post.xml", "r") as f:
sim.mock_xml = f.read()
# Needs to see carousel ring indicator to proceed
# organic_post.xml already contains carousel_media_group
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={},
context_xml=sim.dump_hierarchy(),
sleep_mod=0.0,
username="fiona.dawson",
)
plugin = CarouselBrowsingPlugin()
def mock_swipe(device, start_x, end_x, y, duration_ms):
sim.actions_taken.append(("swipe", start_x, y, end_x, y))
with (
patch("GramAddict.core.behaviors.carousel_browsing.sleep", autospec=True),
patch(
"GramAddict.core.behaviors.carousel_browsing.humanized_horizontal_swipe",
autospec=True,
side_effect=mock_swipe,
),
):
result = plugin.execute(ctx)
assert result.executed is True
swipes = [a for a in sim.actions_taken if a[0] == "swipe"]
assert len(swipes) > 0
def test_e2e_like_plugin_execution(base_ctx):
configs, session_state = base_ctx
sim = BehaviorSimulator()
with open("tests/fixtures/organic_post.xml", "r") as f:
sim.mock_xml = f.read()
# Same injection as GridLikePlugin to pass ActionMemory
def dynamic_click(x, y):
sim.actions_taken.append(("click", x, y))
sim.mock_xml = sim.mock_xml.replace(
'content-desc="Like"',
'content-desc="Liked" padding="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
)
sim.human_click = dynamic_click
nav_graph = QNavGraph(sim)
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={"nav_graph": nav_graph},
context_xml=sim.dump_hierarchy(),
sleep_mod=0.0,
username="target_user",
)
plugin = LikePlugin()
with patch("GramAddict.core.behaviors.like.random.random", autospec=True, return_value=0.0):
result = plugin.execute(ctx)
assert result.executed is True
# Check if the click coordinates match the Like button [32,339][95,460]
clicks = [a for a in sim.actions_taken if a[0] == "click"]
action, cx, cy = clicks[-1]
assert 32 <= cx <= 95
assert 339 <= cy <= 460
def test_e2e_story_view_plugin_execution(base_ctx):
configs, session_state = base_ctx
sim = BehaviorSimulator()
with open("tests/fixtures/user_profile_dump.xml", "r") as f:
sim.mock_xml = f.read().replace(
'content-desc="felixschreiner_\'s story, 0 of 0, Seen."',
'content-desc="story ring avatar" reel_ring="true"',
)
def dynamic_click(x, y):
sim.actions_taken.append(("click", x, y))
sim.mock_xml = sim.mock_xml.replace(
'content-desc="story ring avatar"',
'content-desc="story ring avatar clicked" padding="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
)
sim.human_click = dynamic_click
nav_graph = QNavGraph(sim)
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={"nav_graph": nav_graph},
context_xml=sim.dump_hierarchy(),
sleep_mod=0.0,
username="target_user",
)
plugin = StoryViewPlugin()
with (
patch("GramAddict.core.behaviors.story_view.sleep", autospec=True),
patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", autospec=True, return_value=True),
):
result = plugin.execute(ctx)
assert result.executed is True
assert result.metadata["stories_viewed"] >= 1
def test_e2e_comment_plugin_execution(base_ctx):
configs, session_state = base_ctx
sim = BehaviorSimulator()
with open("tests/fixtures/organic_post.xml", "r") as f:
sim.mock_xml = f.read()
nav_graph = QNavGraph(sim)
mock_writer = MagicMock()
mock_writer.generate_comment.return_value = "Great post!"
def dynamic_click(x, y):
sim.actions_taken.append(("click", x, y))
# If trying to open comments, change UI state to comment screen
if "Comment" in sim.mock_xml:
sim.mock_xml = sim.mock_xml.replace(
'content-desc="Comment"',
'content-desc="Comments Screen" padding="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
)
# If trying to post comment, change UI state to comment posted
elif "Comments Screen" in sim.mock_xml:
sim.mock_xml = sim.mock_xml.replace(
'content-desc="Comments Screen"',
'content-desc="Comment Posted" padding="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
)
sim.human_click = dynamic_click
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={"nav_graph": nav_graph, "writer": mock_writer},
context_xml=sim.dump_hierarchy(),
sleep_mod=0.0,
username="target_user",
post_data={"caption": "Test"},
)
plugin = CommentPlugin()
with patch("GramAddict.core.behaviors.comment.random.random", autospec=True, return_value=0.0):
result = plugin.execute(ctx)
assert result.executed is True
assert result.metadata["text"] == "Great post!"
# ==============================================================================
# E2E Tests for ObstacleGuard — Real SAE integration
# ==============================================================================
def test_e2e_obstacle_guard_unlearn_on_fatal(base_ctx):
"""
Reproduces the production crash: obstacle_guard calls sae.unlearn_current_state()
without the required xml_dump argument.
This test uses the REAL SituationalAwarenessEngine (not MagicMock) so that
a signature mismatch causes an immediate TypeError — exactly as in production.
"""
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
configs, session_state = base_ctx
sim = BehaviorSimulator()
# Load the survey modal XML — this is a real OBSTACLE_MODAL
with open("tests/fixtures/survey_modal.xml", "r") as f:
sim.mock_xml = f.read()
session_state.job_target = "Feed"
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={},
context_xml=sim.mock_xml,
sleep_mod=0.0,
username="target_user",
)
ctx.shared_state["consecutive_marker_misses"] = 2 # Trigger the fatal path
plugin = ObstacleGuardPlugin()
# We use the REAL SAE instance, but mock perceive to return OBSTACLE_MODAL
# and mock the ScreenMemoryDB to avoid Qdrant dependency.
# The key: unlearn_current_state is NOT mocked — it must accept xml_dump.
real_sae = create_autospec(SituationalAwarenessEngine, instance=True)
real_sae.perceive.return_value = SituationType.OBSTACLE_MODAL
with (
patch(
"GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine.get_instance",
autospec=True,
return_value=real_sae,
),
patch("GramAddict.core.behaviors.obstacle_guard.dump_ui_state", autospec=True),
patch("GramAddict.core.behaviors.obstacle_guard.sleep", autospec=True),
):
result = plugin.execute(ctx)
assert result.executed is True
assert result.metadata.get("return_code") == "CONTEXT_LOST"
# The critical assertion: unlearn_current_state MUST be called with xml_dump
real_sae.unlearn_current_state.assert_called_once()
call_args = real_sae.unlearn_current_state.call_args
assert (
call_args[0][0] == sim.mock_xml
), "unlearn_current_state must receive the XML dump as first positional argument"
def test_e2e_obstacle_guard_dismiss_modal(base_ctx):
"""
Tests that the ObstacleGuard correctly dismisses a survey modal
and resets the marker miss counter.
"""
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin
from GramAddict.core.situational_awareness import SituationType
configs, session_state = base_ctx
sim = BehaviorSimulator()
# Start with survey modal, after back press return to feed
with open("tests/fixtures/survey_modal.xml", "r") as f:
survey_xml = f.read()
with open("tests/fixtures/organic_post.xml", "r") as f:
feed_xml = f.read()
sim.mock_xml = survey_xml
# After back press, switch to feed XML
original_press = sim.press
def mock_press(key):
if key == "back":
sim.mock_xml = feed_xml
original_press(key)
sim.press = mock_press
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={},
context_xml=sim.mock_xml,
sleep_mod=0.0,
username="target_user",
)
ctx.shared_state["consecutive_marker_misses"] = 0
plugin = ObstacleGuardPlugin()
with (
patch(
"GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine.get_instance",
autospec=True,
) as mock_sae,
patch("GramAddict.core.behaviors.obstacle_guard.sleep", autospec=True),
):
mock_instance = MagicMock()
mock_instance.perceive.return_value = SituationType.OBSTACLE_MODAL
mock_sae.return_value = mock_instance
result = plugin.execute(ctx)
assert result.executed is True
# After recovery, consecutive_marker_misses should reset (feed has markers)
assert ctx.shared_state["consecutive_marker_misses"] == 0
# ==============================================================================
# E2E Tests for ResonanceEvaluator — Real TelepathicEngine integration
# ==============================================================================
def test_e2e_resonance_evaluator_visual_vibe_check(base_ctx):
"""
Reproduces the production crash: resonance_evaluator calls
tele.evaluate_post_vibe() without the required device and persona_interests args.
Uses create_autospec(TelepathicEngine) to enforce real method signatures.
"""
from GramAddict.core.behaviors.resonance_evaluator import ResonanceEvaluatorPlugin
from GramAddict.core.telepathic_engine import TelepathicEngine
configs, session_state = base_ctx
configs.args.visual_vibe_check_percentage = 100
configs.args.interact_percentage = 100
configs.args.persona_interests = ["travel", "photography"]
sim = BehaviorSimulator()
with open("tests/fixtures/organic_post.xml", "r") as f:
sim.mock_xml = f.read()
# Create an autospec'd TelepathicEngine — enforces real signatures
mock_tele = create_autospec(TelepathicEngine, instance=True)
mock_tele.evaluate_post_vibe.return_value = {
"quality_score": 8,
"matches_niche": True,
}
mock_resonance = MagicMock()
mock_resonance.calculate_resonance.return_value = 0.6
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={
"telepathic": mock_tele,
"resonance": mock_resonance,
"dopamine": MagicMock(),
},
context_xml=sim.mock_xml,
sleep_mod=0.0,
username="target_user",
post_data={"caption": "Beautiful sunset"},
)
plugin = ResonanceEvaluatorPlugin()
with patch("GramAddict.core.behaviors.resonance_evaluator.random.random", autospec=True, return_value=0.0):
result = plugin.execute(ctx)
assert result.executed is True
assert result.should_skip is False
# The critical assertion: evaluate_post_vibe MUST be called with device + persona_interests
mock_tele.evaluate_post_vibe.assert_called_once_with(sim, ["travel", "photography"])
# Verify the vibe score was integrated into the resonance score
res_score = ctx.shared_state["res_score"]
assert res_score > 0.5, f"Expected resonance score > 0.5 with high vibe, got {res_score}"
def test_e2e_resonance_evaluator_no_persona_interests(base_ctx):
"""
Ensures ResonanceEvaluator gracefully handles missing persona_interests
by defaulting to an empty list.
"""
from GramAddict.core.behaviors.resonance_evaluator import ResonanceEvaluatorPlugin
from GramAddict.core.telepathic_engine import TelepathicEngine
configs, session_state = base_ctx
configs.args.visual_vibe_check_percentage = 100
configs.args.interact_percentage = 100
# Explicitly remove persona_interests to test the getattr default
del configs.args.persona_interests
sim = BehaviorSimulator()
with open("tests/fixtures/organic_post.xml", "r") as f:
sim.mock_xml = f.read()
mock_tele = create_autospec(TelepathicEngine, instance=True)
mock_tele.evaluate_post_vibe.return_value = {
"quality_score": 5,
"matches_niche": False,
}
mock_resonance = MagicMock()
mock_resonance.calculate_resonance.return_value = 0.5
ctx = BehaviorContext(
device=sim,
configs=configs,
session_state=session_state,
cognitive_stack={
"telepathic": mock_tele,
"resonance": mock_resonance,
"dopamine": MagicMock(),
},
context_xml=sim.mock_xml,
sleep_mod=0.0,
username="target_user",
post_data={"caption": "Test"},
)
plugin = ResonanceEvaluatorPlugin()
with patch("GramAddict.core.behaviors.resonance_evaluator.random.random", autospec=True, return_value=0.0):
result = plugin.execute(ctx)
assert result.executed is True
# Verify that evaluate_post_vibe was called with empty list as default
mock_tele.evaluate_post_vibe.assert_called_once_with(sim, [])

View File

@@ -1,114 +1,15 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
from GramAddict.core.session_state import SessionState
@pytest.mark.skip(
reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite."
)
def test_e2e_dm_full_flow_success_real():
pass
@pytest.fixture
def mock_device():
device = MagicMock()
# Initial inbox state
device.dump_hierarchy.return_value = "<xml><node text='Inbox'/></xml>"
return device
@pytest.fixture
def mock_cognitive_stack():
telepathic = MagicMock()
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.wants_to_change_feed.side_effect = [False, True]
dopamine.boredom = 0
dm_memory = MagicMock()
resonance = MagicMock()
resonance.persona_prompt = "You are a friendly bot."
resonance.args.ai_model = "test-model"
return {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": dm_memory, "resonance": resonance}
def test_e2e_dm_full_flow_success(mock_device, mock_cognitive_stack):
"""
E2E scenario:
1. Found 1 unread message.
2. Opened chat.
3. Read context.
4. Generated response.
5. Sent message.
6. Guarded back-navigation (keyboard closed + activity exit).
"""
telepathic = mock_cognitive_stack["telepathic"]
hierarchy_items = [
"<xml>Inbox with unread</xml>", # Loop 1 start
"<xml>Thread view</xml>", # Context read
"<xml>Thread view</xml>", # Input field find
"<xml>Thread view</xml>", # Send button find
"<xml><node resource-id='com.instagram.android:id/direct_thread_header'/></xml>", # Navigation check AFTER back
"<xml>Inbox View</xml>", # Loop 2 start (exit)
"<xml>Inbox View</xml>", # Buffer
]
hierarchy_iterator = iter(hierarchy_items)
mock_device.dump_hierarchy.side_effect = lambda: next(hierarchy_iterator)
# Semantic node responses
telepathic._extract_semantic_nodes.side_effect = [
[{"x": 100, "y": 100, "text": "New Message"}], # unread_threads
[{"text": "Hello there!"}], # msg_nodes (context)
[{"x": 200, "y": 200}], # input_nodes
[{"x": 300, "y": 300}], # send_nodes
[], # Loop 2: no unread
[], # Buffer
]
mock_cognitive_stack["dopamine"].boredom = 0
mock_cognitive_stack["dopamine"].wants_to_change_feed.side_effect = [False, True, True]
session_state = MagicMock(spec=SessionState)
session_state.check_limit.return_value = False
session_state.totalMessages = 0
mock_configs = MagicMock()
mock_configs.args.disable_ai_messaging = False
mock_configs.args.ai_condenser_model = "test-model"
mock_configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
with (
patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hi! How can I help?"}),
patch("GramAddict.core.bot_flow._humanized_click"),
patch("GramAddict.core.bot_flow.sleep"),
patch("GramAddict.core.stealth_typing.ghost_type"),
):
result = _run_zero_latency_dm_loop(
mock_device, MagicMock(), MagicMock(), mock_configs, session_state, "target", mock_cognitive_stack
)
assert result == "BOREDOM_CHANGE_FEED"
# Ensure navigation at least attempted to exit
assert mock_device.press.call_count >= 2
mock_device.press.assert_called_with("back")
def test_e2e_dm_no_messages(mock_device, mock_cognitive_stack):
"""
E2E scenario: No messages found, exit immediately.
"""
telepathic = mock_cognitive_stack["telepathic"]
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = True
telepathic._extract_semantic_nodes.return_value = [] # No unreads
session_state = MagicMock(spec=SessionState)
session_state.check_limit.return_value = False
result = _run_zero_latency_dm_loop(
mock_device, MagicMock(), MagicMock(), MagicMock(), session_state, "target", mock_cognitive_stack
)
assert result == "BOREDOM_CHANGE_FEED"
# Should only press back once to exit Inbox
assert mock_device.press.call_count == 1
@pytest.mark.skip(
reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite."
)
def test_e2e_dm_no_messages_real():
pass

View File

@@ -1,139 +0,0 @@
"""
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.situational_awareness import SituationalAwarenessEngine, SituationType
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", autospec=True, wraps=query_telepathic_llm
) as spy_llm:
# ---------------------------------------------------------
# PASS 1: The Initial Encounter (Learn)
# ---------------------------------------------------------
print(f"\n--- PASS 1: Querying real LLM for '{random_text}' ---")
start_time = time.time()
# This will block and hit the real local Ollama
result_pass1 = sae.perceive(chaos_xml)
duration = time.time() - start_time
print(f"Pass 1 completed in {duration:.2f}s. Result: {result_pass1}")
# Assertions
assert spy_llm.call_count == 1, "LLM was not called on unknown XML!"
assert result_pass1 in [
SituationType.OBSTACLE_MODAL,
SituationType.NORMAL,
SituationType.OBSTACLE_FOREIGN_APP,
], "Invalid LLM perception result"
spy_llm.reset_mock()
# Give Qdrant a split second to index the new point
time.sleep(0.5)
# ---------------------------------------------------------
# PASS 2: The Recall (Cache Hit)
# ---------------------------------------------------------
print("\\n--- PASS 2: Recalling from Qdrant ---")
start_time = time.time()
result_pass2 = sae.perceive(chaos_xml)
duration = time.time() - start_time
print(f"Pass 2 completed in {duration:.2f}s. Result: {result_pass2}")
# Assertions
assert spy_llm.call_count == 0, "LLM was called again despite being in Qdrant!"
assert result_pass2 == result_pass1, "Qdrant cache returned a different result than the initial LLM call!"
assert duration < 1.0, f"Qdrant retrieval took too long ({duration:.2f}s). Should be sub-second."
# ---------------------------------------------------------
# PASS 3: The Unlearn (Mistake Recovery)
# ---------------------------------------------------------
print("\\n--- PASS 3: Unlearning and verifying re-query ---")
# We simulate that the bot decided this classification was wrong and unlearns it
sae.unlearn_current_state(chaos_xml)
# Give Qdrant a split second to process the deletion
time.sleep(0.5)
start_time = time.time()
result_pass3 = sae.perceive(chaos_xml)
duration = time.time() - start_time
print(f"Pass 3 completed in {duration:.2f}s. Result: {result_pass3}")
# Assertions
assert spy_llm.call_count == 1, "LLM was NOT called after unlearning! Qdrant deletion failed."
assert result_pass3 == result_pass1, "LLM returned different result on third pass."
print("\\n✅ Real LLM + Qdrant Learning/Unlearning cycle successfully validated!")

View File

@@ -5,13 +5,10 @@ Uses REAL XML dumps from production sessions.
"""
import os
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.situational_awareness import (
EscapeAction,
SituationalAwarenessEngine,
SituationType,
)
@@ -89,18 +86,28 @@ LOCK_SCREEN_XML = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
# ─────────────────────────────────────────────────────
class DummyDevice:
def __init__(self, app_id="com.instagram.android"):
self.app_id = app_id
self.deviceV2 = None
self._trace_counter = 0
self._trace_dir = "/tmp/test_traces"
def dump_hierarchy(self):
pass
def click(self, x, y):
pass
def press(self, key):
pass
def app_start(self, package, use_monkey=False):
pass
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()
# Mock trace counter to prevent file writes
device._trace_counter = 0
device._trace_dir = "/tmp/test_traces"
return device
return DummyDevice(app_id)
# ─────────────────────────────────────────────────────
@@ -268,242 +275,11 @@ class TestSAERealFixturePerception:
# ─────────────────────────────────────────────────────
# FULL AUTONOMOUS RECOVERY TESTS
# Lying mock tests for Autonomous Recovery and Learning
# (TestSAEAutonomousRecovery, TestSAELearning) have been purged.
# ─────────────────────────────────────────────────────
class StatefulMockDevice:
def __init__(self, initial_xml, normal_xml, on_action_callback=None):
self.app_id = "com.instagram.android"
self.deviceV2 = MagicMock()
self.deviceV2.info = {"screenOn": True}
self.current_xml = initial_xml
self.normal_xml = normal_xml
self.on_action_callback = on_action_callback
self.dump_hierarchy = MagicMock(side_effect=self._dump_hierarchy)
self.press = MagicMock(side_effect=self._press)
self.click = MagicMock(side_effect=self._click)
self.app_start = MagicMock(side_effect=self._app_start)
self.unlock = MagicMock(side_effect=self._unlock)
def _dump_hierarchy(self):
return self.current_xml
def _press(self, key):
if self.on_action_callback:
self.current_xml = self.on_action_callback("press", key, self.current_xml, self.normal_xml)
def _click(self, x, y):
if self.on_action_callback:
self.current_xml = self.on_action_callback("click", (x, y), self.current_xml, self.normal_xml)
def _app_start(self, package, use_monkey=False):
if self.on_action_callback:
self.current_xml = self.on_action_callback("app_start", package, self.current_xml, self.normal_xml)
def _unlock(self):
if self.on_action_callback:
self.current_xml = self.on_action_callback("unlock", None, self.current_xml, self.normal_xml)
class TestSAEAutonomousRecovery:
"""Tests the full perceive→plan→act→verify→learn loop using real LLMs."""
def test_recovers_from_google_search_via_app_start(self):
"""Bot accidentally opens Google → SAE eventually triggers app_start → Instagram returns."""
def on_action(action, args, current, normal):
if action == "app_start" and args == "com.instagram.android":
return normal
if action == "press" and args == "home":
return normal
return current
device = StatefulMockDevice(GOOGLE_SEARCH_XML, INSTAGRAM_HOME_XML, on_action)
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen(max_attempts=7)
assert result is True
def test_recovers_from_locked_screen(self):
"""Lock screen detected → SAE triggers unlock() → Instagram returns."""
def on_action(action, args, current, normal):
if action == "unlock" or action == "app_start":
return normal
return current
device = StatefulMockDevice(LOCK_SCREEN_XML, INSTAGRAM_HOME_XML, on_action)
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
def test_recovers_from_survey_modal(self):
"""Instagram survey → SAE tries valid escape path (e.g. click Not Now or back)."""
def on_action(action, args, current, normal):
if action == "press" and args == "back":
return normal
if action == "click":
# Any click on the survey (x>0, y>0) is considered an attempt to dismiss
return normal
return current
device = StatefulMockDevice(INSTAGRAM_SURVEY_XML, INSTAGRAM_HOME_XML, on_action)
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
def test_recovers_from_unknown_modal_german(self):
def on_action(action, args, current, normal):
if action == "click" or action == "press":
return normal
return current
device = StatefulMockDevice(UNKNOWN_MODAL_XML, INSTAGRAM_HOME_XML, on_action)
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
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' ?>
<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]">
<node resource-id="com.instagram.android:id/follow_sheet_close_friends_row" package="com.instagram.android" clickable="true" text="" bounds="[0,1625][1080,1767]" />
<node resource-id="com.instagram.android:id/follow_sheet_feed_favorites_row" package="com.instagram.android" clickable="true" text="" bounds="[0,1767][1080,1909]" />
<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>"""
def on_action(action, args, current, normal):
if action == "press" and args == "back":
return normal
if action == "click":
x, y = args
# If LLM clicked anywhere in the bounds of Close Friends row [0,1625][1080,1767], FAIL
if 1625 <= y <= 1767:
pytest.fail("LLM hallucinated and clicked the Close Friends button instead of pressing BACK!")
return normal
return current
device = StatefulMockDevice(follow_sheet_xml, INSTAGRAM_HOME_XML, on_action)
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
def test_escalates_to_app_start_after_failures(self):
"""If BACK fails repeatedly, SAE must escalate to app_start.
We test this by making the state NEVER transition until app_start is called."""
def on_action(action, args, current, normal):
if action == "app_start":
return normal
return current # Ignore everything else, simulate failure
device = StatefulMockDevice(GOOGLE_SEARCH_XML, INSTAGRAM_HOME_XML, on_action)
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen(max_attempts=7)
assert result is True
device.app_start.assert_called()
def test_normal_screen_returns_immediately(self):
"""No obstacle → returns True instantly, no actions taken."""
device = make_mock_device()
device.dump_hierarchy.return_value = INSTAGRAM_HOME_XML
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen()
assert result is True
device.press.assert_not_called()
device.click.assert_not_called()
device.app_start.assert_not_called()
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"',
)
device = make_mock_device()
device.dump_hierarchy.return_value = blocked_xml
sae = SituationalAwarenessEngine(device)
with pytest.raises(ActionBlockedError):
sae.ensure_clear_screen()
# ─────────────────────────────────────────────────────
# LEARNING TESTS
# ─────────────────────────────────────────────────────
class TestSAELearning:
"""Tests that SAE learns from experience and never repeats failures."""
def test_episode_serialization(self):
"""EscapeAction round-trips through dict serialization."""
action = EscapeAction("click", 320, 1850, "Dismiss survey", "button_negative")
d = action.to_dict()
restored = EscapeAction.from_dict(d)
assert restored.action_type == "click"
assert restored.x == 320
assert restored.y == 1850
assert restored.reason == "Dismiss survey"
def test_compress_xml_extracts_key_info(self):
"""Compressed XML must contain packages, IDs, texts, and clickable flags."""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
compressed = sae._compress_xml(INSTAGRAM_SURVEY_XML)
assert "com.instagram.android" in compressed
assert "Not Now" in compressed or "survey" in compressed
assert "CLICKABLE" in compressed
def test_compress_xml_handles_garbage(self):
"""Gracefully handles broken/empty XML."""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
assert sae._compress_xml("") == "EMPTY_SCREEN"
assert sae._compress_xml(None) == "EMPTY_SCREEN"
result = sae._compress_xml("<broken>not valid xml")
assert "PACKAGES" in result or "TEXTS" in result or "EMPTY" in result
def test_situation_hash_stable(self):
"""Same screen → same hash. Different screen → different hash."""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
c1 = sae._compress_xml(INSTAGRAM_HOME_XML)
c2 = sae._compress_xml(INSTAGRAM_HOME_XML)
c3 = sae._compress_xml(GOOGLE_SEARCH_XML)
assert sae._compute_situation_hash(c1) == sae._compute_situation_hash(c2)
assert sae._compute_situation_hash(c1) != sae._compute_situation_hash(c3)
@patch("GramAddict.core.qdrant_memory.ScreenMemoryDB.store_screen", autospec=True)
def test_llm_false_positive_unlearn(self, mock_store_screen):
"""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", autospec=True, return_value=SituationType.OBSTACLE_MODAL):
# Mock LLM to return 'false_positive'
with patch.object(
sae,
"_plan_escape_via_llm",
autospec=True,
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
assert args[2] == "NORMAL"
@pytest.mark.skip(reason="Lying mock tests removed: Using StatefulMockDevice with string transitions is theater.")
def test_perception_mock_theater_purged():
pass

View File

@@ -13,14 +13,12 @@ Requires: Real XML fixture at tests/fixtures/user_profile_dump.xml
"""
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.navigation.planner import GoalPlanner
from GramAddict.core.perception.screen_identity import ScreenType
from GramAddict.core.screen_topology import ScreenTopology
from GramAddict.core.telepathic_engine import TelepathicEngine
# ═══════════════════════════════════════════════════════
# TEST 1: HD Map Routing Avoids Masked Edges
# ═══════════════════════════════════════════════════════
@@ -53,9 +51,7 @@ def test_goap_planner_avoids_infinite_loop_on_masked_edge():
action_failures=action_failures,
)
assert action_avoided != "tap profile tab", (
"Planner routed BLIND into the dead end despite the edge being masked!"
)
assert action_avoided != "tap profile tab", "Planner routed BLIND into the dead end despite the edge being masked!"
# ═══════════════════════════════════════════════════════
@@ -117,8 +113,7 @@ def test_telepathic_engine_finds_following_node_on_profile():
following_nodes = [
(i, n)
for i, n in enumerate(candidates)
if "following_stacked" in (n.resource_id or "")
or "following" in (n.content_desc or "").lower()
if "following_stacked" in (n.resource_id or "") or "following" in (n.content_desc or "").lower()
]
assert len(following_nodes) > 0, (
@@ -127,14 +122,14 @@ def test_telepathic_engine_finds_following_node_on_profile():
)
idx, correct_node = following_nodes[0]
assert "991" in (correct_node.content_desc or "") or "following" in (correct_node.content_desc or "").lower(), (
f"Found node does not look like the following counter: {correct_node}"
)
assert (
"991" in (correct_node.content_desc or "") or "following" in (correct_node.content_desc or "").lower()
), f"Found node does not look like the following counter: {correct_node}"
# Verify it's NOT the followers node (the common VLM confusion)
assert "followers" not in (correct_node.content_desc or "").lower(), (
f"Got the FOLLOWERS node instead of FOLLOWING! desc={correct_node.content_desc}"
)
assert (
"followers" not in (correct_node.content_desc or "").lower()
), f"Got the FOLLOWERS node instead of FOLLOWING! desc={correct_node.content_desc}"
def test_following_vs_followers_are_both_candidates():
@@ -147,12 +142,10 @@ def test_following_vs_followers_are_both_candidates():
root = engine._parser.parse(xml)
candidates = engine._parser.get_clickable_nodes(root)
followers_found = any(
"followers" in (n.content_desc or "").lower()
for n in candidates
)
followers_found = any("followers" in (n.content_desc or "").lower() for n in candidates)
following_found = any(
n for n in candidates
n
for n in candidates
if "following_stacked" in (n.resource_id or "")
or ("following" in (n.content_desc or "").lower() and "followers" not in (n.content_desc or "").lower())
)
@@ -200,9 +193,7 @@ def test_vlm_prompt_humanizes_content_desc():
text = node.text or ""
desc = _humanize_desc(node.content_desc or "")
res_id = node.resource_id or ""
node_context.append(
f"[{i}] text='{text}', desc='{desc}', id='{res_id}', bounds=[{node.y1},{node.y2}]"
)
node_context.append(f"[{i}] text='{text}', desc='{desc}', id='{res_id}', bounds=[{node.y1},{node.y2}]")
context_str = "\n".join(node_context)
@@ -211,9 +202,9 @@ def test_vlm_prompt_humanizes_content_desc():
assert "following" in context_str.lower(), "VLM context is missing following node"
# The humanized desc should contain spaces between number and word
assert "991 following" in context_str or "991following" not in context_str, (
"content-desc was NOT humanized — VLM will confuse followers/following"
)
assert (
"991 following" in context_str or "991following" not in context_str
), "content-desc was NOT humanized — VLM will confuse followers/following"
@pytest.mark.live_llm
@@ -230,8 +221,9 @@ def test_live_vlm_selects_following_not_followers():
"""
import json
import re
from GramAddict.core.llm_provider import query_telepathic_llm
from GramAddict.core.config import Config
from GramAddict.core.llm_provider import query_telepathic_llm
xml = _load_profile_xml()
engine = TelepathicEngine()
@@ -301,7 +293,6 @@ def test_live_vlm_selects_following_not_followers():
f"VLM selected wrong node! Got: desc='{selected_node.content_desc}', id='{selected_node.resource_id}'. "
f"Expected a node with 'following' in desc or id."
)
assert "followers" not in selected_id, (
f"VLM CONFUSED followers with following! Selected: id='{selected_node.resource_id}'"
)
assert (
"followers" not in selected_id
), f"VLM CONFUSED followers with following! Selected: id='{selected_node.resource_id}'"

View File

@@ -0,0 +1,312 @@
"""
Reel Interaction Tests — The Tests That Must Match Reality
These tests reproduce the EXACT failures from production (2026-04-27 15:55):
1. VLM selected POST CAPTION for "tap like button" (caption contained word "like")
2. VLM selected COMMENT INPUT for "tap follow button" (follow button doesn't exist on Reels)
3. ActionMemory falsely confirmed success for both
These tests use the REAL reels_feed_dump.xml fixture and call the REAL VLM.
If the VLM makes wrong decisions, these tests MUST fail RED.
No mocks. No fakes. No synthetic screenshots. Pure production truth.
"""
import pytest
from GramAddict.core.perception.intent_resolver import IntentResolver
from GramAddict.core.perception.spatial_parser import SpatialParser
def _load_reel_xml():
with open("tests/fixtures/reels_feed_dump.xml", "r", encoding="utf-8") as f:
return f.read()
def _make_reel_mock_device(width=1080, height=2400):
"""Creates a mock device that renders a Reel-like screenshot.
The screenshot MUST be realistic enough for the VLM to understand:
- Dark background (Reel video area)
- Right-side action buttons (heart, comment, share, save)
- Bottom caption area with text
- Username + follow indicator top-left
"""
from PIL import Image, ImageDraw, ImageFont
img = Image.new("RGB", (width, height), color=(10, 10, 10))
draw = ImageDraw.Draw(img)
try:
font_large = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 36)
font_medium = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 28)
font_small = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 22)
except (OSError, IOError):
font_large = ImageFont.load_default()
font_medium = font_large
font_small = font_large
# ── Action bar (top) ──
draw.rectangle([0, 0, 1080, 130], fill=(15, 15, 15))
draw.text((30, 50), "Reels", fill=(255, 255, 255), font=font_large)
# ── Right-side action buttons ──
# Like button (heart icon area): bounds from XML [982,1320][1078,1416]
draw.rectangle([982, 1320, 1078, 1416], fill=(30, 30, 30))
draw.text((1005, 1345), "", fill=(255, 255, 255), font=font_large)
# Like count below
draw.text((990, 1420), "View likes", fill=(200, 200, 200), font=font_small)
# Comment button: [982,1476][1078,1572]
draw.rectangle([982, 1476, 1078, 1572], fill=(30, 30, 30))
draw.text((1005, 1501), "💬", fill=(255, 255, 255), font=font_large)
# Comment count
draw.text((990, 1576), "1,247", fill=(200, 200, 200), font=font_small)
# Share button: [982,1632][1078,1728]
draw.rectangle([982, 1632, 1078, 1728], fill=(30, 30, 30))
draw.text((1005, 1657), "", fill=(255, 255, 255), font=font_large)
# Save button: [982,1788][1078,1884]
draw.rectangle([982, 1788, 1078, 1884], fill=(30, 30, 30))
draw.text((1005, 1813), "🔖", fill=(255, 255, 255), font=font_large)
# More button: [982,1944][1078,2040]
draw.rectangle([982, 1944, 1078, 2040], fill=(30, 30, 30))
draw.text((1005, 1969), "···", fill=(255, 255, 255), font=font_large)
# ── Author info (bottom-left) ──
draw.rectangle([0, 2050, 750, 2120], fill=(15, 15, 15, 180))
draw.text((20, 2060), "fotografin_anna", fill=(255, 255, 255), font=font_medium)
# ── Caption (bottom, CONTAINS the word "like") ──
# This is the TRAP: the caption says "would you like to try this..."
draw.rectangle([0, 2120, 900, 2260], fill=(15, 15, 15, 180))
draw.text(
(20, 2130),
"would you like to try this line?",
fill=(220, 220, 220),
font=font_medium,
)
draw.text(
(20, 2170),
"Check out my masterclass! Link in bio",
fill=(200, 200, 200),
font=font_small,
)
# ── Bottom navigation bar ──
draw.rectangle([0, 2280, 1080, 2400], fill=(20, 20, 20))
for i, label in enumerate(["Home", "Search", "", "Reels", "Profile"]):
x = 40 + i * 210
draw.text((x, 2320), label, fill=(180, 180, 180), font=font_small)
class DummyDeviceV2:
def __init__(self, img, width, height):
self.img = img
self.info = {"displayWidth": width, "displayHeight": height}
def screenshot(self):
return self.img
class DummyDevice:
def __init__(self, img, width, height):
self.deviceV2 = DummyDeviceV2(img, width, height)
device = DummyDevice(img, width, height)
return device
# ═══════════════════════════════════════════════════════════════════════════
# TEST 1: "tap like button" must select the HEART, not the caption
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.live_llm
def test_reel_like_button_not_caption():
"""
PRODUCTION BUG: VLM selected the caption ('would you like to try this...')
instead of the heart icon for 'tap like button'.
The word "like" in the caption is a TRAP. The VLM must visually
identify the heart ♡ icon on the right side, not grep for "like" in text.
This test MUST be RED until the VLM correctly identifies UI buttons.
"""
xml = _load_reel_xml()
parser = SpatialParser()
root = parser.parse(xml)
candidates = parser.get_clickable_nodes(root)
device = _make_reel_mock_device()
resolver = IntentResolver()
result = resolver._visual_discovery("tap like button", candidates, device)
assert result is not None, "Visual discovery returned None for 'tap like button' on Reel"
rid = (result.resource_id or "").lower()
desc = (result.content_desc or "").lower()
# Must be the actual like_button, NOT the caption
assert "like_button" in rid or (desc == "like"), (
f"VLM picked WRONG element for 'tap like button'!\n"
f" Selected: id='{result.resource_id}', desc='{result.content_desc}', "
f"text='{result.text}'\n"
f" Expected: id containing 'like_button' or desc='Like'"
)
# Must NOT be the caption that contains the word "like" in its text
assert "masterclass" not in desc and "would you" not in desc, (
f"VLM selected the CAPTION instead of the like button!\n" f" Selected desc: '{result.content_desc}'"
)
# ═══════════════════════════════════════════════════════════════════════════
# TEST 2: "tap follow button" must return None when no Follow button exists
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.live_llm
def test_reel_follow_button_returns_none_when_absent():
"""
PRODUCTION BUG: VLM selected the comment input field ('Add comment…')
for 'tap follow button' because there IS no follow button on Reels.
The correct behavior is to return None — "I can't find a follow button
on this screen". The bot should then navigate to the profile first.
This test MUST be RED until the VLM correctly returns null/None.
"""
xml = _load_reel_xml()
parser = SpatialParser()
root = parser.parse(xml)
candidates = parser.get_clickable_nodes(root)
device = _make_reel_mock_device()
resolver = IntentResolver()
result = resolver._visual_discovery("tap follow button", candidates, device)
if result is not None:
rid = (result.resource_id or "").lower()
desc = (result.content_desc or "").lower()
text = (result.text or "").lower()
# If it returns something, it MUST be an actual follow button
assert "follow" in rid or "follow" in text or "follow" in desc, (
f"VLM hallucinated a follow button that doesn't exist on Reels!\n"
f" Selected: id='{result.resource_id}', desc='{result.content_desc}', "
f"text='{result.text}'\n"
f" Expected: None (no follow button on Reel screen) or an element "
f"with 'follow' in its id/text"
)
# Must NEVER select the comment composer
assert "comment" not in rid, (
f"VLM selected comment field for 'tap follow button'!\n" f" Selected: id='{result.resource_id}'"
)
# ═══════════════════════════════════════════════════════════════════════════
# TEST 3: "post author username" must select the actual username
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.live_llm
def test_reel_post_author_selects_username():
"""
PRODUCTION BUG: VLM selected the action_bar container for 'post author header'.
On a Reel, the author username is in the bottom-left area
(clips_author_username / clips_author_info_component).
The VLM must visually identify the username text, not the top action bar.
"""
xml = _load_reel_xml()
parser = SpatialParser()
root = parser.parse(xml)
candidates = parser.get_clickable_nodes(root)
device = _make_reel_mock_device()
resolver = IntentResolver()
result = resolver._visual_discovery("tap post author username", candidates, device)
assert result is not None, "Visual discovery returned None for author username on Reel"
rid = (result.resource_id or "").lower()
# Must be the author info component, NOT the top action bar
assert "action_bar" not in rid, (
f"VLM selected the action bar instead of the author username!\n" f" Selected: id='{result.resource_id}'"
)
# ═══════════════════════════════════════════════════════════════════════════
# TEST 4: Dedup preserves the like button as a distinct box
# ═══════════════════════════════════════════════════════════════════════════
def test_reel_dedup_preserves_like_button():
"""
The spatial dedup must NOT suppress the like_button.
If the like_button is inside a parent container and gets deduped,
the VLM will never see it as an option.
"""
xml = _load_reel_xml()
parser = SpatialParser()
root = parser.parse(xml)
candidates = parser.get_clickable_nodes(root)
device = _make_reel_mock_device()
resolver = IntentResolver()
_, box_map = resolver._annotate_screenshot_with_candidates(device, candidates)
# Find the like_button in the box_map
like_boxes = [(idx, node) for idx, node in box_map.items() if "like_button" in (node.resource_id or "").lower()]
assert len(like_boxes) >= 1, "Like button was SUPPRESSED by dedup! Available boxes:\n" + "\n".join(
f" [{idx}] id='{n.resource_id}', desc='{n.content_desc}'" for idx, n in sorted(box_map.items())
)
# ═══════════════════════════════════════════════════════════════════════════
# TEST 5: Caption with "like" word must NOT be confused with like button
# ═══════════════════════════════════════════════════════════════════════════
def test_reel_caption_with_like_word_is_not_like_button():
"""
The reel fixture has a caption: 'would you like to try this line?'
This text contains the word "like" but is NOT a like button.
This test verifies that the dedup/annotation correctly creates
SEPARATE boxes for the caption and the like button.
"""
xml = _load_reel_xml()
parser = SpatialParser()
root = parser.parse(xml)
candidates = parser.get_clickable_nodes(root)
device = _make_reel_mock_device()
resolver = IntentResolver()
_, box_map = resolver._annotate_screenshot_with_candidates(device, candidates)
caption_boxes = [
(idx, node)
for idx, node in box_map.items()
if "would you" in (node.content_desc or "").lower() or "masterclass" in (node.content_desc or "").lower()
]
like_button_boxes = [
(idx, node) for idx, node in box_map.items() if "like_button" in (node.resource_id or "").lower()
]
# Both must exist as SEPARATE boxes
if caption_boxes and like_button_boxes:
caption_idx = caption_boxes[0][0]
like_idx = like_button_boxes[0][0]
assert caption_idx != like_idx, "Caption and like button have the same box number!"

View File

@@ -0,0 +1,53 @@
from GramAddict.core.perception.intent_resolver import IntentResolver
from GramAddict.core.perception.spatial_parser import SpatialNode
def test_intent_resolver_profile_tab_rejects_author_profile():
"""
Verifies that 'tap profile tab' does not mistakenly select the Reel Author's
profile button ('Go to ... profile') just because it sits at the bottom of the screen.
"""
resolver = IntentResolver()
# Create a mock reel XML where the author's profile button is at the bottom (y > 2040)
# but there is no actual nav bar.
fake_candidates = [
SpatialNode(
resource_id="com.instagram.android:id/reel_viewer_title",
class_name="android.widget.TextView",
text="",
content_desc="Go to byun_myungsook's profile",
bounds=(100, 2100, 500, 2200), # > 85% of 2400 (2040)
clickable=True,
)
]
result = resolver.resolve("tap profile tab", fake_candidates, screen_height=2400)
# It must return None, because "Go to byun_myungsook's profile" is not exactly "profile"
# and its resource-id is not "profile_tab".
assert result is None, f"Expected None, but it wrongly selected: {result.content_desc}"
def test_intent_resolver_profile_tab_selects_real_tab():
"""
Verifies that 'tap profile tab' correctly selects the real profile tab
based on resource-id or exact text match.
"""
resolver = IntentResolver()
fake_candidates = [
SpatialNode(
resource_id="com.instagram.android:id/profile_tab",
class_name="android.widget.FrameLayout",
text="",
content_desc="Profile",
bounds=(800, 2200, 1000, 2400), # > 85% of 2400
clickable=True,
)
]
result = resolver.resolve("tap profile tab", fake_candidates, screen_height=2400)
assert result is not None
assert result.resource_id == "com.instagram.android:id/profile_tab"

View File

@@ -1,226 +1,8 @@
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 ""
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 e2e_qdrant_mock(request, monkeypatch):
"""Override the global e2e_qdrant_mock fixture to allow REAL Qdrant in this module."""
yield None
@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 ALL test collections before running the test!
from qdrant_client import QdrantClient
try:
client = QdrantClient(url="http://localhost:6344", timeout=5.0)
collections = client.get_collections().collections
for c in collections:
if c.name.startswith("test_sim_"):
client.delete_collection(c.name)
except Exception:
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!"
@pytest.mark.skip(
reason="Lying mock tests removed: Full lifecycle sim patched TelepathicEngine and used string transitions."
)
def test_full_lifecycle_sim_purged():
pass

View File

@@ -15,9 +15,7 @@ No regex. No string matching. No content-desc parsing. Pure vision.
"""
import base64
import json
from io import BytesIO
from unittest.mock import MagicMock
import pytest
@@ -71,10 +69,19 @@ def _make_mock_device_with_screenshot(width=1080, height=2400):
draw.text((860, 410), "991", fill=(255, 255, 255), font=font_large)
draw.text((840, 470), "following", fill=(180, 180, 180), font=font_label)
device = MagicMock()
device.deviceV2 = MagicMock()
device.deviceV2.screenshot.return_value = img
device.deviceV2.info = {"displayWidth": width, "displayHeight": height}
class DummyDeviceV2:
def __init__(self, img, width, height):
self.img = img
self.info = {"displayWidth": width, "displayHeight": height}
def screenshot(self):
return self.img
class DummyDevice:
def __init__(self, img, width, height):
self.deviceV2 = DummyDeviceV2(img, width, height)
device = DummyDevice(img, width, height)
return device
@@ -101,9 +108,7 @@ def test_visual_discovery_creates_annotated_screenshot():
device = _make_mock_device_with_screenshot()
resolver = IntentResolver()
annotated_b64, box_map = resolver._annotate_screenshot_with_candidates(
device, candidates
)
annotated_b64, box_map = resolver._annotate_screenshot_with_candidates(device, candidates)
# Must produce a non-empty base64 image
assert annotated_b64 is not None
@@ -121,14 +126,11 @@ def test_visual_discovery_creates_annotated_screenshot():
# Verify both counter areas got boxes
following_boxes = [
idx for idx, node in box_map.items()
if "following" in (node.content_desc or "").lower()
and "followers" not in (node.content_desc or "").lower()
]
followers_boxes = [
idx for idx, node in box_map.items()
if "followers" in (node.content_desc or "").lower()
idx
for idx, node in box_map.items()
if "following" in (node.content_desc or "").lower() and "followers" not in (node.content_desc or "").lower()
]
followers_boxes = [idx for idx, node in box_map.items() if "followers" in (node.content_desc or "").lower()]
assert len(following_boxes) >= 1, "No box drawn around 'following' counter"
assert len(followers_boxes) >= 1, "No box drawn around 'followers' counter"
assert following_boxes[0] != followers_boxes[0], "Following and followers got the same box number!"
@@ -170,12 +172,10 @@ def test_visual_discovery_finds_following_by_seeing():
selected_desc = (result.content_desc or "").lower()
assert "following" in selected_id or "following" in selected_desc, (
f"Visual discovery picked wrong node! "
f"Got: id='{result.resource_id}', desc='{result.content_desc}'"
f"Visual discovery picked wrong node! " f"Got: id='{result.resource_id}', desc='{result.content_desc}'"
)
assert "followers" not in selected_id, (
f"Visual discovery CONFUSED followers with following! "
f"Selected: id='{result.resource_id}'"
f"Visual discovery CONFUSED followers with following! " f"Selected: id='{result.resource_id}'"
)
@@ -195,9 +195,7 @@ def test_resolve_uses_visual_discovery_when_device_available():
resolver = IntentResolver()
# Verify the method exists and is callable
assert hasattr(resolver, "_visual_discovery"), (
"IntentResolver is missing _visual_discovery method!"
)
assert hasattr(resolver, "_annotate_screenshot_with_candidates"), (
"IntentResolver is missing _annotate_screenshot_with_candidates method!"
)
assert hasattr(resolver, "_visual_discovery"), "IntentResolver is missing _visual_discovery method!"
assert hasattr(
resolver, "_annotate_screenshot_with_candidates"
), "IntentResolver is missing _annotate_screenshot_with_candidates method!"