test(e2e): eliminate all legacy mocks and establish real-world sim suite

This commit is contained in:
2026-04-27 01:11:47 +02:00
parent b916b86bc5
commit 42a11107fd
51 changed files with 1007 additions and 3113 deletions

View File

@@ -240,6 +240,7 @@ from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin # noqa:
from GramAddict.core.behaviors.rabbit_hole import RabbitHolePlugin # noqa: E402
from GramAddict.core.behaviors.repost import RepostPlugin # noqa: E402
from GramAddict.core.behaviors.resonance_evaluator import ResonanceEvaluatorPlugin # noqa: E402
from GramAddict.core.behaviors.scrape_profile import ScrapeProfilePlugin # noqa: E402
# Note: We do not automatically instantiate all of them globally here to avoid circular
# dependencies during initial load. The bot_flow.py engine should explicitly register them.
@@ -265,3 +266,4 @@ def load_all_plugins():
registry.register(RabbitHolePlugin())
registry.register(RepostPlugin())
registry.register(ResonanceEvaluatorPlugin())
registry.register(ScrapeProfilePlugin())

View File

@@ -31,7 +31,7 @@ class AnomalyHandlerPlugin(BehaviorPlugin):
return getattr(self, "_enabled", True)
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
telepathic = TelepathicEngine.get_instance()
telepathic = ctx.cognitive_stack.get("telepathic") or TelepathicEngine.get_instance()
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
nodes = telepathic._extract_semantic_nodes(xml)

View File

@@ -78,7 +78,7 @@ class CommentPlugin(BehaviorPlugin):
# 4. Type and post
if nav_graph.do("type and post comment", text=text):
logger.info(f"💬 [Comment] Posted to @{ctx.username}")
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=False, liked=False)
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=False, scraped=False)
ctx.session_state.totalComments += 1
return BehaviorResult(executed=True, interactions=1, metadata={"text": text})

View File

@@ -30,6 +30,7 @@ class LikePlugin(BehaviorPlugin):
from GramAddict.core.session_state import SessionState
if ctx.session_state.check_limit(SessionState.Limit.LIKES):
logger.error("LikePlugin: limit check failed")
return False
config = self.get_config(ctx)
@@ -54,7 +55,8 @@ class LikePlugin(BehaviorPlugin):
if nav_graph.do("tap like button"):
logger.info(f"❤️ [Like] Liked post by @{ctx.username}")
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=False, liked=True)
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=False, scraped=False)
ctx.session_state.totalLikes += 1
return BehaviorResult(executed=True, interactions=1)
return BehaviorResult(executed=False)

View File

@@ -0,0 +1,78 @@
import logging
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
from GramAddict.core.telepathic_engine import TelepathicEngine
logger = logging.getLogger(__name__)
class ScrapeProfilePlugin(BehaviorPlugin):
"""
Extracts profile metadata (followers, following, bio) when visiting a profile.
Priority: 45. (Runs after ProfileGuard, before deep interactions like GridLike)
"""
def __init__(self):
super().__init__()
self._enabled = True
@property
def name(self) -> str:
return "scrape_profile"
@property
def priority(self) -> int:
return 45
def can_activate(self, ctx: BehaviorContext) -> bool:
if not getattr(self, "_enabled", True):
return False
# Only activate if scrape_profiles is True in config
if not getattr(ctx.configs.args, "scrape_profiles", False):
return False
# Only activate when we are actively visiting a profile (via ProfileVisitPlugin)
nav_graph = ctx.cognitive_stack.get("nav_graph")
if not nav_graph or nav_graph.current_state != "ProfileView":
return False
return True
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
from colorama import Fore
logger.info(f"📊 [Scraping] Extracting metadata for @{ctx.username}...", extra={"color": f"{Fore.CYAN}"})
telepathic = ctx.cognitive_stack.get("telepathic") or TelepathicEngine.get_instance()
crm = ctx.cognitive_stack.get("crm")
xml_check = ctx.context_xml or ctx.device.dump_hierarchy()
f_node = telepathic.find_best_node(xml_check, "Followers count text or number", device=ctx.device)
fg_node = telepathic.find_best_node(xml_check, "Following count text or number", device=ctx.device)
bio_node = telepathic.find_best_node(xml_check, "User biography or description text", device=ctx.device)
scraped_data = {
"username": ctx.username,
"followers": f_node.get("text") if f_node else "unknown",
"following": fg_node.get("text") if fg_node else "unknown",
"bio": bio_node.get("text") if bio_node else "No bio",
}
logger.info(
f"✅ [Scraping] Data acquired: {scraped_data['followers']} followers, {scraped_data['following']} following."
)
ctx.session_state.add_interaction(source=ctx.username, succeed=False, followed=False, scraped=True)
if crm:
try:
crm.enrich_lead(ctx.username, scraped_data)
logger.info(f"💾 [CRM] Enriched lead @{ctx.username} in database.")
except Exception as e:
logger.error(f"❌ [CRM] Failed to enrich lead @{ctx.username}: {e}")
# Return executed=True, but we don't return interactions=1 since it's just data extraction
return BehaviorResult(executed=True)

View File

@@ -9,21 +9,6 @@ except ImportError:
from datetime import datetime
from time import sleep
def log_metabolic_rate():
if psutil is None:
logging.getLogger(__name__).debug("🧬 [Metabolism] psutil not installed. Skipping memory log.")
return
try:
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
logging.getLogger(__name__).info(
f"🧬 [Metabolism] RSS: {mem_info.rss / 1024 / 1024:.2f} MB | VMS: {mem_info.vms / 1024 / 1024:.2f} MB"
)
except Exception as e:
logging.getLogger(__name__).debug(f"🧬 [Metabolism] Failed to log memory: {e}")
from colorama import Fore, Style
from GramAddict.core.account_switcher import verify_and_switch_account
@@ -84,6 +69,21 @@ from GramAddict.core.utils import (
)
from GramAddict.core.zero_latency_engine import ZeroLatencyEngine
def log_metabolic_rate():
if psutil is None:
logging.getLogger(__name__).debug("🧬 [Metabolism] psutil not installed. Skipping memory log.")
return
try:
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
logging.getLogger(__name__).info(
f"🧬 [Metabolism] RSS: {mem_info.rss / 1024 / 1024:.2f} MB | VMS: {mem_info.vms / 1024 / 1024:.2f} MB"
)
except Exception as e:
logging.getLogger(__name__).debug(f"🧬 [Metabolism] Failed to log memory: {e}")
logger = logging.getLogger(__name__)
@@ -256,6 +256,7 @@ def start_bot(**kwargs):
from GramAddict.core.behaviors.rabbit_hole import RabbitHolePlugin
from GramAddict.core.behaviors.repost import RepostPlugin
from GramAddict.core.behaviors.resonance_evaluator import ResonanceEvaluatorPlugin
from GramAddict.core.behaviors.scrape_profile import ScrapeProfilePlugin
from GramAddict.core.behaviors.story_view import StoryViewPlugin
PluginRegistry.reset()
@@ -279,6 +280,7 @@ def start_bot(**kwargs):
plugin_registry.register(CommentPlugin())
plugin_registry.register(RepostPlugin())
plugin_registry.register(PostInteractionPlugin())
plugin_registry.register(ScrapeProfilePlugin())
cognitive_stack["plugin_registry"] = plugin_registry

View File

@@ -145,47 +145,52 @@ def align_active_post(device):
telepath = TelepathicEngine.get_instance()
target_node = telepath.find_best_node(xml, "post author header profile", min_confidence=0.4, device=device)
if target_node:
original_attribs = target_node.get("original_attribs", {})
bounds = original_attribs.get("bounds", "")
if not bounds:
bounds = target_node.get("bounds", "")
bounds = original_attribs.get("bounds")
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
if m:
l, t, r, b = map(int, m.groups())
header_y = (t + b) // 2
# Instagram's optimal top margin for a snapped post is ~200-280px
target_y = 250
diff = header_y - target_y
# If target is off-center (> 100px), execute precise correction swipe
if abs(diff) > 100:
info = device.get_info()
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
cx = w // 2
max_safe_swipe = int(h * 0.4)
if diff > 0:
# Content is too LOW. Move it UP.
dist = min(diff, max_safe_swipe)
start_y = int(h * 0.7)
end_y = start_y - dist
else:
# Content is too HIGH. Move it DOWN.
dist = min(abs(diff), max_safe_swipe)
start_y = int(h * 0.3)
end_y = start_y + dist
# Duration 1.0s = precise mechanical drag with ZERO momentum
device.swipe(cx, start_y, cx, end_y, duration=1.0)
sleep(1.0)
logger.debug(f"📐 [Alignment] Snapping attempt {attempts}: Shifted {diff}px.")
# If bounds is a tuple from SpatialNode.to_dict()
if isinstance(bounds, tuple) and len(bounds) == 4:
left, t, r, b = bounds
else:
# Fallback to string parsing
if not bounds:
bounds = target_node.get("bounds", "")
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", str(bounds))
if m:
left, t, r, b = map(int, m.groups())
else:
aligned = True
break # Cannot parse bounds
header_y = (t + b) // 2
target_y = 250
diff = header_y - target_y
# If target is off-center (> 100px), execute precise correction swipe
if abs(diff) > 100:
info = device.get_info()
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
cx = w // 2
max_safe_swipe = int(h * 0.4)
if diff > 0:
# Content is too LOW. Move it UP.
dist = min(diff, max_safe_swipe)
start_y = int(h * 0.7)
end_y = start_y - dist
else:
# Content is too HIGH. Move it DOWN.
dist = min(abs(diff), max_safe_swipe)
start_y = int(h * 0.3)
end_y = start_y + dist
# Duration 1.0s = precise mechanical drag with ZERO momentum
device.swipe(cx, start_y, cx, end_y, duration=1.0)
sleep(1.0)
logger.debug(f"📐 [Alignment] Snapping attempt {attempts}: Shifted {diff}px.")
else:
aligned = True
else:
break # No header found, cannot align
except Exception as e:

View File

@@ -1188,6 +1188,25 @@ class ParasocialCRMDB(QdrantBase):
log_success=f"🧠 [ParasocialCRM] Updated @{username} into Qdrant. Stage {stage} ({intent_type})",
)
def enrich_lead(self, username: str, data: dict):
"""
Enriches a lead with scraped data.
"""
if not self.is_connected:
return
current = self.get_relationship_stage(username)
current.update(data)
vector = self._get_embedding(f"User: {username}")
if vector:
self.upsert_point(
seed_string=f"User_{username}",
vector=vector,
payload=current,
log_success=f"🧠 [ParasocialCRM] Enriched @{username} data.",
)
def log_generated_comment(self, username: str, comment_text: str):
"""Phase 10: RAG memory point for specific users."""
if not self.is_connected:

View File

