feat: complete modular plugin refactor with 100% E2E coverage for interactions

This commit is contained in:
2026-04-25 20:58:07 +02:00
parent 77e8251aa7
commit 144d6401b5
232 changed files with 66259 additions and 5410 deletions

View File

@@ -1,145 +1,159 @@
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import patch, MagicMock
import sys
# Force mock qdrant_client before importing any core modules that depend on it
from GramAddict.core.bot_flow import _extract_post_content, _run_zero_latency_feed_loop
class TestBotFlowEdgeCases:
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
def test_extract_post_content_edge_cases(self, mock_get_telepathic):
mock_engine = MagicMock()
mock_get_telepathic.return_value = mock_engine
# 1. Empty string / Invalid XML should not crash (mock finds nothing)
mock_engine.find_best_node.return_value = None
res = _extract_post_content("")
assert res.get("username") == ""
assert res.get("description") == ""
# 2. Extract when only username exists
# Side effect: first call (author) returns node, second (media) returns None
mock_engine.find_best_node.side_effect = [{"original_attribs": {"text": "just_user"}}, None]
res = _extract_post_content("<xml/>")
assert res.get("username") == "just_user"
assert res.get("description") == ""
# 3. Extract description
mock_engine.find_best_node.side_effect = [None, {"original_attribs": {"desc": "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥"}}]
res = _extract_post_content("<xml/>")
assert res.get("description") == "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥"
# 4. Another valid description tag
mock_engine.find_best_node.side_effect = [None, {"original_attribs": {"desc": "some desc with more than 10 chars limits"}}]
mock_engine.find_best_node.side_effect = [
None,
{"original_attribs": {"desc": "some desc with more than 10 chars limits"}},
]
res = _extract_post_content("<xml/>")
assert res.get("description") == "some desc with more than 10 chars limits"
@patch('GramAddict.core.bot_flow.random.random', return_value=0.5)
@patch('GramAddict.core.bot_flow.random.uniform', return_value=1.5)
@patch('GramAddict.core.bot_flow.sleep')
@patch('GramAddict.core.bot_flow._humanized_scroll')
@patch('GramAddict.core.bot_flow.dump_ui_state')
@patch('GramAddict.core.bot_flow.is_ad')
@patch('GramAddict.core.bot_flow._align_active_post')
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
def test_zero_node_recovery(self, mock_get_telepathic, mock_align, mock_ad, mock_dump, mock_scroll, mock_sleep, mock_uniform, mock_random):
@patch("GramAddict.core.bot_flow.random.random", return_value=0.5)
@patch("GramAddict.core.bot_flow.random.uniform", return_value=1.5)
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow._humanized_scroll")
@patch("GramAddict.core.bot_flow.dump_ui_state")
@patch("GramAddict.core.bot_flow.is_ad")
@patch("GramAddict.core.bot_flow._align_active_post")
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
def test_zero_node_recovery(
self, mock_get_telepathic, mock_align, mock_ad, mock_dump, mock_scroll, mock_sleep, mock_uniform, mock_random
):
# Tests the explicit Zero-Node Recovery added previously
device = MagicMock()
zero_engine = MagicMock()
nav_graph = MagicMock()
configs = MagicMock()
session_state = MagicMock()
mock_ad.return_value = False
mock_align.return_value = False
cognitive_stack = {
"dopamine": MagicMock(),
"darwin": MagicMock(),
"resonance": MagicMock(),
"active_inference": MagicMock(),
"growth_brain": MagicMock(),
"swarm": MagicMock()
"swarm": MagicMock(),
}
# Dopamine breaks loop after 1st iteration
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
# Fake extreme limits => doesn't break limits
session_state.check_limit.return_value = [False]*10
session_state.check_limit.return_value = [False] * 10
# Telepathic Engine returns ZERO nodes on extract
mock_engine = MagicMock()
mock_engine._extract_semantic_nodes.return_value = []
mock_get_telepathic.return_value = mock_engine
device.dump_hierarchy.return_value = "<xml></xml>"
# Execute the main loop
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
# It should trigger device.press("back") and then _humanized_scroll
device.press.assert_called_with("back")
assert mock_scroll.call_count >= 1
@patch('GramAddict.core.bot_flow.random.random', return_value=0.5)
@patch('GramAddict.core.bot_flow.random.uniform', return_value=1.5)
@patch('GramAddict.core.bot_flow.sleep')
@patch('GramAddict.core.bot_flow._humanized_scroll')
@patch('GramAddict.core.bot_flow.dump_ui_state')
@patch('GramAddict.core.bot_flow._extract_post_content')
@patch('GramAddict.core.bot_flow.is_ad')
@patch('GramAddict.core.bot_flow._align_active_post')
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
def test_content_extraction_failed_recovery(self, mock_get_telepathic, mock_align, mock_ad, mock_extract, mock_dump, mock_scroll, mock_sleep, mock_uniform, mock_random):
@patch("GramAddict.core.bot_flow.random.random", return_value=0.5)
@patch("GramAddict.core.bot_flow.random.uniform", return_value=1.5)
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow._humanized_scroll")
@patch("GramAddict.core.bot_flow.dump_ui_state")
@patch("GramAddict.core.bot_flow._extract_post_content")
@patch("GramAddict.core.bot_flow.is_ad")
@patch("GramAddict.core.bot_flow._align_active_post")
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
def test_content_extraction_failed_recovery(
self,
mock_get_telepathic,
mock_align,
mock_ad,
mock_extract,
mock_dump,
mock_scroll,
mock_sleep,
mock_uniform,
mock_random,
):
device = MagicMock()
zero_engine = MagicMock()
nav_graph = MagicMock()
configs = MagicMock()
session_state = MagicMock()
mock_ad.return_value = False
mock_align.return_value = False
cognitive_stack = {
"dopamine": MagicMock(),
"darwin": MagicMock()
}
cognitive_stack = {"dopamine": MagicMock(), "darwin": MagicMock()}
# break after 1 loop
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
session_state.check_limit.return_value = [False]*10
session_state.check_limit.return_value = [False] * 10
# Ensure it HAS feed markers
device.dump_hierarchy.return_value = "<xml>row_feed_photo_profile_name</xml>"
# Ensure interactive_nodes is NOT zero
mock_engine = MagicMock()
mock_engine._extract_semantic_nodes.return_value = [{"x": 10}]
mock_get_telepathic.return_value = mock_engine
# Make the extraction fail
mock_extract.return_value = {"username": "", "description": ""}
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
# Should call mock_scroll (Graceful degradation)
mock_scroll.assert_called_once()
mock_dump.assert_called_with(device, "content_extraction_failed", {"feed": "HomeFeed"})
@patch('GramAddict.core.bot_flow.sleep')
@patch('GramAddict.core.bot_flow._humanized_scroll')
@patch('GramAddict.core.bot_flow.is_ad')
@patch('GramAddict.core.bot_flow._align_active_post')
@patch('GramAddict.core.bot_flow._extract_post_content')
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
@patch('GramAddict.core.llm_provider.query_llm')
def test_llm_timeout_handled_smoothly(self, mock_query_llm, mock_get_telepathic, mock_extract, mock_align, mock_ad, mock_scroll, mock_sleep):
@patch("GramAddict.core.bot_flow.sleep")
@patch("GramAddict.core.bot_flow._humanized_scroll")
@patch("GramAddict.core.bot_flow.is_ad")
@patch("GramAddict.core.bot_flow._align_active_post")
@patch("GramAddict.core.bot_flow._extract_post_content")
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
@patch("GramAddict.core.llm_provider.query_llm")
def test_llm_timeout_handled_smoothly(
self, mock_query_llm, mock_get_telepathic, mock_extract, mock_align, mock_ad, mock_scroll, mock_sleep
):
"""
TDD Test: Verifies that if qwen3.5:latest times out during comment generation
(simulated by query_llm returning None after circuit breaker), the bot_flow
@@ -150,43 +164,40 @@ class TestBotFlowEdgeCases:
nav_graph = MagicMock()
configs = MagicMock()
session_state = MagicMock()
mock_ad.return_value = False
mock_align.return_value = False
# Make the LLM generation completely timeout and return None
mock_query_llm.return_value = None
cognitive_stack = {
"dopamine": MagicMock(),
"darwin": MagicMock(),
"resonance": MagicMock()
}
cognitive_stack = {"dopamine": MagicMock(), "darwin": MagicMock(), "resonance": MagicMock()}
# break after 1 loop
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
# Emulate that dopamine WANTS to comment
cognitive_stack["dopamine"].get_action_desires.return_value = {"comment": True, "like": False}
# Avoid MagicMock comparison errors in Resonance Engine
cognitive_stack["resonance"].calculate_resonance.return_value = 0.8
session_state.check_limit.return_value = [False]*10
session_state.check_limit.return_value = [False] * 10
device.dump_hierarchy.return_value = "<xml>row_feed_photo_profile_name</xml>"
mock_engine = MagicMock()
mock_engine._extract_semantic_nodes.return_value = [{"x": 10}]
mock_get_telepathic.return_value = mock_engine
# Valid post content so it proceeds to comment generation
mock_extract.return_value = {"username": "test_user", "description": "a long enough description"}
# Run feed loop - MUST NOT CRASH
try:
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
_run_zero_latency_feed_loop(
device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack
)
except Exception as e:
pytest.fail(f"Feed loop crashed on LLM timeout with {e}")

View File

@@ -1,23 +1,21 @@
import pytest
from GramAddict.core.resonance_engine import ResonanceEngine
from GramAddict.core.darwin_engine import DarwinEngine
from GramAddict.core.growth_brain import GrowthBrain
from GramAddict.core.resonance_engine import ResonanceEngine
class TestCognitiveEdgeCases:
# Resonance Engine
def test_resonance_edge_cases(self):
engine = ResonanceEngine(my_username="test_user")
# 1. Empty strings shouldn't crash
assert engine.calculate_resonance({"description": "", "username": ""}) == 0.5
# 2. Very long descriptions
long_str = "word " * 10000
res = engine.calculate_resonance({"description": long_str})
assert isinstance(res, float)
# 3. None values
score = engine.calculate_resonance({"description": None})
assert score == 0.5
@@ -25,33 +23,32 @@ class TestCognitiveEdgeCases:
# Darwin Engine
def test_darwin_edge_cases(self):
engine = DarwinEngine("test_user")
# 1. synthesize interaction with 0.0
prof = engine.synthesize_interaction_profile(0.0)
assert prof["initial_dwell_sec"] > 0
# 2. Negative resonance (should default upwards or bound)
prof_neg = engine.synthesize_interaction_profile(-10.0)
assert prof_neg["initial_dwell_sec"] > 0
# 3. Extreme resonance
prof_max = engine.synthesize_interaction_profile(10.0) # > 1.0
prof_max = engine.synthesize_interaction_profile(10.0) # > 1.0
assert prof_max["initial_dwell_sec"] > 0
def test_growth_brain_edge_cases(self):
engine = GrowthBrain(username="test")
# 1. Call circadian without history
engine.session_history = []
pacing = engine.get_circadian_pacing()
assert 0.4 <= pacing <= 1.2
# 2. Call with extreme limits
engine.session_history = [{"boredom_peak": 100.0, "time": "unknown"}] * 100
pacing2 = engine.get_circadian_pacing()
assert pacing2 > 0.0
# 3. Evaluate persona drift with empty outcomes
engine.refine_persona([])
engine.refine_persona([])
# Shouldn't crash

View File

@@ -1,58 +1,65 @@
import os
import hashlib
from unittest.mock import MagicMock, patch
import pytest
# Mock directory setup
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIXTURE_DIR = os.path.join(ROOT_DIR, "fixtures")
class ConfigMock:
def __init__(self):
self.args = MagicMock()
self.args.app_id = "com.instagram.android"
def test_fsd_handles_persistent_survey_modal():
"""
Simulates a case where the bot gets stuck on a survey modal.
The FSD (Full Self Driving) anomaly handler should trigger,
detect that 'Back' didn't work, and engage TelepathicEngine
The FSD (Full Self Driving) anomaly handler should trigger,
detect that 'Back' didn't work, and engage TelepathicEngine
to find and tap the 'Not Now' or 'Dismiss' button.
"""
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
from GramAddict.core.telepathic_engine import TelepathicEngine
device = MagicMock()
device.app_id = "com.instagram.android"
device._get_current_app.return_value = "com.instagram.android"
configs = ConfigMock()
# Mock the TelepathicEngine singleton behavior entirely
mock_telepathic = MagicMock()
mock_telepathic.find_best_node.return_value = {"x": 500, "y": 1400, "semantic": "Not Now"}
mock_telepathic._extract_semantic_nodes.return_value = [{"x": 10}]
dopamine = MagicMock()
dopamine.is_app_session_over.side_effect = [False, False, True] # Run twice, then exit
dopamine.is_app_session_over.side_effect = [False, False, True] # Run twice, then exit
dopamine.wants_to_change_feed.return_value = False
dopamine.wants_to_doomscroll.return_value = False
ai = MagicMock()
ai.get_sleep_modifier.return_value = 1.0
cognitive_stack = {"dopamine": dopamine, "growth_brain": None, "active_inference": ai, "telepathic": mock_telepathic}
cognitive_stack = {
"dopamine": dopamine,
"growth_brain": None,
"active_inference": ai,
"telepathic": mock_telepathic,
}
# Load the mock survey modal UI
xml_path = os.path.join(FIXTURE_DIR, "survey_modal.xml")
with open(xml_path, "r") as f:
alien_xml = f.read()
device.dump_hierarchy.return_value = alien_xml
with patch('GramAddict.core.bot_flow.sleep'), \
patch('GramAddict.core.bot_flow._humanized_scroll'), \
patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance', return_value=mock_telepathic):
result = _run_zero_latency_feed_loop(device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack)
with (
patch("GramAddict.core.bot_flow.sleep"),
patch("GramAddict.core.bot_flow._humanized_scroll"),
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic),
):
result = _run_zero_latency_feed_loop(
device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack
)
# VERIFICATION:
# Handler should have called Telepathic after 2 misses
assert mock_telepathic.find_best_node.called

View File

@@ -2,15 +2,17 @@
TDD Tests for Zero-Hardcode Screen Classification and Situational Awareness
"""
import sys
import os
import pytest
from unittest.mock import patch, MagicMock
import sys
from unittest.mock import patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from GramAddict.core.goap import ScreenIdentity, ScreenType
@pytest.fixture
def mock_screen_memory():
with patch("GramAddict.core.qdrant_memory.ScreenMemoryDB") as mock_db:
@@ -18,32 +20,40 @@ def mock_screen_memory():
instance.is_connected = True
yield instance
@pytest.fixture
def mock_query_llm():
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
yield mock_llm
def test_classify_screen_uses_memory(mock_screen_memory, mock_query_llm):
"""
Test that _classify_screen FIRST tries to hit the ScreenMemoryDB.
"""
si = ScreenIdentity("testbot")
# Mock that memory ALREADY knows this screen
mock_screen_memory.get_screen_type.return_value = ScreenType.MODAL.name
# We pass random strings that would previously fail or hit hardcoded checks
res = si._classify_screen(
ids=set(), descs=[], texts=["totally ambiguous text"],
selected_tab=None, desc_lower="", text_lower="",
ids_str="random_id", signature="MOCK_SIGNATURE"
ids=set(),
descs=[],
texts=["totally ambiguous text"],
selected_tab=None,
desc_lower="",
text_lower="",
ids_str="random_id",
signature="MOCK_SIGNATURE",
)
assert res == ScreenType.MODAL
mock_screen_memory.get_screen_type.assert_called_once_with("MOCK_SIGNATURE", similarity_threshold=0.92)
# Should not fall back to LLM if memory hits
mock_query_llm.assert_not_called()
def test_classify_screen_uses_llm_fallback_and_learns(mock_screen_memory, mock_query_llm):
"""
Test that if memory misses, it uses LLM fallback and caches the result.
@@ -51,13 +61,18 @@ def test_classify_screen_uses_llm_fallback_and_learns(mock_screen_memory, mock_q
si = ScreenIdentity("testbot")
mock_screen_memory.get_screen_type.return_value = None
mock_query_llm.return_value = {"response": "HOME_FEED"}
res = si._classify_screen(
ids={'random'}, descs=[], texts=[],
selected_tab=None, desc_lower="", text_lower="",
ids_str="random", signature="MOCK_SIGNATURE_2"
ids={"random"},
descs=[],
texts=[],
selected_tab=None,
desc_lower="",
text_lower="",
ids_str="random",
signature="MOCK_SIGNATURE_2",
)
assert res == ScreenType.HOME_FEED
mock_query_llm.assert_called_once()
mock_screen_memory.store_screen.assert_called_once_with("MOCK_SIGNATURE_2", "HOME_FEED")

View File

@@ -67,7 +67,7 @@ def test_slow_loading_post_recovery(test_dumps):
# We patch sleep to make the test super fast
with patch("GramAddict.core.bot_flow.sleep", return_value=None):
start = time.time()
time.time()
success = _wait_for_post_loaded(device, timeout=5)
# Should return true when it hits the 4th element
assert success is True
@@ -156,7 +156,7 @@ def test_missing_feed_markers_guard(test_dumps):
alien_xml = mutate_xml_remove_feed_markers(test_dumps["post"])
device.dump_hierarchy.return_value = alien_xml
with patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll, patch("GramAddict.core.bot_flow.sleep"):
with patch("GramAddict.core.bot_flow._humanized_scroll"), patch("GramAddict.core.bot_flow.sleep"):
_run_zero_latency_feed_loop(device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack)
@@ -178,7 +178,7 @@ def test_xpath_watcher_initialization(mock_u2):
# Just init the facade
from GramAddict.core.device_facade import create_device
device = create_device("fake_serial", "com.fake.app", MagicMock())
create_device("fake_serial", "com.fake.app", MagicMock())
# Verify exact API call structure for XPath
mock_d.watcher.assert_any_call("crash_dialog")

View File

@@ -4,27 +4,31 @@ Instagram can detect standard `uniform` distributed clicks as bot-like.
This test ensures our click distributions follow a proper biological Gaussian curve.
"""
import sys
import os
import sys
# Ensure the GramAddict module is reachable
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
import numpy as np
from GramAddict.core.device_facade import DeviceFacade
class MockDeviceFacade(DeviceFacade):
def __init__(self):
self.clicks = []
def human_click(self, x, y):
self.clicks.append((x, y))
class MockNode:
def bounds(self):
# returns left, top, right, bottom
return (100, 500, 300, 600) # Width = 200, Height = 100
def test_gaussian_distribution():
device = MockDeviceFacade()
node = MockNode()
@@ -32,30 +36,31 @@ def test_gaussian_distribution():
# Simulate 10,000 clicks
for _ in range(10000):
device.click(obj=node)
xs = [c[0] for c in device.clicks]
ys = [c[1] for c in device.clicks]
mean_x = np.mean(xs)
std_x = np.std(xs)
mean_y = np.mean(ys)
std_y = np.std(ys)
print(f"Total Clicks: {len(device.clicks)}")
print(f"X -> Mean: {mean_x:.2f} (Expected ~190 based on thumb bias), StdDev: {std_x:.2f} (Expected ~30)")
print(f"Y -> Mean: {mean_y:.2f} (Expected ~555 based on thumb bias), StdDev: {std_y:.2f} (Expected ~15)")
# Assertions
assert 185 <= mean_x <= 195, "X Mean does not reflect the 45% thumb bias."
assert 550 <= mean_y <= 560, "Y Mean does not reflect the 55% thumb bias."
# Check for Normal Distribution using a simple heuristic (68-95-99.7 rule)
within_1_std = sum(1 for x in xs if mean_x - std_x <= x <= mean_x + std_x) / len(xs)
print(f"{within_1_std*100:.2f}% of X clicks within 1 standard deviation (should be ~68%)")
assert 0.65 <= within_1_std <= 0.72, "Distribution is not Gaussian!"
print("SUCCESS: Clicks pass the hardware anti-bot anomaly check!")
if __name__ == "__main__":
test_gaussian_distribution()

