feat: complete modular plugin refactor with 100% E2E coverage for interactions
This commit is contained in:
105
tests/unit/behaviors/test_ad_guard.py
Normal file
105
tests/unit/behaviors/test_ad_guard.py
Normal 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)
|
||||
59
tests/unit/behaviors/test_anomaly_handler.py
Normal file
59
tests/unit/behaviors/test_anomaly_handler.py
Normal 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
|
||||
62
tests/unit/behaviors/test_carousel_browsing.py
Normal file
62
tests/unit/behaviors/test_carousel_browsing.py
Normal 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
|
||||
51
tests/unit/behaviors/test_close_friends_guard.py
Normal file
51
tests/unit/behaviors/test_close_friends_guard.py
Normal 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()
|
||||
102
tests/unit/behaviors/test_comment.py
Normal file
102
tests/unit/behaviors/test_comment.py
Normal 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()
|
||||
50
tests/unit/behaviors/test_darwin_dwell.py
Normal file
50
tests/unit/behaviors/test_darwin_dwell.py
Normal 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()
|
||||
77
tests/unit/behaviors/test_follow.py
Normal file
77
tests/unit/behaviors/test_follow.py
Normal 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
|
||||
126
tests/unit/behaviors/test_grid_like.py
Normal file
126
tests/unit/behaviors/test_grid_like.py
Normal 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"
|
||||
91
tests/unit/behaviors/test_like.py
Normal file
91
tests/unit/behaviors/test_like.py
Normal 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()
|
||||
119
tests/unit/behaviors/test_obstacle_guard.py
Normal file
119
tests/unit/behaviors/test_obstacle_guard.py
Normal 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()
|
||||
61
tests/unit/behaviors/test_perfect_snapping.py
Normal file
61
tests/unit/behaviors/test_perfect_snapping.py
Normal 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>")
|
||||
57
tests/unit/behaviors/test_post_data_extraction.py
Normal file
57
tests/unit/behaviors/test_post_data_extraction.py
Normal 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()
|
||||
45
tests/unit/behaviors/test_post_interaction.py
Normal file
45
tests/unit/behaviors/test_post_interaction.py
Normal 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)
|
||||
111
tests/unit/behaviors/test_profile_guard.py
Normal file
111
tests/unit/behaviors/test_profile_guard.py
Normal 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
|
||||
68
tests/unit/behaviors/test_profile_visit.py
Normal file
68
tests/unit/behaviors/test_profile_visit.py
Normal 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()
|
||||
56
tests/unit/behaviors/test_rabbit_hole.py
Normal file
56
tests/unit/behaviors/test_rabbit_hole.py
Normal 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
|
||||
94
tests/unit/behaviors/test_repost.py
Normal file
94
tests/unit/behaviors/test_repost.py
Normal 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()
|
||||
80
tests/unit/behaviors/test_resonance_evaluator.py
Normal file
80
tests/unit/behaviors/test_resonance_evaluator.py
Normal 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()
|
||||
92
tests/unit/behaviors/test_story_view.py
Normal file
92
tests/unit/behaviors/test_story_view.py
Normal 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")
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.*"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
31
tests/unit/test_config_plugins.py
Normal file
31
tests/unit/test_config_plugins.py
Normal 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 == {}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}'"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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"}'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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__), "../../")))
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user