@@ -54,6 +54,8 @@ class TelepathicEngine:
# ──────────────────────────────────────────────
def find_best_node(self, xml_string: str, intent_description: str, device=None, **kwargs) -> Optional[dict]:
print("FIND_BEST_NODE CALLED")
"""
Public facade for resolving a node.
Translates Android UI bounds into standard GramAddict node dicts.

View File

@@ -1,9 +1,12 @@
import logging
import os
from unittest.mock import MagicMock
from unittest.mock import MagicMock, create_autospec
import pytest
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.telepathic_engine import TelepathicEngine
def pytest_addoption(parser):
parser.addoption(
@@ -29,12 +32,6 @@ class MockConfigs:
self.args = args
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"
@@ -154,8 +151,8 @@ def reset_singletons():
@pytest.fixture(autouse=True)
def telepathic_mock(monkeypatch, request):
if request.config.getoption("--live"):
# TelepathicEngine is a singleton, allow it to run natively
if request.config.getoption("--live") or "e2e" in str(request.node.fspath):
# TelepathicEngine is a singleton, allow it to run natively in e2e or live mode
return None
import GramAddict.core.telepathic_engine

View File

@@ -13,9 +13,9 @@ Design Principles:
import os
import signal
import time
from unittest.mock import MagicMock
import pytest
from unittest.mock import MagicMock
from GramAddict.core import utils
@@ -23,7 +23,7 @@ from GramAddict.core import utils
# Constants
# ═══════════════════════════════════════════════════════
E2E_TEST_TIMEOUT_SECONDS = 60
E2E_TEST_TIMEOUT_SECONDS = 300
FIXTURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
E2E_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
@@ -53,40 +53,6 @@ def load_fixture_xml(filename: str) -> str:
)
# ═══════════════════════════════════════════════════════
# VirtualClock — Per-Test Instance, Never Module-Level
# ═══════════════════════════════════════════════════════
class VirtualClock:
"""Deterministic time simulation for E2E tests.
Each test gets its own isolated instance via the `virtual_clock` fixture.
This eliminates state bleed between tests.
"""
def __init__(self):
self.time = 0.0
self.animation_target_time = 0.0
def sleep(self, seconds):
if hasattr(seconds, "__iter__"):
return
self.time += float(seconds)
def is_animating(self) -> bool:
return self.time < self.animation_target_time
def start_animation(self, duration: float = 1.5):
self.animation_target_time = self.time + duration
@pytest.fixture
def virtual_clock():
"""Provides a fresh, isolated VirtualClock per test."""
return VirtualClock()
# ═══════════════════════════════════════════════════════
# Global Test Timeout — Prevents Infinite Hangs
# ═══════════════════════════════════════════════════════
@@ -107,7 +73,7 @@ def e2e_test_timeout():
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
signal.alarm(E2E_TEST_TIMEOUT_SECONDS)
yield
signal.alarm(0)
signal.alarm(E2E_TEST_TIMEOUT_SECONDS)
signal.signal(signal.SIGALRM, old_handler)
@@ -156,44 +122,31 @@ def iteration_guard():
# ═══════════════════════════════════════════════════════
# Qdrant Mock — Clean, Non-Poisoning
# Real Qdrant DB (Isolated Collection)
# ═══════════════════════════════════════════════════════
@pytest.fixture(autouse=True)
def e2e_qdrant_mock(monkeypatch):
"""Mock Qdrant without sys.modules poisoning.
@pytest.fixture(scope="function", autouse=True)
def isolated_screen_memory():
"""Ensures we use a separate Qdrant collection for E2E tests and clean it.
This replaces the old Qdrant mock so tests use the REAL database."""
from GramAddict.core.qdrant_memory import ScreenMemoryDB
Uses monkeypatch to replace QdrantClient at the import site,
which is automatically cleaned up after each test.
"""
mock_qdrant = MagicMock()
original_init = ScreenMemoryDB.__init__
mock_collection = MagicMock()
mock_collection.config.params.vectors.size = 768
mock_qdrant.get_collection.return_value = mock_collection
def test_init(self, *args, **kwargs):
super(ScreenMemoryDB, self).__init__(collection_name="test_e2e_screens")
# Patch at the import site rather than poisoning sys.modules
try:
import qdrant_client
ScreenMemoryDB.__init__ = test_init
monkeypatch.setattr(qdrant_client, "QdrantClient", MagicMock(return_value=mock_qdrant))
except ImportError:
pass
db = ScreenMemoryDB()
if db.is_connected:
db.wipe_collection()
# Also patch at the usage site in our code
try:
import GramAddict.core.qdrant_memory
yield db
monkeypatch.setattr(
GramAddict.core.qdrant_memory,
"QdrantClient",
MagicMock(return_value=mock_qdrant),
)
except (ImportError, AttributeError):
pass
return mock_qdrant
# Restore original
ScreenMemoryDB.__init__ = original_init
# ═══════════════════════════════════════════════════════
@@ -215,133 +168,6 @@ def e2e_device_dump_injector(request):
return _inject_dump
@pytest.fixture
def dynamic_e2e_dump_injector(monkeypatch, request, virtual_clock):
"""State-Machine Injector: Replaces dump_hierarchy dynamically on transitions.
Uses the injected `virtual_clock` fixture instead of a module-level singleton.
Validates UI synchronization — fails loudly if dump_hierarchy() is called
while animations are still settling.
"""
if request.config.getoption("--live"):
return lambda *args, **kwargs: None
def _inject(device_mock, state_map, initial_xml):
from GramAddict.core.q_nav_graph import QNavGraph
device_mock._xml_history = [load_fixture_xml(initial_xml)]
device_mock._current_active_xml = device_mock._xml_history[-1]
import uuid
def _dump_hierarchy_hook():
if virtual_clock.is_animating():
pytest.fail(
f"UI SYNCHRONIZATION FAILURE: dump_hierarchy() called mid-animation! "
f"Virtual Clock at {virtual_clock.time:.1f}s, "
f"UI needs until {virtual_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>',
)
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]
virtual_clock.start_animation()
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":
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:
new_xml = load_fixture_xml(state_map[action])
device_mock._xml_history.append(new_xml)
device_mock._current_active_xml = new_xml
virtual_clock.start_animation()
nav_self.device.click = _click_hook
try:
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":
return True
original_click = goap_self.device.click
def _click_hook(obj=None, *args, **kwargs):
original_click(obj, *args, **kwargs)
lookup_key = action_key if action_key in state_map else action
if lookup_key in state_map:
new_xml = load_fixture_xml(state_map[lookup_key])
device_mock._xml_history.append(new_xml)
device_mock._current_active_xml = new_xml
virtual_clock.start_animation()
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
# ═══════════════════════════════════════════════════════
# Delay Mocking — Uses Fixture-Scoped Clock
# ═══════════════════════════════════════════════════════
@@ -365,22 +191,16 @@ def _patch_module_delays(monkeypatch, module_path: str, sleep_fn, random_sleep_f
@pytest.fixture(autouse=True)
def mock_all_delays(monkeypatch, request, virtual_clock):
"""Replaces all humanized hardware delays with VirtualClock advances.
Uses the per-test `virtual_clock` fixture for complete isolation.
"""
def mock_all_delays(monkeypatch, request):
"""Replaces all humanized hardware delays with no-ops."""
if request.config.getoption("--live"):
return
def simulate_sleep(seconds):
virtual_clock.sleep(seconds)
def money_sleep(*args, **kwargs):
pass
def money_sleep(x):
return simulate_sleep(x)
def random_sleep(a=1.0, b=2.0, *args, **kwargs):
return simulate_sleep(max(1.5, float(a)))
def random_sleep(*args, **kwargs):
pass
monkeypatch.setattr(time, "sleep", money_sleep)
monkeypatch.setattr(utils, "random_sleep", random_sleep)
@@ -394,6 +214,7 @@ def mock_all_delays(monkeypatch, request, virtual_clock):
_patch_module_delays(monkeypatch, "GramAddict.core.q_nav_graph", money_sleep, random_sleep)
_patch_module_delays(monkeypatch, "GramAddict.core.goap", money_sleep, random_sleep)
_patch_module_delays(monkeypatch, "GramAddict.core.device_facade", money_sleep, random_sleep)
_patch_module_delays(monkeypatch, "GramAddict.core.darwin_engine", money_sleep, random_sleep)
# Standardize DarwinEngine to prevent mockup math errors on session end
try:
@@ -428,7 +249,6 @@ def mock_identity_guard(monkeypatch):
@pytest.fixture
def e2e_configs():
import argparse
from unittest.mock import MagicMock
args = argparse.Namespace(
username="testuser",
@@ -463,6 +283,7 @@ def e2e_configs():
visual_vibe_check_percentage=0,
)
configs = MagicMock()
configs.args = args
configs.username = "testuser"
@@ -491,30 +312,6 @@ def e2e_configs():
return configs
# ═══════════════════════════════════════════════════════
# SAE Mock — Scoped Exclusion
# ═══════════════════════════════════════════════════════
@pytest.fixture(autouse=True)
def mock_sae_perceive(request, monkeypatch):
"""Mock SAE.perceive for E2E tests EXCEPT the ones actually testing SAE."""
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,
)
# ═══════════════════════════════════════════════════════
# Plugin Registry — Standard Setup
# ═══════════════════════════════════════════════════════

View File

@@ -1,68 +0,0 @@
"""
Smoke test: Validates that start_bot() can execute a minimal session lifecycle.
This is NOT a real E2E test — it verifies that the bot's initialization,
session loop, and shutdown sequence execute without crashing.
"""
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import start_bot
class _CleanExitSentinel(Exception):
"""Sentinel exception for controlled test termination."""
pass
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.GrowthBrain")
@patch("GramAddict.core.bot_flow.ResonanceEngine")
def test_e2e_story_viewing_simple(
mock_resonance, mock_growth, mock_create_device, mock_dopamine, mock_sess, mock_close, mock_open, e2e_configs
):
device = MagicMock()
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.return_value = True
mock_d_inst.wants_to_doomscroll.return_value = False
mock_d_inst.boredom = 0.0
mock_growth_inst = mock_growth.return_value
mock_growth_inst.get_circadian_pacing.return_value = 1.0
mock_growth_inst.evaluate_governance.return_value = "STAY"
# First call succeeds, second raises sentinel to terminate
mock_sess.inside_working_hours.side_effect = [(True, 0), _CleanExitSentinel("Test complete")]
mock_sess_inst = mock_sess.return_value
mock_sess_inst.check_limit.return_value = (False, False, False)
mock_resonance_inst = mock_resonance.return_value
mock_resonance_inst.find_best_node.return_value = {
"username": "testuser",
"node": {"x": 500, "y": 500},
"score": 1.0,
}
device.dump_hierarchy.return_value = '<html><node resource-id="reel_ring" /></html>'
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
with patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", return_value=True):
with patch("GramAddict.core.q_nav_graph.QNavGraph.do", return_value=True):
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True):
with pytest.raises(_CleanExitSentinel):
start_bot()
# Verify the bot lifecycle actually ran
mock_open.assert_called_once()
mock_dopamine.assert_called()
mock_create_device.assert_called_once()

View File

@@ -1,28 +1,102 @@
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
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
def test_animation_sync_guard_catches_missing_sleep(dynamic_e2e_dump_injector, virtual_clock):
"""
Proves that the Animation Simulator built into conftest.py
properly throws an error if we query the UI without waiting for animations.
def test_wait_for_post_detects_feed():
sim = BehaviorSimulator()
with open("tests/fixtures/organic_post.xml", "r") as f:
sim.mock_xml = f.read()
Uses the fixture-scoped VirtualClock (not module-level singleton).
"""
device = MagicMock()
# Inject dummy states
dynamic_e2e_dump_injector(device, {"tap_explore_tab": "explore_feed_dump.xml"}, "home_feed_with_ad.xml")
with patch("GramAddict.core.physics.timing.sleep"):
assert wait_for_post_loaded(sim, timeout=1) is True
# Force the clock to be behind the animation target to trigger the guard.
# We simulate: transition happened (animation_target_time = 1.5) but no sleep advanced the clock.
virtual_clock.time = 0.0
virtual_clock.animation_target_time = 1.5
from _pytest.outcomes import Failed
def test_wait_for_post_timeout_and_adaptive_snap():
sim = BehaviorSimulator()
# Empty XML will cause timeout
sim.mock_xml = "<hierarchy></hierarchy>"
with pytest.raises(Failed) as exc_info:
# This should fail because virtual_clock.time (0.0) < animation_target_time (1.5)
device.dump_hierarchy()
with patch("GramAddict.core.physics.timing.sleep"):
assert wait_for_post_loaded(sim, timeout=1) is False
assert "UI SYNCHRONIZATION FAILURE" in str(exc_info.value), "The simulator failed to catch the missing sleep guard!"
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"):
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"):
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"):
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"):
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"):
aligned = align_active_post(sim)
assert aligned is False

View File

@@ -0,0 +1,447 @@
import urllib.request
from unittest.mock import MagicMock, 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"):
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"):
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", 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"),
patch("GramAddict.core.behaviors.carousel_browsing.humanized_horizontal_swipe", 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", 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"),
patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", 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", return_value=0.0):
result = plugin.execute(ctx)
assert result.executed is True
assert result.metadata["text"] == "Great post!"

View File

@@ -1,48 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.qdrant_memory import wipe_all_ai_caches
@pytest.mark.filterwarnings("ignore:urllib3")
def test_blank_start_wipes_navigation_memory(monkeypatch):
"""
TDD: Verify that NavigationMemoryDB is wiped when blank_start is True.
We mock the QdrantClient to track if delete_collection was called for the nav graph.
"""
mock_client = MagicMock()
# Mock collection_exists to return True so it tries to wipe
mock_client.collection_exists.return_value = True
# We patch QdrantClient in qdrant_memory
monkeypatch.setattr("GramAddict.core.qdrant_memory.QdrantClient", MagicMock(return_value=mock_client))
# Setup configs with blank_start = True
configs = MagicMock()
configs.args = MagicMock()
configs.args.blank_start = True
configs.args.username = "testuser"
configs.username = "testuser"
# We mock TelepathicEngine to avoid other side effects
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_te:
mock_te.return_value = MagicMock()
# Run stage 0 via a minimal start_bot simulation or direct call
# Since start_bot is huge, let's just test the logic we added to bot_flow
# but in the context of the actual classes.
wipe_all_ai_caches()
# Verify that NavigationMemoryDB's collection was deleted
# NavigationMemoryDB uses "gramaddict_nav_graph_v8"
mock_client.delete_collection.assert_any_call("gramaddict_nav_graph_v8")
mock_client.delete_collection.assert_any_call("gramaddict_heuristics_v7")
mock_client.delete_collection.assert_any_call("gramaddict_ui_cache")
print("✅ All collections were signaled for deletion.")
if __name__ == "__main__":
# Manual run for quick verification
test_blank_start_wipes_navigation_memory(pytest.MonkeyPatch())

View File

@@ -1,90 +0,0 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.behaviors.carousel_browsing.humanized_horizontal_swipe")
@patch("GramAddict.core.behaviors.carousel_browsing.sleep")
def test_full_e2e_carousel_handling(
mock_carousel_sleep,
mock_horizontal_swipe,
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
e2e_configs,
):
"""
Tests that the core feed loop successfully identifies native Carousel identifiers
in the XML and initiates organic swiping inputs.
"""
device = MagicMock(spec=DeviceFacade)
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.shell.return_value = "" # Prevent SendEventInjector detection disruption
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, False, Exception("Clean Exit for Carousel")]
mock_d_inst.wants_to_change_feed.return_value = False
mock_d_inst.wants_to_doomscroll.return_value = False
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.return_value = (True, 0)
# Configure e2e_configs to only allow carousel browsing
e2e_configs.args.feed = "1-2"
e2e_configs.args.interact_percentage = 100
e2e_configs.args.likes_percentage = 0
e2e_configs.args.follow_percentage = 0
e2e_configs.args.profile_visit_percentage = 0
e2e_configs.args.carousel_percentage = 100
e2e_configs.args.carousel_count = "3-3"
def get_plugin_config_mock(plugin_name):
if plugin_name == "carousel_browsing":
return {"percentage": 100, "count": "3-3"}
return {"percentage": 0}
e2e_configs.get_plugin_config.side_effect = get_plugin_config_mock
# Load the captured UI dump containing native carousel_page_indicator
dynamic_e2e_dump_injector(device, {}, "carousel_post_dump.xml")
try:
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.bot_flow.QNavGraph.navigate_to", return_value=True):
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get_telepathic:
mock_engine = MagicMock()
mock_engine.find_best_node.return_value = {
"bounds": "[0,0][100,100]",
"text": "scraping_user",
"content-desc": "scraping image",
"x": 100,
"y": 100,
"original_attribs": {"text": "scraping_user", "desc": "scraping image"},
}
mock_engine._extract_semantic_nodes.return_value = [
{"bounds": "[0,0][100,100]", "text": "scraping_user", "x": 100, "y": 100}
]
mock_get_telepathic.return_value = mock_engine
with patch("secrets.choice", return_value="HomeFeed"):
with patch("random.random", return_value=0.0):
start_bot()
except Exception as e:
if str(e) != "Clean Exit for Carousel":
raise e
assert mock_horizontal_swipe.call_count == 3

View File

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

View File

@@ -1,67 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
class _CleanExitSentinel(Exception):
"""Sentinel exception for controlled test termination."""
pass
@patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "test reply"})
@patch("GramAddict.core.stealth_typing.ghost_type")
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
def test_full_e2e_dm_sequence(
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
mock_ghost_type,
mock_query_llm,
dynamic_e2e_dump_injector,
):
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, False, True, True, True, True]
mock_d_inst.wants_to_change_feed.return_value = True
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.side_effect = [(True, 0), _CleanExitSentinel("Test complete")]
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
disable_ai_messaging = False
feed = None
reels = None
explore = None
stories = None
total_unfollows_limit = 0
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
configs.get_plugin_config.return_value = {}
dynamic_e2e_dump_injector(device, {"tap messages tab": "dm_inbox_dump.xml"}, "home_feed_with_ad.xml")
with patch("secrets.choice", return_value="MessageInbox"):
with pytest.raises(_CleanExitSentinel):
start_bot(configs=configs)
mock_open.assert_called()

View File

@@ -1,48 +0,0 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DojoEngine")
def test_dojo_lifecycle_integration(
mock_dojo, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector
):
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_dojo_inst = mock_dojo.get_instance.return_value
mock_dojo_inst.is_running = True
mock_sess.inside_working_hours.side_effect = [Exception("Lifecycle Exit")]
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
feed = "1"
working_hours = "00:00-23:59"
time_delta_session = "0"
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
configs.get_plugin_config.return_value = {}
dynamic_e2e_dump_injector(device, {"tap_profile_tab": "scraping_profile_dump.xml"}, "home_feed_with_ad.xml")
try:
start_bot(configs=configs)
except Exception as e:
assert "Lifecycle Exit" in str(e)
mock_dojo.get_instance.assert_called()
mock_dojo_inst.start.assert_called()
mock_dojo_inst.stop.assert_called()

View File

@@ -1,68 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
class _CleanExitSentinel(Exception):
"""Sentinel exception for controlled test termination."""
pass
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
def test_full_e2e_explore_feed_sequence(
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
):
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, True]
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.side_effect = [(True, 0), _CleanExitSentinel("Test complete")]
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
explore = "5-8"
feed = None
reels = None
stories = None
interact_percentage = 0
likes_percentage = 0
follow_percentage = 0
comment_percentage = 0
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
def get_plugin_config_mock(plugin_name):
return {}
configs.get_plugin_config.side_effect = get_plugin_config_mock
dynamic_e2e_dump_injector(device, {"tap_explore_tab": "explore_feed_dump.xml"}, "home_feed_with_ad.xml")
with patch("secrets.choice", return_value="ExploreFeed"):
with pytest.raises(_CleanExitSentinel):
start_bot(configs=configs)
mock_open.assert_called()

View File

@@ -1,572 +0,0 @@
"""
GOAP E2E Tests — Tests screen identity, goal planning, and autonomous execution
using REAL XML dumps from production sessions.
References TESTING.md for TDD protocol.
Every test in this file is an assertion about REAL-WORLD behavior.
These tests ensure the bot's brain works correctly WITHOUT any hardcoded navigation.
"""
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.goap import GoalExecutor, GoalPlanner, ScreenIdentity, ScreenType
def mock_vlm_oracle(*args, **kwargs):
sys_prompt = kwargs.get("system", "")
if "profile_header_actions_top_row" in sys_prompt or "profile_header_user_action" in sys_prompt:
return "OTHER_PROFILE"
if "Selected Tab: search_tab" in sys_prompt:
return "EXPLORE_GRID"
if "Selected Tab: feed_tab" in sys_prompt:
return "HOME_FEED"
if "Selected Tab: profile_tab" in sys_prompt:
return "OWN_PROFILE"
if "Selected Tab: clips_tab" in sys_prompt:
return "REELS_FEED"
if "Selected Tab: direct_tab" in sys_prompt or "message_input" in sys_prompt:
return "DM_INBOX"
if "unified_follow_list_tab_layout" in sys_prompt or "follow_list_container" in sys_prompt:
return "FOLLOW_LIST"
if "survey" in sys_prompt or "dialog" in sys_prompt or "follow_sheet" in sys_prompt:
return "MODAL"
if "stories_viewer" in sys_prompt:
return "STORY_VIEW"
if "row_feed_button_like" in sys_prompt:
return "POST_DETAIL"
return "UNKNOWN"
@pytest.fixture(autouse=True)
def auto_mock_query_llm():
with (
patch("GramAddict.core.llm_provider.query_llm", side_effect=mock_vlm_oracle),
patch("GramAddict.core.qdrant_memory.ScreenMemoryDB", autospec=True) as mock_db_class,
patch("GramAddict.core.navigation.planner.NavigationKnowledge") as mock_nav_knowledge,
):
mock_db_instance = mock_db_class.return_value
mock_db_instance.is_connected = True
mock_db_instance.get_screen_type.return_value = None # Force fallback to LLM
# Ensure NavigationKnowledge returns empty results to avoid Qdrant calls
mock_nav_instance = mock_nav_knowledge.return_value
mock_nav_instance.get_requirements.return_value = []
mock_nav_instance.get_action_for_screen.return_value = None
mock_nav_instance.get_screen_for_action.return_value = None
mock_nav_instance.get_screen_for_tab.return_value = None
mock_nav_instance.is_trap.return_value = False
yield
# ─────────────────────────────────────────────────────
# Load REAL XML dumps
# ─────────────────────────────────────────────────────
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
def load_fixture(name):
path = os.path.join(FIXTURES_DIR, name)
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
return None
HOME_FEED_XML = load_fixture("home_feed_real.xml")
EXPLORE_GRID_XML = load_fixture("explore_grid_real.xml")
OTHER_PROFILE_XML = load_fixture("other_profile_real.xml")
POST_DETAIL_XML = load_fixture("post_detail_real.xml")
REELS_FEED_XML = load_fixture("reels_feed_real.xml")
def _make_fullscreen_reels_xml():
"""Simulate full-screen Reels: strips selected=true from clips_tab to emulate hidden tab bar."""
if not REELS_FEED_XML:
return None
import re
# Remove selected="true" ONLY from the clips_tab node (the bottom nav tab)
# This simulates the real production case where Instagram hides tabs in full-screen Reels
return re.sub(
r'(resource-id="com\.instagram\.android:id/clips_tab"[^>]*?)selected="true"',
r'\1selected="false"',
REELS_FEED_XML,
)
REELS_FULLSCREEN_XML = _make_fullscreen_reels_xml()
def _make_own_profile_xml():
"""Simulate own profile by taking other profile and adding a selected profile_tab."""
if not OTHER_PROFILE_XML:
return None
import re
# First unselect whatever tab was selected
xml = re.sub(r'selected="true"', 'selected="false"', OTHER_PROFILE_XML)
# Inject a profile tab if it's missing (bottom nav is often missing from other_profile dumps if scrolled)
mock_profile_tab = (
'<node resource-id="com.instagram.android:id/profile_tab" selected="true" bounds="[0,0][100,100]" />'
)
return xml.replace("</hierarchy>", f" {mock_profile_tab}\n</hierarchy>")
OWN_PROFILE_XML = _make_own_profile_xml()
def make_mock_device():
device = MagicMock(spec=DeviceFacade)
device.app_id = "com.instagram.android"
device.deviceV2 = MagicMock()
return device
# ═══════════════════════════════════════════════════════
# 1. SCREEN IDENTITY TESTS (Real XML Dumps)
# ═══════════════════════════════════════════════════════
class TestScreenIdentity:
"""Tests that ScreenIdentity correctly identifies screens from REAL dumps."""
def setup_method(self):
self.si = ScreenIdentity(bot_username="marisaundmarc")
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_identifies_home_feed(self):
"""Real home feed dump → ScreenType.HOME_FEED"""
result = self.si.identify(HOME_FEED_XML)
assert result["screen_type"] == ScreenType.HOME_FEED
assert result["selected_tab"] == "feed_tab"
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_identifies_explore_grid(self):
"""Real explore grid dump → ScreenType.EXPLORE_GRID"""
result = self.si.identify(EXPLORE_GRID_XML)
assert result["screen_type"] == ScreenType.EXPLORE_GRID
assert result["selected_tab"] == "search_tab"
@pytest.mark.skipif(OTHER_PROFILE_XML is None, reason="Missing fixture")
def test_identifies_other_profile(self):
"""Real other profile dump → ScreenType.OTHER_PROFILE"""
result = self.si.identify(OTHER_PROFILE_XML)
assert result["screen_type"] == ScreenType.OTHER_PROFILE
# Must NOT identify as own profile (different username)
assert result["screen_type"] != ScreenType.OWN_PROFILE
@pytest.mark.skipif(POST_DETAIL_XML is None, reason="Missing fixture")
def test_identifies_post_in_feed(self):
"""Real post detail in feed → ScreenType.HOME_FEED or POST_DETAIL"""
result = self.si.identify(POST_DETAIL_XML)
# A post viewed in feed still shows feed_tab as selected
assert result["screen_type"] in (ScreenType.HOME_FEED, ScreenType.POST_DETAIL)
assert "tap like button" in result["available_actions"]
@pytest.mark.skipif(OWN_PROFILE_XML is None, reason="Missing fixture")
def test_identifies_own_profile(self):
"""Real own profile dump → ScreenType.OWN_PROFILE"""
result = self.si.identify(OWN_PROFILE_XML)
assert result["screen_type"] == ScreenType.OWN_PROFILE
assert result["selected_tab"] == "profile_tab"
def test_identifies_foreign_app(self):
"""Non-Instagram app → ScreenType.FOREIGN_APP"""
foreign_xml = """<?xml version='1.0' ?><hierarchy rotation="0">
<node package="com.google.android.apps.maps" bounds="[0,0][1080,2400]" />
</hierarchy>"""
result = self.si.identify(foreign_xml)
assert result["screen_type"] == ScreenType.FOREIGN_APP
assert "press back" in result["available_actions"]
def test_identifies_empty_dump(self):
"""Empty/None dump → FOREIGN_APP (safe fallback)"""
result = self.si.identify(None)
assert result["screen_type"] == ScreenType.FOREIGN_APP
result2 = self.si.identify("")
assert result2["screen_type"] == ScreenType.FOREIGN_APP
def test_computes_stable_signature(self):
"""Same dump → same signature (deterministic)."""
if HOME_FEED_XML is None:
pytest.skip("Missing fixture")
r1 = self.si.identify(HOME_FEED_XML)
r2 = self.si.identify(HOME_FEED_XML)
assert r1["signature"] == r2["signature"]
def test_different_screens_different_signatures(self):
"""Different screens → different signatures."""
if not (HOME_FEED_XML and EXPLORE_GRID_XML):
pytest.skip("Missing fixtures")
r1 = self.si.identify(HOME_FEED_XML)
r2 = self.si.identify(EXPLORE_GRID_XML)
assert r1["signature"] != r2["signature"]
@pytest.mark.skipif(REELS_FEED_XML is None, reason="Missing fixture")
def test_identifies_reels_with_tab_bar(self):
"""Real Reels dump (tab bar visible) → ScreenType.REELS_FEED"""
result = self.si.identify(REELS_FEED_XML)
assert result["screen_type"] == ScreenType.REELS_FEED
assert result["selected_tab"] == "clips_tab"
@pytest.mark.skipif(REELS_FULLSCREEN_XML is None, reason="Missing fixture")
def test_identifies_reels_fullscreen_without_tab_bar(self):
"""Full-screen Reels (tab bar hidden) → ScreenType.REELS_FEED via structural markers.
This is the CRITICAL production failure: Instagram hides the tab bar during
full-screen Reels scrolling. Without structural Reels markers, the classifier
falls through to the LLM and returns UNKNOWN, triggering the death spiral.
"""
result = self.si.identify(REELS_FULLSCREEN_XML)
assert result["screen_type"] == ScreenType.REELS_FEED, (
f"Full-screen Reels misclassified as {result['screen_type']}. "
f"This causes the navigation death spiral in production."
)
# ═══════════════════════════════════════════════════════
# 2. GOAL PLANNER TESTS
# ═══════════════════════════════════════════════════════
class TestGoalPlanner:
"""Tests that the planner correctly decomposes goals into next steps."""
def setup_method(self):
# Use a hermetic test user so we don't accidentally pull real learned paths from Qdrant
self.planner = GoalPlanner(username="test_hermetic_goap_user")
self.si = ScreenIdentity(bot_username="test_hermetic_goap_user")
# Ensure clean state at setup (wipe all memory banks!)
if getattr(self.planner, "path_memory", None):
self.planner.path_memory.wipe()
if getattr(self.planner, "knowledge", None):
self.planner.knowledge.wipe()
# ── Navigation: "I need to get to the right screen" ──
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_plans_explore_from_home(self):
"""Goal: 'open explore' + On: HOME_FEED → returns goal for autonomous execution"""
screen = self.si.identify(HOME_FEED_XML)
goal = "open explore feed"
action = self.planner.plan_next_step(goal, screen)
assert action == "tap explore tab"
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_recognizes_explore_already_open(self):
"""Goal: 'open explore' + On: EXPLORE_GRID → None (goal achieved)"""
screen = self.si.identify(EXPLORE_GRID_XML)
action = self.planner.plan_next_step("open explore feed", screen)
assert action is None # Already there!
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_recognizes_home_already_open(self):
"""Goal: 'open home feed' + On: HOME_FEED → None (goal achieved)"""
screen = self.si.identify(HOME_FEED_XML)
action = self.planner.plan_next_step("open home feed", screen)
assert action is None
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_plans_home_from_explore(self):
"""Goal: 'open home feed' + On: EXPLORE_GRID → returns goal"""
screen = self.si.identify(EXPLORE_GRID_XML)
goal = "open home feed"
action = self.planner.plan_next_step(goal, screen)
assert action == "tap home tab"
# ── Goal Actions: "I'm on the right screen, execute the goal" ──
@pytest.mark.skipif(POST_DETAIL_XML is None, reason="Missing fixture")
def test_plans_like_on_post(self):
"""Goal: 'like this post' + On: POST/FEED → returns goal"""
screen = self.si.identify(POST_DETAIL_XML)
goal = "like this post"
action = self.planner.plan_next_step(goal, screen)
# Without static heuristics, we just return the raw intent for the VLM
assert action == goal
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_plans_grid_tap_from_explore(self):
"""Goal: 'view a post from explore' + On: EXPLORE_GRID → returns goal"""
screen = self.si.identify(EXPLORE_GRID_XML)
goal = "view a post from explore"
action = self.planner.plan_next_step(goal, screen)
# HD Map transitions from EXPLORE to POST via 'view a post'
assert action == "view a post"
@pytest.mark.skipif(OTHER_PROFILE_XML is None, reason="Missing fixture")
def test_plans_follow_on_profile(self):
"""Goal: 'follow this user' + On: OTHER_PROFILE → returns goal"""
screen = self.si.identify(OTHER_PROFILE_XML)
goal = "follow this user"
action = self.planner.plan_next_step(goal, screen)
# Without static heuristics, we return the raw intent for the VLM
assert action == goal
# ── Multi-step planning: wrong screen for goal ──
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_navigates_before_grid_tap(self):
"""Goal: 'view a post from explore' + On: HOME_FEED → returns goal"""
screen = self.si.identify(HOME_FEED_XML)
goal = "view a post from explore"
action = self.planner.plan_next_step(goal, screen)
assert action == "tap explore tab"
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_likes_require_post_or_feed(self):
"""Goal: 'like a post' + On: EXPLORE_GRID → navigates to required screen"""
screen = self.si.identify(EXPLORE_GRID_XML)
goal = "like a post"
# Configure the planner's knowledge mock to return POST_DETAIL as required screen
from GramAddict.core.screen_topology import ScreenType
self.planner.knowledge.get_requirements.return_value = [ScreenType.POST_DETAIL]
action = self.planner.plan_next_step(goal, screen)
# HD Map transitions from EXPLORE to POST_DETAIL via various routes
# The planner should pick a navigation action, not return the raw goal
assert action != goal, (
f"Planner returned the raw goal '{goal}' instead of a navigation action. "
f"This means knowledge.get_requirements() is not being used."
)
assert action is not None, "Planner should navigate to POST_DETAIL, not give up"
# ═══════════════════════════════════════════════════════
# 3. FULL GOAL ACHIEVEMENT (E2E with mock device)
# ═══════════════════════════════════════════════════════
class TestGoalExecution:
"""Full E2E: give the bot a goal, verify it achieves it autonomously."""
@pytest.mark.skipif(not (HOME_FEED_XML and EXPLORE_GRID_XML), reason="Missing fixtures")
def test_navigates_home_to_explore(self):
"""Goal: 'open explore' from home feed → bot taps explore tab → done."""
device = make_mock_device()
# perceive calls dump_hierarchy once per step
device.dump_hierarchy.side_effect = [
HOME_FEED_XML, # perceive step 1: home feed → plan 'tap explore tab'
EXPLORE_GRID_XML, # perceive step 2: explore grid → goal achieved!
]
goap = GoalExecutor(device, bot_username="marisaundmarc")
with (
patch.object(goap, "_execute_action", return_value=True),
patch.object(goap.path_memory, "recall_path", return_value=None),
patch.object(goap.path_memory, "learn_path"),
):
result = goap.achieve("open explore feed", max_steps=5)
assert result is True
@pytest.mark.skipif(not (HOME_FEED_XML and EXPLORE_GRID_XML), reason="Missing fixtures")
def test_already_at_goal_returns_immediately(self):
"""Goal: 'open explore' when already on explore → returns True instantly."""
device = make_mock_device()
device.dump_hierarchy.return_value = EXPLORE_GRID_XML
goap = GoalExecutor(device, bot_username="marisaundmarc")
with (
patch.object(goap.path_memory, "recall_path", return_value=None),
patch.object(goap.path_memory, "learn_path"),
patch.object(goap, "_execute_action") as mock_exec,
):
result = goap.achieve("open explore feed", max_steps=5)
assert result is True
# Should NOT have executed any actions
mock_exec.assert_not_called()
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_already_at_home_returns_immediately(self):
"""Goal: 'open home feed' when already on home → returns True instantly."""
device = make_mock_device()
device.dump_hierarchy.return_value = HOME_FEED_XML
goap = GoalExecutor(device, bot_username="marisaundmarc")
with (
patch.object(goap.path_memory, "recall_path", return_value=None),
patch.object(goap.path_memory, "learn_path"),
):
result = goap.achieve("open home feed", max_steps=5)
assert result is True
def test_foreign_app_triggers_sae_recovery(self):
"""Foreign app on screen → GOAP delegates to SAE → recovers."""
foreign_xml = """<?xml version='1.0' ?><hierarchy rotation="0">
<node package="com.whatsapp" bounds="[0,0][1080,2400]" />
</hierarchy>"""
home_xml = """<?xml version='1.0' ?><hierarchy rotation="0">
<node package="com.instagram.android" bounds="[0,0][1080,2400]">
<node resource-id="com.instagram.android:id/feed_tab" selected="true"
package="com.instagram.android" bounds="[0,2200][216,2400]" />
</node>
</hierarchy>"""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
foreign_xml, # perceive for recall check
foreign_xml, # perceive in loop step 1: foreign app → SAE recovery
home_xml, # perceive in loop step 2: home feed → goal achieved!
]
goap = GoalExecutor(device, bot_username="marisaundmarc")
# Inject mock SAE directly (GoalExecutor supports dependency injection)
mock_sae = MagicMock()
mock_sae.ensure_clear_screen.return_value = True
goap._sae = mock_sae
with (
patch.object(goap.path_memory, "recall_path", return_value=None),
patch.object(goap.path_memory, "learn_path"),
):
result = goap.achieve("open home feed", max_steps=5)
assert result is True
mock_sae.ensure_clear_screen.assert_called_once()
# ═══════════════════════════════════════════════════════
# 4. PATH MEMORY TESTS
# ═══════════════════════════════════════════════════════
class TestPathMemory:
"""Tests path serialization and recall."""
def test_steps_serialization(self):
"""Steps are simple dicts that can be stored/recalled."""
steps = [
{"screen": "home_feed", "action": "tap explore tab", "success": True},
{"screen": "explore_grid", "action": "tap first grid item", "success": True},
]
# Verify they're JSON-serializable
import json
serialized = json.dumps(steps)
deserialized = json.loads(serialized)
assert deserialized == steps
# ═══════════════════════════════════════════════════════
# 5. BACKWARD COMPATIBILITY
# ═══════════════════════════════════════════════════════
class TestBackwardCompatibility:
"""Tests that the old navigate_to() interface still works via GOAP."""
def test_navigate_to_screen_maps_correctly(self):
"""navigate_to_screen('ExploreFeed') → achieve('open explore feed')"""
device = make_mock_device()
goap = GoalExecutor(device, bot_username="marisaundmarc")
with patch.object(goap, "achieve", return_value=True) as mock_achieve:
goap.navigate_to_screen("ExploreFeed")
mock_achieve.assert_called_once_with("open explore feed")
def test_navigate_to_screen_homefeed(self):
device = make_mock_device()
goap = GoalExecutor(device, bot_username="marisaundmarc")
with patch.object(goap, "achieve", return_value=True) as mock_achieve:
goap.navigate_to_screen("HomeFeed")
mock_achieve.assert_called_once_with("open home feed")
def test_navigate_to_screen_stories(self):
"""StoriesFeed maps to 'open home feed' (stories are on home)"""
device = make_mock_device()
goap = GoalExecutor(device, bot_username="marisaundmarc")
with patch.object(goap, "achieve", return_value=True) as mock_achieve:
goap.navigate_to_screen("StoriesFeed")
mock_achieve.assert_called_once_with("open home feed")
# ═══════════════════════════════════════════════════════
# 6. INTENT RESOLVER TESTS (Real XML Execution)
# ═══════════════════════════════════════════════════════
class TestIntentResolution:
"""Tests that IntentResolver actually finds the RIGHT node in real XML.
These tests are the CRITICAL gap in coverage. The existing E2E tests mock
_execute_action, so they never verify that the IntentResolver finds the
correct button. These tests prove that tab navigation intents resolve
to the bottom navigation bar, NOT to content-area profile pictures.
"""
def setup_method(self):
from GramAddict.core.perception.intent_resolver import IntentResolver
from GramAddict.core.perception.spatial_parser import SpatialParser
self.parser = SpatialParser()
self.resolver = IntentResolver()
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_tap_profile_tab_resolves_to_nav_bar(self):
"""CRITICAL: 'tap profile tab' must resolve to bottom nav, NOT a content profile pic.
Production failure: VLM selects clips_author_profile_pic (content area)
instead of profile_tab (bottom bar). This single bug causes 90% of
the navigation death spiral.
"""
root = self.parser.parse(HOME_FEED_XML)
candidates = self.parser.get_clickable_nodes(root)
result = self.resolver.resolve("tap profile tab", candidates)
assert result is not None, "IntentResolver returned None for 'tap profile tab'"
assert result.y1 > 2100, (
f"'tap profile tab' resolved to Y={result.y1} (content area). "
f"Must be in bottom nav zone (Y > 2100). "
f"Resolved node: id={result.resource_id}, text={result.text}"
)
assert "profile_tab" in (result.resource_id or "").lower(), f"Resolved to wrong element: {result.resource_id}"
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_tap_home_tab_resolves_to_nav_bar(self):
"""'tap home tab' must resolve to feed_tab in bottom nav."""
root = self.parser.parse(EXPLORE_GRID_XML)
candidates = self.parser.get_clickable_nodes(root)
result = self.resolver.resolve("tap home tab", candidates)
assert result is not None, "IntentResolver returned None for 'tap home tab'"
assert result.y1 > 2100, f"'tap home tab' resolved to Y={result.y1}. Must be in bottom nav zone."
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_tap_explore_tab_resolves_to_nav_bar(self):
"""'tap explore tab' must resolve to search_tab in bottom nav."""
root = self.parser.parse(HOME_FEED_XML)
candidates = self.parser.get_clickable_nodes(root)
result = self.resolver.resolve("tap explore tab", candidates)
assert result is not None, "IntentResolver returned None for 'tap explore tab'"
assert result.y1 > 2100, f"'tap explore tab' resolved to Y={result.y1}. Must be in bottom nav zone."

View File

@@ -1,138 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.session_state import SessionState
class _CleanExitSentinel(Exception):
"""Sentinel exception for controlled test termination."""
pass
def setup_common_mocks(mock_sess, mock_dopamine, mock_create_device, device):
mock_create_device.return_value = device
# Mock DopamineEngine
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, False, True] + [True] * 20
mock_d_inst.wants_to_doomscroll.return_value = False
mock_d_inst.get_current_desire.return_value = "DiscoverNewContent"
mock_d_inst.boredom = 0.0 # Must be a real float, format strings use :.1f
mock_d_inst.wants_to_change_feed.return_value = False
# Mock SessionState (Class methods)
mock_sess.inside_working_hours.side_effect = [(True, 0), _CleanExitSentinel("Test complete")]
mock_sess.Limit = SessionState.Limit
# Mock SessionState (Instance)
mock_sess_inst = mock_sess.return_value
def check_limit_side_effect(limit_type=None, output=False):
if limit_type == SessionState.Limit.ALL:
return (False, False, False)
return False
mock_sess_inst.check_limit.side_effect = check_limit_side_effect
mock_sess_inst.startTime = MagicMock()
return mock_sess_inst
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.GrowthBrain")
def test_e2e_ad_guard_scrolling(
mock_growth, mock_create_device, mock_dopamine, mock_sess, mock_close, mock_open, e2e_configs, monkeypatch
):
"""Verifies that AdGuard correctly detects an ad and scrolls past it."""
device = MagicMock(spec=DeviceFacade)
setup_common_mocks(mock_sess, mock_dopamine, mock_create_device, device)
mock_growth_inst = mock_growth.return_value
mock_growth_inst.get_circadian_pacing.return_value = 1.0
mock_growth_inst.evaluate_governance.return_value = "STAY"
# Mock is_ad to return True for the first post, then False
with patch("GramAddict.core.behaviors.ad_guard.is_ad") as mock_is_ad:
mock_is_ad.side_effect = [True, False]
# Mock humanized_scroll to track calls
with patch("GramAddict.core.behaviors.ad_guard.humanized_scroll") as mock_scroll:
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True):
with pytest.raises(_CleanExitSentinel):
start_bot()
# AdGuard should have called scroll once for the first ad
assert mock_scroll.called, "AdGuard should have scrolled past the ad!"
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.GrowthBrain")
def test_e2e_anomaly_recovery(
mock_growth, mock_create_device, mock_dopamine, mock_sess, mock_close, mock_open, e2e_configs, monkeypatch
):
"""Verifies that AnomalyHandler detects zero nodes and triggers recovery."""
device = MagicMock(spec=DeviceFacade)
setup_common_mocks(mock_sess, mock_dopamine, mock_create_device, device)
mock_growth_inst = mock_growth.return_value
mock_growth_inst.get_circadian_pacing.return_value = 1.0
mock_growth_inst.evaluate_governance.return_value = "STAY"
# Mock TelepathicEngine to return empty nodes for the first call
mock_tele = MagicMock()
mock_tele._extract_semantic_nodes.side_effect = [[], [{"x": 500, "y": 500}]]
with patch("GramAddict.core.behaviors.anomaly_handler.TelepathicEngine.get_instance", return_value=mock_tele):
with patch("GramAddict.core.behaviors.anomaly_handler.humanized_scroll") as mock_scroll:
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True):
with pytest.raises(_CleanExitSentinel):
start_bot()
# AnomalyHandler should have pressed back and scrolled
device.press.assert_called_with("back")
assert mock_scroll.call_count > 0, "AnomalyHandler should have scrolled for recovery!"
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.GrowthBrain")
def test_e2e_close_friends_guard(
mock_growth, mock_create_device, mock_dopamine, mock_sess, mock_close, mock_open, e2e_configs, monkeypatch
):
"""Verifies that Close Friends posts are skipped when configured."""
device = MagicMock(spec=DeviceFacade)
setup_common_mocks(mock_sess, mock_dopamine, mock_create_device, device)
mock_growth_inst = mock_growth.return_value
mock_growth_inst.get_circadian_pacing.return_value = 1.0
mock_growth_inst.evaluate_governance.return_value = "STAY"
# Enable close friends guard
e2e_configs.args.ignore_close_friends = True
device.dump_hierarchy.return_value = '<html><node text="enge freunde" /><node resource-id="post" /></html>'
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True):
with pytest.raises(_CleanExitSentinel):
start_bot()
# The CloseFriendsGuardPlugin triggers chain termination (should_skip=True),
# causing the feed loop to skip the post. Verify the bot lifecycle completed.
mock_open.assert_called()

View File

@@ -1,80 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
class _CleanExitSentinel(Exception):
"""Sentinel exception for controlled test termination. ONLY this exception is acceptable."""
pass
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
def test_full_e2e_home_feed_sequence(
mock_dopamine,
mock_sess,
mock_create_device,
mock_random_sleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
):
"""
Test a full E2E sequence for Home Feed using actual real XML dumps.
Validates bot_flow session lifecycle — navigation is mocked via GOAP.
"""
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
# Setup mock dopamine & session
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, True]
mock_d_inst.boredom = 0.0
# First call succeeds, second raises our sentinel to exit the outer loop
mock_sess.inside_working_hours.side_effect = [(True, 0), _CleanExitSentinel("Test complete")]
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
feed = "5-8"
explore = None
reels = None
stories = None
interact_percentage = 100
likes_percentage = 100
follow_percentage = 100
comment_percentage = 100
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
def get_plugin_config_mock(plugin_name):
return {}
configs.get_plugin_config.side_effect = get_plugin_config_mock
dynamic_e2e_dump_injector(device, {}, "home_feed_with_ad.xml")
# Mock GOAP to bypass real navigation (this test validates bot_flow, not nav)
with (
patch("secrets.choice", return_value="HomeFeed"),
patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True),
):
with pytest.raises(_CleanExitSentinel):
start_bot(configs=configs)
mock_open.assert_called()

View File

@@ -1,281 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.session_state import SessionState
class _CleanExitSentinel(Exception):
"""Sentinel exception for controlled test termination."""
pass
def setup_common_mocks(mock_sess, mock_dopamine, mock_create_device, device):
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
# Break the loop after one session
mock_d_inst.is_app_session_over.side_effect = [False, False, False, True] + [True] * 50
mock_d_inst.wants_to_doomscroll.return_value = False
mock_d_inst.wants_to_change_feed.return_value = False
mock_d_inst.get_current_desire.return_value = "NurtureCommunity" # Forces HomeFeed usually
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.side_effect = [(True, 0), _CleanExitSentinel("Test complete")]
mock_sess_inst = mock_sess.return_value
mock_sess_inst.inside_working_hours.return_value = (True, 0)
mock_sess_inst.Limit = SessionState.Limit
def check_limit_side_effect(limit_type=None, output=False):
return (False, False, False) if limit_type == SessionState.Limit.ALL else False
mock_sess_inst.check_limit.side_effect = check_limit_side_effect
mock_sess_inst.startTime = MagicMock()
return mock_sess_inst
def get_mock_telepathic():
mock_telepathic = MagicMock()
mock_telepathic.find_best_node.return_value = {
"x": 250,
"y": 50,
"bounds": "[200,10][300,100]",
"skip": False,
"score": 1.0,
"original_attribs": {"text": "testuser", "desc": "A test post"},
}
mock_telepathic.classify_screen_content.return_value = "normal"
mock_telepathic._extract_semantic_nodes.return_value = [
{"x": 250, "y": 50, "resource_id": "reel_ring", "clickable": True},
{"x": 50, "y": 50, "resource_id": "com.instagram.android:id/feed_post_author", "clickable": True},
{"x": 150, "y": 550, "resource_id": "row_feed_button_like", "clickable": True},
]
return mock_telepathic
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.GrowthBrain")
@patch("GramAddict.core.sensors.honeypot_radome.HoneypotRadome.sanitize_xml", side_effect=lambda x: x)
def test_e2e_story_viewing(
mock_sanitize,
mock_growth,
mock_create_device,
mock_dopamine,
mock_sess,
mock_close,
mock_open,
e2e_configs,
monkeypatch,
):
"""Verifies that StoryViewPlugin correctly identifies and views stories."""
device = MagicMock(spec=DeviceFacade)
setup_common_mocks(mock_sess, mock_dopamine, mock_create_device, device)
mock_growth_inst = mock_growth.return_value
mock_growth_inst.get_circadian_pacing.return_value = 1.0
mock_growth_inst.evaluate_governance.return_value = "STAY"
e2e_configs.args.stories_percentage = 100
e2e_configs.args.stories_count = "1-1"
# Mock story ring in XML + feed markers to satisfy ObstacleGuard
device.dump_hierarchy.return_value = '<hierarchy><node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]"><node resource-id="reel_ring" clickable="true" bounds="[200,10][300,100]" /><node resource-id="row_feed_button_like" clickable="true" bounds="[100,500][200,600]" /></node></hierarchy>'
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.shell.return_value = MagicMock(output="")
mock_telepathic = get_mock_telepathic()
# Mock ResonanceEngine
mock_resonance = MagicMock()
mock_resonance.return_value.calculate_resonance.return_value = 1.0
mock_resonance.return_value.find_best_node.return_value = {
"username": "testuser",
"node": {"x": 250, "y": 50},
"score": 1.0,
}
with patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", return_value=True):
with patch("GramAddict.core.q_nav_graph.QNavGraph.do", return_value=True) as mock_nav_do:
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic):
with patch("GramAddict.core.bot_flow.ResonanceEngine", new=mock_resonance):
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True):
with patch("GramAddict.core.bot_flow.wait_for_next_session", side_effect=KeyboardInterrupt):
with patch(
"GramAddict.core.llm_provider.query_llm",
return_value={"persona": "test", "vibe": "test"},
):
with patch("secrets.choice", return_value="StoriesFeed"):
with patch("random.random", return_value=0.0):
with pytest.raises(_CleanExitSentinel):
start_bot()
calls = [call[0][0] for call in mock_nav_do.call_args_list]
assert any("tap story ring" in c for c in calls)
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.GrowthBrain")
@patch("GramAddict.core.sensors.honeypot_radome.HoneypotRadome.sanitize_xml", side_effect=lambda x: x)
def test_e2e_commenting_and_reposting(
mock_sanitize,
mock_growth,
mock_create_device,
mock_dopamine,
mock_sess,
mock_close,
mock_open,
e2e_configs,
monkeypatch,
):
"""Verifies that CommentPlugin and RepostPlugin work together."""
device = MagicMock(spec=DeviceFacade)
setup_common_mocks(mock_sess, mock_dopamine, mock_create_device, device)
mock_growth_inst = mock_growth.return_value
mock_growth_inst.get_circadian_pacing.return_value = 1.0
mock_growth_inst.evaluate_governance.return_value = "STAY"
e2e_configs.args.comment_percentage = 100
e2e_configs.args.repost_percentage = 100
# Update config mock to support repost
original_get_config = e2e_configs.get_plugin_config.side_effect
def patched_get_config(plugin_name):
if plugin_name == "repost":
return {"percentage": 100}
return original_get_config(plugin_name)
e2e_configs.get_plugin_config.side_effect = patched_get_config
mock_writer = MagicMock()
mock_writer.generate_comment.return_value = "Nice post!"
mock_resonance = MagicMock()
mock_resonance.return_value.calculate_resonance.return_value = 1.0
mock_resonance.return_value.find_best_node.return_value = {
"username": "testuser",
"node": {"x": 50, "y": 50},
"score": 1.0,
}
# Patch BehaviorContext.cognitive_stack to ensure 'writer' is present
from GramAddict.core.behaviors import BehaviorContext
original_init = BehaviorContext.__init__
def patched_init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
self.cognitive_stack["writer"] = mock_writer
monkeypatch.setattr(BehaviorContext, "__init__", patched_init)
device.dump_hierarchy.return_value = '<hierarchy><node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]"><node resource-id="com.instagram.android:id/feed_post_author" text="testuser" clickable="true" bounds="[10,10][100,100]" /><node resource-id="row_feed_button_like" clickable="true" bounds="[100,500][200,600]" /></node></hierarchy>'
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.shell.return_value = MagicMock(output="")
mock_telepathic = get_mock_telepathic()
with patch("GramAddict.core.q_nav_graph.QNavGraph.do", return_value=True) as mock_nav_do:
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic):
with patch("GramAddict.core.bot_flow.ResonanceEngine", new=mock_resonance):
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True):
with patch("GramAddict.core.bot_flow.wait_for_next_session", side_effect=KeyboardInterrupt):
with patch(
"GramAddict.core.llm_provider.query_llm",
return_value={"persona": "test", "vibe": "test"},
):
with patch("secrets.choice", return_value="HomeFeed"):
with patch("random.random", return_value=0.0):
e2e_configs.args.profile_visit_percentage = 100
with pytest.raises(_CleanExitSentinel):
start_bot()
calls = [call[0][0] for call in mock_nav_do.call_args_list]
assert any("open comments" in c for c in calls)
assert any("type and post comment" in c for c in calls)
assert any("share to story" in c for c in calls)
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.GrowthBrain")
@patch("GramAddict.core.sensors.honeypot_radome.HoneypotRadome.sanitize_xml", side_effect=lambda x: x)
def test_e2e_rabbit_hole_activation(
mock_sanitize,
mock_growth,
mock_create_device,
mock_dopamine,
mock_sess,
mock_close,
mock_open,
e2e_configs,
monkeypatch,
):
"""Verifies that RabbitHolePlugin activates when a high-score user is found."""
device = MagicMock(spec=DeviceFacade)
setup_common_mocks(mock_sess, mock_dopamine, mock_create_device, device)
mock_growth_inst = mock_growth.return_value
mock_growth_inst.get_circadian_pacing.return_value = 1.0
mock_growth_inst.evaluate_governance.return_value = "STAY"
e2e_configs.args.rabbit_hole_percentage = 100
# Update config mock to support rabbit_hole
original_get_config = e2e_configs.get_plugin_config.side_effect
def patched_get_config(plugin_name):
if plugin_name == "rabbit_hole":
return {"percentage": 100}
return original_get_config(plugin_name)
e2e_configs.get_plugin_config.side_effect = patched_get_config
mock_resonance = MagicMock()
mock_resonance.return_value.calculate_resonance.return_value = 1.0
mock_resonance.return_value.find_best_node.return_value = {
"username": "high_score_user",
"node": {"x": 50, "y": 50},
"score": 0.95,
}
device.dump_hierarchy.return_value = '<hierarchy><node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]"><node resource-id="com.instagram.android:id/feed_post_author" text="testuser" clickable="true" bounds="[10,10][100,100]" /><node resource-id="row_feed_button_like" clickable="true" bounds="[100,500][200,600]" /></node></hierarchy>'
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.shell.return_value = MagicMock(output="")
mock_telepathic = get_mock_telepathic()
with patch("GramAddict.core.q_nav_graph.QNavGraph.do", return_value=True) as mock_nav_do:
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic):
with patch("GramAddict.core.bot_flow.ResonanceEngine", new=mock_resonance):
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True):
with patch("GramAddict.core.bot_flow.wait_for_next_session", side_effect=KeyboardInterrupt):
with patch(
"GramAddict.core.llm_provider.query_llm",
return_value={"persona": "test", "vibe": "test"},
):
with patch("secrets.choice", return_value="HomeFeed"):
with patch("random.random", return_value=0.0):
with pytest.raises(_CleanExitSentinel):
start_bot()
calls = [call[0][0] for call in mock_nav_do.call_args_list]
assert any("tap post username" in c for c in calls)

View File

@@ -1,160 +0,0 @@
"""
TDD RED PHASE — DM-Hijacking Navigation Escape Test
====================================================
Reproduces the exact failure from the 2026-04-17_12-51-29 session dump:
The bot navigated to a target profile (e.g. irwansbudiman / julia_semenchuk),
but instead of reaching ProfileGrid, the Telepathic Engine accidentally triggered
the "Message" button on the profile header. The bot entered a DM thread and was
SOFT-LOCKED: QNavGraph had no mechanism to:
1. DETECT that the current UI is a DM thread (not a profile)
2. REFUSE profile-intent queries when the screen is a DM thread
3. ESCAPE from a DM thread back to HomeFeed automatically
These three missing capabilities are the root cause. This test suite makes them
explicit and FAILS until the implementation is correct.
Root Cause Summary
------------------
``QNavGraph.detect_current_state()`` — DOES NOT EXIST
The graph always trusts its internal ``self.current_state`` string, even when
the real UI has drifted to a completely different screen.
``TelepathicEngine._structural_sanity_check()`` — MISSING DM GUARD
The structural filter has no "Forbidden Node" concept. When the intent is
"profile-seeking" (e.g. navigate to a user's grid), nodes belonging to DM-thread
UI structures (``direct_thread_header``, ``row_thread_composer_edittext``) are
NOT filtered out. The engine is therefore free to hallucinate a valid target
within the DM thread.
``QNavGraph._clear_anomaly_obstacles()`` — DM THREAD NOT TREATED AS OBSTACLE
The anomaly clearance logic knows about OS dialogs, survey sheets, and action
sheets — but a DM thread is treated as a valid UI state, so the bot never
attempts to back out of it.
Expected Behaviour After Green Phase
--------------------------------------
1. ``QNavGraph.detect_current_state(xml)`` returns ``"MessageThread"`` for DM XML.
2. ``QNavGraph.navigate_to("HomeFeed")`` when ``current_state == "MessageThread"``
automatically executes ``tap_back`` and returns ``True``.
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
# ──────────────────────────────────────────────
# Fixture Helpers
# ──────────────────────────────────────────────
FIXTURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
def _load_fixture(filename: str) -> str:
path = os.path.join(FIXTURES_DIR, filename)
if not os.path.exists(path):
pytest.fail(
f"MISSING FIXTURE: '{filename}' not found at {path}. "
"This file MUST exist for the DM-trap regression suite.",
pytrace=False,
)
with open(path, "r", encoding="utf-8") as f:
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
(e.g. "first image post in profile grid", "tap follow button on profile"),
TelepathicEngine MUST NOT return a node.
Currently there is no DM-forbidden-zone check in find_best_node() or
_structural_sanity_check(). The engine happily returns any clickable node
it finds — including the "View Profile" button inside the DM thread header,
which is what caused the hallucination in the live session.
"""
def _make_engine(self):
# 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):
"""
FAILS (RED): find_best_node() with a profile-grid intent against DM thread XML
currently returns a node (the DM "View Profile" button or the header avatar).
After the fix, it must return None or a blocked sentinel.
"""
engine = self._make_engine()
dm_xml = _load_fixture("dm_thread_dump.xml")
device = MagicMock()
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.app_id = "com.instagram.android"
device._get_current_app.return_value = "com.instagram.android"
profile_seeking_intents = [
"first image post in profile grid",
"tap follow button on profile",
"profile picture avatar story ring",
"tap grid first post",
]
for intent in profile_seeking_intents:
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
# BEFORE the keyword stage returns a node.
assert result is None or result.get("blocked_by_dm_thread"), (
f"STRUCTURAL BUG: TelepathicEngine returned a node for profile-intent "
f"'{intent}' while the UI is a DM thread.\n"
f"Returned: {result}\n"
f"The engine is hallucinating a profile target inside a DM conversation. "
f"This is the exact failure mode from the 2026-04-17 session dump. "
f"Add a DM-thread structural guard that returns {{'blocked_by_dm_thread': True}} "
f"when the XML contains 'direct_thread_header' or 'row_thread_composer_edittext' "
f"and the intent is profile-seeking."
)
def test_dm_intents_are_still_allowed_in_dm_thread_xml(self):
"""
Negative test: DM-related intents (e.g. sent from dm_engine.py) must still
work correctly inside a DM thread. The guard must be scoped to PROFILE intents only.
"""
engine = self._make_engine()
dm_xml = _load_fixture("dm_thread_dump.xml")
device = MagicMock()
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.app_id = "com.instagram.android"
device._get_current_app.return_value = "com.instagram.android"
# This intent is used by dm_engine.py to find the message composer
dm_intent = "find the message input text field"
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)
if result is not None:
assert not result.get("blocked_by_dm_thread"), (
f"DM intent '{dm_intent}' was incorrectly blocked inside a DM thread. "
f"The structural guard must only block PROFILE-seeking intents."
)

View File

@@ -1,119 +0,0 @@
import traceback
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.session_state import SessionState
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.behaviors.profile_visit.random.random", return_value=0.1)
@patch("GramAddict.core.behaviors.follow.random.random", return_value=0.1)
@patch("GramAddict.core.behaviors.like.random.random", return_value=0.1)
def test_full_e2e_plugin_profile_interaction(
mock_like_random,
mock_follow_random,
mock_visit_random,
mock_create_device,
mock_dopamine,
mock_sess,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
e2e_configs,
):
"""
Validates that the plugin architecture correctly chains ProfileGuard -> ProfileVisit -> Follow -> Like
during a feed iteration.
"""
device = MagicMock(spec=DeviceFacade)
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.shell.return_value = ""
mock_create_device.return_value = device
# Mock DopamineEngine
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, False, True]
mock_d_inst.boredom = 0.0
mock_d_inst.wants_to_doomscroll.return_value = False
mock_d_inst.get_current_desire.return_value = "DiscoverNewContent"
# Track the state transition when clicking on the username (it goes to the profile)
state_map = {
"tap post username": "user_profile_dump.xml",
}
dynamic_e2e_dump_injector(device, state_map, "organic_post.xml")
# Mock SessionState (Class methods)
mock_sess.inside_working_hours.side_effect = [(True, 0), (False, 3600)]
mock_sess.Limit = SessionState.Limit
# Mock SessionState (Instance)
mock_sess_inst = mock_sess.return_value
def check_limit_side_effect(limit_type=None, output=False):
if limit_type == SessionState.Limit.ALL:
return (False, False, False)
return False
mock_sess_inst.check_limit.side_effect = check_limit_side_effect
mock_sess_inst.totalFollowed = {}
mock_sess_inst.totalLikes = 0
mock_sess_inst.totalComments = 0
mock_sess_inst.startTime = MagicMock()
e2e_configs.args.feed = "1-1" # Only 1 iteration
e2e_configs.args.interact_percentage = 100
e2e_configs.args.likes_percentage = 100
e2e_configs.args.follow_percentage = 100
e2e_configs.args.profile_visit_percentage = 100
e2e_configs.args.comment_percentage = 0
e2e_configs.args.repost_percentage = 0
e2e_configs.args.working_hours = ["00:00-23:59"]
e2e_configs.args.time_delta_session = "0"
# Mock Engines
mock_telepathic = MagicMock()
mock_telepathic.find_best_node.return_value = {
"x": 500,
"y": 500,
"skip": False,
"score": 1.0,
"original_attribs": {"text": "testuser", "desc": "A test post"},
}
mock_telepathic._extract_semantic_nodes.return_value = [{"x": 500, "y": 500}]
mock_resonance = MagicMock()
mock_resonance.calculate_resonance.return_value = 1.0
mock_growth = MagicMock()
mock_growth.evaluate_governance.return_value = "STAY"
mock_growth.get_circadian_pacing.return_value = 1.0
mock_growth.get_current_desire.return_value = "DiscoverNewContent"
# Mock QNavGraph.do to simulate success
with patch("GramAddict.core.q_nav_graph.QNavGraph.do", return_value=True) as mock_nav_do:
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic):
with patch("GramAddict.core.bot_flow.ResonanceEngine", return_value=mock_resonance):
with patch("GramAddict.core.bot_flow.GrowthBrain", return_value=mock_growth):
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with (
patch("secrets.choice", return_value="HomeFeed"),
patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True),
):
try:
start_bot()
except Exception as e:
print(f"CRASH DETECTED: {e}")
traceback.print_exc()
# Check specific calls
calls = [call[0][0] for call in mock_nav_do.call_args_list]
print(f"NAV CALLS: {calls}")
assert "tap post username" in calls
assert "tap follow button" in calls
assert "tap like button" in calls

View File

@@ -1,59 +0,0 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
def test_full_e2e_reels_feed_sequence(
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
):
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, False, True]
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.side_effect = [(True, 0), Exception("Clean Exit for Reels")]
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
reels = "10"
feed = None
explore = None
stories = None
interact_percentage = 0
likes_percentage = 0
follow_percentage = 0
comment_percentage = 0
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
configs.get_plugin_config.return_value = {}
dynamic_e2e_dump_injector(device, {"tap_reels_tab": "reels_feed_dump.xml"}, "home_feed_with_ad.xml")
try:
with patch("secrets.choice", return_value="ReelsFeed"):
start_bot(configs=configs)
except Exception as e:
if str(e) != "Clean Exit for Reels":
raise e
mock_open.assert_called()

View File

@@ -1,85 +0,0 @@
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
class _CleanExitSentinel(Exception):
"""Sentinel exception for controlled test termination."""
pass
@pytest.mark.xfail(
reason="Pre-existing: Mock XML lacks profile-visit triggers for ProfileVisitPlugin to fire _interact_with_profile. "
"Requires dedicated scraping XML fixtures with profile-visit markers.",
strict=False,
)
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.ResonanceEngine")
@patch("GramAddict.core.bot_flow._interact_with_profile")
def test_full_e2e_scraping_sequence(
mock_interact,
mock_resonance,
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
e2e_configs,
):
device = MagicMock(spec=DeviceFacade)
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.shell.return_value = "" # Prevent SendEventInjector detection disruption
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.wants_to_change_feed.return_value = False
mock_d_inst.wants_to_doomscroll.return_value = False
type(mock_d_inst).boredom = PropertyMock(return_value=0.0)
mock_d_inst.is_app_session_over.side_effect = [False] * 8 + [True] * 50
mock_res_inst = mock_resonance.return_value
mock_res_inst.calculate_resonance.return_value = 100.0
mock_sess.inside_working_hours.side_effect = [(True, 0), _CleanExitSentinel("Test complete")]
e2e_configs.args.scrape_profiles = True
e2e_configs.args.interact_percentage = 100
e2e_configs.args.feed = "1"
dynamic_e2e_dump_injector(device, {"tap_profile_tab": "scraping_profile_dump.xml"}, "carousel_post_dump.xml")
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
with patch("GramAddict.core.bot_flow.QNavGraph.navigate_to", return_value=True):
with patch("GramAddict.core.bot_flow.QNavGraph.do", return_value=True):
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get_telepathic:
mock_engine = MagicMock()
mock_engine.find_best_node.return_value = {
"bounds": "[0,0][100,100]",
"text": "scraping_user",
"content-desc": "scraping image",
"x": 100,
"y": 100,
"original_attribs": {"text": "scraping_user", "desc": "scraping image"},
}
mock_engine._extract_semantic_nodes.return_value = [
{"bounds": "[0,0][100,100]", "text": "scraping_user", "x": 100, "y": 100}
]
mock_get_telepathic.return_value = mock_engine
with patch("secrets.choice", return_value="HomeFeed"):
with pytest.raises(_CleanExitSentinel):
start_bot()
mock_interact.assert_called()

View File

@@ -1,63 +0,0 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
def test_full_e2e_search_sequence(
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
):
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, True]
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.side_effect = [(True, 0), Exception("Clean Exit for Search")]
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
search = "coding"
feed = None
reels = None
explore = None
stories = None
working_hours = "00:00-23:59"
time_delta_session = "0"
interact_percentage = 0
likes_percentage = 0
follow_percentage = 0
comment_percentage = 0
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
configs.get_plugin_config.return_value = {}
dynamic_e2e_dump_injector(device, {"tap_explore_tab": "explore_feed_dump.xml"}, "home_feed_with_ad.xml")
try:
with patch("secrets.choice", return_value="SearchFeed"):
start_bot(configs=configs)
except Exception as e:
assert "Clean Exit" in str(e)
mock_open.assert_called()

View File

@@ -1,85 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
class _CleanExitSentinel(Exception):
"""Sentinel exception for controlled test termination."""
pass
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
@patch("GramAddict.core.bot_flow.GrowthBrain")
def test_full_start_bot_e2e_working_hours_limits(
mock_brain,
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
):
"""
Test start_bot full loop with working hours limits.
Verifies that the bot correctly sleeps when outside working hours
and exits the loop when session limits are reached.
"""
device = MagicMock(spec=DeviceFacade)
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
mock_create_device.return_value = device
# Setup mock dopamine
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False] * 15 + [True] * 50
mock_d_inst.boredom = 0.0
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
feed = "5-8"
explore = None
reels = None
stories = None
total_unfollows_limit = 0
working_hours = ["10.00-11.00", "15.00-16.00"]
time_delta_session = 10
interact_percentage = 100
likes_percentage = 100
follow_percentage = 100
comment_percentage = 100
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
def get_plugin_config_mock(plugin_name):
return {}
configs.get_plugin_config.side_effect = get_plugin_config_mock
# On iteration 1: valid working hours
# On iteration 2: Exception to jump out of loop
mock_sess.inside_working_hours.side_effect = [(True, 0), _CleanExitSentinel("Test complete")]
dynamic_e2e_dump_injector(device, {}, "home_feed_with_ad.xml")
with pytest.raises(_CleanExitSentinel):
start_bot(configs=configs)
# Verify key interactions
mock_sess.inside_working_hours.assert_called()
mock_open.assert_called()

View File

@@ -1,60 +0,0 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
def test_full_e2e_stories_feed_sequence(
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
):
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, False, True]
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.side_effect = [(True, 0), Exception("Clean Exit for Stories")]
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
stories = "5-8"
feed = None
reels = None
explore = None
interact_percentage = 0
likes_percentage = 0
follow_percentage = 0
comment_percentage = 0
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
configs.get_plugin_config.return_value = {}
# The agent taps 'tap story ring avatar' to open stories.
# The injector tracks clicks, so it needs to transition to the story dump when the avatar is clicked.
dynamic_e2e_dump_injector(device, {"tap story ring avatar": "stories_feed_dump.xml"}, "home_feed_with_ad.xml")
try:
with patch("secrets.choice", return_value="StoriesFeed"):
start_bot(configs=configs)
except Exception as e:
assert str(e) == "Clean Exit for Stories"
mock_open.assert_called()

View File

@@ -1,63 +0,0 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow.random_sleep")
@patch("GramAddict.core.bot_flow.create_device")
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
def test_full_e2e_unfollow_sequence(
mock_dopamine,
mock_sess,
mock_create_device,
mock_rsleep,
mock_sleep,
mock_close,
mock_open,
dynamic_e2e_dump_injector,
):
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, True]
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.side_effect = [(True, 0), Exception("Clean Exit for Unfollow")]
class ConfigArgs:
username = "testuser"
device = "emulator-5554"
app_id = "com.instagram.android"
debug = True
total_unfollows_limit = 10
feed = None
reels = None
explore = None
stories = None
interact_percentage = 0
likes_percentage = 0
follow_percentage = 0
comment_percentage = 0
configs = MagicMock()
configs.username = "testuser"
configs.args = ConfigArgs()
configs.get_plugin_config.return_value = {}
dynamic_e2e_dump_injector(
device,
{"tap_profile_tab": "scraping_profile_dump.xml", "tap_following_list": "unfollow_list_dump.xml"},
"home_feed_with_ad.xml",
)
try:
with patch("secrets.choice", return_value="FollowingList"):
start_bot(configs=configs)
except Exception as e:
assert str(e) == "Clean Exit for Unfollow"
mock_open.assert_called()

View File

@@ -16,34 +16,8 @@ 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)

View File

@@ -21,73 +21,6 @@ from GramAddict.core.situational_awareness import (
# ─────────────────────────────────────────────────────
@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"),
):
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)
):
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_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' ?>
<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]">
@@ -339,88 +272,98 @@ class TestSAERealFixturePerception:
# ─────────────────────────────────────────────────────
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."""
"""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 triggers app_start → Instagram returns."""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
GOOGLE_SEARCH_XML, # perceive
INSTAGRAM_HOME_XML, # verify after escape
]
"""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)
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=3)
result = sae.ensure_clear_screen(max_attempts=7)
assert result is True
device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
def test_recovers_from_locked_screen(self):
"""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
]
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)
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=3)
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.unlock.assert_called_once()
device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
def test_recovers_from_survey_back_first_then_click(self):
"""Instagram survey → SAE tries BACK first → if BACK fails → clicks 'Not Now'."""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
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!)
]
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)
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=5)
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# First action was BACK, second was click
device.press.assert_called_with("back")
device.click.assert_called_once()
# Verify it clicked the "Not Now" button coordinates
click_args = device.click.call_args
assert click_args[0] == (320, 1850)
def test_recovers_from_survey_via_back(self):
"""Instagram survey → BACK works immediately."""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
INSTAGRAM_SURVEY_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"):
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):
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'
]
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)
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=5)
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
device.click.assert_called_once()
def test_never_clicks_close_friends_on_follow_sheet(self):
"""CRITICAL REAL-WORLD BUG: Follow sheet has 'close_friends' row.
@@ -435,43 +378,35 @@ class TestSAEAutonomousRecovery:
</node>
</node>
</hierarchy>"""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
follow_sheet_xml, # perceive: modal
INSTAGRAM_HOME_XML, # verify after BACK (worked!)
]
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)
with patch.object(sae.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=5)
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# CRITICAL: Must use BACK, never click any follow sheet button
device.press.assert_called_with("back")
device.click.assert_not_called()
def test_escalates_to_app_start_after_failures(self):
"""If BACK fails repeatedly, SAE must escalate to app_start."""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
GOOGLE_SEARCH_XML, # attempt 1: perceive
GOOGLE_SEARCH_XML, # attempt 1: verify (BACK failed)
GOOGLE_SEARCH_XML, # attempt 2: perceive
GOOGLE_SEARCH_XML, # attempt 2: verify (BACK failed)
GOOGLE_SEARCH_XML, # attempt 3: perceive
GOOGLE_SEARCH_XML, # attempt 3: verify (BACK failed)
GOOGLE_SEARCH_XML, # attempt 4: perceive
GOOGLE_SEARCH_XML, # attempt 4: verify (LLM failed)
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!)
]
"""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)
# 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.episodes, "recall", return_value=None), patch.object(sae.episodes, "learn"):
result = sae.ensure_clear_screen(max_attempts=7)
result = sae.ensure_clear_screen(max_attempts=7)
assert result is True
device.app_start.assert_called()

View File

@@ -136,6 +136,12 @@ class AndroidEnvironmentSimulator(DeviceFacade):
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."""
@@ -146,13 +152,16 @@ def setup_qdrant_isolation():
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
# We aggressively wipe ALL test collections before running the test!
from qdrant_client import QdrantClient
qb = NavigationMemoryDB()
try:
qb.wipe_collection()
except:
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

