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

@@ -0,0 +1,105 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.ad_guard import AdGuardPlugin
@pytest.fixture
def ad_guard():
plugin = AdGuardPlugin()
return plugin
@pytest.fixture
def mock_context():
device = MagicMock()
configs = MagicMock()
session_state = MagicMock()
# Mocking cognitive stack to have qdrant_memory
memory = MagicMock()
# Mocking radome for xml sanitization
radome = MagicMock()
radome.sanitize_xml.return_value = "<xml>dummy</xml>"
# Mocking nav_graph for escape
nav_graph = MagicMock()
# Mocking zero_latency_engine
zero_engine = MagicMock()
cognitive_stack = {
"qdrant_memory": memory,
"radome": radome,
"nav_graph": nav_graph,
"zero_latency_engine": zero_engine,
}
ctx = BehaviorContext(device=device, configs=configs, session_state=session_state, cognitive_stack=cognitive_stack)
return ctx
class TestAdGuardPlugin:
def test_can_activate(self, ad_guard, mock_context):
assert ad_guard.can_activate(mock_context) is True
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
result = ad_guard.execute(mock_context)
assert result.executed is True
assert ad_guard.consecutive_ads == 1
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
ad_guard.consecutive_ads = 2
result = ad_guard.execute(mock_context)
assert result.executed is True
assert ad_guard.consecutive_ads == 3
# Should do aggressive double skip
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
ad_guard.consecutive_ads = 5
result = ad_guard.execute(mock_context)
assert result.executed is True
assert ad_guard.consecutive_ads == 0 # Resets after home feed escape
nav_graph = mock_context.cognitive_stack["nav_graph"]
zero_engine = mock_context.cognitive_stack["zero_latency_engine"]
nav_graph.navigate_to.assert_called_once_with("HomeFeed", zero_engine)

View File

@@ -0,0 +1,59 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.anomaly_handler import AnomalyHandlerPlugin
@pytest.fixture
def anomaly_handler():
plugin = AnomalyHandlerPlugin()
return plugin
@pytest.fixture
def mock_context():
device = MagicMock()
configs = MagicMock()
session_state = MagicMock()
ctx = BehaviorContext(device=device, configs=configs, session_state=session_state, cognitive_stack={})
return ctx
class TestAnomalyHandlerPlugin:
def test_can_activate(self, anomaly_handler, mock_context):
assert anomaly_handler.can_activate(mock_context) is True
@patch("GramAddict.core.behaviors.anomaly_handler.TelepathicEngine")
@patch("GramAddict.core.behaviors.anomaly_handler.sleep")
@patch("GramAddict.core.behaviors.anomaly_handler.humanized_scroll")
def test_execute_with_nodes(self, mock_scroll, mock_sleep, mock_telepathic, anomaly_handler, mock_context):
mock_instance = MagicMock()
mock_instance._extract_semantic_nodes.return_value = [{"node": "dummy"}]
mock_telepathic.get_instance.return_value = mock_instance
result = anomaly_handler.execute(mock_context)
# It shouldn't execute exclusive action, but it should populate shared_state
assert result.executed is False
assert mock_context.shared_state["interactive_nodes"] == [{"node": "dummy"}]
mock_scroll.assert_not_called()
@patch("GramAddict.core.behaviors.anomaly_handler.TelepathicEngine")
@patch("GramAddict.core.behaviors.anomaly_handler.sleep")
@patch("GramAddict.core.behaviors.anomaly_handler.humanized_scroll")
def test_execute_zero_nodes(self, mock_scroll, mock_sleep, mock_telepathic, anomaly_handler, mock_context):
mock_instance = MagicMock()
mock_instance._extract_semantic_nodes.return_value = []
mock_telepathic.get_instance.return_value = mock_instance
result = anomaly_handler.execute(mock_context)
# Should execute recovery
assert result.executed is True
assert mock_context.shared_state["interactive_nodes"] == []
mock_context.device.press.assert_called_once_with("back")
mock_scroll.assert_called_once()
assert mock_sleep.call_count == 2

View File

@@ -0,0 +1,62 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
@pytest.fixture
def carousel_plugin():
return CarouselBrowsingPlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.carousel_percentage = 100
ctx.configs.args.carousel_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}
ctx.sleep_mod = 1.0
return ctx
class TestCarouselBrowsingPlugin:
@patch("GramAddict.core.behaviors.carousel_browsing.has_carousel_in_view", return_value=True)
def test_can_activate_enabled(self, mock_has_carousel, carousel_plugin, mock_context):
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
assert carousel_plugin.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.carousel_browsing.has_carousel_in_view", return_value=False)
def test_can_activate_no_carousel(self, mock_has_carousel, carousel_plugin, mock_context):
assert carousel_plugin.can_activate(mock_context) is False
@patch("GramAddict.core.behaviors.carousel_browsing.random.random", return_value=0.1) # 0.1 < 1.0 (100%)
@patch("GramAddict.core.behaviors.carousel_browsing.humanized_horizontal_swipe")
@patch("GramAddict.core.behaviors.carousel_browsing.sleep")
def test_execute_success(self, mock_sleep, mock_swipe, mock_random, carousel_plugin, mock_context):
result = carousel_plugin.execute(mock_context)
assert result.executed is True
assert result.interactions == 2
# It should swipe 2 times based on "2-2" config
assert mock_swipe.call_count == 2
# Verify swipe arguments
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

@@ -0,0 +1,51 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.close_friends_guard import CloseFriendsGuardPlugin
@pytest.fixture
def cf_guard():
plugin = CloseFriendsGuardPlugin()
return plugin
@pytest.fixture
def mock_context():
device = MagicMock()
configs = MagicMock()
session_state = MagicMock()
ctx = BehaviorContext(device=device, configs=configs, session_state=session_state, cognitive_stack={})
return ctx
class TestCloseFriendsGuardPlugin:
def test_can_activate(self, cf_guard, mock_context):
assert cf_guard.can_activate(mock_context) is True
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):
mock_context.device.dump_hierarchy.return_value = "<xml>post enge freunde badge</xml>"
result = cf_guard.execute(mock_context)
assert result.executed is True
mock_scroll.assert_called_once_with(mock_context.device, is_skip=True)
mock_sleep.assert_called_once()

View File

@@ -0,0 +1,102 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.comment import CommentPlugin
@pytest.fixture
def comment_plugin():
return CommentPlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.interact_percentage = 100
ctx.shared_state = {"res_score": 1.0}
ctx.context_xml = "<xml></xml>"
ctx.device = MagicMock()
ctx.post_data = {"description": "test desc"}
# Mock cognitive stack
writer = MagicMock()
writer.generate_comment.return_value = "Great post!"
ctx.cognitive_stack = {"writer": writer}
return ctx
class TestCommentPlugin:
def test_can_activate_enabled(self, 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
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
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")
@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 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
result = comment_plugin.execute(mock_context)
assert result.executed is False
mock_context.device.click.assert_not_called()

View File

@@ -0,0 +1,50 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.darwin_dwell import DarwinDwellPlugin
@pytest.fixture
def darwin_dwell():
return DarwinDwellPlugin()
@pytest.fixture
def mock_context():
ctx = BehaviorContext(
device=MagicMock(),
configs=MagicMock(),
session_state=MagicMock(),
cognitive_stack={
"darwin": MagicMock(),
"nav_graph": MagicMock(),
"zero_latency_engine": MagicMock(),
"resonance": MagicMock(),
},
context_xml="<xml></xml>",
post_data={"description": "test desc"},
username="test_user",
shared_state={"res_score": 0.8},
)
return ctx
class TestDarwinDwellPlugin:
def test_execute_with_darwin(self, darwin_dwell, mock_context):
result = darwin_dwell.execute(mock_context)
assert result.executed is True
mock_darwin = mock_context.cognitive_stack["darwin"]
mock_darwin.execute_micro_wobble.assert_called_once_with(mock_context.device)
mock_darwin.execute_proof_of_resonance.assert_called_once()
@patch("GramAddict.core.behaviors.darwin_dwell.sleep")
def test_execute_without_darwin(self, mock_sleep, darwin_dwell, mock_context):
mock_context.cognitive_stack.pop("darwin")
result = darwin_dwell.execute(mock_context)
assert result.executed is True
mock_sleep.assert_called_once()

View File

@@ -0,0 +1,77 @@
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
def follow_plugin():
return FollowPlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.follow_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
return ctx
class TestFollowPlugin:
def test_can_activate_enabled(self, 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)
def test_can_activate_disabled_via_config(self, follow_plugin, mock_context):
mock_context.configs.args.follow_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):
mock_nav = MagicMock()
mock_nav.do.return_value = True
mock_qnavgraph.return_value = mock_nav
result = follow_plugin.execute(mock_context)
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):
mock_nav = MagicMock()
mock_nav.do.return_value = False
mock_qnavgraph.return_value = mock_nav
result = follow_plugin.execute(mock_context)
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

@@ -0,0 +1,126 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.grid_like import GridLikePlugin
@pytest.fixture
def grid_like_plugin():
return GridLikePlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.likes_percentage = 100
ctx.configs.args.likes_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.device = MagicMock()
ctx.device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
ctx.device.dump_hierarchy.return_value = "<xml></xml>"
ctx.username = "test_user"
ctx.sleep_mod = 1.0
growth_brain = MagicMock()
growth_brain.wants_to_double_tap.return_value = False
ctx.cognitive_stack = {"growth_brain": growth_brain}
return ctx
class TestGridLikePlugin:
def test_can_activate_enabled(self, grid_like_plugin, mock_context):
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
assert grid_like_plugin.can_activate(mock_context) is False
def test_can_activate_limit_reached(self, grid_like_plugin, mock_context):
mock_context.session_state.check_limit.return_value = True
assert grid_like_plugin.can_activate(mock_context) is False
def test_can_activate_wrong_screen(self, grid_like_plugin, mock_context):
mock_context.context_xml = '<xml><node content-desc="home_feed"/></xml>'
assert grid_like_plugin.can_activate(mock_context) is False
@patch("random.random", return_value=0.1)
@patch("GramAddict.core.q_nav_graph.QNavGraph")
@patch("GramAddict.core.behaviors.grid_like.wait_for_post_loaded", return_value=True)
@patch("GramAddict.core.behaviors.grid_like.humanized_scroll")
@patch("GramAddict.core.behaviors.grid_like.humanized_click")
@patch("GramAddict.core.behaviors.grid_like.sleep")
def test_execute_success_heart_button(
self,
mock_sleep,
mock_click,
mock_scroll,
mock_wait,
mock_qnavgraph,
mock_random,
grid_like_plugin,
mock_context,
):
mock_nav = MagicMock()
mock_nav.do.return_value = True # tap first image post AND tap like button
mock_qnavgraph.return_value = mock_nav
result = grid_like_plugin.execute(mock_context)
assert result.executed is True
assert result.interactions == 2
assert mock_context.session_state.totalLikes == 2
mock_nav.do.assert_any_call("tap first image post in profile grid")
mock_nav.do.assert_any_call("tap like button")
mock_context.device.press.assert_called_once_with("back")
@patch("random.random", return_value=0.1)
@patch("GramAddict.core.q_nav_graph.QNavGraph")
@patch("GramAddict.core.behaviors.grid_like.wait_for_post_loaded", return_value=True)
@patch("GramAddict.core.behaviors.grid_like.humanized_scroll")
@patch("GramAddict.core.behaviors.grid_like.humanized_click")
@patch("GramAddict.core.behaviors.grid_like.sleep")
def test_execute_success_double_tap(
self,
mock_sleep,
mock_click,
mock_scroll,
mock_wait,
mock_qnavgraph,
mock_random,
grid_like_plugin,
mock_context,
):
mock_nav = MagicMock()
mock_nav.do.return_value = True
mock_qnavgraph.return_value = mock_nav
mock_context.cognitive_stack["growth_brain"].wants_to_double_tap.return_value = True
result = grid_like_plugin.execute(mock_context)
assert result.executed is True
assert result.interactions == 2
assert mock_context.session_state.totalLikes == 2
mock_click.assert_called()
@patch("random.random", return_value=0.1)
@patch("GramAddict.core.q_nav_graph.QNavGraph")
def test_execute_nav_failed(self, mock_qnavgraph, mock_random, grid_like_plugin, mock_context):
mock_nav = MagicMock()
mock_nav.do.return_value = False
mock_qnavgraph.return_value = mock_nav
result = grid_like_plugin.execute(mock_context)
assert result.executed is False
assert result.metadata.get("reason") == "grid_nav_failed"

View File