View File

@@ -1,47 +1,50 @@
import pytest
import os
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.device_facade import DeviceFacade
def test_adb_retry_recovers_from_transient_error():
# Attempt simulated disconnect on dump_hierarchy
device_id = "test"
app_id = "test"
with patch('uiautomator2.connect') as mock_connect:
with patch("uiautomator2.connect") as mock_connect:
mock_device = MagicMock()
mock_connect.return_value = mock_device
facade = DeviceFacade(device_id, app_id, None)
# Make the first 2 calls fail, the 3rd one pass
mock_device.dump_hierarchy.side_effect = [
Exception("ConnectError uiautomator2"),
Exception("RPC Error"),
"<hierarchy></hierarchy>"
"<hierarchy></hierarchy>",
]
# Patch sleep to speed up test
with patch('GramAddict.core.device_facade.sleep'):
with patch("GramAddict.core.device_facade.sleep"):
res = facade.dump_hierarchy()
assert res == "<hierarchy></hierarchy>"
assert mock_device.dump_hierarchy.call_count == 3
def test_adb_retry_crashes_gracefully_after_all_retries():
# Attempt simulated disconnect on dump_hierarchy
device_id = "test"
app_id = "test"
with patch('uiautomator2.connect') as mock_connect:
with patch("uiautomator2.connect") as mock_connect:
mock_device = MagicMock()
mock_connect.return_value = mock_device
facade = DeviceFacade(device_id, app_id, None)
# Always fail
mock_device.dump_hierarchy.side_effect = Exception("Permanent ConnectError")
with patch('GramAddict.core.device_facade.sleep'):
with patch("GramAddict.core.device_facade.sleep"):
with pytest.raises(Exception, match="Permanent ConnectError"):
facade.dump_hierarchy()
assert mock_device.dump_hierarchy.call_count == 3