View File

@@ -41,31 +41,21 @@ def mock_context():
class TestAdGuardPlugin:
def test_can_activate(self, ad_guard, mock_context):
@patch("GramAddict.core.behaviors.ad_guard.is_ad")
def test_can_activate(self, mock_is_ad, ad_guard, mock_context):
mock_context.context_xml = "<xml>dummy</xml>"
mock_is_ad.return_value = True
assert ad_guard.can_activate(mock_context) is True
mock_is_ad.return_value = False
assert ad_guard.can_activate(mock_context) is False
ad_guard._enabled = False
assert ad_guard.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.ad_guard.is_ad")
@patch("GramAddict.core.behaviors.ad_guard.sleep")
@patch("GramAddict.core.behaviors.ad_guard.humanized_scroll")
def test_execute_no_ad(self, mock_scroll, mock_sleep, mock_is_ad, ad_guard, mock_context):
mock_is_ad.return_value = False
ad_guard.consecutive_ads = 1
result = ad_guard.execute(mock_context)
assert result.executed is False
assert ad_guard.consecutive_ads == 0 # Resets on non-ad
mock_scroll.assert_not_called()
@patch("GramAddict.core.behaviors.ad_guard.is_ad")
@patch("GramAddict.core.behaviors.ad_guard.sleep")
@patch("GramAddict.core.behaviors.ad_guard.humanized_scroll")
def test_execute_single_ad(self, mock_scroll, mock_sleep, mock_is_ad, ad_guard, mock_context):
mock_is_ad.return_value = True
def test_execute_single_ad(self, mock_scroll, mock_sleep, ad_guard, mock_context):
result = ad_guard.execute(mock_context)
assert result.executed is True
@@ -73,11 +63,9 @@ class TestAdGuardPlugin:
mock_scroll.assert_called_once_with(mock_context.device, is_skip=True)
mock_sleep.assert_called_once()
@patch("GramAddict.core.behaviors.ad_guard.is_ad")
@patch("GramAddict.core.behaviors.ad_guard.sleep")
@patch("GramAddict.core.behaviors.ad_guard.humanized_scroll")
def test_execute_triple_ad(self, mock_scroll, mock_sleep, mock_is_ad, ad_guard, mock_context):
mock_is_ad.return_value = True
def test_execute_triple_ad(self, mock_scroll, mock_sleep, ad_guard, mock_context):
ad_guard.consecutive_ads = 2
result = ad_guard.execute(mock_context)
@@ -88,11 +76,9 @@ class TestAdGuardPlugin:
assert mock_scroll.call_count == 2
mock_sleep.assert_called()
@patch("GramAddict.core.behaviors.ad_guard.is_ad")
@patch("GramAddict.core.behaviors.ad_guard.sleep")
@patch("GramAddict.core.behaviors.ad_guard.humanized_scroll")
def test_execute_deadlock_ad(self, mock_scroll, mock_sleep, mock_is_ad, ad_guard, mock_context):
mock_is_ad.return_value = True
def test_execute_deadlock_ad(self, mock_scroll, mock_sleep, ad_guard, mock_context):
ad_guard.consecutive_ads = 5
result = ad_guard.execute(mock_context)

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
@@ -13,10 +12,11 @@ def carousel_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.carousel_percentage = 100
ctx.configs.args.carousel_count = "2-2"
ctx.configs.get_plugin_config.return_value = {"percentage": 100, "count": "2-2"}
ctx.context_xml = '<xml><node content-desc="carousel_indicator"/></xml>'
ctx.device = MagicMock()
ctx.device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
@@ -30,7 +30,7 @@ class TestCarouselBrowsingPlugin:
assert carousel_plugin.can_activate(mock_context) is True
def test_can_activate_disabled_via_config(self, carousel_plugin, mock_context):
mock_context.configs.args.carousel_percentage = 0
mock_context.configs.get_plugin_config.return_value = {"percentage": 0, "count": "2-2"}
assert carousel_plugin.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.carousel_browsing.has_carousel_in_view", return_value=False)
@@ -53,10 +53,3 @@ class TestCarouselBrowsingPlugin:
mock_swipe.assert_any_call(
mock_context.device, start_x=1080 * 0.8, end_x=1080 * 0.2, y=2400 * 0.5, duration_ms=250
)
@patch("GramAddict.core.behaviors.carousel_browsing.random.random", return_value=0.9) # 0.9 > 0.0 (0%)
def test_execute_skip_due_to_chance(self, mock_random, carousel_plugin, mock_context):
mock_context.configs.args.carousel_percentage = 0
result = carousel_plugin.execute(mock_context)
assert result.executed is False