@@ -0,0 +1,91 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.like import LikePlugin
@pytest.fixture
def like_plugin():
return LikePlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.likes_count = "1-2"
ctx.shared_state = {"res_score": 1.0}
ctx.context_xml = "<xml></xml>"
ctx.device = MagicMock()
return ctx
class TestLikePlugin:
def test_can_activate_enabled(self, 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"
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
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
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()

View File

@@ -0,0 +1,119 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin
from GramAddict.core.situational_awareness import SituationType
@pytest.fixture
def obstacle_guard():
plugin = ObstacleGuardPlugin()
return plugin
@pytest.fixture
def mock_context():
device = MagicMock()
configs = MagicMock()
session_state = MagicMock()
ctx = BehaviorContext(device=device, configs=configs, session_state=session_state, cognitive_stack={})
return ctx
class TestObstacleGuardPlugin:
def test_can_activate(self, obstacle_guard, mock_context):
assert obstacle_guard.can_activate(mock_context) is True
@patch("GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine")
def test_execute_no_obstacle_has_markers(self, mock_sae, obstacle_guard, mock_context):
mock_instance = MagicMock()
mock_instance.perceive.return_value = SituationType.NORMAL
mock_sae.get_instance.return_value = mock_instance
# Give context xml that has feed markers
mock_context.device.dump_hierarchy.return_value = "<xml>dummy row_feed_button_like</xml>"
mock_context.shared_state["consecutive_marker_misses"] = 2
result = obstacle_guard.execute(mock_context)
assert result.executed is False
assert mock_context.shared_state["consecutive_marker_misses"] == 0
@patch("GramAddict.core.behaviors.obstacle_guard.humanized_scroll")
@patch("GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine")
def test_execute_no_obstacle_no_markers(self, mock_sae, mock_scroll, obstacle_guard, mock_context):
mock_instance = MagicMock()
mock_instance.perceive.return_value = SituationType.NORMAL
mock_sae.get_instance.return_value = mock_instance
mock_context.device.dump_hierarchy.return_value = "<xml>dummy</xml>"
mock_context.shared_state["consecutive_marker_misses"] = 1
result = obstacle_guard.execute(mock_context)
assert result.executed is True
assert mock_context.shared_state["consecutive_marker_misses"] == 2
mock_scroll.assert_called_once_with(mock_context.device)
@patch("GramAddict.core.behaviors.obstacle_guard.TelepathicEngine")
@patch("GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine")
@patch("GramAddict.core.behaviors.obstacle_guard.sleep")
def test_execute_obstacle_miss_1(self, mock_sleep, mock_sae, mock_telepathic, obstacle_guard, mock_context):
mock_instance = MagicMock()
mock_instance.perceive.return_value = SituationType.OBSTACLE_MODAL
mock_sae.get_instance.return_value = mock_instance
mock_context.device.dump_hierarchy.return_value = "<xml>dummy</xml>"
mock_context.shared_state["consecutive_marker_misses"] = 0
result = obstacle_guard.execute(mock_context)
assert result.executed is True
assert mock_context.shared_state["consecutive_marker_misses"] == 1
mock_context.device.press.assert_called_once_with("back")
mock_sleep.assert_called_once()
@patch("GramAddict.core.behaviors.obstacle_guard.TelepathicEngine")
@patch("GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine")
@patch("GramAddict.core.behaviors.obstacle_guard.sleep")
def test_execute_obstacle_miss_2_recovery(
self, mock_sleep, mock_sae, mock_telepathic, obstacle_guard, mock_context
):
mock_instance = MagicMock()
mock_instance.perceive.return_value = SituationType.OBSTACLE_MODAL
mock_sae.get_instance.return_value = mock_instance
mock_tele = MagicMock()
mock_tele.find_best_node.return_value = {"x": 10, "y": 20, "semantic": "Dismiss"}
mock_telepathic.get_instance.return_value = mock_tele
# Before recovery
mock_context.device.dump_hierarchy.side_effect = ["<xml>dummy</xml>", "<xml>row_feed_button_like</xml>"]
mock_context.shared_state["consecutive_marker_misses"] = 1
result = obstacle_guard.execute(mock_context)
assert result.executed is True
assert mock_context.shared_state["consecutive_marker_misses"] == 0
@patch("GramAddict.core.behaviors.obstacle_guard.dump_ui_state")
@patch("GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine")
def test_execute_obstacle_miss_3_abort(self, mock_sae, mock_dump, obstacle_guard, mock_context):
mock_instance = MagicMock()
mock_instance.perceive.return_value = SituationType.OBSTACLE_MODAL
mock_sae.get_instance.return_value = mock_instance
mock_context.device.dump_hierarchy.return_value = "<xml>dummy</xml>"
mock_context.device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
mock_context.shared_state["consecutive_marker_misses"] = 2
mock_context.session_state.job_target = "Feed"
result = obstacle_guard.execute(mock_context)
assert result.executed is True
assert result.metadata.get("return_code") == "CONTEXT_LOST"
mock_instance.unlearn_current_state.assert_called_once()
mock_dump.assert_called_once()

View File

@@ -0,0 +1,61 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.perfect_snapping import PerfectSnappingPlugin
@pytest.fixture
def perfect_snapping():
return PerfectSnappingPlugin()
@pytest.fixture
def mock_context():
ctx = BehaviorContext(
device=MagicMock(),
configs=MagicMock(),
session_state=MagicMock(),
cognitive_stack={},
context_xml="<xml>old</xml>",
)
return ctx
class TestPerfectSnappingPlugin:
@patch("GramAddict.core.behaviors.perfect_snapping._align_active_post")
def test_execute_no_alignment_needed(self, mock_align, perfect_snapping, mock_context):
mock_align.return_value = False
result = perfect_snapping.execute(mock_context)
assert result.executed is False
assert mock_context.context_xml == "<xml>old</xml>"
mock_align.assert_called_once_with(mock_context.device)
@patch("GramAddict.core.behaviors.perfect_snapping._align_active_post")
def test_execute_alignment_done(self, mock_align, perfect_snapping, mock_context):
mock_align.return_value = True
mock_context.device.dump_hierarchy.return_value = "<xml>new</xml>"
result = perfect_snapping.execute(mock_context)
assert result.executed is True
assert mock_context.context_xml == "<xml>new</xml>"
mock_align.assert_called_once_with(mock_context.device)
@patch("GramAddict.core.behaviors.perfect_snapping._align_active_post")
def test_execute_alignment_with_radome(self, mock_align, perfect_snapping, mock_context):
mock_align.return_value = True
mock_context.device.dump_hierarchy.return_value = "<xml>new</xml>"
mock_radome = MagicMock()
mock_radome.sanitize_xml.return_value = "<xml>sanitized</xml>"
mock_context.cognitive_stack["radome"] = mock_radome
result = perfect_snapping.execute(mock_context)
assert result.executed is True
assert mock_context.context_xml == "<xml>sanitized</xml>"
mock_radome.sanitize_xml.assert_called_once_with("<xml>new</xml>")

View File

@@ -0,0 +1,57 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.post_data_extraction import PostDataExtractionPlugin
@pytest.fixture
def post_data_extraction():
return PostDataExtractionPlugin()
@pytest.fixture
def mock_context():
session_state = MagicMock()
session_state.job_target = "Feed"
ctx = BehaviorContext(
device=MagicMock(),
configs=MagicMock(),
session_state=session_state,
cognitive_stack={},
context_xml="<xml></xml>",
)
return ctx
class TestPostDataExtractionPlugin:
@patch("GramAddict.core.behaviors.post_data_extraction.extract_post_content")
def test_execute_success(self, mock_extract, post_data_extraction, mock_context):
mock_extract.return_value = {"username": "test_user", "description": "hello"}
result = post_data_extraction.execute(mock_context)
assert result.executed is True
assert result.should_skip is False
assert mock_context.post_data == {"username": "test_user", "description": "hello"}
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": ""}
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()

View File

@@ -0,0 +1,45 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.post_interaction import PostInteractionPlugin
@pytest.fixture
def post_interaction_plugin():
return PostInteractionPlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.shared_state = {"session_outcomes": ["like", "comment"]}
ctx.device = MagicMock()
ctx.post_data = {"id": "123"}
# Mock cognitive stack
telemetry = MagicMock()
ctx.cognitive_stack = {"telemetry": telemetry}
return ctx
class TestPostInteractionPlugin:
def test_can_activate_enabled(self, post_interaction_plugin, mock_context):
assert post_interaction_plugin.can_activate(mock_context) is True
@patch("GramAddict.core.behaviors.post_interaction.humanized_scroll")
@patch("GramAddict.core.behaviors.post_interaction.sleep")
def test_execute_success(self, mock_sleep, mock_scroll, post_interaction_plugin, mock_context):
result = post_interaction_plugin.execute(mock_context)
assert result.executed is True
assert result.should_skip is True # Signals the end of processing for this post
# Telemetry should be called
mock_context.cognitive_stack["telemetry"].log_post_interaction.assert_called_once_with(
{"id": "123"}, ["like", "comment"]
)
# Scroll should be called
mock_scroll.assert_called_once_with(mock_context.device)

View File

@@ -0,0 +1,111 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
@pytest.fixture
def profile_guard_plugin():
return ProfileGuardPlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.ignore_close_friends = True
ctx.configs.args.visual_vibe_check_percentage = 0
ctx.session_state = MagicMock()
ctx.session_state.my_username = "test_bot"
ctx.context_xml = '<xml><node content-desc="profile_header"/></xml>'
ctx.device = MagicMock()
ctx.username = "target_user"
nav_graph = MagicMock()
nav_graph.current_state = "ProfileView"
ctx.cognitive_stack = {"nav_graph": nav_graph}
return ctx
class TestProfileGuardPlugin:
def test_can_activate_enabled(self, profile_guard_plugin, mock_context):
assert profile_guard_plugin.can_activate(mock_context) is True
def test_can_activate_wrong_screen(self, profile_guard_plugin, mock_context):
mock_context.cognitive_stack["nav_graph"].current_state = "HomeFeed"
assert profile_guard_plugin.can_activate(mock_context) is False
def test_can_activate_no_username(self, profile_guard_plugin, mock_context):
mock_context.username = None
assert profile_guard_plugin.can_activate(mock_context) is False
def test_execute_guard_self_profile(self, profile_guard_plugin, mock_context):
mock_context.username = "test_bot"
result = profile_guard_plugin.execute(mock_context)
assert result.executed is True
assert result.should_skip is True
assert result.metadata["reason"] == "self_profile"
def test_execute_guard_private(self, profile_guard_plugin, mock_context):
mock_context.context_xml = "<xml>this account is private</xml>"
result = profile_guard_plugin.execute(mock_context)
assert result.executed is True
assert result.should_skip is True
assert result.metadata["reason"] == "private"
def test_execute_guard_empty(self, profile_guard_plugin, mock_context):
mock_context.context_xml = "<xml>no posts yet</xml>"
result = profile_guard_plugin.execute(mock_context)
assert result.executed is True
assert result.should_skip is True
assert result.metadata["reason"] == "empty"
def test_execute_guard_close_friend(self, profile_guard_plugin, mock_context):
mock_context.context_xml = "<xml>close friend</xml>"
result = profile_guard_plugin.execute(mock_context)
assert result.executed is True
assert result.should_skip is True
assert result.metadata["reason"] == "close_friend"
def test_execute_guard_pass(self, profile_guard_plugin, mock_context):
# Normal profile, no red flags
result = profile_guard_plugin.execute(mock_context)
assert result.executed is False
@patch("random.random", return_value=0.1) # 0.1 < 1.0
def test_execute_guard_vibe_check_fail(self, mock_random, profile_guard_plugin, mock_context):
mock_context.configs.args.visual_vibe_check_percentage = 100
mock_telepathic = MagicMock()
mock_telepathic.evaluate_profile_vibe.return_value = {
"quality_score": 3,
"matches_niche": False,
"reason": "Bad vibes",
}
mock_context.cognitive_stack["telepathic"] = mock_telepathic
result = profile_guard_plugin.execute(mock_context)
assert result.executed is True
assert result.should_skip is True
assert result.metadata["reason"] == "vibe_check_failed"
@patch("random.random", return_value=0.1)
def test_execute_guard_vibe_check_pass(self, mock_random, profile_guard_plugin, mock_context):
mock_context.configs.args.visual_vibe_check_percentage = 100
mock_telepathic = MagicMock()
mock_telepathic.evaluate_profile_vibe.return_value = {"quality_score": 8, "matches_niche": True}
mock_context.cognitive_stack["telepathic"] = mock_telepathic
result = profile_guard_plugin.execute(mock_context)
# Passes vibe check, continues execution
assert result.executed is False

View File

@@ -0,0 +1,68 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin
@pytest.fixture
def profile_visit_plugin():
return ProfileVisitPlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.interact_percentage = 100
ctx.shared_state = {"res_score": 1.0}
ctx.context_xml = "<xml></xml>"
ctx.device = MagicMock()
ctx.username = "test_user"
return ctx
class TestProfileVisitPlugin:
def test_can_activate_enabled(self, 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
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()
def mock_find_best_node(xml, intent_description, **kwargs):
if intent_description == "Post username":
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 = 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()

View File

@@ -0,0 +1,56 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.rabbit_hole import RabbitHolePlugin
@pytest.fixture
def rabbit_hole():
return RabbitHolePlugin()
@pytest.fixture
def mock_context():
ctx = BehaviorContext(
device=MagicMock(),
configs=MagicMock(),
session_state=MagicMock(),
cognitive_stack={"nav_graph": MagicMock()},
context_xml="<xml></xml>",
shared_state={"res_score": 0.95},
)
return ctx
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):
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)
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
def test_execute_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 result.executed is False
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)
assert result.executed is True # Did the random chance, but couldn't execute nav_graph

View File

@@ -0,0 +1,94 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.repost import RepostPlugin
@pytest.fixture
def repost_plugin():
return RepostPlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.interact_percentage = 100
ctx.shared_state = {"res_score": 1.0}
ctx.context_xml = "<xml></xml>"
ctx.device = MagicMock()
return ctx
class TestRepostPlugin:
def test_can_activate_enabled(self, 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
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
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)
@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
result = repost_plugin.execute(mock_context)
assert result.executed is False
mock_context.device.click.assert_not_called()

View File

@@ -0,0 +1,80 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.resonance_evaluator import ResonanceEvaluatorPlugin
@pytest.fixture
def resonance_evaluator():
return ResonanceEvaluatorPlugin()
@pytest.fixture
def mock_context():
configs = MagicMock()
configs.args.visual_vibe_check_percentage = 0
configs.args.interact_percentage = 100 # Never skip
ctx = BehaviorContext(
device=MagicMock(),
configs=configs,
session_state=MagicMock(),
cognitive_stack={"resonance": MagicMock(), "dopamine": MagicMock()},
context_xml="<xml></xml>",
post_data={"username": "test_user"},
username="test_user",
shared_state={"session_outcomes": []},
)
return ctx
class TestResonanceEvaluatorPlugin:
def test_execute_high_resonance(self, resonance_evaluator, mock_context):
mock_context.cognitive_stack["resonance"].calculate_resonance.return_value = 0.9
result = resonance_evaluator.execute(mock_context)
assert result.executed is True
assert result.should_skip is False
assert mock_context.shared_state["res_score"] == 0.9
mock_context.cognitive_stack["dopamine"].process_content.assert_called_once_with(
{"score": 9.0, "quality": "high"}
)
@patch("GramAddict.core.behaviors.resonance_evaluator.humanized_scroll")
@patch("GramAddict.core.behaviors.resonance_evaluator.sleep")
def test_execute_low_resonance_skip(self, mock_sleep, mock_scroll, resonance_evaluator, mock_context):
mock_context.configs.args.interact_percentage = 0 # Force skip
mock_context.cognitive_stack["resonance"].calculate_resonance.return_value = 0.1
# Mock random so it always skips
with patch("GramAddict.core.behaviors.resonance_evaluator.random.random", return_value=0.0):
result = resonance_evaluator.execute(mock_context)
assert result.executed is True
assert result.should_skip is True
mock_scroll.assert_called_once_with(mock_context.device)
assert len(mock_context.shared_state["session_outcomes"]) == 1
assert mock_context.shared_state["session_outcomes"][0]["resonance"] == 0.1
assert mock_context.shared_state["session_outcomes"][0]["action"] == "skip"
@patch("GramAddict.core.behaviors.resonance_evaluator.humanized_scroll")
@patch("GramAddict.core.behaviors.resonance_evaluator.sleep")
def test_execute_visual_vibe_check(self, mock_sleep, mock_scroll, resonance_evaluator, mock_context):
mock_context.configs.args.visual_vibe_check_percentage = 100
mock_context.cognitive_stack["resonance"].calculate_resonance.return_value = 0.5
mock_tele = MagicMock()
mock_tele.evaluate_post_vibe.return_value = {"quality_score": 10, "matches_niche": True}
mock_context.cognitive_stack["telepathic"] = mock_tele
with patch("GramAddict.core.behaviors.resonance_evaluator.random.random", return_value=0.0):
result = resonance_evaluator.execute(mock_context)
assert result.executed is True
assert result.should_skip is False
# res_score = 0.5 * 0.3 + 1.0 * 0.7 = 0.15 + 0.70 = 0.85
assert round(mock_context.shared_state["res_score"], 2) == 0.85
mock_tele.evaluate_post_vibe.assert_called_once()