View File

@@ -1,12 +1,13 @@
import unittest
import sys
import os
import sys
import unittest
# Add parent dir to path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from GramAddict.core.telepathic_engine import TelepathicEngine
class DummyDevice:
class DeviceV2:
def __init__(self):
@@ -14,27 +15,29 @@ class DummyDevice:
def click(self, x, y):
self.last_click = (x, y)
def screenshot(self, path=None):
return "fake_screenshot"
def __init__(self):
import unittest
self.deviceV2 = self.DeviceV2()
self.app_id = "com.instagram.android"
self.args = unittest.mock.MagicMock()
self.args.ai_telepathic_model = "qwen2.5:3b"
self.args.ai_telepathic_url = "http://localhost:11434/api/generate"
def _get_current_app(self):
return "com.instagram.android"
def get_info(self):
return {"displayHeight": 2400, "displayWidth": 1080}
def screenshot(self, path=None):
return "fake_screenshot"
class TestHumanHesitation(unittest.TestCase):
def setUp(self):
self.telepathic = TelepathicEngine()
@@ -42,12 +45,12 @@ class TestHumanHesitation(unittest.TestCase):
def test_discard_dialog_extraction(self):
"""
Prove that the Telepathic Engine can correctly identify the 'Discard'
button inside a synthetic XML dump, ensuring the 'Umentscheidung'
Prove that the Telepathic Engine can correctly identify the 'Discard'
button inside a synthetic XML dump, ensuring the 'Umentscheidung'
abort logic works in the wild.
"""
# Synthetic Discard Dialog XML
synthetic_dump = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
synthetic_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" bounds="[0,0][1080,2400]" package="com.instagram.android">
<node index="1" class="android.widget.TextView" text="Discard Comment?" bounds="[200,1000][800,1100]" />
@@ -55,16 +58,16 @@ class TestHumanHesitation(unittest.TestCase):
<node index="3" class="android.widget.Button" text="Verwerfen" content-desc="Discard or Verwerfen popup button" bounds="[600,1200][800,1300]" resource-id="com.instagram.android:id/button_discard" />
</node>
</hierarchy>
'''
"""
# Act
result = self.telepathic.find_best_node(
synthetic_dump,
"Discard or Verwerfen popup button to cancel comment",
synthetic_dump,
"Discard or Verwerfen popup button to cancel comment",
device=self.device,
min_confidence=0.5
min_confidence=0.5,
)
# Assert (Should hit the [600,1200][800,1300] box, which centers to (700, 1250))
self.assertIsNotNone(result, "Telepathic engine failed to find 'Verwerfen'.")
self.assertEqual(result["x"], 700)
@@ -75,12 +78,12 @@ class TestHumanHesitation(unittest.TestCase):
Verify that teleporting specifically to the Inbox tab (DM button)
succeeds if 'Message' describes it.
"""
synthetic_dump = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
synthetic_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="2" text="" id="direct_tab" package="com.instagram.android" content-desc="Direct messages tab button" bounds="[432,2235][648,2361]" resource-id="com.instagram.android:id/direct_tab">
<node content-desc=""/>
</node>
</hierarchy>'''
</hierarchy>"""
# If ID didn't match perfectly, we fall back to description as programmed.
# Direct simulation of UI Automator check isn't in scope for this telepathic test,
@@ -88,5 +91,6 @@ class TestHumanHesitation(unittest.TestCase):
result = self.telepathic.find_best_node(synthetic_dump, "Direct messages tab button", device=self.device)
self.assertIsNotNone(result, "Should find the Message tab")
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main()

View File

@@ -1,26 +1,24 @@
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
from GramAddict.core.llm_provider import query_llm
from GramAddict.core.resonance_engine import ResonanceEngine
def test_query_llm_hallucination_recovery():
# Test that when the primary model hallucinates non-JSON, it triggers fallback
with patch('requests.post') as mock_post:
with patch("requests.post") as mock_post:
# 1st call: Primary fails entirely (e.g., Timeout or strange error)
mock_response_1 = MagicMock()
mock_response_1.status_code = 500
mock_response_1.raise_for_status.side_effect = Exception("500 Server Error")
# 2nd call: Fallback works and returns valid JSON
mock_response_2 = MagicMock()
mock_response_2.status_code = 200
mock_response_2.raise_for_status.return_value = None
mock_response_2.json.return_value = {
"choices": [{"message": {"content": '{"test": "success"}'}}]
}
mock_response_2.json.return_value = {"choices": [{"message": {"content": '{"test": "success"}'}}]}
mock_post.side_effect = [mock_response_1, mock_response_2]
# Attempt a query with a primary model
res = query_llm(
url="http://fake.api/v1/chat/completions",
@@ -28,29 +26,27 @@ def test_query_llm_hallucination_recovery():
prompt="Hello",
format_json=True,
fallback_model="fallback-model",
fallback_url="http://fake.api/v1/chat/completions"
fallback_url="http://fake.api/v1/chat/completions",
)
assert res is not None
assert "response" in res
assert res["response"] == '{"test": "success"}'
assert mock_post.call_count == 2
def test_query_llm_double_hallucination_safe_return():
# Test that when both models hallucinate, we return None gracefully
with patch('requests.post') as mock_post:
with patch("requests.post") as mock_post:
# Both models fail
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = Exception("500 Server Error")
mock_post.side_effect = [mock_response, mock_response]
res = query_llm(
url="http://fake.api/v1/chat/completions",
model="primary-model",
prompt="Hello",
format_json=True
url="http://fake.api/v1/chat/completions", model="primary-model", prompt="Hello", format_json=True
)
assert res is None

View File

@@ -1,12 +1,12 @@
import pytest
import os
from unittest.mock import MagicMock, patch
from GramAddict.core.q_nav_graph import QNavGraph
def test_tap_home_tab_recovery_from_homescreen():
"""
TDD: Reproduce the failure where tap_home_tab fails because the bot is on
the Android Homescreen (app.lawnchair), and verify that it recovers
TDD: Reproduce the failure where tap_home_tab fails because the bot is on
the Android Homescreen (app.lawnchair), and verify that it recovers
via app_start instead of enterring an auto-repair loop.
"""
# 1. Setup Mock Device
@@ -14,32 +14,36 @@ def test_tap_home_tab_recovery_from_homescreen():
mock_device.app_id = "com.instagram.android"
# Return homescreen package to simulate context loss
mock_device._get_current_app.return_value = "app.lawnchair"
# 2. Mock DeviceV2 responses
mock_device.dump_hierarchy.return_value = "<hierarchy />"
mock_device.app_start.return_value = True
# 3. Initialize NavGraph
graph = QNavGraph(mock_device)
graph.current_state = "ProfileFeed" # Assume stale state
graph.current_state = "ProfileFeed" # Assume stale state
# 4. Patch TelepathicEngine.get_instance to return a mock engine
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get_instance, \
patch("GramAddict.core.goap.PathMemory.learn_path"), \
patch("GramAddict.core.goap.PathMemory.recall_path", return_value=None), \
patch("GramAddict.core.qdrant_memory.ScreenMemoryDB._get_embedding", return_value=[0]*1536), \
patch("GramAddict.core.situational_awareness.SituationalAwarenessEngine.ensure_clear_screen", return_value=False), \
patch("GramAddict.core.q_nav_graph.time.sleep"):
with (
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get_instance,
patch("GramAddict.core.goap.PathMemory.learn_path"),
patch("GramAddict.core.goap.PathMemory.recall_path", return_value=None),
patch("GramAddict.core.qdrant_memory.ScreenMemoryDB._get_embedding", return_value=[0] * 1536),
patch(
"GramAddict.core.situational_awareness.SituationalAwarenessEngine.ensure_clear_screen", return_value=False
),
patch("GramAddict.core.q_nav_graph.time.sleep"),
):
mock_engine = MagicMock()
mock_get_instance.return_value = mock_engine
# Simulate Context Guard hitting: return None forever
mock_engine.find_best_node.return_value = None
# 5. Execute
# We expect this to return False gracefully after 3 attempts, without infinitely looping
success = graph.navigate_to("ExploreFeed", zero_engine=None)
# 6. Assertion
assert not success, "Navigation should fail gracefully when context cannot be recovered"
assert mock_device.app_start.called, "Should have force-started the app when context was lost"

View File

@@ -1,13 +1,12 @@
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import patch, MagicMock
import sys
# Force mock qdrant_client before importing any core modules that depend on it
from GramAddict.core.q_nav_graph import QNavGraph
class TestQNavGraphEdgeCases:
@pytest.fixture(autouse=True)
def setup_graph(self):
self.device = MagicMock()
@@ -15,110 +14,110 @@ class TestQNavGraphEdgeCases:
self.device.info = {"screenOn": True}
self.device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" /></hierarchy>'
self.device._get_current_app = MagicMock(return_value="com.instagram.android")
# Prevent Dojo engine instantiation during tests
with patch('GramAddict.core.compiler_engine.VLMCompilerEngine'):
with patch("GramAddict.core.compiler_engine.VLMCompilerEngine"):
self.graph = QNavGraph(self.device)
def test_find_path_edge_cases(self):
# 1. Start == End
assert self.graph._find_path("HomeFeed", "HomeFeed") == []
# 2. Start not in nodes
assert self.graph._find_path("UnknownState", "HomeFeed") == None
assert self.graph._find_path("UnknownState", "HomeFeed") is None
# 3. Unreachable states
self.graph.nodes = {
"HomeFeed": {"transitions": {"tap_explore": "ExploreFeed"}},
"IsolatedFeed": {"transitions": {}}
"IsolatedFeed": {"transitions": {}},
}
assert self.graph._find_path("HomeFeed", "IsolatedFeed") == None
assert self.graph._find_path("HomeFeed", "IsolatedFeed") is None
# 4. Infinite loop protection (A -> B -> A)
self.graph.nodes = {
"A": {"transitions": {"to_b": "B"}},
"B": {"transitions": {"to_a": "A"}}
}
assert self.graph._find_path("A", "C") == None # Should safely return None without exceeding recursion/loop depth
self.graph.nodes = {"A": {"transitions": {"to_b": "B"}}, "B": {"transitions": {"to_a": "A"}}}
assert (
self.graph._find_path("A", "C") is None
) # Should safely return None without exceeding recursion/loop depth
# 5. Longest path possible before unreachability is confirmed
assert self.graph._find_path("B", "D") == None
assert self.graph._find_path("B", "D") is None
# 6. Diamond shape path
self.graph.nodes = {
"Start": {"transitions": {"top": "Top", "bottom": "Bottom"}},
"Top": {"transitions": {"top_to_end": "End"}},
"Bottom": {"transitions": {"bottom_to_end": "End"}},
"End": {}
"End": {},
}
# BFS should find shortest path (len 2)
assert len(self.graph._find_path("Start", "End")) == 2
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
@patch('GramAddict.core.situational_awareness.random_sleep', return_value=None)
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
@patch("GramAddict.core.q_nav_graph.time.sleep", return_value=None)
@patch("GramAddict.core.q_nav_graph.random_sleep", return_value=None)
@patch("GramAddict.core.situational_awareness.random_sleep", return_value=None)
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
def test_execute_transition_edge_cases(self, mock_get_telepathic, mock_sae_sleep, mock_q_rand_sleep, mock_q_sleep):
from GramAddict.core.telepathic_engine import TelepathicEngine
mock_engine = MagicMock(spec=TelepathicEngine)
mock_get_telepathic.return_value = mock_engine
# Case 1: Telepathic engine finds nothing
mock_engine.find_best_node.return_value = None
# If still in Instagram, it returns False
self.device._get_current_app.return_value = "com.instagram.android"
assert self.graph._execute_transition("unknown_action", mock_engine) == False
assert not self.graph._execute_transition("unknown_action", mock_engine)
# If app is different, it returns "CONTEXT_LOST"
self.device._get_current_app.return_value = "com.android.launcher3"
assert self.graph._execute_transition("unknown_action", mock_engine) == "CONTEXT_LOST"
# Case 2: Best node has skip flag
mock_engine.find_best_node.return_value = {"skip": True}
assert self.graph._execute_transition("already_done_action", mock_engine) == True
assert self.graph._execute_transition("already_done_action", mock_engine)
# Case 3: Proper interaction, but XML doesn't change (verification fail)
mock_engine.find_best_node.return_value = {"x": 10, "y": 10, "score": 0.9}
same_xml = '<hierarchy><node package="com.instagram.android" class="same" /></hierarchy>'
self.device.dump_hierarchy.side_effect = None
self.device.dump_hierarchy.return_value = same_xml
assert self.graph._execute_transition("click_action", mock_engine) == False
assert not self.graph._execute_transition("click_action", mock_engine)
assert mock_engine.reject_click.call_count == 3
# Case 4: Proper interaction, XML changes (verification pass)
mock_engine.reset_mock()
mock_engine.find_best_node.return_value = {"x": 10, "y": 10, "score": 0.9}
before_xml = '<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>'
after_xml = '<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>'
initial_clicks = self.device.click.call_count
def dynamic_xml(*args, **kwargs):
return after_xml if self.device.click.call_count > initial_clicks else before_xml
self.device.dump_hierarchy.side_effect = dynamic_xml
# Explicitly ensure verify_success is truthy
mock_engine.verify_success.return_value = True
assert self.graph._execute_transition("click_action", mock_engine) == True
assert self.graph._execute_transition("click_action", mock_engine)
mock_engine.confirm_click.assert_called_once()
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
@patch('GramAddict.core.situational_awareness.random_sleep', return_value=None)
@patch('GramAddict.core.dojo_engine.DojoEngine.get_instance')
@patch("GramAddict.core.q_nav_graph.time.sleep", return_value=None)
@patch("GramAddict.core.q_nav_graph.random_sleep", return_value=None)
@patch("GramAddict.core.situational_awareness.random_sleep", return_value=None)
@patch("GramAddict.core.dojo_engine.DojoEngine.get_instance")
def test_navigate_to_recovery_edge_cases(self, mock_dojo, mock_sae_sleep, mock_q_rand_sleep, mock_q_sleep):
# We test the deepest recovery logic: when everything fails
zero_engine = MagicMock()
# Mock transitions completely failing
with patch.object(self.graph.goap, 'navigate_to_screen', return_value=False):
with patch.object(self.graph.goap, "navigate_to_screen", return_value=False):
# Recovery attempts maxed out
assert self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=3) == False
assert not self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=3)
# Start logic where path is None and direct fallback also fails
self.graph.current_state = "IsolatedNode"
# It should trigger fallback and then return False because `navigate_to_screen` always returns False
assert self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=0) == False
assert not self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=0)

View File

@@ -1,12 +1,14 @@
import sys
import os
import pytest
from unittest.mock import patch, MagicMock
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from GramAddict.core.goap import GoalExecutor, ScreenType
@pytest.fixture
def mock_device():
device = MagicMock()
@@ -15,6 +17,7 @@ def mock_device():
device.app_id = "com.instagram.android"
return device
@pytest.fixture
def mock_telepathic():
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock:
@@ -22,34 +25,35 @@ def mock_telepathic():
engine.find_best_node.return_value = {"x": 100, "y": 200, "semantic_string": "mock_node"}
yield engine
def test_execution_rejects_wrong_screen(mock_device, mock_telepathic):
"""
TDD Case: If we intend to go to DMs but land on Reels,
TDD Case: If we intend to go to DMs but land on Reels,
TelepathicEngine.confirm_click should NOT be called.
"""
executor = GoalExecutor(mock_device, "testuser")
# We mock perceive to return ReelsFeed after the click
with patch.object(executor, "perceive") as mock_perceive:
# Before click
mock_perceive.side_effect = [
{"screen_type": ScreenType.HOME_FEED}, # Initial
{"screen_type": ScreenType.REELS_FEED} # After click (WRONG!)
{"screen_type": ScreenType.HOME_FEED}, # Initial
{"screen_type": ScreenType.REELS_FEED}, # After click (WRONG!)
]
# Action that intends to go to DM_INBOX
action = "tap messages tab"
# We need to make sure _execute_action knows the goal is "open messages"
# Since _execute_action is usually called from achieve(), we mock that flow
success = executor._execute_action(action, goal="open messages")
# Success should be False because we didn't reach the goal
# (Or True if we only care about XML change, but that's what we're changing)
assert success is False
# CRITICAL: confirm_click should NOT have been called for 'messages tab'
# CRITICAL: confirm_click should NOT have been called for 'messages tab'
# since we are on Reels.
mock_telepathic.confirm_click.assert_not_called()
mock_telepathic.reject_click.assert_called_once_with(action)

View File

@@ -57,7 +57,6 @@ class TestTelepathicGuards:
import re
xml_dump_success = '<node class="android.widget.ImageView" content-desc="Unlike" />'
intent = "tap like button"
marker_found = re.search(r"\b(liked|unlike|gefällt mir nicht mehr|gefällt mir am)\b", xml_dump_success.lower())
assert marker_found is not None

View File

@@ -1,60 +1,60 @@
import sys
import os
import sys
import unittest
import types
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.telepathic_engine import TelepathicEngine
class TestTrapEscape(unittest.TestCase):
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
@patch('GramAddict.core.situational_awareness.SituationalAwarenessEngine.ensure_clear_screen', return_value=False)
@patch("GramAddict.core.q_nav_graph.time.sleep", return_value=None)
@patch("GramAddict.core.q_nav_graph.random_sleep", return_value=None)
@patch("GramAddict.core.situational_awareness.SituationalAwarenessEngine.ensure_clear_screen", return_value=False)
def test_trap_guard_autonomous_ai_escape(self, mock_sae_clear, mock_q_rand_sleep, mock_q_sleep):
print("Starting TDD: Testing autonomous Trap Escape with semantic bypass...")
# 1. Setup mocks
mock_device = MagicMock()
mock_device.app_id = "com.instagram.android"
mock_device._get_current_app.return_value = "com.instagram.android"
trap_xml = "<hierarchy><node resource-id='modal_trap' /></hierarchy>"
current_xml = [trap_xml]
# Dynamic dump that changes after click
def dynamic_dump():
return current_xml[0]
def dynamic_click(**kwargs):
if kwargs.get('obj') and kwargs['obj'].get('semantic') and "done" in kwargs['obj'].get('semantic').lower():
if kwargs.get("obj") and kwargs["obj"].get("semantic") and "done" in kwargs["obj"].get("semantic").lower():
current_xml[0] = "<html><node text='Reels'/><node text='Home'/></html>"
mock_device.dump_hierarchy.side_effect = dynamic_dump
mock_device.click.side_effect = dynamic_click
nav_graph = QNavGraph(device=mock_device)
engine = TelepathicEngine.get_instance()
engine.confirm_click = MagicMock()
engine.reject_click = MagicMock()
original_find_best_node = engine.find_best_node
def spy_find_best_node(xml_hierarchy, intent_description, **kwargs):
if "tap home tab" in intent_description.lower():
return None
return original_find_best_node(xml_hierarchy, intent_description, **kwargs)
engine.find_best_node = spy_find_best_node
nav_graph.engine = engine # explicitly enforce
nav_graph.engine = engine # explicitly enforce
# 2. Execute transition
# Mock engine finds nothing, triggering the final fallback escape
result = nav_graph._execute_transition("tap_home_tab", max_retries=1, mock_semantic_engine=engine)
nav_graph._execute_transition("tap_home_tab", max_retries=1, mock_semantic_engine=engine)
# 3. Assertions
# The new SAE/nav_graph behavior explicitly presses BACK when 'tap_home_tab' fails after all retries
self.assertTrue(mock_device.press.called, "Trap guard did not autonomously press BACK to escape the sub-view!")
@@ -62,5 +62,6 @@ class TestTrapEscape(unittest.TestCase):
self.assertEqual(called_key, "back")
print("TDD SUCCESS: Autonomous Backend fallback confirmed.")
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main()

View File

@@ -1,44 +1,56 @@
import pytest
import xml.etree.ElementTree as ET
import pytest
from GramAddict.core.sensors.honeypot_radome import HoneypotRadome
@pytest.fixture
def radome():
# Provide dummy screen dimensions for the Radome
return HoneypotRadome(display_width=1080, display_height=2400)
def create_node(bounds: str, clickable="true", visible_to_user="true", text="", cdesc="", res_id="") -> ET.Element:
node = ET.Element("node", {
"bounds": bounds,
"clickable": clickable,
"visible-to-user": visible_to_user,
"text": text,
"content-desc": cdesc,
"resource-id": res_id
})
node = ET.Element(
"node",
{
"bounds": bounds,
"clickable": clickable,
"visible-to-user": visible_to_user,
"text": text,
"content-desc": cdesc,
"resource-id": res_id,
},
)
return node
def test_zero_point_trap(radome):
node = create_node("[0,0][0,0]")
assert radome._is_honeypot(node) is True
def test_micro_pixel_trap(radome):
node = create_node("[100,100][101,101]", clickable="true")
assert radome._is_honeypot(node) is True
def test_safe_normal_button(radome):
node = create_node("[500,500][600,600]", text="Like", clickable="true")
assert radome._is_honeypot(node) is False
def test_transparent_interceptor_trap(radome):
# A full screen clickable node with NO text/id/desc is a trap!
node = create_node("[0,0][1080,2400]", text="", cdesc="", res_id="", clickable="true")
assert radome._is_honeypot(node) is True
# If it has text (e.g. a legit full screen modal), it's NOT flagged by this specific trap rule
safe_modal = create_node("[0,0][1080,2400]", text="Warning", clickable="true")
assert radome._is_honeypot(safe_modal) is False
def test_accessibility_trap(radome):
# Visible-to-user is false but it is clickable
node = create_node("[100,100][300,300]", visible_to_user="false", clickable="true")