View File

@@ -24,21 +24,15 @@ def mock_context():
class TestCloseFriendsGuardPlugin:
def test_can_activate(self, cf_guard, mock_context):
mock_context.context_xml = "<xml>enge freunde</xml>"
assert cf_guard.can_activate(mock_context) is True
mock_context.context_xml = "<xml>regular post</xml>"
assert cf_guard.can_activate(mock_context) is False
cf_guard._enabled = False
assert cf_guard.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.close_friends_guard.sleep")
@patch("GramAddict.core.behaviors.close_friends_guard.humanized_scroll")
def test_execute_no_badge(self, mock_scroll, mock_sleep, cf_guard, mock_context):
mock_context.device.dump_hierarchy.return_value = "<xml>regular post</xml>"
result = cf_guard.execute(mock_context)
assert result.executed is False
mock_scroll.assert_not_called()
@patch("GramAddict.core.behaviors.close_friends_guard.sleep")
@patch("GramAddict.core.behaviors.close_friends_guard.humanized_scroll")
def test_execute_has_badge(self, mock_scroll, mock_sleep, cf_guard, mock_context):

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.comment import CommentPlugin
@@ -13,90 +12,55 @@ def comment_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.interact_percentage = 100
ctx.configs.get_plugin_config.return_value = {"percentage": 50}
ctx.configs.args.dry_run_comments = False
ctx.shared_state = {"res_score": 1.0}
ctx.context_xml = "<xml></xml>"
ctx.device = MagicMock()
ctx.post_data = {"description": "test desc"}
ctx.session_state = MagicMock()
ctx.session_state.check_limit.return_value = False
# Mock cognitive stack
writer = MagicMock()
writer.generate_comment.return_value = "Great post!"
ctx.cognitive_stack = {"writer": writer}
mock_nav = MagicMock()
mock_nav.do.return_value = True
ctx.cognitive_stack = {"writer": writer, "nav_graph": mock_nav}
return ctx
class TestCommentPlugin:
def test_can_activate_enabled(self, comment_plugin, mock_context):
@patch("GramAddict.core.behaviors.comment.random.random", return_value=0.1)
def test_can_activate_enabled(self, mock_random, comment_plugin, mock_context):
assert comment_plugin.can_activate(mock_context) is True
def test_can_activate_disabled_via_config(self, comment_plugin, mock_context):
mock_context.configs.args.interact_percentage = 0
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
assert comment_plugin.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.comment.random.random", return_value=0.1) # 0.1 < (1.0 * 0.5)
@patch("GramAddict.core.behaviors.comment.TelepathicEngine")
@patch("GramAddict.core.behaviors.comment.sleep")
def test_execute_success(self, mock_sleep, mock_telepathic, mock_random, comment_plugin, mock_context):
mock_tele = MagicMock()
def mock_find_best_node(xml, intent_description, **kwargs):
if intent_description == "Comment button":
return {"x": 50, "y": 60}
elif intent_description == "Post comment button":
return {"x": 200, "y": 300}
return None
mock_tele.find_best_node.side_effect = mock_find_best_node
mock_telepathic.get_instance.return_value = mock_tele
def test_execute_success(self, comment_plugin, mock_context):
result = comment_plugin.execute(mock_context)
assert result.executed is True
assert result.interactions == 1
# Check that it clicked the comment button
mock_context.device.click.assert_any_call(50, 60)
# Check that it typed the text
mock_context.device.type_text.assert_called_once_with("Great post!")
# Check that it submitted the comment
mock_context.device.click.assert_any_call(200, 300)
# Check that it backed out
mock_context.device.press.assert_called_once_with("back")
mock_context.cognitive_stack["nav_graph"].do.assert_any_call("open comments")
mock_context.cognitive_stack["nav_graph"].do.assert_any_call("type and post comment", text="Great post!")
@patch("GramAddict.core.behaviors.comment.random.random", return_value=0.1)
@patch("GramAddict.core.behaviors.comment.TelepathicEngine")
@patch("GramAddict.core.behaviors.comment.sleep")
def test_execute_fails_no_submit_button(
self, mock_sleep, mock_telepathic, mock_random, comment_plugin, mock_context
):
mock_tele = MagicMock()
def test_execute_fails_type_and_post(self, comment_plugin, mock_context):
def mock_do(intent, **kwargs):
if intent == "type and post comment":
return False
return True
def mock_find_best_node(xml, intent_description, **kwargs):
if intent_description == "Comment button":
return {"x": 50, "y": 60}
return None
mock_tele.find_best_node.side_effect = mock_find_best_node
mock_telepathic.get_instance.return_value = mock_tele
result = comment_plugin.execute(mock_context)
# Did not find submit, so didn't execute fully, returning executed=False
assert result.executed is False
mock_context.device.click.assert_called_once_with(50, 60)
mock_context.device.press.assert_called_once_with("back")
@patch("GramAddict.core.behaviors.comment.random.random", return_value=0.9) # 0.9 > (1.0 * 0.5)
@patch("GramAddict.core.behaviors.comment.TelepathicEngine")
def test_execute_skip_due_to_chance(self, mock_telepathic, mock_random, comment_plugin, mock_context):
mock_tele = MagicMock()
mock_tele.find_best_node.return_value = {"x": 50, "y": 60}
mock_telepathic.get_instance.return_value = mock_tele
mock_context.cognitive_stack["nav_graph"].do.side_effect = mock_do
result = comment_plugin.execute(mock_context)
assert result.executed is False
mock_context.device.click.assert_not_called()
mock_context.cognitive_stack["nav_graph"].do.assert_any_call("open comments")