View File

@@ -0,0 +1,92 @@
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.story_view import StoryViewPlugin
@pytest.fixture
def story_view_plugin():
return StoryViewPlugin()
@pytest.fixture
def mock_context():
ctx = MagicMock(spec=BehaviorContext)
ctx.configs = MagicMock()
ctx.configs.args.stories_percentage = 100
ctx.configs.args.stories_count = "2-2"
ctx.context_xml = '<xml><node content-desc="reel_ring"/></xml>'
ctx.device = MagicMock()
ctx.device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
ctx.device.dump_hierarchy.return_value = '<xml><node content-desc="reel_ring"/></xml>'
ctx.username = "test_user"
ctx.sleep_mod = 1.0
return ctx
class TestStoryViewPlugin:
def test_can_activate_enabled(self, story_view_plugin, mock_context):
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
assert story_view_plugin.can_activate(mock_context) is False
@patch("random.random", return_value=0.1)
def test_execute_no_story_in_xml(self, mock_random, story_view_plugin, mock_context):
mock_context.context_xml = "<xml></xml>"
mock_context.device.dump_hierarchy.return_value = "<xml></xml>"
result = story_view_plugin.execute(mock_context)
assert result.executed is False
assert result.metadata["reason"] == "no_story"
@patch("random.random", return_value=0.1)
@patch("GramAddict.core.q_nav_graph.QNavGraph")
def test_execute_nav_failed(self, mock_qnavgraph, mock_random, story_view_plugin, mock_context):
mock_nav = MagicMock()
mock_nav.do.return_value = False
mock_qnavgraph.return_value = mock_nav
result = story_view_plugin.execute(mock_context)
assert result.executed is False
assert result.metadata["reason"] == "nav_failed"
@patch("random.random", return_value=0.1)
@patch("GramAddict.core.q_nav_graph.QNavGraph")
@patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", return_value=False)
def test_execute_load_timeout(self, mock_wait, mock_qnavgraph, mock_random, story_view_plugin, mock_context):
mock_nav = MagicMock()
mock_nav.do.return_value = True
mock_qnavgraph.return_value = mock_nav
result = story_view_plugin.execute(mock_context)
assert result.executed is False
assert result.metadata["reason"] == "load_timeout"
@patch("random.random", return_value=0.1)
@patch("GramAddict.core.q_nav_graph.QNavGraph")
@patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", return_value=True)
@patch("GramAddict.core.behaviors.story_view.humanized_click")
@patch("GramAddict.core.behaviors.story_view.sleep")
def test_execute_success(
self, mock_sleep, mock_click, mock_wait, mock_qnavgraph, mock_random, story_view_plugin, mock_context
):
mock_nav = MagicMock()
mock_nav.do.return_value = True
mock_qnavgraph.return_value = mock_nav
result = story_view_plugin.execute(mock_context)
assert result.executed is True
assert result.interactions == 2
assert result.metadata["stories_viewed"] == 2
# 2 stories, so 1 click (to advance from 1st to 2nd)
mock_click.assert_called_once()
mock_context.device.press.assert_called_once_with("back")

View File

@@ -63,7 +63,7 @@ class TestSpatialParser:
assert post.contains(home_tabs[0]) is False
def test_spatial_intersection(self):
parser = SpatialParser()
SpatialParser()
# Node 1: Left side
n1 = SpatialNode(bounds=(0, 0, 100, 100))

View File