View File

@@ -2,9 +2,7 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.follow import FollowPlugin
from GramAddict.core.session_state import SessionState
@pytest.fixture
@@ -14,37 +12,38 @@ def follow_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.follow_percentage = 100
ctx.configs.get_plugin_config.return_value = {"percentage": 100}
ctx.session_state = MagicMock()
ctx.session_state.check_limit.return_value = False
ctx.session_state.totalFollowed = {}
ctx.device = MagicMock()
ctx.username = "test_user"
ctx.sleep_mod = 1.0
ctx.cognitive_stack = {}
return ctx
class TestFollowPlugin:
def test_can_activate_enabled(self, follow_plugin, mock_context):
@patch("random.random", return_value=0.1)
def test_can_activate_enabled(self, mock_random, follow_plugin, mock_context):
assert follow_plugin.can_activate(mock_context) is True
mock_context.session_state.check_limit.assert_called_once_with(SessionState.Limit.FOLLOWS)
mock_context.session_state.check_limit.assert_called_once()
def test_can_activate_disabled_via_config(self, follow_plugin, mock_context):
mock_context.configs.args.follow_percentage = 0
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
assert follow_plugin.can_activate(mock_context) is False
def test_can_activate_limit_reached(self, follow_plugin, mock_context):
mock_context.session_state.check_limit.return_value = True
assert follow_plugin.can_activate(mock_context) is False
@patch("random.random", return_value=0.1) # 0.1 < 1.0
@patch("GramAddict.core.q_nav_graph.QNavGraph")
@patch("GramAddict.core.behaviors.follow.sleep")
def test_execute_success(self, mock_sleep, mock_qnavgraph, mock_random, follow_plugin, mock_context):
def test_execute_success(self, mock_sleep, mock_qnavgraph, follow_plugin, mock_context):
mock_nav = MagicMock()
mock_nav.do.return_value = True
mock_qnavgraph.return_value = mock_nav
@@ -53,13 +52,11 @@ class TestFollowPlugin:
assert result.executed is True
assert result.interactions == 1
assert mock_context.session_state.totalFollowed["test_user"] == 1
mock_nav.do.assert_called_once_with("tap follow button")
@patch("random.random", return_value=0.1)
@patch("GramAddict.core.q_nav_graph.QNavGraph")
def test_execute_nav_failed(self, mock_qnavgraph, mock_random, follow_plugin, mock_context):
def test_execute_nav_failed(self, mock_qnavgraph, follow_plugin, mock_context):
mock_nav = MagicMock()
mock_nav.do.return_value = False
mock_qnavgraph.return_value = mock_nav
@@ -68,10 +65,3 @@ class TestFollowPlugin:
assert result.executed is False
assert result.metadata.get("reason") == "nav_failed"
@patch("random.random", return_value=0.9) # 0.9 > 0.0
def test_execute_skip_due_to_chance(self, mock_random, follow_plugin, mock_context):
mock_context.configs.args.follow_percentage = 0
result = follow_plugin.execute(mock_context)
assert result.executed is False

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.grid_like import GridLikePlugin
@@ -13,16 +12,15 @@ def grid_like_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.likes_percentage = 100
ctx.configs.args.likes_count = "2-2"
ctx.configs.get_plugin_config.return_value = {"percentage": 100, "count": "2-2"}
ctx.session_state = MagicMock()
ctx.session_state.check_limit.return_value = False
ctx.session_state.totalLikes = 0
ctx.context_xml = '<xml><node content-desc="profile_header"/></xml>'
ctx.context_xml = '<xml><node content-desc="profile_header" text="followers"/></xml>'
ctx.device = MagicMock()
ctx.device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
ctx.device.dump_hierarchy.return_value = "<xml></xml>"
@@ -40,7 +38,7 @@ class TestGridLikePlugin:
assert grid_like_plugin.can_activate(mock_context) is True
def test_can_activate_disabled_via_config(self, grid_like_plugin, mock_context):
mock_context.configs.args.likes_percentage = 0
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
assert grid_like_plugin.can_activate(mock_context) is False
def test_can_activate_limit_reached(self, grid_like_plugin, mock_context):

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.like import LikePlugin
@@ -13,79 +12,44 @@ def like_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.likes_count = "1-2"
ctx.configs.get_plugin_config.return_value = {"percentage": 100}
ctx.shared_state = {"res_score": 1.0}
ctx.context_xml = "<xml></xml>"
ctx.device = MagicMock()
ctx.session_state = MagicMock()
ctx.session_state.check_limit.return_value = False
ctx.session_state.totalLikes = 0
mock_nav = MagicMock()
mock_nav.do.return_value = True
ctx.cognitive_stack = {"nav_graph": mock_nav}
return ctx
class TestLikePlugin:
def test_can_activate_enabled(self, like_plugin, mock_context):
@patch("GramAddict.core.behaviors.like.random.random", return_value=0.1)
def test_can_activate_enabled(self, mock_random, like_plugin, mock_context):
assert like_plugin.can_activate(mock_context) is True
def test_can_activate_disabled_via_config(self, like_plugin, mock_context):
mock_context.configs.args.likes_count = "0"
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
assert like_plugin.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.like.TelepathicEngine")
def test_execute_already_liked(self, mock_telepathic, like_plugin, mock_context):
mock_tele = MagicMock()
# Find unlike button returns a node
mock_tele.find_best_node.side_effect = (
lambda xml, intent_description, **kwargs: {"x": 10, "y": 20}
if intent_description == "Unlike button"
else None
)
mock_telepathic.get_instance.return_value = mock_tele
def test_execute_already_liked(self, like_plugin, mock_context):
mock_nav = mock_context.cognitive_stack["nav_graph"]
mock_nav.do.return_value = False
result = like_plugin.execute(mock_context)
assert result.executed is False
@patch("GramAddict.core.behaviors.like.random.random", return_value=0.5)
@patch("GramAddict.core.behaviors.like.TelepathicEngine")
@patch("GramAddict.core.behaviors.like.sleep")
def test_execute_success(self, mock_sleep, mock_telepathic, mock_random, like_plugin, mock_context):
mock_tele = MagicMock()
# No unlike button, but finds like button
def mock_find_best_node(xml, intent_description, **kwargs):
if intent_description == "Like button":
return {"x": 100, "y": 200}
return None
mock_tele.find_best_node.side_effect = mock_find_best_node
mock_telepathic.get_instance.return_value = mock_tele
# res_score = 1.0 > 0.5
mock_context.shared_state["res_score"] = 1.0
def test_execute_success(self, like_plugin, mock_context):
result = like_plugin.execute(mock_context)
assert result.executed is True
assert result.interactions == 1
mock_context.device.click.assert_called_once_with(100, 200)
@patch("GramAddict.core.behaviors.like.random.random", return_value=0.9)
@patch("GramAddict.core.behaviors.like.TelepathicEngine")
def test_execute_skip_due_to_chance(self, mock_telepathic, mock_random, like_plugin, mock_context):
mock_tele = MagicMock()
def mock_find_best_node(xml, intent_description, **kwargs):
if intent_description == "Like button":
return {"x": 100, "y": 200}
return None
mock_tele.find_best_node.side_effect = mock_find_best_node
mock_telepathic.get_instance.return_value = mock_tele
# res_score = 0.5 < 0.9
mock_context.shared_state["res_score"] = 0.5
result = like_plugin.execute(mock_context)
assert result.executed is False
mock_context.device.click.assert_not_called()
mock_context.cognitive_stack["nav_graph"].do.assert_called_once_with("tap like button")

View File

@@ -39,19 +39,9 @@ class TestPostDataExtractionPlugin:
assert mock_context.username == "test_user"
@patch("GramAddict.core.behaviors.post_data_extraction.extract_post_content")
@patch("GramAddict.core.behaviors.post_data_extraction.humanized_scroll")
@patch("GramAddict.core.behaviors.post_data_extraction.sleep")
@patch("GramAddict.core.behaviors.post_data_extraction.dump_ui_state")
def test_execute_failure(
self, mock_dump, mock_sleep, mock_scroll, mock_extract, post_data_extraction, mock_context
):
mock_extract.return_value = {"username": "", "description": ""}
def test_execute_failure(self, mock_extract, post_data_extraction, mock_context):
mock_extract.return_value = None
result = post_data_extraction.execute(mock_context)
assert result.executed is True
assert result.should_skip is True
mock_dump.assert_called_once_with(mock_context.device, "content_extraction_failed", {"feed": "Feed"})
mock_scroll.assert_called_once_with(mock_context.device)
mock_sleep.assert_called_once()
assert result.executed is False

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.post_interaction import PostInteractionPlugin
@@ -13,7 +12,7 @@ def post_interaction_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.shared_state = {"session_outcomes": ["like", "comment"]}
ctx.device = MagicMock()
ctx.post_data = {"id": "123"}

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
@@ -13,7 +12,7 @@ def profile_guard_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.ignore_close_friends = True
ctx.configs.args.visual_vibe_check_percentage = 0

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin
@@ -13,56 +12,45 @@ def profile_visit_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.interact_percentage = 100
ctx.configs.args.profile_visit_percentage = 30
ctx.configs.get_plugin_config.return_value = {"percentage": 30}
ctx.shared_state = {"res_score": 1.0}
ctx.context_xml = "<xml></xml>"
ctx.device = MagicMock()
ctx.username = "test_user"
mock_nav = MagicMock()
mock_nav.current_state = "HomeFeed"
ctx.cognitive_stack = {"nav_graph": mock_nav}
return ctx
class TestProfileVisitPlugin:
def test_can_activate_enabled(self, profile_visit_plugin, mock_context):
@patch("GramAddict.core.behaviors.profile_visit.random.random", return_value=0.1) # 0.1 < 0.3
def test_can_activate_enabled(self, mock_random, profile_visit_plugin, mock_context):
assert profile_visit_plugin.can_activate(mock_context) is True
def test_can_activate_disabled_via_config(self, profile_visit_plugin, mock_context):
mock_context.configs.args.interact_percentage = 0
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
assert profile_visit_plugin.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.profile_visit.random.random", return_value=0.1) # 0.1 < (1.0 * 0.3)
@patch("GramAddict.core.behaviors.profile_visit.TelepathicEngine")
@patch("GramAddict.core.behaviors.profile_visit.sleep")
def test_execute_success(self, mock_sleep, mock_telepathic, mock_random, profile_visit_plugin, mock_context):
mock_tele = MagicMock()
@patch("GramAddict.core.behaviors.PluginRegistry")
def test_execute_success(self, mock_registry, mock_sleep, profile_visit_plugin, mock_context):
mock_nav = mock_context.cognitive_stack["nav_graph"]
mock_nav.do.return_value = True
def mock_find_best_node(xml, intent_description, **kwargs):
if intent_description == "Post username":
return {"x": 50, "y": 60}
return None
mock_registry_instance = MagicMock()
mock_registry.get_instance.return_value = mock_registry_instance
mock_tele.find_best_node.side_effect = mock_find_best_node
mock_telepathic.get_instance.return_value = mock_tele
from GramAddict.core.behaviors import BehaviorResult
mock_registry_instance.execute_all.return_value = [BehaviorResult(executed=True)]
result = profile_visit_plugin.execute(mock_context)
assert result.executed is True
assert result.interactions == 1
# Check that it clicked the username
mock_context.device.click.assert_called_once_with(50, 60)
# Check that it backed out
mock_context.device.press.assert_called_once_with("back")
@patch("GramAddict.core.behaviors.profile_visit.random.random", return_value=0.9) # 0.9 > (1.0 * 0.3)
@patch("GramAddict.core.behaviors.profile_visit.TelepathicEngine")
def test_execute_skip_due_to_chance(self, mock_telepathic, mock_random, profile_visit_plugin, mock_context):
mock_tele = MagicMock()
mock_tele.find_best_node.return_value = {"x": 50, "y": 60}
mock_telepathic.get_instance.return_value = mock_tele
result = profile_visit_plugin.execute(mock_context)
assert result.executed is False
mock_context.device.click.assert_not_called()
mock_nav.do.assert_any_call("tap post username")
mock_context.device.press.assert_called_with("back")