@@ -1,28 +1,29 @@
import pytest
from unittest.mock import MagicMock, call, patch
from unittest.mock import MagicMock, patch
from GramAddict.core.q_nav_graph import QNavGraph
class TestAnomalyInterruptions:
class TestAnomalyInterruptions:
def setup_method(self):
self.mock_device = MagicMock()
self.mock_device.deviceV2 = MagicMock()
self.mock_device.app_id = "com.instagram.android"
self.mock_device._get_current_app.return_value = "com.instagram.android"
self.nav_graph = QNavGraph(self.mock_device)
self.nav_graph = QNavGraph(self.mock_device)
# We test the LLM fallback planner logic here.
# Since the legacy structural planner was removed, we must mock the LLM
# to ensure deterministic test execution.
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
sae = SituationalAwarenessEngine.get_instance(self.mock_device)
sae.episodes.recall = MagicMock(return_value=None)
sae.episodes.learn = MagicMock()
# We must also mock ScreenMemoryDB to prevent cached misclassifications
# from bypassing the LLM in perceive()
self.screen_memory_patcher = patch('GramAddict.core.qdrant_memory.ScreenMemoryDB')
self.screen_memory_patcher = patch("GramAddict.core.qdrant_memory.ScreenMemoryDB")
self.mock_screen_memory_cls = self.screen_memory_patcher.start()
self.mock_screen_memory = self.mock_screen_memory_cls.return_value
self.mock_screen_memory.get_screen_type.return_value = None
@@ -35,7 +36,7 @@ class TestAnomalyInterruptions:
Z-Depth Guard must explicitly attempt to find a 'Deny' / 'Don't allow' button
when encountering the Android permission modal instead of just pressing BACK.
"""
obstacle_xml = '''
obstacle_xml = """
<hierarchy>
<node package="com.android.permissioncontroller" resource-id="com.android.permissioncontroller:id/grant_dialog">
<node package="com.android.permissioncontroller" text="Allow Instagram to access your location?" />
@@ -43,32 +44,32 @@ class TestAnomalyInterruptions:
<node package="com.android.permissioncontroller" text="Don't allow" resource-id="com.android.permissioncontroller:id/permission_deny_button" bounds="[100,650][900,750]" clickable="true" />
</node>
</hierarchy>
'''
"""
normal_xml = '<hierarchy><node package="com.instagram.android" /></hierarchy>'
# 1. Obstacle Check (Attempt 1 Start) uses initial_xml (passed in _clear_anomaly_obstacles)
# 2. Re-dump in evaluate loop (next iterations)
self.mock_device.dump_hierarchy.side_effect = [normal_xml, normal_xml, normal_xml]
def mock_llm_side_effect(*args, **kwargs):
system_arg = kwargs.get('system')
system_arg = kwargs.get("system")
if not system_arg and len(args) > 4:
system_arg = args[4]
prompt_arg = kwargs.get('prompt')
prompt_arg = kwargs.get("prompt")
if not prompt_arg and len(args) > 2:
prompt_arg = args[2]
if system_arg == "Strict JSON classifier.":
if "How are we doing?" in prompt_arg or "Allow Instagram" in prompt_arg:
return {"response": '{"situation": "OBSTACLE_MODAL"}'}
return {"response": '{"situation": "NORMAL"}'}
return {"response": '{"action": "click", "x": 500, "y": 700, "reason": "Deny permission"}'}
with patch('GramAddict.core.llm_provider.query_llm') as mock_llm:
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
mock_llm.side_effect = mock_llm_side_effect
# When checking for obstacles, it should clear it by clicking deny
cleared = self.nav_graph._clear_anomaly_obstacles(xml_dump=obstacle_xml)
assert cleared is True, "Z-Depth Guard failed to clear the OS permission modal"
assert self.mock_device.click.call_count >= 1
# Verify it clicked the 'Don't allow' button coordinates ([100,650][900,750] avg is [500, 700])
@@ -76,7 +77,6 @@ class TestAnomalyInterruptions:
assert args[0] == 500
assert args[1] == 700
def test_instagram_survey_dismissal(self):
"""
Instagram can prompt surveys mid-run. We must dismiss them gracefully.
@@ -91,7 +91,7 @@ class TestAnomalyInterruptions:
(either a BACK press OR a direct click on 'Not Now') and that the function
reports the obstacle as cleared.
"""
obstacle_xml = '''
obstacle_xml = """
<hierarchy>
<node package="com.instagram.android" resource-id="com.instagram.android:id/survey_container">
<node package="com.instagram.android" text="How are we doing?" resource-id="com.instagram.android:id/survey_title" />
@@ -99,60 +99,57 @@ class TestAnomalyInterruptions:
<node package="com.instagram.android" text="Not Now" resource-id="com.instagram.android:id/button_negative" bounds="[200,950][800,1050]" clickable="true" />
</node>
</hierarchy>
'''
"""
normal_xml = '<hierarchy><node package="com.instagram.android" /></hierarchy>'
# After any dismissal action the screen returns to normal
self.mock_device.dump_hierarchy.side_effect = [normal_xml, normal_xml, normal_xml]
def mock_llm_side_effect(*args, **kwargs):
system_arg = kwargs.get('system')
system_arg = kwargs.get("system")
if not system_arg and len(args) > 4:
system_arg = args[4]
prompt_arg = kwargs.get('prompt')
prompt_arg = kwargs.get("prompt")
if not prompt_arg and len(args) > 2:
prompt_arg = args[2]
if system_arg == "Strict JSON classifier.":
if "How are we doing?" in prompt_arg or "Allow Instagram" in prompt_arg:
return {"response": '{"situation": "OBSTACLE_MODAL"}'}
return {"response": '{"situation": "NORMAL"}'}
return {"response": '{"action": "back", "reason": "Safe dismissal of modal"}'}
with patch('GramAddict.core.llm_provider.query_llm') as mock_llm:
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
mock_llm.side_effect = mock_llm_side_effect
cleared = self.nav_graph._clear_anomaly_obstacles(xml_dump=obstacle_xml)
# Primary assertion: the SAE reported success
assert cleared is True, "Instagram survey was not dismissed"
# Secondary assertion: at least one dismissal action occurred.
# The SAE may press BACK (priority=-1 for OBSTACLE_MODAL) or click 'Not Now'.
pressed_back = (
self.mock_device.press.called and
any(
(a.args[0] if a.args else None) == "back"
for a in self.mock_device.press.call_args_list
)
pressed_back = self.mock_device.press.called and any(
(a.args[0] if a.args else None) == "back" for a in self.mock_device.press.call_args_list
)
did_click = self.mock_device.click.call_count >= 1
assert pressed_back or did_click, (
"SAE did not take any dismissal action (expected BACK press or click on 'Not Now')"
)
assert (
pressed_back or did_click
), "SAE did not take any dismissal action (expected BACK press or click on 'Not Now')"
def test_fake_creation_flow_in_bio_is_ignored(self):
"""
Ensures that a user bio containing 'quick_capture' or 'creation_flow'
Ensures that a user bio containing 'quick_capture' or 'creation_flow'
does not falsely trigger the structural OBSTACLE_MODAL states.
"""
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
sae = SituationalAwarenessEngine.get_instance()
# XML containing the marker in text, not id
xml = '''<hierarchy><node package="com.instagram.android" text="I love the post creation_flow" /></hierarchy>'''
with patch('GramAddict.core.llm_provider.query_telepathic_llm') as mock_llm:
xml = """<hierarchy><node package="com.instagram.android" text="I love the post creation_flow" /></hierarchy>"""
with patch("GramAddict.core.llm_provider.query_telepathic_llm") as mock_llm:
mock_llm.return_value = {"response": '{"situation": "NORMAL"}'}
result = sae.perceive(xml)
assert result == SituationType.NORMAL
@@ -162,11 +159,12 @@ class TestAnomalyInterruptions:
Ensures that a real structural marker in the resource-id triggers OBSTACLE_MODAL.
"""
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
sae = SituationalAwarenessEngine.get_instance()
xml = '''<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/quick_capture_root_container" /></hierarchy>'''
with patch('GramAddict.core.llm_provider.query_telepathic_llm') as mock_llm:
xml = """<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/quick_capture_root_container" /></hierarchy>"""
with patch("GramAddict.core.llm_provider.query_telepathic_llm") as mock_llm:
result = sae.perceive(xml)
assert result == SituationType.OBSTACLE_MODAL
mock_llm.assert_not_called()
@@ -177,11 +175,12 @@ class TestAnomalyInterruptions:
does NOT trigger DANGER_ACTION_BLOCKED.
"""
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
sae = SituationalAwarenessEngine.get_instance()
xml = '''<hierarchy><node package="com.instagram.android" text="My account was action blocked yesterday!" /></hierarchy>'''
with patch('GramAddict.core.llm_provider.query_telepathic_llm') as mock_llm:
xml = """<hierarchy><node package="com.instagram.android" text="My account was action blocked yesterday!" /></hierarchy>"""
with patch("GramAddict.core.llm_provider.query_telepathic_llm") as mock_llm:
mock_llm.return_value = {"response": '{"situation": "NORMAL"}'}
result = sae.perceive(xml)
assert result != SituationType.DANGER_ACTION_BLOCKED
@@ -191,9 +190,10 @@ class TestAnomalyInterruptions:
Ensures that 'action blocked' with a dialog container triggers DANGER_ACTION_BLOCKED.
"""
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
sae = SituationalAwarenessEngine.get_instance()
xml = '''<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/dialog_container"><node package="com.instagram.android" text="Action Blocked" /></node></hierarchy>'''
xml = """<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/dialog_container"><node package="com.instagram.android" text="Action Blocked" /></node></hierarchy>"""
result = sae.perceive(xml)
assert result == SituationType.DANGER_ACTION_BLOCKED

View File

@@ -1,45 +1,53 @@
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
from unittest.mock import MagicMock
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.situational_awareness import SituationType
def test_app_perimeter_guard_after_click():
"""
Simulates a catastrophic VLM hallucination where clicking an element
Simulates a catastrophic VLM hallucination where clicking an element
(e.g., an ad) causes the OS to switch to another app (e.g., Google Play Store).
The QNavGraph MUST detect this post-click, reject the telemetry,
The QNavGraph MUST detect this post-click, reject the telemetry,
and return CONTEXT_LOST immediately to prevent memory poisoning.
"""
mock_device = MagicMock()
# Initial state is Instagram
mock_device._get_current_app.side_effect = [
"com.android.vending", # POST-CLICK verification at line 361 (App drifted!)
"com.android.vending", # double check after BACK press in recovery
"com.android.vending" # fallback start check if needed
"com.android.vending", # POST-CLICK verification at line 361 (App drifted!)
"com.android.vending", # double check after BACK press in recovery
"com.android.vending", # fallback start check if needed
] + ["com.android.vending"] * 50
mock_device.app_id = "com.instagram.android"
state_counter = {"calls": 0}
def dynamic_xml():
state_counter["calls"] += 1
if state_counter["calls"] <= 2:
return '<hierarchy><node resource-id="com.instagram.android:id/row_feed_photo_profile_name" /></hierarchy>'
return '<hierarchy><node resource-id="play_store_ui" /></hierarchy>'
mock_device.dump_hierarchy.side_effect = dynamic_xml
# Mock Telepathic Engine
mock_engine = MagicMock()
mock_engine.find_best_node.return_value = {"x": 50, "y": 50, "semantic_string": "fake profile link", "source": "vlm", "score": 1.0}
mock_engine.find_best_node.return_value = {
"x": 50,
"y": 50,
"semantic_string": "fake profile link",
"source": "vlm",
"score": 1.0,
}
mock_engine.verify_success.return_value = True
# Mock SAE to be hermetic (no real Ollama calls)
mock_sae = MagicMock()
# Before click: no obstacles (return False = nothing cleared)
# After click: detect foreign app
# After click: detect foreign app
mock_sae.ensure_clear_screen.return_value = False
mock_sae.perceive.return_value = SituationType.OBSTACLE_FOREIGN_APP
nav_graph = QNavGraph.__new__(QNavGraph)
nav_graph.device = mock_device
nav_graph.nodes = {}
@@ -48,16 +56,15 @@ def test_app_perimeter_guard_after_click():
nav_graph.sae = mock_sae
nav_graph.goap = MagicMock()
nav_graph.compiler = MagicMock()
# Execute the transition
result = nav_graph._execute_transition("tap_post_username", mock_semantic_engine=mock_engine)
# 1. It must return CONTEXT_LOST without saving to memory
assert result == "CONTEXT_LOST", f"Did not return CONTEXT_LOST after app drifted to Play Store! Got: {result}"
# 2. It MUST NOT confirm the click and poison telemetry!
mock_engine.confirm_click.assert_not_called()
# 3. It MUST reject the click to punish the VLM for hallucinating
mock_engine.reject_click.assert_called_once()

View File

@@ -1,7 +1,8 @@
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
from GramAddict.core.q_nav_graph import QNavGraph
def test_autonomous_retry_on_ambiguity_failure():
"""
Verifies that _execute_transition uses an internal retry loop.
@@ -11,11 +12,11 @@ def test_autonomous_retry_on_ambiguity_failure():
mock_device = MagicMock()
mock_device._get_current_app.side_effect = ["com.instagram.android"] * 100
mock_device.app_id = "com.instagram.android"
normal_xml = '<hierarchy><node package="com.instagram.android" /></hierarchy>'
wrong_context = '<hierarchy><node package="com.instagram.android" text="wrong" /></hierarchy>'
correct_context = '<hierarchy><node package="com.instagram.android" text="correct" /></hierarchy>'
# Each attempt consumes:
# 1. Initial context_xml dump
# 2. Re-acquire dump (triggered because _clear_anomaly_obstacles returns True when NORMAL)
@@ -23,14 +24,14 @@ def test_autonomous_retry_on_ambiguity_failure():
# Attempt 1 → normal, normal, wrong_context (verify=False → retry)
# Attempt 2 → normal, normal, correct_context (verify=True → return True)
mock_device.dump_hierarchy.side_effect = [
normal_xml, # Attempt 1: initial
normal_xml, # Attempt 1: re-acquire (cleared_something=True from SAE perceive=NORMAL)
wrong_context, # Attempt 1: post-click (verify_success=False)
normal_xml, # Attempt 2: initial
normal_xml, # Attempt 2: re-acquire
correct_context, # Attempt 2: post-click (verify_success=True)
normal_xml, # Attempt 1: initial
normal_xml, # Attempt 1: re-acquire (cleared_something=True from SAE perceive=NORMAL)
wrong_context, # Attempt 1: post-click (verify_success=False)
normal_xml, # Attempt 2: initial
normal_xml, # Attempt 2: re-acquire
correct_context, # Attempt 2: post-click (verify_success=True)
] + [normal_xml] * 20
mock_engine = MagicMock()
# Return a different node on each find_best_node call (plus extras for safety)
mock_engine.find_best_node.side_effect = [
@@ -38,18 +39,20 @@ def test_autonomous_retry_on_ambiguity_failure():
{"x": 20, "y": 20, "semantic_string": "Correct Grid", "source": "vlm"},
{"x": 20, "y": 20, "semantic_string": "Correct Grid", "source": "vlm"}, # safety extra
]
# Mock verify_success: fail first (triggering Ambiguity Guard), succeed second
mock_engine.verify_success.side_effect = [False, True]
nav_graph = QNavGraph(mock_device)
with patch("time.sleep"), \
patch("random.uniform"), \
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_engine):
with (
patch("time.sleep"),
patch("random.uniform"),
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_engine),
):
result = nav_graph._execute_transition("tap_grid_first_post", mock_semantic_engine=mock_engine)
assert result is True, "Autonomous retry loop failed to complete successfully."
assert mock_device.click.call_count >= 2, "Should have attempted at least two different coordinates."
mock_engine.reject_click.assert_called() # Should have rejected the first mapping
mock_engine.confirm_click.assert_called() # Should have confirmed the final successful mapping
mock_engine.confirm_click.assert_called() # Should have confirmed the final successful mapping

View File

@@ -1,42 +1,45 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
@patch('GramAddict.core.behaviors.PluginRegistry.get_instance')
@patch('GramAddict.core.bot_flow.sleep')
@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):
@patch("GramAddict.core.behaviors.PluginRegistry.get_instance")
@patch("GramAddict.core.bot_flow.sleep")
@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
):
# Setup mocks
device = MagicMock()
zero_engine = MagicMock()
nav_graph = MagicMock()
configs = MagicMock()
session_state = MagicMock()
# Cognitive stack setup
cognitive_stack = {
"dopamine": MagicMock(),
"darwin": MagicMock(),
"resonance": MagicMock(),
"active_inference": MagicMock()
"active_inference": MagicMock(),
}
# 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>"
mock_telepathic_instance = MagicMock()
mock_telepathic_instance._extract_semantic_nodes.return_value = [{"x": 10}]
mock_telepathic.return_value = mock_telepathic_instance
mock_extract.return_value = {"username": "test", "description": "", "caption": ""}
# Setup PluginRegistry to return a skip result
mock_registry_instance = MagicMock()
mock_plugin_result = MagicMock()
@@ -44,9 +47,9 @@ def test_plugin_skip_breaks_feed_loop(mock_telepathic, mock_ad, mock_align, mock
mock_plugin_result.should_skip = True
mock_registry_instance.execute_all.return_value = [mock_plugin_result]
mock_registry_get_instance.return_value = mock_registry_instance
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
# Assert that because should_skip was True, active_inference.predict_state was NEVER called
# (Meaning the 'continue' correctly bypassed the rest of the feed loop)
cognitive_stack["active_inference"].predict_state.assert_not_called()

View File

@@ -1,7 +1,8 @@
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
from GramAddict.core.compiler_engine import VLMCompilerEngine
def test_compiler_intent_list_handling():
"""
Test that VLMCompilerEngine gracefully handles intents that are lists,
@@ -9,24 +10,24 @@ def test_compiler_intent_list_handling():
"""
mock_device = MagicMock()
compiler = VLMCompilerEngine(mock_device)
# We submit a string representation of a list as intent, simulating Dojo's behavior:
# dojo.submit_snapshot(heuristic_name=str(expected_signature), ... intent_prompt=f"... {expected_signature}")
raw_list_intent = "['row_feed', 'button_like']"
# We want the prompt to be clean. Let's intercept the prompt going to the LLM.
with patch('GramAddict.core.llm_provider.query_telepathic_llm') as mock_query:
# Mock LLM to return a valid JSON so the rest of the flow can execute
with patch("GramAddict.core.llm_provider.query_telepathic_llm") as mock_query:
# Mock LLM to return a valid JSON so the rest of the flow can execute
mock_query.return_value = '{"rule_type": "regex", "target_attribute": "resource-id", "pattern": ".*row_feed.*", "confidence": 0.95, "reasoning": "Test"}'
result = compiler.generate_heuristic(raw_list_intent, "<xml/>")
# Verify the prompt is properly sanitized
called_args = mock_query.call_args
assert called_args is not None
user_prompt = called_args.kwargs['user_prompt']
user_prompt = called_args.kwargs["user_prompt"]
# User prompt should contain a readable description, NOT a python list string.
# e.g., if intent has "button_like", prompt should ask for it clearly.
assert "TARGET INTENT" in user_prompt
@@ -34,4 +35,4 @@ def test_compiler_intent_list_handling():
assert "button_like" in user_prompt
assert "['" not in user_prompt # No python list syntax!
assert result is not None
assert result['pattern'] == ".*row_feed.*"
assert result["pattern"] == ".*row_feed.*"

View File

@@ -1,15 +1,19 @@
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import patch, MagicMock
from GramAddict.core.bot_flow import _interact_with_profile
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
from GramAddict.core.bot_flow import _interact_with_profile
from tests.conftest import MockArgs, MockConfigs
@pytest.fixture(autouse=True)
def silent_sleep(monkeypatch):
import GramAddict.core.bot_flow
import GramAddict.core.physics.timing
import GramAddict.core.physics.humanized_input
import GramAddict.core.physics.timing
monkeypatch.setattr(GramAddict.core.bot_flow, "sleep", lambda x: None)
monkeypatch.setattr(GramAddict.core.physics.timing, "sleep", lambda x: None)
monkeypatch.setattr(GramAddict.core.physics.humanized_input, "sleep", lambda x: None)
@@ -18,159 +22,159 @@ def silent_sleep(monkeypatch):
@patch("random.random")
def test_interact_with_profile_all_100_percent(mock_random, device, telepathic_mock, mock_logger):
# Guaranteed to pass checks
mock_random.return_value = 0.0
mock_random.return_value = 0.0
args = MockArgs(
stories_percentage=100,
stories_count="3-3",
follow_percentage=100,
likes_percentage=100,
likes_count="2-2"
stories_percentage=100, stories_count="3-3", follow_percentage=100, likes_percentage=100, likes_count="2-2"
)
configs = MockConfigs(args)
from GramAddict.core.session_state import SessionState
session_state = SessionState(configs)
session_state.set_limits_session()
_interact_with_profile(device, configs, "testuser", session_state, 0.0, mock_logger)
# Grid loop finishes with scroll logic
assert device.shell.call_count >= 0
@patch("random.random")
def test_interact_with_profile_zero_percent(mock_random, device, telepathic_mock, mock_logger):
# Guaranteed to fail chance logic
mock_random.return_value = 0.99
args = MockArgs(
stories_percentage=0,
follow_percentage=0,
likes_percentage=0
)
args = MockArgs(stories_percentage=0, follow_percentage=0, likes_percentage=0)
configs = MockConfigs(args)
from GramAddict.core.session_state import SessionState
session_state = SessionState(configs)
session_state.set_limits_session()
_interact_with_profile(device, configs, "testuser", session_state, 0.0, mock_logger)
# No interaction blocks run, so no shells.
assert device.shell.call_count == 0
@patch("random.random")
def test_interact_with_profile_mixed_probability(mock_random, device, telepathic_mock, mock_logger):
def mock_random_side_effect():
return 0.5
mock_random.return_value = 0.5
args = MockArgs(
stories_percentage=0,
follow_percentage=100,
likes_percentage=100,
likes_count="1-1"
)
args = MockArgs(stories_percentage=0, follow_percentage=100, likes_percentage=100, likes_count="1-1")
configs = MockConfigs(args)
from GramAddict.core.session_state import SessionState
session_state = SessionState(configs)
session_state.set_limits_session()
# Should not throw any exception
_interact_with_profile(device, configs, "testuser", session_state, 0.0, mock_logger)
# Grid loop finishes with 1 scroll for 1 post.
assert device.shell.call_count >= 0
@patch("random.random")
@patch("GramAddict.core.behaviors.carousel_browsing.humanized_horizontal_swipe")
def test_carousel_100_percent(mock_swipe, mock_random, device):
mock_random.return_value = 0.0
args = MockArgs(
carousel_percentage=100,
carousel_count="4-4"
)
args = MockArgs(carousel_percentage=100, carousel_count="4-4")
configs = MockConfigs(args)
ctx = BehaviorContext(device=device, configs=configs, session_state=MagicMock(), cognitive_stack={}, context_xml="<carousel_page_indicator/>", sleep_mod=1.0)
ctx = BehaviorContext(
device=device,
configs=configs,
session_state=MagicMock(),
cognitive_stack={},
context_xml="<carousel_page_indicator/>",
sleep_mod=1.0,
)
plugin = CarouselBrowsingPlugin()
res = plugin.execute(ctx)
assert res.executed
assert mock_swipe.call_count == 4
@patch("random.random")
@patch("GramAddict.core.behaviors.carousel_browsing.humanized_horizontal_swipe")
def test_carousel_zero_percent(mock_swipe, mock_random, device):
mock_random.return_value = 0.99
args = MockArgs(
carousel_percentage=0,
carousel_count="4-4"
)
args = MockArgs(carousel_percentage=0, carousel_count="4-4")
configs = MockConfigs(args)
ctx = BehaviorContext(device=device, configs=configs, session_state=MagicMock(), cognitive_stack={}, context_xml="<carousel_page_indicator/>", sleep_mod=1.0)
ctx = BehaviorContext(
device=device,
configs=configs,
session_state=MagicMock(),
cognitive_stack={},
context_xml="<carousel_page_indicator/>",
sleep_mod=1.0,
)
plugin = CarouselBrowsingPlugin()
res = plugin.execute(ctx)
assert not res.executed
assert mock_swipe.call_count == 0
@patch("random.random")
def test_interact_with_profile_follow_limit_enforcement(mock_random, device, telepathic_mock, mock_logger):
# Guaranteed 100% probability
mock_random.return_value = 0.0
mock_random.return_value = 0.0
args = MockArgs(
follow_percentage=100,
total_follows_limit=0, # Set hard limit to 0
follow_percentage=100,
total_follows_limit=0, # Set hard limit to 0
stories_percentage=0,
likes_percentage=0
likes_percentage=0,
)
configs = MockConfigs(args)
from GramAddict.core.session_state import SessionState
# Mocking that 1 follow was already made to exceed the 0 limit
session_state = SessionState(configs)
session_state.set_limits_session()
session_state.totalFollowed["targetuser"] = 1
_interact_with_profile(device, configs, "targetuser", session_state, 0.0, mock_logger)
# Assert shells is 0 (assuming stories and likes probability mathematically default to 0 due to MockArgs empty fallback)
assert device.shell.call_count == 0
@patch("random.random")
def test_interact_with_profile_likes_limit_enforcement(mock_random, device, telepathic_mock, mock_logger):
# Guaranteed 100% probability
mock_random.return_value = 0.0
mock_random.return_value = 0.0
args = MockArgs(
likes_percentage=100,
likes_count="1-1",
total_likes_limit=2,
stories_percentage=0,
follow_percentage=0
likes_percentage=100, likes_count="1-1", total_likes_limit=2, stories_percentage=0, follow_percentage=0
)
configs = MockConfigs(args)
from GramAddict.core.session_state import SessionState
session_state = SessionState(configs)
session_state.set_limits_session()
# Faking exhaustion of total likes limit
session_state.totalLikes = 3
session_state.totalLikes = 3
_interact_with_profile(device, configs, "targetuser", session_state, 0.0, mock_logger)
# Limit restricts likes block.
# Limit restricts likes block.
assert device.shell.call_count == 0
# NOTE: Repost is deeply integrated into `bot_flow._run_zero_latency_feed_loop`. We can't mock the
# NOTE: Repost is deeply integrated into `bot_flow._run_zero_latency_feed_loop`. We can't mock the
# entire NavGraph loop here easily because it requires massive setup, but we verified the probability
# syntax by applying the same logic as Carousel/Profile interaction.
#
# Therefore we verify it via the manual `run.py` validation.
# syntax by applying the same logic as Carousel/Profile interaction.
#
# Therefore we verify it via the manual `run.py` validation.
# However, this test suite guarantees the atomic config mapping syntax is correct.

View File

@@ -1,31 +1,31 @@
import pytest
import argparse
from unittest.mock import MagicMock, patch
from GramAddict.core.resonance_engine import ResonanceEngine
def test_config_persona_mapping():
"""Verify that bot_flow.py correctly extracts persona from config arguments."""
from GramAddict.core.config import Config
mock_args = argparse.Namespace()
mock_args.ai_target_audience = "travel, photography, coffee"
configs = MagicMock()
configs.args = mock_args
configs.username = "test_bot"
# Simulating bot_flow.py lines 61-62
persona_raw = getattr(configs.args, "ai_target_audience", getattr(configs.args, "persona_interests", ""))
persona_interests = [p.strip() for p in persona_raw.split(",") if p.strip()] if persona_raw else []
assert "coffee" in persona_interests
assert len(persona_interests) == 3
# Ensure ResonanceEngine accepts it without type tracebacks
with patch('GramAddict.core.resonance_engine.ContentMemoryDB'), \
patch('GramAddict.core.resonance_engine.ParasocialCRMDB'), \
patch('GramAddict.core.resonance_engine.PersonaMemoryDB'):
with (
patch("GramAddict.core.resonance_engine.ContentMemoryDB"),
patch("GramAddict.core.resonance_engine.ParasocialCRMDB"),
patch("GramAddict.core.resonance_engine.PersonaMemoryDB"),
):
engine = ResonanceEngine(configs.username, persona_interests=persona_interests)
assert engine._persona_interests == ["travel", "photography", "coffee"]

View File

@@ -0,0 +1,31 @@
from GramAddict.core.config import Config
def test_config_plugin_section():
# Mock config
config = Config(first_run=True, **{})
config.config = {"plugins": {"dummy_plugin": {"enabled": False, "percentage": 50}}}
config.args = type("Args", (), {})()
# We should be able to get the plugin config
plugin_config = config.get_plugin_config("dummy_plugin")
assert plugin_config == {"enabled": False, "percentage": 50}
def test_config_plugin_fallback():
config = Config(first_run=True, **{})
config.config = {}
config.args = type("Args", (), {"dummy_plugin_percentage": 75, "dummy_plugin_enabled": True})()
plugin_config = config.get_plugin_config("dummy_plugin")
assert plugin_config == {"percentage": 75}
def test_config_plugin_not_found():
config = Config(first_run=True, **{})
config.config = {}
config.args = type("Args", (), {})()
# Non-existent plugin should return empty dict
plugin_config = config.get_plugin_config("nonexistent")
assert plugin_config == {}

View File

@@ -1,19 +1,21 @@
import pytest
import logging
from unittest.mock import MagicMock
import pytest
from GramAddict.core.bot_flow import _interact_with_profile
from GramAddict.core.exceptions import ActionBlockedError
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.bot_flow import _interact_with_profile
import logging
class TestCriticalAnomalyGuards:
def setup_method(self):
self.mock_device = MagicMock()
self.mock_device.deviceV2 = MagicMock()
self.mock_device.app_id = "com.instagram.android"
self.mock_device._get_current_app.return_value = "com.instagram.android"
self.mock_device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
self.nav_graph = QNavGraph(self.mock_device)
self.logger = logging.getLogger("test")
@@ -22,7 +24,7 @@ class TestCriticalAnomalyGuards:
If the OS/App shows a 'Try Again Later' block, we must explicitly crash the bot
by throwing an ActionBlockedError to prevent spamming and risking permanent bans.
"""
self.mock_device.dump_hierarchy.return_value = '''
self.mock_device.dump_hierarchy.return_value = """
<hierarchy>
<node package="com.instagram.android" resource-id="com.instagram.android:id/dialog_container">
<node package="com.instagram.android" text="Try Again Later" resource-id="com.instagram.android:id/dialog_title" />
@@ -30,30 +32,31 @@ class TestCriticalAnomalyGuards:
<node package="com.instagram.android" text="OK" resource-id="com.instagram.android:id/button_positive" />
</node>
</hierarchy>
'''
"""
with pytest.raises(ActionBlockedError, match="Instagram action block detected"):
self.nav_graph._clear_anomaly_obstacles()
def test_interact_with_private_profile_aborts(self):
"""
If a user account is private, _interact_with_profile must skip everything
If a user account is private, _interact_with_profile must skip everything
and return without doing ANY interactions.
"""
self.mock_device.dump_hierarchy.return_value = '''
self.mock_device.dump_hierarchy.return_value = """
<hierarchy>
<node text="marisaundmarc" />
<node text="This account is private" bounds="[100,500][900,600]" />
<node text="Follow to see their photos and videos." />
</hierarchy>
'''
"""
configs = MagicMock()
session_state = MagicMock()
_interact_with_profile(self.mock_device, configs, "test_private", session_state, sleep_mod=0.0, logger=self.logger)
_interact_with_profile(
self.mock_device, configs, "test_private", session_state, sleep_mod=0.0, logger=self.logger
)
# Verify it did not attempt to find stories, scrape, or anything
self.mock_device.click.assert_not_called()
self.mock_device.press.assert_not_called()
@@ -62,18 +65,20 @@ class TestCriticalAnomalyGuards:
"""
If a user account has 0 posts, we must skip.
"""
self.mock_device.dump_hierarchy.return_value = '''
self.mock_device.dump_hierarchy.return_value = """
<hierarchy>
<node text="marisaundmarc" />
<node text="No posts yet" bounds="[100,500][900,600]" />
</hierarchy>
'''
"""
configs = MagicMock()
session_state = MagicMock()
_interact_with_profile(self.mock_device, configs, "test_empty", session_state, sleep_mod=0.0, logger=self.logger)
_interact_with_profile(
self.mock_device, configs, "test_empty", session_state, sleep_mod=0.0, logger=self.logger
)
self.mock_device.click.assert_not_called()
self.mock_device.press.assert_not_called()
@@ -83,19 +88,20 @@ class TestCriticalAnomalyGuards:
instead of defaulting to the VLM fallback which hallucinates clicks on random UI elements.
"""
from GramAddict.core.telepathic_engine import TelepathicEngine
engine = TelepathicEngine() # Bypass singleton mock
xml_dump = '''
engine = TelepathicEngine() # Bypass singleton mock
xml_dump = """
<hierarchy>
<node text="marisaundmarc" />
<node text="comments are turned off." bounds="[100,500][900,600]" />
<node text="Following" />
</hierarchy>
'''
"""
# TelepathicEngine should instantly return a "skip" object without invoking memory or vectors
result = engine.find_best_node(xml_dump, "tap comment button", device=self.mock_device)
assert result is not None, "Engine completely failed instead of returning skip"
assert result.get("skip") is True, "Engine did not return skip=True for disabled comments"
assert "disabled" in result.get("semantic", ""), "Semantic tag should reflect disabled comments"

View File

@@ -1,12 +1,12 @@
import os
from pathlib import Path
import pytest
from unittest.mock import Mock
from GramAddict.core.darwin_engine import DarwinEngine
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures"
def read_fixture(filename: str) -> str:
path = FIXTURES_DIR / filename
if not path.exists():
@@ -14,26 +14,31 @@ def read_fixture(filename: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()
@pytest.fixture
def darwin():
engine = DarwinEngine("test_user")
return engine
def test_has_comments_true_reel(darwin):
# This reel has "Comment number is181. View comments"
xml = read_fixture("explore_feed_reel.xml")
assert darwin._has_comments(xml) is True
def test_has_comments_true_organic(darwin):
# This organic post has "Photo 1 of 13 by Fiona Dawson, Liked by ..., 23 comments"
xml = read_fixture("organic_post.xml")
assert darwin._has_comments(xml) is True
def test_has_comments_zero_reel(darwin):
# This reel has "Comment number is1247. View comments" so it DOES have comments
xml = read_fixture("reels_feed_dump.xml")
assert darwin._has_comments(xml) is True
def test_has_comments_regex_cases(darwin):
# Specific edge cases string tests
assert darwin._has_comments('<node text="View all 12 comments" />') is True

View File

@@ -1,44 +1,49 @@
import pytest
import time
import pytest
from GramAddict.core.dopamine_engine import DopamineEngine
def test_dopamine_engine_wants_to_change_feed():
try:
engine = DopamineEngine()
except Exception as e:
pytest.fail(f"DopamineEngine failed to initialize: {e}")
# Set boredom to trigger threshold
engine.boredom = 90.0
# Assert that the method exists and returns a boolean (probabilistic, so we just check type)
result = engine.wants_to_change_feed()
assert isinstance(result, bool), "wants_to_change_feed() must return a boolean"
def test_dopamine_engine_reset_session_clears_boredom():
engine = DopamineEngine()
# Simulate a crashed/burnt out session
engine.boredom = 100.0
assert engine.is_app_session_over() is True, "Session should be over when boredom is at 100"
time.sleep(0.1) # small buffer for time
old_start = engine.session_start
# Trigger the fix
engine.reset_session()
# Verify exact state reset
assert engine.boredom == 0.0, "Boredom must be reset to 0.0 on a new session"
assert engine.session_start > old_start, "Session start time must be updated"
assert engine.is_app_session_over() is False, "Session should no longer be over"
def test_dopamine_engine_wants_to_doomscroll():
engine = DopamineEngine()
engine.boredom = 50.0
assert engine.wants_to_doomscroll() is False
# Trigger doomscroll threshold
engine.boredom = 95.0
result = engine.wants_to_doomscroll()

View File

@@ -1,7 +1,5 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.dopamine_engine import DopamineEngine
import GramAddict.core.bot_flow as bot_flow
def test_feed_switch_resets_boredom():
"""
@@ -11,25 +9,26 @@ def test_feed_switch_resets_boredom():
"""
# Initialize DopamineEngine and simulate the state right before a feed switch
dopamine = DopamineEngine()
# Simulate a full inbox clear causing maximum boredom
dopamine.boredom = 100.0
# Assert that ordinarily, the session WOULD be over
assert dopamine.is_app_session_over() is True
# SIMULATE bot_flow.py logic that occurs during BOREDOM_CHANGE_FEED
result = "BOREDOM_CHANGE_FEED"
assert result == "BOREDOM_CHANGE_FEED"
# Apply the fix from bot_flow.py lines 210-215
dopamine.boredom = max(0.0, dopamine.boredom * 0.2)
# Assert that the session is NO LONGER over, and the bot can continue to the new feed
assert dopamine.boredom == 20.0
assert dopamine.is_app_session_over() is False
def test_session_limit_terminates_session():
dopamine = DopamineEngine()
dopamine.session_limit_seconds = 0 # force time limit
dopamine.session_limit_seconds = 0 # force time limit
assert dopamine.is_app_session_over() is True

View File

@@ -9,7 +9,6 @@ The root cause: _run_zero_latency_stories_loop returns "SESSION_OVER" when
stories are exhausted, and the main loop interprets this as "end the entire
bot session" via `else: break`.
"""
import pytest
class TestFeedLoopContinuation:
@@ -28,14 +27,15 @@ class TestFeedLoopContinuation:
# the return value semantics are correct.
# If stories loop returns "SESSION_OVER", the main flow breaks.
# If it returns "FEED_EXHAUSTED", the main flow can switch feeds.
# This test checks the contract: after a sub-feed completes naturally,
# the session should NOT be over unless dopamine says so.
from GramAddict.core.bot_flow import _run_zero_latency_stories_loop
import inspect
from GramAddict.core.bot_flow import _run_zero_latency_stories_loop
source = inspect.getsource(_run_zero_latency_stories_loop)
# The function must return FEED_EXHAUSTED when stories are done naturally
assert "FEED_EXHAUSTED" in source, (
"StoriesFeed loop still returns 'SESSION_OVER' when stories are exhausted. "
@@ -48,11 +48,12 @@ class TestFeedLoopContinuation:
The main session loop must handle 'FEED_EXHAUSTED' by switching
to another available feed target, NOT by breaking.
"""
from GramAddict.core import bot_flow
import inspect
from GramAddict.core import bot_flow
source = inspect.getsource(bot_flow.start_bot)
assert "FEED_EXHAUSTED" in source, (
"Main loop does not handle 'FEED_EXHAUSTED' result. "
"When a sub-feed is exhausted, the bot must switch to another feed."

View File

@@ -8,10 +8,11 @@ Bug A: VLM guard enforces "must be at bottom" for ALL nav intent keywords,
Bug B: GOAP back-press loop has no circuit breaker. If the planner keeps
pressing back on the same screen, it eventually exits Instagram.
"""
import pytest
from GramAddict.core.telepathic_engine import TelepathicEngine
from unittest.mock import MagicMock
from GramAddict.core.goap import GoalExecutor, ScreenType
from unittest.mock import MagicMock, patch
from GramAddict.core.telepathic_engine import TelepathicEngine
class TestVlmGuardFollowingConsistency:
@@ -60,9 +61,7 @@ class TestVlmGuardFollowingConsistency:
intent = "open followers list"
is_valid = engine._structural_sanity_check(follower_node, intent, screen_height)
assert is_valid is True, (
"Inner structural guard rejected 'followers' element at Y=246."
)
assert is_valid is True, "Inner structural guard rejected 'followers' element at Y=246."
class TestGoapBackPressCircuitBreaker:
@@ -84,6 +83,7 @@ class TestGoapBackPressCircuitBreaker:
# Simulate being stuck on HOME_FEED with only back available
call_count = 0
def mock_perceive():
nonlocal call_count
call_count += 1
@@ -102,10 +102,7 @@ class TestGoapBackPressCircuitBreaker:
result = goap.achieve("open following list", max_steps=15)
# The bot should NOT have pressed back more than 3 times
back_calls = [
c for c in goap._execute_action.call_args_list
if c[0][0] == "press back"
]
back_calls = [c for c in goap._execute_action.call_args_list if c[0][0] == "press back"]
assert len(back_calls) <= 3, (
f"GOAP pressed back {len(back_calls)} times on the same screen. "
"It should abort after 3 consecutive back-presses with no progress."

View File

@@ -6,10 +6,11 @@ routing strategy. When asked to reach FollowingList from HomeFeed,
it should return "tap profile tab" (first step of the BFS route),
NOT "open following list" (impossible direct action).
"""
import pytest
import sys
import os
from unittest.mock import MagicMock
import sys
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
@@ -98,6 +99,4 @@ class TestGoapGraphRouting:
"selected_tab": "explore_tab",
}
action = planner.plan_next_step("open following list", screen)
assert action == "tap profile tab", (
f"From EXPLORE_GRID, planner should route via OWN_PROFILE, got '{action}'"
)
assert action == "tap profile tab", f"From EXPLORE_GRID, planner should route via OWN_PROFILE, got '{action}'"

View File

@@ -6,14 +6,16 @@ Tests that validate the GOAP planner correctly handles multi-step navigation:
- Wrong screens (tap profile tab → REELS_FEED) must be REJECTED
- Structural HD Map actions must NEVER be aversively learned as traps
"""
import pytest
import sys
import os
from unittest.mock import MagicMock, patch
import sys
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from GramAddict.core.goap import GoalExecutor, GoalPlanner, ScreenType, NavigationKnowledge
from GramAddict.core.goap import GoalExecutor, GoalPlanner, ScreenType
from GramAddict.core.screen_topology import ScreenTopology

View File

@@ -10,14 +10,7 @@ def test_goap_unlearns_transition_on_back_press_trap():
orchestrator = GoalExecutor(device, "testuser")
# Mocking internal state
start_screen = ScreenType.HOME_FEED
goal = "open messages"
steps_taken = [
{"action": "tap explore tab"},
{"action": "press back"},
{"action": "press back"},
{"action": "press back"},
]
def mock_exec(*args, **kwargs):
print("EXECUTING:", args, kwargs)

View File

@@ -1,11 +1,10 @@
import pytest
from GramAddict.core.telepathic_engine import TelepathicEngine
class TestGridRetryDiversity:
"""
TDD Tests: Reproduces Bug 2 from the 2026-04-17 09:56 run.
The Grid Fast-Path always selects the exact same node on every retry
because it sorts by (y, x) and picks index 0. When a click fails,
the retry should skip previously-failed positions.
@@ -17,30 +16,66 @@ class TestGridRetryDiversity:
def _make_grid_nodes(self):
"""Create 6 realistic explore grid nodes (2 rows × 3 cols)."""
return [
{"semantic_string": "id context: 'image button'", "x": 178, "y": 558,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 540, "y": 558,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 902, "y": 558,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 178, "y": 1040,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 540, "y": 1040,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{"semantic_string": "id context: 'image button'", "x": 902, "y": 1040,
"area": 169100, "resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button", "selected": False,
"original_attribs": {"text": "", "desc": ""}},
{
"semantic_string": "id context: 'image button'",
"x": 178,
"y": 558,
"area": 169100,
"resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button",
"selected": False,
"original_attribs": {"text": "", "desc": ""},
},
{
"semantic_string": "id context: 'image button'",
"x": 540,
"y": 558,
"area": 169100,
"resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button",
"selected": False,
"original_attribs": {"text": "", "desc": ""},
},
{
"semantic_string": "id context: 'image button'",
"x": 902,
"y": 558,
"area": 169100,
"resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button",
"selected": False,
"original_attribs": {"text": "", "desc": ""},
},
{
"semantic_string": "id context: 'image button'",
"x": 178,
"y": 1040,
"area": 169100,
"resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button",
"selected": False,
"original_attribs": {"text": "", "desc": ""},
},
{
"semantic_string": "id context: 'image button'",
"x": 540,
"y": 1040,
"area": 169100,
"resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button",
"selected": False,
"original_attribs": {"text": "", "desc": ""},
},
{
"semantic_string": "id context: 'image button'",
"x": 902,
"y": 1040,
"area": 169100,
"resource_id": "com.instagram.android:id/image_button",
"class_name": "android.widget.Button",
"selected": False,
"original_attribs": {"text": "", "desc": ""},
},
]
def test_first_call_returns_topmost_leftmost(self):
@@ -54,20 +89,15 @@ class TestGridRetryDiversity:
def test_retry_skips_failed_position(self):
"""With skip_positions={(178, 558)}, the next node (540, 558) is returned."""
nodes = self._make_grid_nodes()
result = self.engine._grid_fast_path(
"first image in explore grid", nodes,
skip_positions={(178, 558)}
)
result = self.engine._grid_fast_path("first image in explore grid", nodes, skip_positions={(178, 558)})
assert result is not None
assert (result["x"], result["y"]) == (540, 558), \
f"Expected (540, 558) but got ({result['x']}, {result['y']})"
assert (result["x"], result["y"]) == (540, 558), f"Expected (540, 558) but got ({result['x']}, {result['y']})"
def test_skip_multiple_positions(self):
"""Skipping 2 positions returns the 3rd grid item."""
nodes = self._make_grid_nodes()
result = self.engine._grid_fast_path(
"first image in explore grid", nodes,
skip_positions={(178, 558), (540, 558)}
"first image in explore grid", nodes, skip_positions={(178, 558), (540, 558)}
)
assert result is not None
assert (result["x"], result["y"]) == (902, 558)
@@ -76,8 +106,5 @@ class TestGridRetryDiversity:
"""If every grid node is skipped, return None to trigger VLM fallback."""
nodes = self._make_grid_nodes()
all_positions = {(n["x"], n["y"]) for n in nodes}
result = self.engine._grid_fast_path(
"first image in explore grid", nodes,
skip_positions=all_positions
)
result = self.engine._grid_fast_path("first image in explore grid", nodes, skip_positions=all_positions)
assert result is None

View File

@@ -1,27 +1,29 @@
import pytest
from GramAddict.core.utils import is_ad
def test_is_ad_false_positive_abroad():
# Simulate an IG node with 'abroad' in the text
xml_false_positive = '''<?xml version="1.0"?>
xml_false_positive = """<?xml version="1.0"?>
<hierarchy>
<node resource-id="com.instagram.android:id/secondary_label" text="brunette_abroad" content-desc="" />
</hierarchy>'''
</hierarchy>"""
assert not is_ad(xml_false_positive), "Bot flagged 'abroad' as an AD because it contains 'ad'!"
def test_is_ad_true_positive():
xml_true_positive = '''<?xml version="1.0"?>
xml_true_positive = """<?xml version="1.0"?>
<hierarchy>
<node resource-id="com.instagram.android:id/secondary_label" text="Sponsored" content-desc="" />
</hierarchy>'''
</hierarchy>"""
assert is_ad(xml_true_positive), "Bot failed to flag 'Sponsored'"
def test_is_ad_true_positive_ad_word():
xml_ad = '''<?xml version="1.0"?>
xml_ad = """<?xml version="1.0"?>
<hierarchy>
<node resource-id="com.instagram.android:id/secondary_label" text="Ad" content-desc="" />
</hierarchy>'''
</hierarchy>"""
assert is_ad(xml_ad), "Bot failed to flag standalone 'Ad'"

View File

@@ -1,67 +1,71 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.llm_provider import query_telepathic_llm
import requests
class TestLLMProvider:
from GramAddict.core.llm_provider import query_telepathic_llm
class TestLLMProvider:
def test_vlm_timeout_aborts_fast_connections(self, monkeypatch):
"""
OpenRouter/Cloud connections must timeout around ~45s.
If a timeout exception is raised by requests, query_telepathic_llm should gracefully catch it
and return empty "{}" JSON.
"""
def mock_post(*args, **kwargs):
timeout = kwargs.get("timeout")
assert timeout == 45, "Expected 45s strict timeout for openrouter"
raise requests.exceptions.ReadTimeout("Mocked read timeout")
monkeypatch.setattr(requests, "post", mock_post)
# Test Cloud URL
result = query_telepathic_llm(
model="openrouter/qwen3.5:latest",
url="https://openrouter.ai/api/v1/chat/completions",
system_prompt="sys",
user_prompt="user",
use_local_edge=False
use_local_edge=False,
)
assert result == "{}"
def test_vlm_timeout_allows_local_processing(self, monkeypatch):
"""
Localhost (Ollama) connections must have a significantly higher timeout (180s)
so cold starts loading into VRAM don't drop.
"""
def mock_post(*args, **kwargs):
url = kwargs.get("url") or args[0]
kwargs.get("url") or args[0]
timeout = kwargs.get("timeout")
assert timeout == 180, f"Expected 180s extended timeout for localhost, got {timeout}"
mock_resp = MagicMock()
mock_resp.status_code = 200
# For ollama/vllm mimic structure
mock_resp.json.return_value = {"response": '{"clicked": "true"}'}
return mock_resp
monkeypatch.setattr(requests, "post", mock_post)
# Test Local URL
result = query_telepathic_llm(
model="qwen3.5:latest",
url="http://localhost:11434/api/generate",
system_prompt="sys",
user_prompt="user",
use_local_edge=False
use_local_edge=False,
)
assert result == '{"clicked": "true"}'
def test_local_edge_override_applies_timeout(self, monkeypatch):
"""
If use_local_edge=True is set, it overrides the URL to localhost and MUST apply 180s.
"""
def mock_post(*args, **kwargs):
timeout = kwargs.get("timeout")
assert timeout == 180, "Expected 180s extended timeout when forced to local edge"
@@ -69,15 +73,15 @@ class TestLLMProvider:
mock_resp.status_code = 200
mock_resp.json.return_value = {"response": '{"edge": "true"}'}
return mock_resp
monkeypatch.setattr(requests, "post", mock_post)
result = query_telepathic_llm(
model="openrouter",
url="https://openrouter...",
system_prompt="sys",
user_prompt="user",
use_local_edge=True # Force local
use_local_edge=True, # Force local
)
assert result == '{"edge": "true"}'

View File

@@ -1,7 +1,8 @@
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
from GramAddict.core.llm_provider import query_llm
def test_query_llm_passes_timeout_to_requests():
"""
Verifies that the query_llm wrapper passes the configured
@@ -11,26 +12,27 @@ def test_query_llm_passes_timeout_to_requests():
mock_response = MagicMock()
mock_response.json.return_value = {"response": "test"}
mock_response.raise_for_status.return_value = None
with patch("GramAddict.core.llm_provider.requests.post", return_value=mock_response) as mock_post:
# Act with custom 180s timeout
# Using format_json=False because Ollama branch accesses resp_json["response"]
query_llm("http://localhost:11434", "test_model", "test_prompt", timeout=180, format_json=False)
# Assert
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert kwargs.get("timeout") == 180, "query_llm failed to pass the custom timeout to requests.post"
def test_query_llm_default_timeout_is_configurable():
"""
Verifies that if no timeout is passed, by default we still use a configured or sensible value (60s).
"""
mock_response = MagicMock()
mock_response.json.return_value = {"response": "test"}
with patch("GramAddict.core.llm_provider.requests.post", return_value=mock_response) as mock_post:
query_llm("http://localhost:11434", "test_model", "test_prompt", format_json=False)
_, kwargs = mock_post.call_args
assert kwargs.get("timeout") == 180, "query_llm default timeout should act as fallback"

View File

@@ -1,7 +1,10 @@
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import patch, MagicMock
from GramAddict.core.llm_provider import unload_ollama_models
def test_unload_ollama_models_sends_keep_alive_0():
"""
Ensures that when unload_ollama_models is called, it correctly identifies
@@ -11,62 +14,65 @@ def test_unload_ollama_models_sends_keep_alive_0():
mock_configs = MagicMock()
mock_configs.args.ai_telepathic_model = "llama3.2:1b"
mock_configs.args.ai_telepathic_url = "http://localhost:11434/api/generate"
mock_configs.args.ai_fallback_model = "qwen2.5:latest"
mock_configs.args.ai_fallback_url = "http://127.0.0.1:11434/api/generate"
# Cloud model should NOT be unloaded
mock_configs.args.ai_model = "openrouter/anthropic/claude"
mock_configs.args.ai_model_url = "https://openrouter.ai/api/v1/chat/completions"
with patch("GramAddict.core.llm_provider.requests.post") as mock_post:
unload_ollama_models(mock_configs)
# unload_ollama_models uses a background thread, so we must wait slightly or mock the threading.
# But wait! We can just call the inner _unload directly, or wait a fraction of a second.
import time
time.sleep(0.1)
# Expect 2 calls (for the 2 local models)
assert mock_post.call_count == 2
# Extract the JSON bodies of the calls
called_json_args = [call.kwargs.get("json") for call in mock_post.call_args_list]
# Verify keep_alive: 0 is present for both
assert {"model": "llama3.2:1b", "keep_alive": 0} in called_json_args
assert {"model": "qwen2.5:latest", "keep_alive": 0} in called_json_args
# Verify cloud model was skipped
assert not any(arg.get("model") == "openrouter/anthropic/claude" for arg in called_json_args)
def test_bot_flow_triggers_ollama_cleanup():
"""
Ensures that the start_bot function triggers unload_ollama_models
Ensures that the start_bot function triggers unload_ollama_models
in its finally block when finishing or aborting.
"""
from GramAddict.core.bot_flow import start_bot
with patch("GramAddict.core.bot_flow.Config") as mock_config_cls, \
patch("GramAddict.core.bot_flow.configure_logger"), \
patch("GramAddict.core.bot_flow.check_if_updated"), \
patch("GramAddict.core.benchmark_guard.check_model_benchmarks"), \
patch("GramAddict.core.llm_provider.log_openrouter_burn"), \
patch("GramAddict.core.llm_provider.prewarm_ollama_models"), \
patch("GramAddict.core.bot_flow.create_device") as mock_create_device, \
patch("GramAddict.core.session_state.SessionState.inside_working_hours", return_value=(True, 0)), \
patch("GramAddict.core.llm_provider.unload_ollama_models") as mock_unload:
with (
patch("GramAddict.core.bot_flow.Config") as mock_config_cls,
patch("GramAddict.core.bot_flow.configure_logger"),
patch("GramAddict.core.bot_flow.check_if_updated"),
patch("GramAddict.core.benchmark_guard.check_model_benchmarks"),
patch("GramAddict.core.llm_provider.log_openrouter_burn"),
patch("GramAddict.core.llm_provider.prewarm_ollama_models"),
patch("GramAddict.core.bot_flow.create_device") as mock_create_device,
patch("GramAddict.core.session_state.SessionState.inside_working_hours", return_value=(True, 0)),
patch("GramAddict.core.llm_provider.unload_ollama_models") as mock_unload,
):
mock_configs = MagicMock()
mock_config_cls.return_value = mock_configs
# Simulate a crash inside the try block
mock_device = MagicMock()
mock_device.wake_up.side_effect = Exception("Simulate immediate crash")
mock_create_device.return_value = mock_device
with pytest.raises(Exception, match="Simulate immediate crash"):
start_bot()
# Verify the cleanup was STILL called even during a crash
mock_unload.assert_called_once_with(mock_configs)

View File

@@ -1,16 +1,19 @@
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 PathMemory
class FakePoint:
def __init__(self, payload):
self.payload = payload
@pytest.fixture
def mock_qdrant_base():
with patch("GramAddict.core.qdrant_memory.QdrantBase") as mock:
@@ -21,43 +24,44 @@ def mock_qdrant_base():
instance._get_embedding.return_value = [0.1] * 768
yield instance
def test_path_overwrites_on_failure(mock_qdrant_base):
"""
TDD Case: If a success path exists, but then a failure occurs,
the failure should overwrite the success (or at least be the
TDD Case: If a success path exists, but then a failure occurs,
the failure should overwrite the success (or at least be the
recalled path) because they share the same seed.
"""
pm = PathMemory()
pm._db = mock_qdrant_base # Inject our mock
pm._db = mock_qdrant_base # Inject our mock
goal = "open messages"
start = "home_feed"
# 1. Learn success
pm.learn_path(goal, start, [{"action": "tap messages tab"}], True)
# Verify upsert seed
# With our fix, it should be simply "open messages|home_feed"
args, kwargs = mock_qdrant_base.upsert_point.call_args
assert args[0] == f"{goal}|{start}"
assert args[1]["success"] is True
# 2. Learn failure (same goal, same start)
# This should call upsert_point with the SAME seed, thus overwriting
pm.learn_path(goal, start, [{"action": "tap reels tab"}] * 15, False)
args, kwargs = mock_qdrant_base.upsert_point.call_args
assert args[0] == f"{goal}|{start}"
assert args[1]["success"] is False
assert args[1]["step_count"] == 15
# 3. Recall
# We mock return from Qdrant - only one item (the latest)
query_result = MagicMock()
query_result.points = [FakePoint(args[1])] # The failure payload
query_result.points = [FakePoint(args[1])] # The failure payload
mock_qdrant_base.client.query_points.return_value = query_result
recalled = pm.recall_path(goal, start)
# Should be None because the latest entry has success=False
assert recalled is None

View File

@@ -4,8 +4,10 @@ Unit test: Humanized Scroll Speed Variations.
Validates that different scroll behavior branches produce
gestures with different timing characteristics.
"""
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import patch, MagicMock
from GramAddict.core.physics.biomechanics import PhysicsBody
from GramAddict.core.physics.sendevent_injector import SendEventInjector
@@ -53,7 +55,7 @@ def test_humanized_scroll_skip_is_strictly_forward(MockInjector):
"""
Ensures that when is_skip=True (e.g. for aggressive ad skipping),
the generated gesture is purely forward (bottom to top swipe),
and does NOT contain backwards 'Doomscroll corrections' or
and does NOT contain backwards 'Doomscroll corrections' or
biomechanical 'reading pauses'.
"""
mock_injector = MagicMock()
@@ -68,18 +70,20 @@ def test_humanized_scroll_skip_is_strictly_forward(MockInjector):
for _ in range(50):
mock_injector.reset_mock()
humanized_scroll(device, is_skip=True)
args = mock_injector.inject_gesture.call_args
points = args[0][0]
timing = args[0][1]
# In a forward scroll (bottom to top), the Y coordinate must go from a higher number to a lower number.
start_y = points[0][1]
end_y = points[-1][1]
# End Y MUST be less than Start Y (meaning we scrolled down the feed, swiping finger up)
assert end_y < start_y, f"Expected end_y ({end_y}) to be < start_y ({start_y}) for a skip."
# Verify no long pauses (reading pause adds 0.5s to 2.0s to timing)
for t in timing:
assert t < 500, "Found a massive pause in a skip gesture, meaning a reading_pause or dwell was incorrectly inserted!"
assert (
t < 500
), "Found a massive pause in a skip gesture, meaning a reading_pause or dwell was incorrectly inserted!"

View File

@@ -24,19 +24,6 @@ class TestProfileInteractionSync:
the engine must skip the click to prevent opening the Favorites/Mute bottom sheet.
"""
# Simulate a profile where the user is already followed
viable_nodes = [
{
"semantic_string": "id context: 'profile header user action follow button', text: 'Following'",
"x": 500,
"y": 600,
"width": 100,
"height": 50,
"area": 5000,
"class_name": "android.widget.Button",
"resource_id": "com.instagram.android:id/button",
"original_attribs": {"text": "Following"},
}
]
# Test vector-based matching fallback
self.engine._blacklist = {}

View File

@@ -1,8 +1,9 @@
import pytest
from unittest.mock import patch, MagicMock, call
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _interact_with_profile
from GramAddict.core.session_state import SessionState
class FakeConfig:
def __init__(self):
self.args = MagicMock()
@@ -11,6 +12,7 @@ class FakeConfig:
self.args.stories_percentage = 0
self.args.likes_count = "1-1"
def test_profile_grid_sync_delay_after_follow():
"""
Verifies that _interact_with_profile enforces a sleep delay
@@ -22,62 +24,63 @@ def test_profile_grid_sync_delay_after_follow():
mock_device.app_id = "com.instagram.android"
mock_device.dump_hierarchy.return_value = "<hierarchy><node package='com.instagram.android' resource-id='com.instagram.android:id/profile_header' /><node text='following' /><node text='followers' /></hierarchy>"
mock_configs = FakeConfig()
mock_session_state = MagicMock(spec=SessionState)
mock_session_state.check_limit.return_value = False
manager = MagicMock()
with patch("GramAddict.core.q_nav_graph.QNavGraph") as MockQNavGraph, \
patch("GramAddict.core.bot_flow.sleep") as mock_sleep_bot_flow, \
patch("GramAddict.core.behaviors.follow.sleep") as mock_sleep, \
patch("GramAddict.core.behaviors.grid_like.wait_for_post_loaded", return_value=True), \
patch("random.random", return_value=0.0): # Use global random patch for local import robustness
with (
patch("GramAddict.core.q_nav_graph.QNavGraph") as MockQNavGraph,
patch("GramAddict.core.bot_flow.sleep"),
patch("GramAddict.core.behaviors.follow.sleep") as mock_sleep,
patch("GramAddict.core.behaviors.grid_like.wait_for_post_loaded", return_value=True),
patch("random.random", return_value=0.0),
): # Use global random patch for local import robustness
mock_nav_instance = MagicMock()
mock_nav_instance.do.return_value = True # Always succeed transition
mock_nav_instance.do.return_value = True # Always succeed transition
MockQNavGraph.return_value = mock_nav_instance
manager.attach_mock(mock_nav_instance.do, 'do')
manager.attach_mock(mock_sleep, 'sleep')
manager.attach_mock(mock_nav_instance.do, "do")
manager.attach_mock(mock_sleep, "sleep")
mock_stack = {"growth_brain": MagicMock()}
from GramAddict.core.behaviors import PluginRegistry
from GramAddict.core.behaviors.follow import FollowPlugin
from GramAddict.core.behaviors.grid_like import GridLikePlugin
registry = PluginRegistry.get_instance()
registry.register(FollowPlugin())
registry.register(GridLikePlugin())
# Act
_interact_with_profile(mock_device, mock_configs, "test_user", mock_session_state, 1.0, MagicMock(), mock_stack)
follow_idx = -1
grid_idx = -1
for i, mock_call in enumerate(manager.mock_calls):
# mock_call format: ('name', (args,), {kwargs})
if mock_call[0] == 'do':
if mock_call[0] == "do":
args = mock_call[1]
if args and args[0] == "tap follow button":
follow_idx = i
elif args and args[0] == "tap first image post in profile grid":
grid_idx = i
assert follow_idx != -1, "Follow transition was not executed"
assert grid_idx != -1, "Grid interaction was not executed"
assert follow_idx < grid_idx, "Follow must happen before grid interaction"
# Verify that more than 1.5s delay happened between follow and grid interaction
# (The bot_flow.py uses random.uniform(1.8, 3.2))
found_delay = False
for i in range(follow_idx + 1, grid_idx):
if manager.mock_calls[i][0] == 'sleep':
if manager.mock_calls[i][0] == "sleep":
sleep_val = manager.mock_calls[i][1][0]
if sleep_val >= 1.5:
found_delay = True
break
assert found_delay, "No significant sleep delay found between follow and grid interaction"

View File

@@ -1,43 +1,47 @@
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock
# Attempt to load the module
from GramAddict.core.qdrant_memory import UIMemoryDB
class DummyMemory(UIMemoryDB):
def __init__(self):
# Prevent actual QdrantClient initialization for offline tests
self.client = None
self.collection_name = "test_collection"
def test_decay_confidence_signature():
"""Ensure decay_confidence doesn't crash from legacy plugins passing xml_context."""
mem = DummyMemory()
# Mock _adjust_confidence to just capture arguments
mem._adjust_confidence = MagicMock()
# Legacy caller pattern A (positional intent and amount)
mem.decay_confidence("my_intent", 0.40)
# Wait, passing positional "my_intent", 0.40 makes `args[0] = "my_intent"`, `args[1] = 0.40`.
# kwargs.get("amount") is 0.25 (default). But if passed positionally, args[1] was xml_context
# in legacy! Let's ensure it doesn't crash.
# Legacy caller pattern B (explicit kwargs)
mem.decay_confidence(intent="my_intent", amount=0.40)
mem._adjust_confidence.assert_called_with("my_intent", -0.40)
# Legacy caller pattern C (positional with string where amount should be)
mem.decay_confidence("my_intent", "xml_context_string")
mem._adjust_confidence.assert_called_with("my_intent", -0.50)
# Legacy caller pattern D (kwargs with xml_context)
mem.decay_confidence(intent="my_intent", xml_context="<node/>", amount=0.30)
mem._adjust_confidence.assert_called_with("my_intent", -0.30)
def test_boost_confidence_signature():
mem = DummyMemory()
mem._adjust_confidence = MagicMock()
mem.boost_confidence("intent", "xml_string", 0.5)
mem._adjust_confidence.assert_called_with("intent", 0.15) # Because "xml_string" fails float conversion, defaults to 0.15
mem._adjust_confidence.assert_called_with(
"intent", 0.15
) # Because "xml_string" fails float conversion, defaults to 0.15

View File

@@ -65,7 +65,7 @@ class TestSAETeslaUpgrade:
db._db._client = mock_client
db._db.client = mock_client
with patch.object(db._db, "upsert_point") as mock_upsert:
with patch.object(db._db, "upsert_point"):
# Mock retrieve to return an existing point with confidence 0.4
mock_payload = {"confidence": 0.4, "action": {"action_type": "back", "x": 0, "y": 0, "reason": ""}}
mock_client.retrieve.return_value = [MagicMock(payload=mock_payload)]

View File

@@ -4,9 +4,9 @@
Tests for BFS pathfinding between Instagram screens.
The killer test: HOME_FEED → OWN_PROFILE → FOLLOW_LIST must be a 2-step route.
"""
import pytest
import sys
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))

View File

@@ -1,23 +1,19 @@
def test_global_session_limit_evaluation(mock_logger):
from GramAddict.core.session_state import SessionState
from tests.conftest import MockArgs, MockConfigs
args = MockArgs(
total_likes_limit=100,
total_follows_limit=100,
total_interactions_limit=1000
)
args = MockArgs(total_likes_limit=100, total_follows_limit=100, total_interactions_limit=1000)
configs = MockConfigs(args)
session_state = SessionState(configs)
session_state.set_limits_session()
# Simulate a fresh session - Limit should NOT be reached
limit_tuple_clean = session_state.check_limit(SessionState.Limit.ALL)
assert not any(limit_tuple_clean), "Fresh session should not evaluate to true for limits"
# Exhaust global limit
for _ in range(1001):
session_state.add_interaction("Feed", succeed=True, followed=False, scraped=False)
limit_tuple_exhausted = session_state.check_limit(SessionState.Limit.ALL)
assert any(limit_tuple_exhausted), "Exhausted session MUST evaluate to true for limits"

View File

@@ -1,7 +1,6 @@
import pytest
import GramAddict.core.telepathic_engine as telepathic_engine
from GramAddict.core.telepathic_engine import TelepathicEngine
def test_structural_guard_rejects_own_story_for_post_username():
"""
TDD Test: Reproduces the bug where Telepathic Engine might select the user's
@@ -10,41 +9,43 @@ def test_structural_guard_rejects_own_story_for_post_username():
"""
engine = TelepathicEngine()
screen_height = 2400
# Mock node representing the user's "Your Story" circle at the top
# It contains "story" or "your story", has low Y (top of screen)
your_story_node = {
"semantic_string": "description: 'Your Story', id context: 'row feed photo profile imageview'",
"y": 250, # Top story tray
"class_name": "android.widget.ImageView"
"y": 250, # Top story tray
"class_name": "android.widget.ImageView",
}
# Intent
intent = "tap post username"
# Expected behavior: Structural sanity check must REJECT this node to prevent
# clicking our own story/profile
is_valid = engine._structural_sanity_check(your_story_node, intent, screen_height)
assert is_valid is False, "Structural Guard failed to reject 'Your Story' when looking for 'post username'."
def test_structural_guard_accepts_actual_post_username():
engine = TelepathicEngine()
screen_height = 2400
actual_post_node = {
"semantic_string": "text: 'estherabad9', id context: 'row feed photo profile name'",
"y": 1200, # Middle of screen (feed post header)
"y": 1200, # Middle of screen (feed post header)
"area": 5000,
"class_name": "android.widget.TextView"
"class_name": "android.widget.TextView",
}
intent = "tap post username"
is_valid = engine._structural_sanity_check(actual_post_node, intent, screen_height)
assert is_valid is True, "Structural Guard incorrectly rejected the actual post username."
def test_structural_guard_rejects_own_username_story():
"""
TDD Test: Reproduces 2026-04-16 23:18 bug where bot selected 'marisaundmarc's story'
@@ -52,25 +53,26 @@ def test_structural_guard_rejects_own_username_story():
"""
engine = TelepathicEngine()
screen_height = 2400
# Simulate current user is marisaundmarc
engine._get_current_username = lambda: "marisaundmarc"
# Mock node representing the user's OWN story, which contains their username
own_story_node = {
"semantic_string": "description: 'marisaundmarc\\'s story, 0 of 27, Unseen.', id context: 'avatar image view'",
"y": 250, # Top story tray
"class_name": "android.widget.ImageView"
"y": 250, # Top story tray
"class_name": "android.widget.ImageView",
}
intent = "profile picture avatar story ring"
# Should reject the user's own profile because clicking it means we edit/view our own story
# Should reject the user's own profile because clicking it means we edit/view our own story
# instead of doing interactions with prospects.
is_valid = engine._structural_sanity_check(own_story_node, intent, screen_height)
assert is_valid is False, "Structural Guard failed to reject the bot's OWN username story."
def test_structural_reels_first_grid_item_y_coords():
"""
TDD Test: Reels viewer layout has grid items that are structurally valid.
@@ -79,30 +81,32 @@ def test_structural_reels_first_grid_item_y_coords():
"""
engine = TelepathicEngine()
screen_height = 2400
# Valid first grid item in a profile's reel tab, usually around y=700 to 1200
valid_grid_node = {
"semantic_string": "description: 'reel, 1 of 20', id context: 'image button'",
"y": 800, # well within safe zone, ~33%
"y": 800, # well within safe zone, ~33%
"area": 40000,
"class_name": "android.widget.ImageView"
"class_name": "android.widget.ImageView",
}
# Hallucinated navigation tab node pretending to be "Home" around y=1200 (middle of screen)
hallucinated_nav_node = {
"semantic_string": "description: 'Home', id context: 'tab'",
"y": 1200, # 50% height
"y": 1200, # 50% height
"area": 1000,
"class_name": "android.view.View"
"class_name": "android.view.View",
}
intent_grid = "first grid item"
intent_nav = "tap home tab"
is_valid_grid = engine._structural_sanity_check(valid_grid_node, intent_grid, screen_height)
assert is_valid_grid is True, "Structural Guard rejected a valid reels grid item."
# The hallucinated nav node should be rejected because navigation tabs belong at the bottom!
# Currently it might fail if we don't have relative coordinate checks!
is_valid_nav = engine._structural_sanity_check(hallucinated_nav_node, intent_nav, screen_height)
assert is_valid_nav is False, "Structural Guard failed to reject a hallucinated navigation tab in the middle of the screen."
assert (
is_valid_nav is False
), "Structural Guard failed to reject a hallucinated navigation tab in the middle of the screen."

View File

@@ -1,6 +1,6 @@
import pytest
from GramAddict.core.telepathic_engine import TelepathicEngine
def test_media_intent_rejects_grid_containers():
"""
TDD Test: Reproduces the bug where intents containing "post" but
@@ -10,23 +10,25 @@ def test_media_intent_rejects_grid_containers():
"""
engine = TelepathicEngine()
screen_height = 2400
# Mock node representing a massive RecyclerView containing the entire grid
# Area is 1080 * 2400 = 2592000 > MAX_CONTAINER_AREA (500000)
massive_grid_container = {
"semantic_string": "id context: 'swipeable nav view pager inner recycler view'",
"area": 2592000,
"y": 1200,
"y": 1200,
"class_name": "androidx.recyclerview.widget.RecyclerView",
"resource_id": "com.instagram.android:id/swipeable_nav_view_pager_inner_recycler_view"
"resource_id": "com.instagram.android:id/swipeable_nav_view_pager_inner_recycler_view",
}
# Intent
intent = "first image post in profile grid"
# Expected behavior: Structural sanity check must REJECT this node because
# although it's a "post" intent, it is specifically looking for an item within a grid/list,
# meaning we should NOT click massive screen-sized containers.
is_valid = engine._structural_sanity_check(massive_grid_container, intent, screen_height)
assert is_valid is False, "Structural Guard failed to reject massive Grid Container when specifically looking for a grid item."
assert (
is_valid is False
), "Structural Guard failed to reject massive Grid Container when specifically looking for a grid item."

View File

@@ -1,89 +1,106 @@
import pytest
from unittest.mock import MagicMock, call
from GramAddict.core.unfollow_engine import _run_zero_latency_unfollow_loop
import logging
from unittest.mock import MagicMock, call
from GramAddict.core.unfollow_engine import _run_zero_latency_unfollow_loop
class TestUnfollowEngine:
def setup_method(self):
self.mock_device = MagicMock()
self.mock_device.deviceV2 = MagicMock()
self.mock_device.app_id = "com.instagram.android"
self.mock_device._get_current_app.return_value = "com.instagram.android"
self.mock_device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
self.mock_telepathic = MagicMock()
self.mock_dopamine = MagicMock()
self.mock_dopamine.is_app_session_over.return_value = False
self.mock_dopamine.wants_to_change_feed.return_value = False
self.mock_dopamine.boredom = 0.0
self.mock_resonance = MagicMock()
self.mock_resonance.calculate_resonance.return_value = 0.2
self.cognitive_stack = {
"telepathic": self.mock_telepathic,
"dopamine": self.mock_dopamine,
"resonance": self.mock_resonance,
}
self.mock_configs = MagicMock()
self.mock_configs.args.total_unfollows_limit = 50
self.mock_session_state = MagicMock()
self.mock_session_state.totalUnfollowed = 0
self.mock_session_state.check_limit.return_value = False
self.logger = logging.getLogger("test")
def test_unfollow_loop_unfollows_low_resonance(self, monkeypatch):
"""
Happy path (Smart Unfollow):
1. Clicks profile row.
2. Dumps profile UI.
3. Resonance is low (< 0.4).
4. Clicks 'Following' button on profile.
5. Clicks 'Unfollow' confirm.
Happy path (Smart Unfollow):
1. Clicks profile row.
2. Dumps profile UI.
3. Resonance is low (< 0.4).
4. Clicks 'Following' button on profile.
5. Clicks 'Unfollow' confirm.
6. Clicks back.
"""
def fake_extract_semantic_nodes(xml, intent, **kwargs):
if "profile rows" in intent:
return [{"semantic_string": "Profile Row", "x": 100, "y": 200, "bounds": "[50,150]", "skip": False}]
elif "Unfollow" in intent:
return [{"semantic_string": "Unfollow Confirmation", "x": 500, "y": 1500, "bounds": "[100,200]", "skip": False}]
return [
{
"semantic_string": "Unfollow Confirmation",
"x": 500,
"y": 1500,
"bounds": "[100,200]",
"skip": False,
}
]
else:
return [{"semantic_string": "Following Button", "x": 900, "y": 600, "bounds": "[100,200]", "skip": False}]
return [
{"semantic_string": "Following Button", "x": 900, "y": 600, "bounds": "[100,200]", "skip": False}
]
self.mock_telepathic._extract_semantic_nodes.side_effect = fake_extract_semantic_nodes
self.mock_dopamine.wants_to_change_feed.side_effect = [True]
self.mock_device.dump_hierarchy.return_value = '<node text="Some Bio"/>'
# Patch local imports inside the bot_flow / unfollow_engine namespace
mock_sleep = MagicMock()
mock_click = MagicMock()
import GramAddict.core.bot_flow
monkeypatch.setattr(GramAddict.core.bot_flow, "sleep", mock_sleep)
monkeypatch.setattr(GramAddict.core.bot_flow, "random_sleep", mock_sleep)
monkeypatch.setattr(GramAddict.core.bot_flow, "_humanized_click", mock_click)
import GramAddict.core.utils
monkeypatch.setattr(GramAddict.core.utils, "random_sleep", mock_sleep)
result = _run_zero_latency_unfollow_loop(
self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack
self.mock_device,
None,
None,
self.mock_configs,
self.mock_session_state,
"FollowingList",
self.cognitive_stack,
)
# Verify result and clicks
assert result == "BOREDOM_CHANGE_FEED"
assert self.mock_session_state.totalUnfollowed == 1
# Assert humanized clicks were logged correctly (Profile Row -> Following Button -> Unfollow Confirm)
assert mock_click.call_count == 3
mock_click.assert_has_calls([
call(self.mock_device, 100, 200),
call(self.mock_device, 900, 600),
call(self.mock_device, 500, 1500)
])
mock_click.assert_has_calls(
[call(self.mock_device, 100, 200), call(self.mock_device, 900, 600), call(self.mock_device, 500, 1500)]
)
assert self.mock_device.back.call_count == 1
def test_unfollow_loop_keeps_high_resonance_profile(self, monkeypatch):
@@ -91,32 +108,40 @@ class TestUnfollowEngine:
If resonance is high, we keep following the account and go back to the list.
"""
self.mock_resonance.calculate_resonance.return_value = 0.8
def fake_extract_semantic_nodes(xml, intent, **kwargs):
if "profile rows" in intent:
return [{"semantic_string": "Profile Row", "x": 100, "y": 200, "bounds": "[50,150]", "skip": False}]
return []
self.mock_telepathic._extract_semantic_nodes.side_effect = fake_extract_semantic_nodes
self.mock_dopamine.wants_to_change_feed.side_effect = [True]
self.mock_device.dump_hierarchy.return_value = '<node text="Awesome Bio"/>'
mock_sleep = MagicMock()
mock_click = MagicMock()
import GramAddict.core.bot_flow
monkeypatch.setattr(GramAddict.core.bot_flow, "sleep", mock_sleep)
monkeypatch.setattr(GramAddict.core.bot_flow, "random_sleep", mock_sleep)
monkeypatch.setattr(GramAddict.core.bot_flow, "_humanized_click", mock_click)
import GramAddict.core.utils
monkeypatch.setattr(GramAddict.core.utils, "random_sleep", mock_sleep)
result = _run_zero_latency_unfollow_loop(
self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack
self.mock_device,
None,
None,
self.mock_configs,
self.mock_session_state,
"FollowingList",
self.cognitive_stack,
)
assert result == "BOREDOM_CHANGE_FEED"
assert self.mock_session_state.totalUnfollowed == 0
# Clicked profile, then went back (no unfollow clicks)
assert mock_click.call_count == 1
mock_click.assert_has_calls([call(self.mock_device, 100, 200)])
@@ -126,33 +151,42 @@ class TestUnfollowEngine:
"""
If 'enge freunde' is in the XML, skip resonance eval, skip unfollowing, just go back.
"""
def fake_extract_semantic_nodes(xml, intent, **kwargs):
if "profile rows" in intent:
return [{"semantic_string": "Profile Row", "x": 100, "y": 200, "bounds": "[50,150]", "skip": False}]
return []
self.mock_telepathic._extract_semantic_nodes.side_effect = fake_extract_semantic_nodes
self.mock_dopamine.wants_to_change_feed.side_effect = [True]
# Mock XML with Enge Freunde
self.mock_device.dump_hierarchy.side_effect = [
'<node text="FollowingList"/>', # First dump on list
'<node text="Enge Freunde"/>' # Second dump on profile
'<node text="FollowingList"/>', # First dump on list
'<node text="Enge Freunde"/>', # Second dump on profile
]
mock_sleep = MagicMock()
mock_click = MagicMock()
import GramAddict.core.bot_flow
monkeypatch.setattr(GramAddict.core.bot_flow, "sleep", mock_sleep)
monkeypatch.setattr(GramAddict.core.bot_flow, "random_sleep", mock_sleep)
monkeypatch.setattr(GramAddict.core.bot_flow, "_humanized_click", mock_click)
import GramAddict.core.utils
monkeypatch.setattr(GramAddict.core.utils, "random_sleep", mock_sleep)
result = _run_zero_latency_unfollow_loop(
self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack
self.mock_device,
None,
None,
self.mock_configs,
self.mock_session_state,
"FollowingList",
self.cognitive_stack,
)
assert result == "BOREDOM_CHANGE_FEED"
assert self.mock_session_state.totalUnfollowed == 0
assert mock_click.call_count == 1
@@ -167,16 +201,23 @@ class TestUnfollowEngine:
"""
# Always return empty nodes
self.mock_telepathic._extract_semantic_nodes.return_value = []
# Patch the imported _humanized_scroll_down so we can track it
mock_scroll = MagicMock()
import GramAddict.core.unfollow_engine
monkeypatch.setattr(GramAddict.core.unfollow_engine, "_humanized_scroll_down", mock_scroll)
result = _run_zero_latency_unfollow_loop(
self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack
self.mock_device,
None,
None,
self.mock_configs,
self.mock_session_state,
"FollowingList",
self.cognitive_stack,
)
assert result == "BOREDOM_CHANGE_FEED"
# It should scroll exactly 6 times (failed_scrolls > 5)
assert mock_scroll.call_count == 6
@@ -189,11 +230,17 @@ class TestUnfollowEngine:
"""
# Tell session_state that UNFOLLOWS limit is hit
self.mock_session_state.check_limit.return_value = (True, "Unfollow limit reached")
result = _run_zero_latency_unfollow_loop(
self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack
self.mock_device,
None,
None,
self.mock_configs,
self.mock_session_state,
"FollowingList",
self.cognitive_stack,
)
assert result == "BOREDOM_CHANGE_FEED"
self.mock_device.dump_hierarchy.assert_not_called()
self.mock_device.click.assert_not_called()