View File

@@ -25,32 +25,30 @@ def mock_context():
class TestRabbitHolePlugin:
@patch("GramAddict.core.behaviors.rabbit_hole.humanized_scroll")
@patch("GramAddict.core.behaviors.rabbit_hole.sleep")
def test_execute_success(self, mock_sleep, mock_scroll, rabbit_hole, mock_context):
def test_execute_success(self, mock_sleep, rabbit_hole, mock_context):
mock_context.cognitive_stack["nav_graph"].do.return_value = True
with patch("GramAddict.core.behaviors.rabbit_hole.random.random", return_value=0.0):
result = rabbit_hole.execute(mock_context)
result = rabbit_hole.execute(mock_context)
assert result.executed is True
mock_context.cognitive_stack["nav_graph"].do.assert_called_once_with("tap post username")
mock_scroll.assert_called_once_with(mock_context.device, is_skip=True)
mock_context.device.press.assert_called_once_with("back")
assert mock_sleep.call_count == 3
assert mock_sleep.call_count == 2
def test_execute_low_resonance(self, rabbit_hole, mock_context):
def test_can_activate_low_resonance(self, rabbit_hole, mock_context):
mock_context.shared_state["res_score"] = 0.5
with patch("GramAddict.core.behaviors.rabbit_hole.random.random", return_value=0.0):
result = rabbit_hole.execute(mock_context)
assert rabbit_hole.can_activate(mock_context) is False
assert result.executed is False
def test_can_activate_success(self, rabbit_hole, mock_context):
mock_context.configs.get_plugin_config.return_value = {"percentage": 100}
with patch("GramAddict.core.behaviors.rabbit_hole.random.random", return_value=0.0):
assert rabbit_hole.can_activate(mock_context) is True
def test_execute_no_nav_graph(self, rabbit_hole, mock_context):
mock_context.cognitive_stack.pop("nav_graph")
with patch("GramAddict.core.behaviors.rabbit_hole.random.random", return_value=0.0):
result = rabbit_hole.execute(mock_context)
result = rabbit_hole.execute(mock_context)
assert result.executed is True # Did the random chance, but couldn't execute nav_graph
assert result.executed is False # Fails to execute if no nav_graph

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.repost import RepostPlugin
@@ -13,82 +12,39 @@ def repost_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.interact_percentage = 100
ctx.configs.get_plugin_config.return_value = {"percentage": 100}
ctx.shared_state = {"res_score": 1.0}
ctx.context_xml = "<xml></xml>"
ctx.device = MagicMock()
mock_nav = MagicMock()
mock_nav.do.return_value = True
ctx.cognitive_stack = {"nav_graph": mock_nav}
return ctx
class TestRepostPlugin:
def test_can_activate_enabled(self, repost_plugin, mock_context):
@patch("GramAddict.core.behaviors.repost.random.random", return_value=0.1)
def test_can_activate_enabled(self, mock_random, repost_plugin, mock_context):
assert repost_plugin.can_activate(mock_context) is True
def test_can_activate_disabled_via_config(self, repost_plugin, mock_context):
mock_context.configs.args.interact_percentage = 0
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
assert repost_plugin.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.repost.random.random", return_value=0.1) # 0.1 < (1.0 * 0.2)
@patch("GramAddict.core.behaviors.repost.TelepathicEngine")
@patch("GramAddict.core.behaviors.repost.sleep")
def test_execute_success(self, mock_sleep, mock_telepathic, mock_random, repost_plugin, mock_context):
mock_tele = MagicMock()
def mock_find_best_node(xml, intent_description, **kwargs):
if intent_description == "Share button":
return {"x": 50, "y": 60}
elif intent_description == "Add to story button":
return {"x": 100, "y": 100}
elif intent_description == "Share story button":
return {"x": 200, "y": 300}
return None
mock_tele.find_best_node.side_effect = mock_find_best_node
mock_telepathic.get_instance.return_value = mock_tele
def test_execute_success(self, repost_plugin, mock_context):
result = repost_plugin.execute(mock_context)
assert result.executed is True
assert result.interactions == 1
# Check that it clicked the share button
mock_context.device.click.assert_any_call(50, 60)
# Check that it clicked add to story
mock_context.device.click.assert_any_call(100, 100)
# Check that it clicked share story
mock_context.device.click.assert_any_call(200, 300)
mock_context.cognitive_stack["nav_graph"].do.assert_called_once_with("share to story")
@patch("GramAddict.core.behaviors.repost.random.random", return_value=0.1)
@patch("GramAddict.core.behaviors.repost.TelepathicEngine")
@patch("GramAddict.core.behaviors.repost.sleep")
def test_execute_fails_no_add_to_story(self, mock_sleep, mock_telepathic, mock_random, repost_plugin, mock_context):
mock_tele = MagicMock()
def mock_find_best_node(xml, intent_description, **kwargs):
if intent_description == "Share button":
return {"x": 50, "y": 60}
return None
mock_tele.find_best_node.side_effect = mock_find_best_node
mock_telepathic.get_instance.return_value = mock_tele
result = repost_plugin.execute(mock_context)
# Did not find add to story, so didn't execute fully
assert result.executed is False
mock_context.device.click.assert_called_once_with(50, 60)
mock_context.device.press.assert_called_once_with("back")
@patch("GramAddict.core.behaviors.repost.random.random", return_value=0.9) # 0.9 > (1.0 * 0.2)
@patch("GramAddict.core.behaviors.repost.TelepathicEngine")
def test_execute_skip_due_to_chance(self, mock_telepathic, mock_random, repost_plugin, mock_context):
mock_tele = MagicMock()
mock_tele.find_best_node.return_value = {"x": 50, "y": 60}
mock_telepathic.get_instance.return_value = mock_tele
def test_execute_fails_no_add_to_story(self, repost_plugin, mock_context):
mock_context.cognitive_stack["nav_graph"].do.return_value = False
result = repost_plugin.execute(mock_context)
assert result.executed is False
mock_context.device.click.assert_not_called()

View File

@@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.story_view import StoryViewPlugin
@@ -13,10 +12,9 @@ def story_view_plugin():
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx = MagicMock()
ctx.configs = MagicMock()
ctx.configs.args.stories_percentage = 100
ctx.configs.args.stories_count = "2-2"
ctx.configs.get_plugin_config.return_value = {"percentage": 100, "count": "2-2"}
ctx.context_xml = '<xml><node content-desc="reel_ring"/></xml>'
ctx.device = MagicMock()
@@ -24,6 +22,8 @@ def mock_context():
ctx.device.dump_hierarchy.return_value = '<xml><node content-desc="reel_ring"/></xml>'
ctx.username = "test_user"
ctx.sleep_mod = 1.0
ctx.cognitive_stack = {}
return ctx
@@ -32,7 +32,7 @@ class TestStoryViewPlugin:
assert story_view_plugin.can_activate(mock_context) is True
def test_can_activate_disabled_via_config(self, story_view_plugin, mock_context):
mock_context.configs.args.stories_percentage = 0
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
assert story_view_plugin.can_activate(mock_context) is False
@patch("random.random", return_value=0.1)

View File

@@ -12,54 +12,42 @@ def test_bot_flow_unlearns_on_context_loss():
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.
with (
patch("GramAddict.core.behaviors.PluginRegistry.get_instance") as MockRegistry,
patch("GramAddict.core.bot_flow.sleep"),
patch("GramAddict.core.physics.humanized_input.humanized_scroll"),
patch("GramAddict.core.bot_flow._humanized_scroll"),
):
from GramAddict.core.behaviors import BehaviorResult
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"
mock_registry_instance = MockRegistry.return_value
mock_registry_instance.execute_all.return_value = [
BehaviorResult(executed=True, should_skip=True, metadata={"return_code": "CONTEXT_LOST"})
]
# 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
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()
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
mock_cognitive_stack.get.side_effect = stack_get
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,
)
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>")
# Assert (RED)
assert result == "CONTEXT_LOST"

View File

@@ -8,10 +8,9 @@ from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
@patch("GramAddict.core.bot_flow._humanized_scroll")
@patch("GramAddict.core.bot_flow._extract_post_content")
@patch("GramAddict.core.bot_flow._align_active_post")
@patch("GramAddict.core.bot_flow.is_ad")
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
def test_plugin_skip_breaks_feed_loop(
mock_telepathic, mock_ad, mock_align, mock_extract, mock_scroll, mock_sleep, mock_registry_get_instance
mock_telepathic, mock_align, mock_extract, mock_scroll, mock_sleep, mock_registry_get_instance
):
# Setup mocks
device = MagicMock()
@@ -31,7 +30,6 @@ def test_plugin_skip_breaks_feed_loop(
# Dopamine should not abort the session on first run, but abort on second
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
mock_ad.return_value = False
mock_align.return_value = False
device.dump_hierarchy.return_value = "<xml>row_feed_photo_profile_name</xml>"