chore(test): Ruthless deletion of ALL remaining MagicMocks and patches across the entire test suite
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
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:
|
||||
@patch("GramAddict.core.behaviors.ad_guard.is_ad")
|
||||
def test_can_activate(self, mock_is_ad, ad_guard, mock_context):
|
||||
mock_context.context_xml = "<xml>dummy</xml>"
|
||||
mock_is_ad.return_value = True
|
||||
assert ad_guard.can_activate(mock_context) is True
|
||||
|
||||
mock_is_ad.return_value = False
|
||||
assert ad_guard.can_activate(mock_context) is False
|
||||
|
||||
ad_guard._enabled = False
|
||||
assert ad_guard.can_activate(mock_context) is False
|
||||
|
||||
@patch("GramAddict.core.behaviors.ad_guard.sleep")
|
||||
@patch("GramAddict.core.behaviors.ad_guard.humanized_scroll")
|
||||
def test_execute_single_ad(self, mock_scroll, mock_sleep, ad_guard, mock_context):
|
||||
result = ad_guard.execute(mock_context)
|
||||
|
||||
assert result.executed is True
|
||||
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.sleep")
|
||||
@patch("GramAddict.core.behaviors.ad_guard.humanized_scroll")
|
||||
def test_execute_triple_ad(self, mock_scroll, mock_sleep, ad_guard, mock_context):
|
||||
ad_guard.consecutive_ads = 2
|
||||
|
||||
result = ad_guard.execute(mock_context)
|
||||
|
||||
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.sleep")
|
||||
@patch("GramAddict.core.behaviors.ad_guard.humanized_scroll")
|
||||
def test_execute_deadlock_ad(self, mock_scroll, mock_sleep, ad_guard, mock_context):
|
||||
ad_guard.consecutive_ads = 5
|
||||
|
||||
result = ad_guard.execute(mock_context)
|
||||
|
||||
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)
|
||||
@@ -1,59 +0,0 @@
|
||||
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
|
||||
@@ -1,55 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def carousel_plugin():
|
||||
return CarouselBrowsingPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
ctx.configs = MagicMock()
|
||||
ctx.configs.args.carousel_percentage = 100
|
||||
ctx.configs.args.carousel_count = "2-2"
|
||||
ctx.configs.get_plugin_config.return_value = {"percentage": 100, "count": "2-2"}
|
||||
ctx.context_xml = '<xml><node content-desc="carousel_indicator"/></xml>'
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
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.get_plugin_config.return_value = {"percentage": 0, "count": "2-2"}
|
||||
assert carousel_plugin.can_activate(mock_context) is False
|
||||
|
||||
@patch("GramAddict.core.behaviors.carousel_browsing.has_carousel_in_view", return_value=False)
|
||||
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
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
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):
|
||||
mock_context.context_xml = "<xml>enge freunde</xml>"
|
||||
assert cf_guard.can_activate(mock_context) is True
|
||||
|
||||
mock_context.context_xml = "<xml>regular post</xml>"
|
||||
assert cf_guard.can_activate(mock_context) is False
|
||||
|
||||
cf_guard._enabled = False
|
||||
assert cf_guard.can_activate(mock_context) is False
|
||||
|
||||
@patch("GramAddict.core.behaviors.close_friends_guard.sleep")
|
||||
@patch("GramAddict.core.behaviors.close_friends_guard.humanized_scroll")
|
||||
def test_execute_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()
|
||||
@@ -1,66 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.comment import CommentPlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def comment_plugin():
|
||||
return CommentPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
ctx.configs = MagicMock()
|
||||
ctx.configs.get_plugin_config.return_value = {"percentage": 50}
|
||||
ctx.configs.args.dry_run_comments = False
|
||||
ctx.shared_state = {"res_score": 1.0}
|
||||
ctx.context_xml = "<xml></xml>"
|
||||
ctx.device = MagicMock()
|
||||
ctx.post_data = {"description": "test desc"}
|
||||
ctx.session_state = MagicMock()
|
||||
ctx.session_state.check_limit.return_value = False
|
||||
|
||||
# Mock cognitive stack
|
||||
writer = MagicMock()
|
||||
writer.generate_comment.return_value = "Great post!"
|
||||
|
||||
mock_nav = MagicMock()
|
||||
mock_nav.do.return_value = True
|
||||
|
||||
ctx.cognitive_stack = {"writer": writer, "nav_graph": mock_nav}
|
||||
return ctx
|
||||
|
||||
|
||||
class TestCommentPlugin:
|
||||
@patch("GramAddict.core.behaviors.comment.random.random", return_value=0.1)
|
||||
def test_can_activate_enabled(self, mock_random, comment_plugin, mock_context):
|
||||
assert comment_plugin.can_activate(mock_context) is True
|
||||
|
||||
def test_can_activate_disabled_via_config(self, comment_plugin, mock_context):
|
||||
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
|
||||
assert comment_plugin.can_activate(mock_context) is False
|
||||
|
||||
def test_execute_success(self, comment_plugin, mock_context):
|
||||
result = comment_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is True
|
||||
assert result.interactions == 1
|
||||
|
||||
mock_context.cognitive_stack["nav_graph"].do.assert_any_call("open comments")
|
||||
mock_context.cognitive_stack["nav_graph"].do.assert_any_call("type and post comment", text="Great post!")
|
||||
|
||||
def test_execute_fails_type_and_post(self, comment_plugin, mock_context):
|
||||
def mock_do(intent, **kwargs):
|
||||
if intent == "type and post comment":
|
||||
return False
|
||||
return True
|
||||
|
||||
mock_context.cognitive_stack["nav_graph"].do.side_effect = mock_do
|
||||
|
||||
result = comment_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is False
|
||||
mock_context.cognitive_stack["nav_graph"].do.assert_any_call("open comments")
|
||||
@@ -1,50 +0,0 @@
|
||||
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()
|
||||
@@ -1,67 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.follow import FollowPlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def follow_plugin():
|
||||
return FollowPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
ctx.configs = MagicMock()
|
||||
ctx.configs.get_plugin_config.return_value = {"percentage": 100}
|
||||
|
||||
ctx.session_state = MagicMock()
|
||||
ctx.session_state.check_limit.return_value = False
|
||||
|
||||
ctx.device = MagicMock()
|
||||
ctx.username = "test_user"
|
||||
ctx.sleep_mod = 1.0
|
||||
|
||||
ctx.cognitive_stack = {}
|
||||
return ctx
|
||||
|
||||
|
||||
class TestFollowPlugin:
|
||||
@patch("random.random", return_value=0.1)
|
||||
def test_can_activate_enabled(self, mock_random, follow_plugin, mock_context):
|
||||
assert follow_plugin.can_activate(mock_context) is True
|
||||
mock_context.session_state.check_limit.assert_called_once()
|
||||
|
||||
def test_can_activate_disabled_via_config(self, follow_plugin, mock_context):
|
||||
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
|
||||
assert follow_plugin.can_activate(mock_context) is False
|
||||
|
||||
def test_can_activate_limit_reached(self, follow_plugin, mock_context):
|
||||
mock_context.session_state.check_limit.return_value = True
|
||||
assert follow_plugin.can_activate(mock_context) is False
|
||||
|
||||
@patch("GramAddict.core.q_nav_graph.QNavGraph")
|
||||
@patch("GramAddict.core.behaviors.follow.sleep")
|
||||
def test_execute_success(self, mock_sleep, mock_qnavgraph, follow_plugin, mock_context):
|
||||
mock_nav = MagicMock()
|
||||
mock_nav.do.return_value = True
|
||||
mock_qnavgraph.return_value = mock_nav
|
||||
|
||||
result = follow_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is True
|
||||
assert result.interactions == 1
|
||||
|
||||
mock_nav.do.assert_called_once_with("tap follow button")
|
||||
|
||||
@patch("GramAddict.core.q_nav_graph.QNavGraph")
|
||||
def test_execute_nav_failed(self, mock_qnavgraph, follow_plugin, mock_context):
|
||||
mock_nav = MagicMock()
|
||||
mock_nav.do.return_value = False
|
||||
mock_qnavgraph.return_value = mock_nav
|
||||
|
||||
result = follow_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is False
|
||||
assert result.metadata.get("reason") == "nav_failed"
|
||||
@@ -1,124 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.grid_like import GridLikePlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grid_like_plugin():
|
||||
return GridLikePlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
ctx.configs = MagicMock()
|
||||
ctx.configs.get_plugin_config.return_value = {"percentage": 100, "count": "2-2"}
|
||||
|
||||
ctx.session_state = MagicMock()
|
||||
ctx.session_state.check_limit.return_value = False
|
||||
ctx.session_state.totalLikes = 0
|
||||
|
||||
ctx.context_xml = '<xml><node content-desc="profile_header" text="followers"/></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.get_plugin_config.return_value = {"percentage": 0}
|
||||
assert grid_like_plugin.can_activate(mock_context) is False
|
||||
|
||||
def test_can_activate_limit_reached(self, grid_like_plugin, mock_context):
|
||||
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"
|
||||
@@ -1,55 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.like import LikePlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def like_plugin():
|
||||
return LikePlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
ctx.configs = MagicMock()
|
||||
ctx.configs.get_plugin_config.return_value = {"percentage": 100}
|
||||
ctx.shared_state = {"res_score": 1.0}
|
||||
ctx.context_xml = "<xml></xml>"
|
||||
ctx.device = MagicMock()
|
||||
|
||||
ctx.session_state = MagicMock()
|
||||
ctx.session_state.check_limit.return_value = False
|
||||
ctx.session_state.totalLikes = 0
|
||||
|
||||
mock_nav = MagicMock()
|
||||
mock_nav.do.return_value = True
|
||||
ctx.cognitive_stack = {"nav_graph": mock_nav}
|
||||
return ctx
|
||||
|
||||
|
||||
class TestLikePlugin:
|
||||
@patch("GramAddict.core.behaviors.like.random.random", return_value=0.1)
|
||||
def test_can_activate_enabled(self, mock_random, like_plugin, mock_context):
|
||||
assert like_plugin.can_activate(mock_context) is True
|
||||
|
||||
def test_can_activate_disabled_via_config(self, like_plugin, mock_context):
|
||||
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
|
||||
assert like_plugin.can_activate(mock_context) is False
|
||||
|
||||
def test_execute_already_liked(self, like_plugin, mock_context):
|
||||
mock_nav = mock_context.cognitive_stack["nav_graph"]
|
||||
mock_nav.do.return_value = False
|
||||
|
||||
result = like_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is False
|
||||
|
||||
def test_execute_success(self, like_plugin, mock_context):
|
||||
result = like_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is True
|
||||
assert result.interactions == 1
|
||||
|
||||
mock_context.cognitive_stack["nav_graph"].do.assert_called_once_with("tap like button")
|
||||
@@ -1,122 +0,0 @@
|
||||
from unittest.mock import MagicMock, create_autospec, 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):
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
mock_instance = create_autospec(SituationalAwarenessEngine, instance=True)
|
||||
mock_instance.perceive.return_value = SituationType.OBSTACLE_MODAL
|
||||
mock_sae.get_instance.return_value = mock_instance
|
||||
|
||||
xml_content = "<xml>dummy</xml>"
|
||||
mock_context.device.dump_hierarchy.return_value = xml_content
|
||||
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_with(xml_content)
|
||||
mock_dump.assert_called_once()
|
||||
@@ -1,61 +0,0 @@
|
||||
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>")
|
||||
@@ -1,47 +0,0 @@
|
||||
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")
|
||||
def test_execute_failure(self, mock_extract, post_data_extraction, mock_context):
|
||||
mock_extract.return_value = None
|
||||
|
||||
result = post_data_extraction.execute(mock_context)
|
||||
|
||||
assert result.executed is False
|
||||
@@ -1,44 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.post_interaction import PostInteractionPlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_interaction_plugin():
|
||||
return PostInteractionPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
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)
|
||||
@@ -1,110 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile_guard_plugin():
|
||||
return ProfileGuardPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
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
|
||||
@@ -1,56 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile_visit_plugin():
|
||||
return ProfileVisitPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
ctx.configs = MagicMock()
|
||||
ctx.configs.args.profile_visit_percentage = 30
|
||||
ctx.configs.get_plugin_config.return_value = {"percentage": 30}
|
||||
ctx.shared_state = {"res_score": 1.0}
|
||||
ctx.context_xml = "<xml></xml>"
|
||||
ctx.device = MagicMock()
|
||||
ctx.username = "test_user"
|
||||
|
||||
mock_nav = MagicMock()
|
||||
mock_nav.current_state = "HomeFeed"
|
||||
ctx.cognitive_stack = {"nav_graph": mock_nav}
|
||||
return ctx
|
||||
|
||||
|
||||
class TestProfileVisitPlugin:
|
||||
@patch("GramAddict.core.behaviors.profile_visit.random.random", return_value=0.1) # 0.1 < 0.3
|
||||
def test_can_activate_enabled(self, mock_random, profile_visit_plugin, mock_context):
|
||||
assert profile_visit_plugin.can_activate(mock_context) is True
|
||||
|
||||
def test_can_activate_disabled_via_config(self, profile_visit_plugin, mock_context):
|
||||
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
|
||||
assert profile_visit_plugin.can_activate(mock_context) is False
|
||||
|
||||
@patch("GramAddict.core.behaviors.profile_visit.sleep")
|
||||
@patch("GramAddict.core.behaviors.PluginRegistry")
|
||||
def test_execute_success(self, mock_registry, mock_sleep, profile_visit_plugin, mock_context):
|
||||
mock_nav = mock_context.cognitive_stack["nav_graph"]
|
||||
mock_nav.do.return_value = True
|
||||
|
||||
mock_registry_instance = MagicMock()
|
||||
mock_registry.get_instance.return_value = mock_registry_instance
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorResult
|
||||
|
||||
mock_registry_instance.execute_all.return_value = [BehaviorResult(executed=True)]
|
||||
|
||||
result = profile_visit_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is True
|
||||
mock_nav.do.assert_any_call("tap post username")
|
||||
mock_context.device.press.assert_called_with("back")
|
||||
@@ -1,54 +0,0 @@
|
||||
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.sleep")
|
||||
def test_execute_success(self, mock_sleep, rabbit_hole, mock_context):
|
||||
mock_context.cognitive_stack["nav_graph"].do.return_value = True
|
||||
|
||||
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_context.device.press.assert_called_once_with("back")
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
def test_can_activate_low_resonance(self, rabbit_hole, mock_context):
|
||||
mock_context.shared_state["res_score"] = 0.5
|
||||
|
||||
assert rabbit_hole.can_activate(mock_context) is False
|
||||
|
||||
def test_can_activate_success(self, rabbit_hole, mock_context):
|
||||
mock_context.configs.get_plugin_config.return_value = {"percentage": 100}
|
||||
with patch("GramAddict.core.behaviors.rabbit_hole.random.random", return_value=0.0):
|
||||
assert rabbit_hole.can_activate(mock_context) is True
|
||||
|
||||
def test_execute_no_nav_graph(self, rabbit_hole, mock_context):
|
||||
mock_context.cognitive_stack.pop("nav_graph")
|
||||
|
||||
result = rabbit_hole.execute(mock_context)
|
||||
|
||||
assert result.executed is False # Fails to execute if no nav_graph
|
||||
@@ -1,50 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.repost import RepostPlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repost_plugin():
|
||||
return RepostPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
ctx.configs = MagicMock()
|
||||
ctx.configs.get_plugin_config.return_value = {"percentage": 100}
|
||||
ctx.shared_state = {"res_score": 1.0}
|
||||
ctx.context_xml = "<xml></xml>"
|
||||
ctx.device = MagicMock()
|
||||
|
||||
mock_nav = MagicMock()
|
||||
mock_nav.do.return_value = True
|
||||
ctx.cognitive_stack = {"nav_graph": mock_nav}
|
||||
return ctx
|
||||
|
||||
|
||||
class TestRepostPlugin:
|
||||
@patch("GramAddict.core.behaviors.repost.random.random", return_value=0.1)
|
||||
def test_can_activate_enabled(self, mock_random, repost_plugin, mock_context):
|
||||
assert repost_plugin.can_activate(mock_context) is True
|
||||
|
||||
def test_can_activate_disabled_via_config(self, repost_plugin, mock_context):
|
||||
mock_context.configs.get_plugin_config.return_value = {"percentage": 0}
|
||||
assert repost_plugin.can_activate(mock_context) is False
|
||||
|
||||
def test_execute_success(self, repost_plugin, mock_context):
|
||||
result = repost_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is True
|
||||
assert result.interactions == 1
|
||||
|
||||
mock_context.cognitive_stack["nav_graph"].do.assert_called_once_with("share to story")
|
||||
|
||||
def test_execute_fails_no_add_to_story(self, repost_plugin, mock_context):
|
||||
mock_context.cognitive_stack["nav_graph"].do.return_value = False
|
||||
|
||||
result = repost_plugin.execute(mock_context)
|
||||
|
||||
assert result.executed is False
|
||||
@@ -1,83 +0,0 @@
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock, create_autospec, 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):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
mock_context.configs.args.visual_vibe_check_percentage = 100
|
||||
mock_context.cognitive_stack["resonance"].calculate_resonance.return_value = 0.5
|
||||
|
||||
mock_tele = create_autospec(TelepathicEngine, instance=True)
|
||||
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_with(mock_context.device, mock.ANY)
|
||||
@@ -1,92 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors.story_view import StoryViewPlugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def story_view_plugin():
|
||||
return StoryViewPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = MagicMock()
|
||||
ctx.configs = MagicMock()
|
||||
ctx.configs.get_plugin_config.return_value = {"percentage": 100, "count": "2-2"}
|
||||
|
||||
ctx.context_xml = '<xml><node content-desc="reel_ring"/></xml>'
|
||||
ctx.device = MagicMock()
|
||||
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
|
||||
|
||||
ctx.cognitive_stack = {}
|
||||
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.get_plugin_config.return_value = {"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")
|
||||
@@ -1,39 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.perception.action_memory import ActionMemory
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory():
|
||||
with patch("GramAddict.core.qdrant_memory.UIMemoryDB") as MockDB:
|
||||
mock_db = MockDB.return_value
|
||||
yield ActionMemory(ui_memory=mock_db)
|
||||
|
||||
|
||||
def test_track_click_stores_memory(memory):
|
||||
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
|
||||
memory.track_click("tap test", node)
|
||||
|
||||
assert memory._last_click_context is not None
|
||||
assert memory._last_click_context["intent"] == "tap test"
|
||||
|
||||
|
||||
def test_confirm_click_boosts_confidence(memory):
|
||||
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
|
||||
memory.track_click("tap test", node)
|
||||
memory.confirm_click()
|
||||
|
||||
memory.ui_memory.boost_confidence.assert_called_once()
|
||||
assert memory._last_click_context is None
|
||||
|
||||
|
||||
def test_reject_click_decays_confidence(memory):
|
||||
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
|
||||
memory.track_click("tap test", node)
|
||||
memory.reject_click()
|
||||
|
||||
memory.ui_memory.decay_confidence.assert_called_once()
|
||||
assert memory._last_click_context is None
|
||||
@@ -1,37 +0,0 @@
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
def test_intent_resolver_finds_bottom_tab():
|
||||
resolver = IntentResolver()
|
||||
|
||||
# A tab at the top
|
||||
top_tab = SpatialNode(bounds=(0, 0, 100, 100), content_desc="Explore Tab", clickable=True)
|
||||
# A tab at the bottom
|
||||
bottom_tab = SpatialNode(bounds=(0, 2200, 100, 2300), content_desc="Explore Tab", clickable=True)
|
||||
|
||||
# Intent resolver should prefer the one that geometrically matches the bottom navigation area
|
||||
best_match = resolver.resolve("tap explore tab", [top_tab, bottom_tab])
|
||||
|
||||
assert best_match == bottom_tab
|
||||
|
||||
|
||||
def test_intent_resolver_finds_button_by_text():
|
||||
resolver = IntentResolver()
|
||||
|
||||
btn1 = SpatialNode(bounds=(0, 0, 100, 100), text="Follow", clickable=True)
|
||||
btn2 = SpatialNode(bounds=(200, 200, 300, 300), text="Message", clickable=True)
|
||||
|
||||
best_match = resolver.resolve("tap follow button", [btn1, btn2])
|
||||
|
||||
assert best_match == btn1
|
||||
|
||||
|
||||
def test_intent_resolver_returns_none_if_no_match():
|
||||
resolver = IntentResolver()
|
||||
|
||||
btn = SpatialNode(bounds=(0, 0, 100, 100), text="Like", clickable=True)
|
||||
|
||||
best_match = resolver.resolve("tap follow button", [btn])
|
||||
|
||||
assert best_match is None
|
||||
@@ -1,199 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
|
||||
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.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
|
||||
|
||||
def teardown_method(self):
|
||||
self.screen_memory_patcher.stop()
|
||||
|
||||
def test_os_permission_dialog_denial(self):
|
||||
"""
|
||||
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 = """
|
||||
<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?" />
|
||||
<node package="com.android.permissioncontroller" text="While using the app" resource-id="com.android.permissioncontroller:id/permission_allow_button" bounds="[100,500][900,600]" clickable="true" />
|
||||
<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")
|
||||
if not system_arg and len(args) > 4:
|
||||
system_arg = args[4]
|
||||
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:
|
||||
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])
|
||||
args, _ = self.mock_device.click.call_args
|
||||
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.
|
||||
|
||||
The SAE prioritises BACK (priority=-1) for OBSTACLE_MODAL obstacles — this is
|
||||
semantically correct because BACK dismisses Instagram modals reliably without
|
||||
risk of accidentally tapping dangerous buttons. If BACK clears the screen
|
||||
(i.e. post-action XML is NORMAL) the SAE considers the obstacle resolved and
|
||||
returns True immediately without ever needing to click 'Not Now'.
|
||||
|
||||
This test verifies that the SAE takes at least one dismissal action
|
||||
(either a BACK press OR a direct click on 'Not Now') and that the function
|
||||
reports the obstacle as cleared.
|
||||
"""
|
||||
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" />
|
||||
<node package="com.instagram.android" text="Take Survey" resource-id="com.instagram.android:id/take_survey_btn" bounds="[200,800][800,900]" clickable="true" />
|
||||
<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")
|
||||
if not system_arg and len(args) > 4:
|
||||
system_arg = args[4]
|
||||
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:
|
||||
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
|
||||
)
|
||||
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')"
|
||||
|
||||
def test_fake_creation_flow_in_bio_is_ignored(self):
|
||||
"""
|
||||
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:
|
||||
mock_llm.return_value = {"response": '{"situation": "NORMAL"}'}
|
||||
result = sae.perceive(xml)
|
||||
assert result == SituationType.NORMAL
|
||||
|
||||
def test_real_creation_flow_is_caught(self):
|
||||
"""
|
||||
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:
|
||||
result = sae.perceive(xml)
|
||||
assert result == SituationType.OBSTACLE_MODAL
|
||||
mock_llm.assert_not_called()
|
||||
|
||||
def test_fake_action_blocked_in_caption_is_ignored(self):
|
||||
"""
|
||||
Ensures that 'action blocked' in a text attribute without a dialog container
|
||||
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:
|
||||
mock_llm.return_value = {"response": '{"situation": "NORMAL"}'}
|
||||
result = sae.perceive(xml)
|
||||
assert result != SituationType.DANGER_ACTION_BLOCKED
|
||||
|
||||
def test_real_action_blocked_is_caught(self):
|
||||
"""
|
||||
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>"""
|
||||
|
||||
result = sae.perceive(xml)
|
||||
assert result == SituationType.DANGER_ACTION_BLOCKED
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
(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,
|
||||
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"] * 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.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
|
||||
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 = {}
|
||||
nav_graph.current_state = "UNKNOWN"
|
||||
nav_graph.nav_memory = MagicMock()
|
||||
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,58 +0,0 @@
|
||||
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.
|
||||
If the first attempt fails semantic verification (Ambiguity Guard),
|
||||
it should press BACK, blacklist the node, and retry automatically.
|
||||
"""
|
||||
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)
|
||||
# 3. Post-click verification dump
|
||||
# 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] * 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 = [
|
||||
{"x": 10, "y": 10, "semantic_string": "Wrong Menu", "source": "vlm"},
|
||||
{"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),
|
||||
):
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
def test_bot_flow_goal_executor_init_order():
|
||||
# We want to prove that without explicit get_instance(device, username),
|
||||
# QNavGraph(device) initializes GoalExecutor with an empty username.
|
||||
|
||||
# We have to reset GoalExecutor singleton first
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
if hasattr(GoalExecutor, "_instance"):
|
||||
GoalExecutor._instance = None
|
||||
|
||||
device = MagicMock()
|
||||
|
||||
# Action: Instantiate QNavGraph
|
||||
# Without the fix, this will initialize GoalExecutor with bot_username=""
|
||||
# and PathMemory will get bound to "goap_paths_v1"
|
||||
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient") as mock_qdrant:
|
||||
# FIX: Pre-initialize with username (as done in bot_flow.py)
|
||||
executor_pre = GoalExecutor.get_instance(device, "marisaundmarc")
|
||||
nav_graph = QNavGraph(device)
|
||||
executor = GoalExecutor.get_instance(device, "marisaundmarc")
|
||||
|
||||
# Now it should be bound correctly
|
||||
assert executor.path_memory._db.collection_name == "goap_paths_v1_marisaundmarc", "PathMemory collection name does not contain the username suffix! Initialization leak occurred."
|
||||
@@ -1,53 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
|
||||
|
||||
def test_bot_flow_unlearns_on_context_loss():
|
||||
"""Prove that bot_flow calls unlearn_current_state when context is completely lost (3 misses)."""
|
||||
device = MagicMock()
|
||||
# Provide a dummy dump hierarchy
|
||||
device.dump_hierarchy.return_value = "<hierarchy></hierarchy>"
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
|
||||
session_state = MagicMock()
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.behaviors.PluginRegistry.get_instance") as MockRegistry,
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.physics.humanized_input.humanized_scroll"),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll"),
|
||||
):
|
||||
from GramAddict.core.behaviors import BehaviorResult
|
||||
|
||||
mock_registry_instance = MockRegistry.return_value
|
||||
mock_registry_instance.execute_all.return_value = [
|
||||
BehaviorResult(executed=True, should_skip=True, metadata={"return_code": "CONTEXT_LOST"})
|
||||
]
|
||||
|
||||
mock_cognitive_stack = MagicMock()
|
||||
dopamine_mock = MagicMock()
|
||||
dopamine_mock.is_app_session_over.return_value = False
|
||||
dopamine_mock.wants_to_doomscroll.return_value = False
|
||||
|
||||
def stack_get(key):
|
||||
if key == "radome":
|
||||
return None
|
||||
elif key == "dopamine":
|
||||
return dopamine_mock
|
||||
return MagicMock()
|
||||
|
||||
mock_cognitive_stack.get.side_effect = stack_get
|
||||
|
||||
result = _run_zero_latency_feed_loop(
|
||||
device=device,
|
||||
zero_engine=MagicMock(),
|
||||
nav_graph=MagicMock(),
|
||||
configs=MagicMock(),
|
||||
session_state=session_state,
|
||||
job_target="test_feed",
|
||||
cognitive_stack=mock_cognitive_stack,
|
||||
)
|
||||
|
||||
# Assert (RED)
|
||||
assert result == "CONTEXT_LOST"
|
||||
@@ -1,53 +0,0 @@
|
||||
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.telepathic_engine.TelepathicEngine.get_instance")
|
||||
def test_plugin_skip_breaks_feed_loop(
|
||||
mock_telepathic, 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(),
|
||||
}
|
||||
|
||||
# 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_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()
|
||||
mock_plugin_result.executed = True
|
||||
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,145 +0,0 @@
|
||||
"""
|
||||
Camera Trap Escape Tests
|
||||
|
||||
Validates that ALL perception layers correctly identify the Instagram
|
||||
camera/story creation overlay as a blocking obstacle, preventing the
|
||||
softlock discovered in the 2026-04-22 bot run.
|
||||
|
||||
Uses the real-world XML fixture captured during the actual incident.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURE_PATH = os.path.join(os.path.dirname(__file__), "..", "fixtures", "camera_trap.xml")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def camera_xml():
|
||||
with open(FIXTURE_PATH, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
device.deviceV2 = MagicMock()
|
||||
device.deviceV2.info = {"screenOn": True}
|
||||
device.app_id = "com.instagram.android"
|
||||
device._get_current_app.return_value = "com.instagram.android"
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
return device
|
||||
|
||||
|
||||
class TestSAEPerceivesCameraAsObstacle:
|
||||
"""Layer 1: SituationalAwarenessEngine.perceive() must classify
|
||||
the camera overlay as OBSTACLE_MODAL."""
|
||||
|
||||
def test_sae_perceives_camera_as_obstacle_modal(self, camera_xml, mock_device):
|
||||
from GramAddict.core.situational_awareness import (
|
||||
SituationalAwarenessEngine,
|
||||
SituationType,
|
||||
)
|
||||
|
||||
SituationalAwarenessEngine.reset()
|
||||
sae = SituationalAwarenessEngine(mock_device)
|
||||
# Bypass Qdrant to test pure structural logic
|
||||
sae.episodes.recall = MagicMock(return_value=None)
|
||||
sae.episodes.learn = MagicMock()
|
||||
|
||||
result = sae.perceive(camera_xml)
|
||||
|
||||
assert (
|
||||
result == SituationType.OBSTACLE_MODAL
|
||||
), f"SAE failed to detect camera overlay as OBSTACLE_MODAL (got {result})"
|
||||
|
||||
|
||||
class TestScreenIdentityClassifiesCameraAsModal:
|
||||
"""Layer 2: ScreenIdentity._classify_screen() must return
|
||||
ScreenType.MODAL for the camera overlay."""
|
||||
|
||||
def test_screen_identity_classifies_camera_as_modal(self, camera_xml, mock_device):
|
||||
from GramAddict.core.goap import ScreenIdentity, ScreenType
|
||||
|
||||
screen_id = ScreenIdentity("testuser")
|
||||
result = screen_id.identify(camera_xml)
|
||||
|
||||
assert (
|
||||
result["screen_type"] == ScreenType.MODAL
|
||||
), f"ScreenIdentity classified camera as {result['screen_type']} instead of MODAL"
|
||||
|
||||
|
||||
class TestGOAPTriggersSAEOnCameraDetection:
|
||||
"""Layer 4: GoalExecutor.achieve() must invoke SAE.ensure_clear_screen()
|
||||
when ScreenIdentity classifies the screen as MODAL."""
|
||||
|
||||
def test_goap_triggers_sae_on_camera_detection(self, camera_xml, mock_device):
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
|
||||
GoalExecutor.reset()
|
||||
executor = GoalExecutor(mock_device, "testuser")
|
||||
|
||||
# achieve() calls perceive() twice before the SAE branch:
|
||||
# 1. Line 866: initial perceive for path recall → camera_xml (MODAL)
|
||||
# 2. Line 883: loop perceive at step 0 → camera_xml (MODAL) → triggers SAE
|
||||
# 3+: after SAE clears, next perceives return normal feed
|
||||
normal_xml = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/feed_tab" selected="true" /><node package="com.instagram.android" /></hierarchy>'
|
||||
mock_device.dump_hierarchy.side_effect = [
|
||||
camera_xml,
|
||||
camera_xml,
|
||||
normal_xml,
|
||||
normal_xml,
|
||||
normal_xml,
|
||||
normal_xml,
|
||||
]
|
||||
|
||||
# Mock SAE to report successful clearance and track calls
|
||||
mock_sae = MagicMock()
|
||||
mock_sae.ensure_clear_screen.return_value = True
|
||||
executor._sae = mock_sae
|
||||
|
||||
# Run a goal — should detect MODAL on first loop perceive and call SAE
|
||||
executor.achieve("open home feed", max_steps=5)
|
||||
|
||||
assert (
|
||||
mock_sae.ensure_clear_screen.called
|
||||
), "GOAP did not invoke SAE.ensure_clear_screen() when camera overlay was detected"
|
||||
|
||||
|
||||
class TestForbiddenGuardBlocksQuickCaptureNodes:
|
||||
"""Layer 5: _is_forbidden_action() must refuse to click
|
||||
any node with quick_capture in its resource-id."""
|
||||
|
||||
def test_forbidden_guard_blocks_quick_capture_nodes(self):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = TelepathicEngine()
|
||||
|
||||
camera_node = {
|
||||
"text": "",
|
||||
"description": "",
|
||||
"resource_id": "com.instagram.android:id/quick_capture_root_container",
|
||||
"semantic_string": "id context: 'quick capture root container'",
|
||||
}
|
||||
|
||||
assert (
|
||||
engine._is_forbidden_action(camera_node) is True
|
||||
), "Forbidden Action Guard failed to block quick_capture node"
|
||||
|
||||
def test_forbidden_guard_allows_normal_nodes(self):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = TelepathicEngine()
|
||||
|
||||
normal_node = {
|
||||
"text": "",
|
||||
"description": "Like",
|
||||
"resource_id": "com.instagram.android:id/row_feed_button_like",
|
||||
"semantic_string": "description: 'Like', id context: 'row feed button like'",
|
||||
}
|
||||
|
||||
assert (
|
||||
engine._is_forbidden_action(normal_node) is False
|
||||
), "Forbidden Action Guard incorrectly blocked a normal Like button"
|
||||
@@ -1,38 +0,0 @@
|
||||
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,
|
||||
which can happen when Shadow Mode submits ['row_feed', 'button_like'].
|
||||
"""
|
||||
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
|
||||
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 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
|
||||
assert "row_feed" in user_prompt
|
||||
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.*"
|
||||
@@ -1,180 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext
|
||||
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
|
||||
from GramAddict.core.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.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)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
args = MockArgs(
|
||||
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)
|
||||
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")
|
||||
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")
|
||||
configs = MockConfigs(args)
|
||||
configs.get_plugin_config = MagicMock(return_value={})
|
||||
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")
|
||||
configs = MockConfigs(args)
|
||||
configs.get_plugin_config = MagicMock(return_value={})
|
||||
ctx = BehaviorContext(
|
||||
device=device,
|
||||
configs=configs,
|
||||
session_state=MagicMock(),
|
||||
cognitive_stack={},
|
||||
context_xml="<carousel_page_indicator/>",
|
||||
sleep_mod=1.0,
|
||||
)
|
||||
|
||||
plugin = CarouselBrowsingPlugin()
|
||||
assert not plugin.can_activate(ctx)
|
||||
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
|
||||
|
||||
args = MockArgs(
|
||||
follow_percentage=100,
|
||||
total_follows_limit=0, # Set hard limit to 0
|
||||
stories_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
|
||||
|
||||
args = MockArgs(
|
||||
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
|
||||
|
||||
_interact_with_profile(device, configs, "targetuser", session_state, 0.0, mock_logger)
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
# However, this test suite guarantees the atomic config mapping syntax is correct.
|
||||
@@ -1,31 +0,0 @@
|
||||
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."""
|
||||
|
||||
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"),
|
||||
):
|
||||
engine = ResonanceEngine(configs.username, persona_interests=persona_interests)
|
||||
assert engine._persona_interests == ["travel", "photography", "coffee"]
|
||||
@@ -1,107 +0,0 @@
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
def test_action_blocked_dialog_raises_exception(self):
|
||||
"""
|
||||
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 = """
|
||||
<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" />
|
||||
<node package="com.instagram.android" text="We restrict certain activity to protect our community." />
|
||||
<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
|
||||
and return without doing ANY interactions.
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
def test_interact_with_empty_profile_aborts(self):
|
||||
"""
|
||||
If a user account has 0 posts, we must skip.
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
self.mock_device.click.assert_not_called()
|
||||
self.mock_device.press.assert_not_called()
|
||||
|
||||
def test_comments_disabled_guard(self):
|
||||
"""
|
||||
If the user has disabled comments, 'tap_comment_button' must abort and return skip=True
|
||||
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 = """
|
||||
<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,42 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
from GramAddict.core.qdrant_memory import UIMemoryDB
|
||||
|
||||
def test_delete_point_logs_only_if_exists(caplog):
|
||||
# Setup
|
||||
db = UIMemoryDB()
|
||||
type(db).is_connected = PropertyMock(return_value=True)
|
||||
db.client = MagicMock()
|
||||
db.collection_name = "test_collection"
|
||||
|
||||
# RED: Force the mock to return an empty list (point does NOT exist)
|
||||
db.client.retrieve.return_value = []
|
||||
|
||||
# Action
|
||||
result = db.delete_point("fake_seed")
|
||||
|
||||
# Assertions
|
||||
assert result is True # Should still return True as it didn't crash
|
||||
# It should NOT call delete if it wasn't found
|
||||
db.client.delete.assert_not_called()
|
||||
# It should NOT log the "Purged poisoned memory" line
|
||||
assert "Purged poisoned memory vector" not in caplog.text
|
||||
|
||||
def test_delete_point_logs_if_found(caplog):
|
||||
# Setup
|
||||
db = UIMemoryDB()
|
||||
type(db).is_connected = PropertyMock(return_value=True)
|
||||
db.client = MagicMock()
|
||||
db.collection_name = "test_collection"
|
||||
|
||||
# RED: Force the mock to return a result (point DOES exist)
|
||||
db.client.retrieve.return_value = [{"id": "some_uuid"}]
|
||||
|
||||
# Action
|
||||
with patch("GramAddict.core.qdrant_memory.logger") as mock_logger:
|
||||
result = db.delete_point("fake_seed")
|
||||
|
||||
# Assertions
|
||||
assert result is True
|
||||
db.client.delete.assert_called_once()
|
||||
mock_logger.info.assert_called_once()
|
||||
assert "Purged poisoned memory vector" in mock_logger.info.call_args[0][0]
|
||||
@@ -1,165 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
|
||||
from GramAddict.core.qdrant_memory import DMMemoryDB, ParasocialCRMDB
|
||||
|
||||
|
||||
def test_parasocial_crm_missing_log_sent_dm():
|
||||
"""
|
||||
RED: This test proves that ParasocialCRMDB lacks log_sent_dm,
|
||||
which caused the crash in the last production run.
|
||||
"""
|
||||
crm = ParasocialCRMDB()
|
||||
with pytest.raises(AttributeError) as excinfo:
|
||||
crm.log_sent_dm("test_user", "hello", "bio", [])
|
||||
|
||||
assert "'ParasocialCRMDB' object has no attribute 'log_sent_dm'" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_dm_engine_uses_correct_dm_memory_logging(monkeypatch):
|
||||
"""
|
||||
RED: This test checks if dm_engine correctly uses dm_memory from cognitive_stack.
|
||||
Note: I am writing this to PROVE the fix is necessary.
|
||||
"""
|
||||
mock_device = MagicMock()
|
||||
mock_device.dump_hierarchy.return_value = (
|
||||
'<xml>resource-id="com.instagram.android:id/inbox_refreshable_thread_list_recyclerview"</xml>'
|
||||
)
|
||||
|
||||
mock_telepathic = MagicMock()
|
||||
# Mock unread thread found
|
||||
mock_thread = {"x": 100, "y": 100, "text": "Mariischen"}
|
||||
|
||||
def side_effect_logging(*args, **kwargs):
|
||||
res = next(iterator)
|
||||
print(f"DEBUG: extract_semantic_nodes called with {args[1]}. Returning {res}")
|
||||
return res
|
||||
|
||||
iterator = iter(
|
||||
[
|
||||
[mock_thread], # Step 1: unread threads
|
||||
[{"text": "Hey"}], # Step 2: context
|
||||
[{"x": 200, "y": 200}], # Step 3: input field
|
||||
[{"x": 300, "y": 300}], # Step 4: send button
|
||||
[], # Step 5: next iteration no unread
|
||||
]
|
||||
)
|
||||
mock_telepathic._extract_semantic_nodes.side_effect = side_effect_logging
|
||||
|
||||
mock_dopamine = MagicMock()
|
||||
mock_dopamine.is_app_session_over.return_value = False
|
||||
mock_dopamine.wants_to_change_feed.return_value = True # exit after one
|
||||
mock_dopamine.boredom = 0
|
||||
|
||||
mock_crm = ParasocialCRMDB()
|
||||
mock_dm_memory = MagicMock(spec=DMMemoryDB)
|
||||
mock_resonance = MagicMock()
|
||||
mock_resonance.persona_prompt = "Persona prompt"
|
||||
mock_resonance.args.ai_model = "test-model"
|
||||
|
||||
cognitive_stack = {
|
||||
"telepathic": mock_telepathic,
|
||||
"dopamine": mock_dopamine,
|
||||
"crm": mock_crm,
|
||||
"dm_memory": mock_dm_memory,
|
||||
"resonance": mock_resonance,
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.check_limit.return_value = False
|
||||
mock_session.totalMessages = 0
|
||||
|
||||
# Mock LLM response
|
||||
monkeypatch.setattr("GramAddict.core.llm_provider.query_llm", lambda **k: {"response": "hi"})
|
||||
monkeypatch.setattr("GramAddict.core.bot_flow._humanized_click", lambda *a: None)
|
||||
monkeypatch.setattr("GramAddict.core.bot_flow.sleep", lambda *a: None)
|
||||
monkeypatch.setattr("GramAddict.core.stealth_typing.ghost_type", lambda *a, **k: None)
|
||||
|
||||
mock_configs = MagicMock()
|
||||
mock_configs.args.disable_ai_messaging = False
|
||||
mock_configs.args.ai_condenser_model = "test-model"
|
||||
mock_configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
|
||||
|
||||
# This should NOT crash now because I fixed it, but we are testing the logic.
|
||||
|
||||
_run_zero_latency_dm_loop(
|
||||
mock_device, MagicMock(), MagicMock(), mock_configs, mock_session, "target", cognitive_stack
|
||||
)
|
||||
|
||||
# Verify dm_memory was used, NOT crm
|
||||
mock_dm_memory.log_sent_dm.assert_called_once()
|
||||
|
||||
|
||||
def test_dm_navigation_double_back_guard():
|
||||
"""
|
||||
Verifies that if we are still in a thread after one back press,
|
||||
we press back again.
|
||||
"""
|
||||
mock_device = MagicMock()
|
||||
# Mocking hierarchy sequence
|
||||
mock_device.dump_hierarchy.side_effect = [
|
||||
'<xml>resource-id="com.instagram.android:id/inbox_refreshable_thread_list_recyclerview"</xml>', # Loop 1 start
|
||||
'<xml>resource-id="com.instagram.android:id/direct_thread_header"</xml>', # Context read
|
||||
'<xml>resource-id="com.instagram.android:id/direct_thread_header"</xml>', # Send button find
|
||||
'<xml>resource-id="com.instagram.android:id/direct_thread_header"</xml>', # Navigation check AFTER back
|
||||
'<xml>resource-id="com.instagram.android:id/inbox_refreshable_thread_list_recyclerview"</xml>', # Loop 2 start (exit)
|
||||
'<xml>resource-id="com.instagram.android:id/inbox_refreshable_thread_list_recyclerview"</xml>', # Buffer
|
||||
]
|
||||
|
||||
mock_telepathic = MagicMock()
|
||||
mock_telepathic._extract_semantic_nodes.side_effect = [
|
||||
[{"x": 1, "y": 1}], # unread found in Loop 1
|
||||
[{"text": "msg"}], # context
|
||||
[{"x": 2, "y": 2}], # input
|
||||
[{"x": 3, "y": 3}], # send
|
||||
[], # Loop 2: no unread
|
||||
[], # Buffer
|
||||
]
|
||||
|
||||
mock_dopamine = MagicMock()
|
||||
mock_dopamine.is_app_session_over.return_value = False
|
||||
mock_dopamine.wants_to_change_feed.side_effect = [False, True, True] # exit after Loop 1
|
||||
mock_dopamine.boredom = 0
|
||||
|
||||
mock_resonance = MagicMock()
|
||||
mock_resonance.persona_prompt = "Persona"
|
||||
mock_resonance.args.ai_model = "model"
|
||||
|
||||
cognitive_stack = {
|
||||
"telepathic": mock_telepathic,
|
||||
"dopamine": mock_dopamine,
|
||||
"dm_memory": MagicMock(),
|
||||
"resonance": mock_resonance,
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.check_limit.return_value = False
|
||||
mock_session.totalMessages = 0
|
||||
|
||||
mock_configs = MagicMock()
|
||||
mock_configs.args.disable_ai_messaging = False
|
||||
mock_configs.args.ai_condenser_model = "test-model"
|
||||
mock_configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import GramAddict.core.dm_engine as dm_engine
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "hi"}),
|
||||
patch("GramAddict.core.bot_flow._humanized_click"),
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.stealth_typing.ghost_type"),
|
||||
):
|
||||
dm_engine._run_zero_latency_dm_loop(
|
||||
mock_device, MagicMock(), MagicMock(), mock_configs, mock_session, "target", cognitive_stack
|
||||
)
|
||||
|
||||
# Expected calls:
|
||||
# 1. First back from success flow (thread -> inbox)
|
||||
# 2. Second back from guard check (if still in thread)
|
||||
# 3. Third back from inbox exit (boredom check)
|
||||
assert mock_device.press.call_count == 3
|
||||
mock_device.press.assert_called_with("back")
|
||||
@@ -1,253 +0,0 @@
|
||||
"""
|
||||
TDD Test Suite: Explore Grid Navigation Hardening
|
||||
==================================================
|
||||
Reproduces the exact production failure from 2026-04-16 22:59 where the bot:
|
||||
1. Blacklisted ALL image_buttons because of generic semantic strings
|
||||
2. Could not match "first image in explore grid" via the keyword fast-path
|
||||
3. VLM picked row 3 instead of row 1 because the prompt lacks spatial ranking
|
||||
|
||||
These tests MUST fail before the fix and pass after.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
# ── Realistic node fixtures extracted from live XML dump 2026-04-16_22-59-53 ──
|
||||
|
||||
|
||||
def make_node(x, y, bounds, semantic, res_id="com.instagram.android:id/dummy", text="", desc=""):
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
l, t, r, b = map(int, m.groups()) if m else (0, 0, 0, 0)
|
||||
return {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": r - l,
|
||||
"height": b - t,
|
||||
"area": (r - l) * (b - t),
|
||||
"raw_bounds": bounds,
|
||||
"semantic_string": semantic,
|
||||
"resource_id": res_id,
|
||||
"class_name": "android.widget.FrameLayout",
|
||||
"selected": False,
|
||||
"original_attribs": {"text": text, "desc": desc},
|
||||
}
|
||||
|
||||
|
||||
EXPLORE_GRID_NODES = [
|
||||
# Row 1, Col 1 — this is what "first image" should match
|
||||
make_node(
|
||||
178,
|
||||
559,
|
||||
"[0,321][356,797]",
|
||||
"description: '4 photos by Patsy Weingart at row 1, column 1', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="4 photos by Patsy Weingart at row 1, column 1",
|
||||
),
|
||||
# Row 1, Col 1 — child image_button (same area, no semantic info)
|
||||
make_node(
|
||||
178, 558, "[0,321][356,796]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
|
||||
),
|
||||
# Row 1, Col 2
|
||||
make_node(
|
||||
540,
|
||||
559,
|
||||
"[362,321][718,797]",
|
||||
"description: 'Photo by Barbara at Row 1, Column 2', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="Photo by Barbara at Row 1, Column 2",
|
||||
),
|
||||
make_node(
|
||||
540, 558, "[362,321][718,796]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
|
||||
),
|
||||
# Row 2, Col 2
|
||||
make_node(
|
||||
540,
|
||||
1041,
|
||||
"[362,803][718,1279]",
|
||||
"description: 'Photo by Garima Bhaskar at Row 2, Column 2', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="Photo by Garima Bhaskar at Row 2, Column 2",
|
||||
),
|
||||
make_node(
|
||||
540, 1040, "[362,803][718,1278]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
|
||||
),
|
||||
# Row 3, Col 1 — this is what the VLM wrongly picked
|
||||
make_node(
|
||||
178,
|
||||
1523,
|
||||
"[0,1285][356,1761]",
|
||||
"description: '4 photos by Soul Of Nature Photography at row 3, column 1', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="4 photos by Soul Of Nature Photography at row 3, column 1",
|
||||
),
|
||||
make_node(
|
||||
178, 1522, "[0,1285][356,1760]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
|
||||
),
|
||||
# Search bar
|
||||
make_node(
|
||||
487,
|
||||
219,
|
||||
"[32,173][943,265]",
|
||||
"text: 'Search', id context: 'action bar search edit text'",
|
||||
res_id="com.instagram.android:id/action_bar_search_edit_text",
|
||||
text="Search",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class TestBlacklistPoisoning:
|
||||
"""
|
||||
Bug: Generic semantic strings like 'id context: image button' get blacklisted,
|
||||
which kills ALL grid items because they share the same semantic string.
|
||||
"""
|
||||
|
||||
def test_generic_semantic_should_not_be_blacklistable(self):
|
||||
"""
|
||||
A semantic string consisting ONLY of a generic id context (no text, no
|
||||
description) is too ambiguous to blacklist. The engine must refuse to
|
||||
blacklist it because it would poison all similar nodes.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
# Clear any persisted state to test pure logic
|
||||
engine._blacklist = {}
|
||||
|
||||
# Simulate the flow: the engine "clicked" an image_button and it failed
|
||||
TelepathicEngine._last_click_context = {
|
||||
"intent": "first image in explore grid",
|
||||
"semantic_string": "id context: 'image button'",
|
||||
"x": 178,
|
||||
"y": 558,
|
||||
"timestamp": 1000,
|
||||
}
|
||||
|
||||
engine.reject_click("first image in explore grid")
|
||||
|
||||
# The blacklist should NOT contain this generic entry
|
||||
blacklisted = engine._blacklist.get("first image in explore grid", [])
|
||||
assert "id context: 'image button'" not in blacklisted, (
|
||||
"CRITICAL: Generic semantic 'id context: image button' was blacklisted! "
|
||||
"This poisons ALL image_buttons in ALL grids."
|
||||
)
|
||||
|
||||
|
||||
class TestExploreGridFastPath:
|
||||
"""
|
||||
Bug: There was no fast-path to match 'first image in explore grid' to a
|
||||
grid_card_layout_container node. Now the Grid Fast-Path (Stage 1.25) handles
|
||||
this deterministically via resource-ID + spatial sorting.
|
||||
"""
|
||||
|
||||
def test_grid_fastpath_matches_container(self):
|
||||
"""
|
||||
The Grid Fast-Path must match 'first image in explore grid' to
|
||||
a grid_card_layout_container node without calling VLM/embeddings.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# Build a minimal XML that the engine can parse — but we test the fast-path
|
||||
# directly by calling find_best_node with mocked extraction
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(engine, "_extract_semantic_nodes", return_value=EXPLORE_GRID_NODES):
|
||||
with patch.object(engine, "_is_instagram_context", return_value=True):
|
||||
result = engine.find_best_node(
|
||||
"<fake>", "first image in explore grid", min_confidence=0.82, device=None
|
||||
)
|
||||
|
||||
assert result is not None, (
|
||||
"Grid Fast-Path returned None for 'first image in explore grid'. "
|
||||
"This forces every explore grid tap to use the expensive VLM fallback."
|
||||
)
|
||||
assert any(
|
||||
k in result.get("semantic_string", "").lower() for k in ["image button", "grid card layout container"]
|
||||
), f"Grid Fast-Path selected wrong node type: {result.get('semantic_string')}"
|
||||
|
||||
def test_grid_fastpath_prefers_topmost_row(self):
|
||||
"""
|
||||
When multiple grid items match, the Grid Fast-Path must prefer the
|
||||
topmost one (smallest Y = row 1) since the intent says 'first'.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(engine, "_extract_semantic_nodes", return_value=EXPLORE_GRID_NODES):
|
||||
with patch.object(engine, "_is_instagram_context", return_value=True):
|
||||
result = engine.find_best_node(
|
||||
"<fake>", "first image in explore grid", min_confidence=0.82, device=None
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
# Row 1 items have y ≈ 559, Row 3 items have y ≈ 1523
|
||||
assert result["y"] < 800, (
|
||||
f"Grid Fast-Path selected a grid item at y={result['y']} (row 3+) "
|
||||
f"instead of row 1 (y≈559). The intent says 'first image'!"
|
||||
)
|
||||
|
||||
|
||||
class TestVerifySuccessExploreGrid:
|
||||
"""
|
||||
Bug: verify_success for explore grid tap checks for feed markers, but
|
||||
the post_load_timeout dump proved the bot was STILL on the explore grid.
|
||||
The verification correctly returned False, but the response was to blacklist
|
||||
the grid_card_layout_container — which is the WRONG reaction. The tap
|
||||
just didn't register; it doesn't mean the mapping is wrong.
|
||||
"""
|
||||
|
||||
def test_verify_success_returns_none_when_still_on_grid(self):
|
||||
"""
|
||||
If we tapped a grid item but the screen still shows the explore grid
|
||||
(no feed markers), verify_success must return None (Inconclusive).
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# Simulate that we just clicked a grid item
|
||||
TelepathicEngine._last_click_context = {
|
||||
"intent": "first image in explore grid",
|
||||
"semantic_string": "description: '4 photos by Patsy Weingart at row 1, column 1'",
|
||||
"x": 178,
|
||||
"y": 559,
|
||||
"timestamp": 1000,
|
||||
}
|
||||
|
||||
# Post-click XML still shows the explore grid (no feed markers)
|
||||
still_on_grid_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/explore_action_bar" />
|
||||
<node resource-id="com.instagram.android:id/grid_card_layout_container"
|
||||
content-desc="4 photos by Patsy Weingart at row 1, column 1" />
|
||||
<node resource-id="com.instagram.android:id/image_button" />
|
||||
</node>
|
||||
"""
|
||||
|
||||
result = engine.verify_success("first image in explore grid", still_on_grid_xml)
|
||||
assert result is None, "verify_success should be inconclusive (None) when still on explore grid"
|
||||
|
||||
def test_verify_success_returns_true_when_post_opened(self):
|
||||
"""
|
||||
If the grid tap succeeded and we're now viewing a post with feed markers,
|
||||
verify_success must return True.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
TelepathicEngine._last_click_context = {
|
||||
"intent": "first image in explore grid",
|
||||
"semantic_string": "description: '4 photos by Patsy Weingart at row 1, column 1'",
|
||||
"x": 178,
|
||||
"y": 559,
|
||||
"timestamp": 1000,
|
||||
}
|
||||
|
||||
# Post-click XML shows a feed post (has feed markers)
|
||||
post_view_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_like" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_comment" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_share" />
|
||||
</node>
|
||||
"""
|
||||
|
||||
result = engine.verify_success("first image in explore grid", post_view_xml)
|
||||
assert result is True, "verify_success should pass when post view is visible"
|
||||
@@ -1,110 +0,0 @@
|
||||
"""
|
||||
🔴 RED TDD: VLM Structural Guard Inconsistency + GOAP Back-Press Loop
|
||||
|
||||
Bug A: VLM guard enforces "must be at bottom" for ALL nav intent keywords,
|
||||
but "following"/"follower" are PROFILE STATS, not nav tabs. The inner
|
||||
_structural_sanity_check correctly only enforces for "tab" intents.
|
||||
|
||||
Bug B: GOAP back-press loop has no circuit breaker. If the planner keeps
|
||||
pressing back on the same screen, it eventually exits Instagram.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
class TestVlmGuardFollowingConsistency:
|
||||
"""The VLM guard must NOT reject 'following' count elements at the top of the screen."""
|
||||
|
||||
def test_following_intent_allows_top_screen_elements(self):
|
||||
"""
|
||||
The 'following' count on a profile is at Y≈246 (top 10%).
|
||||
The VLM Structural Guard must NOT reject this as a 'hallucinated nav tab'.
|
||||
It's a profile stat, not a tab.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
screen_height = 2424
|
||||
|
||||
# Simulate the exact node the VLM found
|
||||
following_node = {
|
||||
"semantic_string": "text: '2,285 following', id context: 'row profile header following container'",
|
||||
"y": 246,
|
||||
"area": 5000,
|
||||
"class_name": "android.widget.TextView",
|
||||
"resource_id": "row_profile_header_following_container",
|
||||
}
|
||||
|
||||
intent = "tap following list"
|
||||
|
||||
# Inner structural guard should accept this (it has the "tab" check)
|
||||
is_valid = engine._structural_sanity_check(following_node, intent, screen_height)
|
||||
assert is_valid is True, (
|
||||
"Inner structural guard rejected 'following' element at Y=246. "
|
||||
"The intent 'tap following list' does not contain 'tab', so the nav-tab enforcement should not apply."
|
||||
)
|
||||
|
||||
def test_follower_intent_allows_top_screen_elements(self):
|
||||
"""Same bug for 'follower' count on a profile."""
|
||||
engine = TelepathicEngine()
|
||||
screen_height = 2424
|
||||
|
||||
follower_node = {
|
||||
"semantic_string": "text: '2,622 followers', id context: 'row profile header followers container'",
|
||||
"y": 246,
|
||||
"area": 5000,
|
||||
"class_name": "android.widget.TextView",
|
||||
"resource_id": "row_profile_header_followers_container",
|
||||
}
|
||||
|
||||
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."
|
||||
|
||||
|
||||
class TestGoapBackPressCircuitBreaker:
|
||||
"""GOAP must detect and abort back-press loops on the same screen."""
|
||||
|
||||
def test_consecutive_back_presses_on_same_screen_aborts(self):
|
||||
"""
|
||||
If the GOAP planner presses back 3+ times on the same screen type
|
||||
without any screen transition, it should abort instead of continuing
|
||||
to press back until it exits the app.
|
||||
"""
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
device._get_current_app.return_value = "com.instagram.android"
|
||||
|
||||
goap = GoalExecutor(device, bot_username="testbot")
|
||||
goap._sae = MagicMock()
|
||||
goap._sae.ensure_clear_screen.return_value = True
|
||||
|
||||
# Simulate being stuck on HOME_FEED with only back available
|
||||
call_count = 0
|
||||
|
||||
def mock_perceive():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return {
|
||||
"screen_type": ScreenType.HOME_FEED,
|
||||
"available_actions": ["press back"],
|
||||
"context": {},
|
||||
"selected_tab": "feed_tab",
|
||||
}
|
||||
|
||||
goap.perceive = mock_perceive
|
||||
|
||||
# Mock _execute_action to always succeed (pressing back "works" but stays on same screen)
|
||||
goap._execute_action = MagicMock(return_value=True)
|
||||
|
||||
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"]
|
||||
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."
|
||||
)
|
||||
assert result is False, "GOAP should fail when stuck in a back-press loop."
|
||||
@@ -1,35 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from GramAddict.core.goap import GoalPlanner, ScreenType
|
||||
|
||||
|
||||
def test_goal_planner_uses_hd_map_over_linguistic_match():
|
||||
"""
|
||||
Tests that the GoalPlanner uses the HD Map (ScreenTopology) as its
|
||||
primary routing strategy, even during Blank Start discovery.
|
||||
The HD Map returns canonical action intents that the TelepathicEngine
|
||||
interprets — not just available_actions from the XML dump.
|
||||
"""
|
||||
planner = GoalPlanner("test_user")
|
||||
|
||||
# Mock the internal structures
|
||||
planner.knowledge = MagicMock()
|
||||
planner.knowledge.is_trap.return_value = False
|
||||
planner.knowledge.get_requirements.return_value = None # Force Blank Start
|
||||
|
||||
# Simulate current screen
|
||||
screen = {
|
||||
"screen_type": ScreenType.HOME_FEED,
|
||||
"available_actions": ["tap explore grid", "tap messages"],
|
||||
"context": {},
|
||||
}
|
||||
|
||||
goal = "open explore feed"
|
||||
|
||||
result = planner.plan_next_step(goal, screen, explored_nav_actions=set())
|
||||
|
||||
# HD Map routes HOME_FEED → EXPLORE_GRID via "tap explore tab"
|
||||
assert result == "tap explore tab", (
|
||||
f"Expected HD Map to route via 'tap explore tab', got '{result}'. "
|
||||
"The HD Map should override linguistic matching as the primary strategy."
|
||||
)
|
||||
@@ -1,132 +0,0 @@
|
||||
"""
|
||||
🔴 RED TDD: GOAP False Unlearning Fix
|
||||
|
||||
Reproduces Bug 3: The bot taps 'home tab' while ALREADY on home_feed,
|
||||
detects 'no UI change', and destructively unlearns the correct mapping.
|
||||
|
||||
These tests MUST FAIL before the fix and PASS after.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
|
||||
|
||||
def _make_goap(screen_type=ScreenType.HOME_FEED, available_actions=None):
|
||||
"""Create a GoalExecutor with a mocked device that returns a fixed screen state."""
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
device._get_current_app.return_value = "com.instagram.android"
|
||||
|
||||
goap = GoalExecutor(device, bot_username="testbot")
|
||||
goap._sae = MagicMock()
|
||||
goap._sae.ensure_clear_screen.return_value = True
|
||||
|
||||
# Mock perceive to return a fixed screen state
|
||||
if available_actions is None:
|
||||
available_actions = ["tap profile tab", "tap explore tab", "scroll down", "press back", "tap home tab"]
|
||||
|
||||
goap.perceive = MagicMock(
|
||||
return_value={
|
||||
"screen_type": screen_type,
|
||||
"available_actions": available_actions,
|
||||
"context": {},
|
||||
"selected_tab": "feed_tab",
|
||||
}
|
||||
)
|
||||
|
||||
return goap
|
||||
|
||||
|
||||
class TestGoapRecalledPathPreCheck:
|
||||
"""Bug 3 Part A: Recalled path should skip execution when goal is already achieved."""
|
||||
|
||||
def test_recalled_path_skips_when_goal_already_achieved(self):
|
||||
"""
|
||||
If the bot is already on HOME_FEED and the goal is 'open home feed',
|
||||
_execute_recalled_path must return True WITHOUT executing any steps.
|
||||
"""
|
||||
goap = _make_goap(screen_type=ScreenType.HOME_FEED)
|
||||
|
||||
steps = [{"action": "tap home tab"}]
|
||||
|
||||
# Mock _execute_action to track if it was called
|
||||
goap._execute_action = MagicMock(return_value=True)
|
||||
|
||||
result = goap._execute_recalled_path(steps, "open home feed")
|
||||
|
||||
assert result is True, "Recalled path should succeed immediately when goal is already achieved."
|
||||
(
|
||||
goap._execute_action.assert_not_called(),
|
||||
("_execute_action should NOT have been called — goal was already achieved."),
|
||||
)
|
||||
|
||||
|
||||
class TestGoapNoUnlearnWhenAlreadyOnTarget:
|
||||
"""Bug 3 Part B: Navigation to current screen should not trigger reject_click."""
|
||||
|
||||
def test_no_unlearn_when_tapping_home_on_home_feed(self):
|
||||
"""
|
||||
If the bot is on HOME_FEED and taps 'home tab', the XML won't change.
|
||||
But since the goal 'open home feed' is already achieved, the action
|
||||
should be treated as SUCCESS, not FAILURE.
|
||||
"""
|
||||
goap = _make_goap(screen_type=ScreenType.HOME_FEED)
|
||||
|
||||
# Make dump_hierarchy return the same XML (no change)
|
||||
static_xml = "<hierarchy><node resource-id='feed_tab' text='Home' /></hierarchy>"
|
||||
goap.device.dump_hierarchy.return_value = static_xml
|
||||
|
||||
# Mock the TelepathicEngine
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.find_best_node.return_value = {
|
||||
"x": 540,
|
||||
"y": 2300,
|
||||
"score": 0.95,
|
||||
"semantic": "description: 'Home', id context: 'feed tab'",
|
||||
"source": "qdrant_nav",
|
||||
"original_attribs": {},
|
||||
}
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_engine):
|
||||
result = goap._execute_action("tap home tab", goal="open home feed")
|
||||
|
||||
# The action should succeed (we're already where we need to be)
|
||||
assert result is True, (
|
||||
"GOAP incorrectly treated 'tap home tab' on home_feed as a failure. "
|
||||
"This causes destructive unlearning of valid navigation knowledge."
|
||||
)
|
||||
|
||||
# Critically: reject_click should NEVER have been called
|
||||
(
|
||||
mock_engine.reject_click.assert_not_called(),
|
||||
("reject_click was called — this destroys the correct 'tap home tab' → 'feed tab' mapping!"),
|
||||
)
|
||||
|
||||
def test_genuine_navigation_failure_still_triggers_reject(self):
|
||||
"""
|
||||
If the bot is on HOME_FEED and taps 'tap explore tab' but nothing changes,
|
||||
that IS a genuine failure and reject_click SHOULD be called.
|
||||
"""
|
||||
goap = _make_goap(screen_type=ScreenType.HOME_FEED)
|
||||
|
||||
static_xml = "<hierarchy><node resource-id='feed_tab' text='Home' /></hierarchy>"
|
||||
goap.device.dump_hierarchy.return_value = static_xml
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.find_best_node.return_value = {
|
||||
"x": 540,
|
||||
"y": 2300,
|
||||
"score": 0.95,
|
||||
"semantic": "description: 'Explore', id context: 'explore_tab'",
|
||||
"source": "qdrant_nav",
|
||||
"original_attribs": {},
|
||||
}
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_engine):
|
||||
result = goap._execute_action("tap explore tab", goal="open explore feed")
|
||||
|
||||
# This should fail — we expected to go to explore but UI didn't change
|
||||
assert result is False, (
|
||||
"GOAP should reject a navigation that produced no UI change " "when the goal is NOT already achieved."
|
||||
)
|
||||
@@ -1,118 +0,0 @@
|
||||
"""
|
||||
🔴 RED TDD: Step-Aware Navigation Validation + Topology Guard
|
||||
|
||||
Tests that validate the GOAP planner correctly handles multi-step navigation:
|
||||
- Intermediate steps (tap profile tab → OWN_PROFILE) must be ACCEPTED
|
||||
- Wrong screens (tap profile tab → REELS_FEED) must be REJECTED
|
||||
- Structural HD Map actions must NEVER be aversively learned as traps
|
||||
"""
|
||||
|
||||
import os
|
||||
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
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
|
||||
class TestStepAwareValidation:
|
||||
"""_execute_action must validate intermediate steps, not just final goals."""
|
||||
|
||||
@pytest.fixture
|
||||
def executor(self):
|
||||
device = MagicMock()
|
||||
device.dump_hierarchy.return_value = "<xml>mock</xml>"
|
||||
device.click = MagicMock()
|
||||
executor = GoalExecutor(device, bot_username="testbot")
|
||||
executor.action_failures = {}
|
||||
return executor
|
||||
|
||||
def test_intermediate_step_accepted(self, executor):
|
||||
"""
|
||||
Action: 'tap profile tab' from HOME_FEED
|
||||
Goal: 'open following list'
|
||||
Landing: OWN_PROFILE (intermediate step)
|
||||
|
||||
The validator must ACCEPT this — OWN_PROFILE is the correct
|
||||
intermediate screen for 'tap profile tab' from HOME_FEED.
|
||||
It must NOT reject it because the goal says 'following'.
|
||||
"""
|
||||
# The critical question: does expected_screen_for_action return OWN_PROFILE?
|
||||
expected = ScreenTopology.expected_screen_for_action("tap profile tab", ScreenType.HOME_FEED)
|
||||
assert expected == ScreenType.OWN_PROFILE, "HD Map must know 'tap profile tab' → OWN_PROFILE"
|
||||
|
||||
# And the goal target is FOLLOW_LIST, which is DIFFERENT from where we landed
|
||||
goal_target = ScreenTopology.goal_to_target_screen("open following list")
|
||||
assert goal_target == ScreenType.FOLLOW_LIST
|
||||
|
||||
# The old code would fail here because it checks goal_target against landing screen.
|
||||
# The new code checks expected (from action) against landing screen.
|
||||
landing = ScreenType.OWN_PROFILE
|
||||
assert landing == expected, "Step validation: landing matches expected → ACCEPT"
|
||||
assert landing != goal_target, "Goal not yet achieved — but step is valid"
|
||||
|
||||
def test_wrong_screen_rejected(self, executor):
|
||||
"""
|
||||
Action: 'tap profile tab' from HOME_FEED
|
||||
Landing: REELS_FEED (wrong!)
|
||||
|
||||
The validator must REJECT this — expected was OWN_PROFILE.
|
||||
"""
|
||||
expected = ScreenTopology.expected_screen_for_action("tap profile tab", ScreenType.HOME_FEED)
|
||||
landing = ScreenType.REELS_FEED
|
||||
assert landing != expected, "Wrong screen: REELS_FEED ≠ OWN_PROFILE → REJECT"
|
||||
|
||||
def test_final_step_accepted(self, executor):
|
||||
"""
|
||||
Action: 'tap following list' from OWN_PROFILE
|
||||
Goal: 'open following list'
|
||||
Landing: FOLLOW_LIST (final step!)
|
||||
|
||||
The validator must ACCEPT this.
|
||||
"""
|
||||
expected = ScreenTopology.expected_screen_for_action("tap following list", ScreenType.OWN_PROFILE)
|
||||
assert expected == ScreenType.FOLLOW_LIST
|
||||
landing = ScreenType.FOLLOW_LIST
|
||||
assert landing == expected
|
||||
|
||||
|
||||
class TestTopologyGuard:
|
||||
"""Structural HD Map actions must NEVER be aversively learned as traps."""
|
||||
|
||||
def test_structural_action_not_burned(self):
|
||||
"""'tap profile tab' on HOME_FEED is structural — must not be burned."""
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap profile tab") is True
|
||||
|
||||
def test_non_structural_action_can_be_burned(self):
|
||||
"""'open following list' on HOME_FEED is NOT structural — can be burned."""
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "open following list") is False
|
||||
|
||||
|
||||
class TestSSOTConsolidation:
|
||||
"""All goal→screen mappings must use ScreenTopology as SSOT."""
|
||||
|
||||
def test_is_goal_achieved_uses_topology_for_following(self):
|
||||
"""_is_goal_achieved must recognize FOLLOW_LIST for 'open following list'."""
|
||||
planner = GoalPlanner("testbot")
|
||||
planner.knowledge = MagicMock()
|
||||
planner.knowledge.is_trap.return_value = False
|
||||
planner.knowledge.get_requirements.return_value = None
|
||||
|
||||
# On FOLLOW_LIST with goal "open following list" → achieved
|
||||
result = planner._is_goal_achieved("open following list", ScreenType.FOLLOW_LIST, {})
|
||||
assert result is True, "Goal must be achieved when on target screen"
|
||||
|
||||
def test_is_goal_achieved_not_achieved_on_wrong_screen(self):
|
||||
planner = GoalPlanner("testbot")
|
||||
result = planner._is_goal_achieved("open following list", ScreenType.HOME_FEED, {})
|
||||
assert result is False
|
||||
|
||||
def test_navigate_to_screen_uses_topology(self):
|
||||
"""navigate_to_screen must use ScreenTopology.screen_name_to_goal()."""
|
||||
assert ScreenTopology.screen_name_to_goal("FollowingList") == "open following list"
|
||||
assert ScreenTopology.screen_name_to_goal("ExploreFeed") == "open explore feed"
|
||||
assert ScreenTopology.screen_name_to_goal("OwnProfile") == "open profile"
|
||||
@@ -1,55 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.screen_topology import ScreenType
|
||||
|
||||
|
||||
def test_goap_unlearns_transition_on_back_press_trap():
|
||||
"""Prove that GOAP forgets a specific topological transition when it gets trapped and spams 'back'."""
|
||||
device = MagicMock()
|
||||
orchestrator = GoalExecutor(device, "testuser")
|
||||
|
||||
# Mocking internal state
|
||||
goal = "open messages"
|
||||
|
||||
def mock_exec(*args, **kwargs):
|
||||
print("EXECUTING:", args, kwargs)
|
||||
return True
|
||||
|
||||
orchestrator._execute_action = MagicMock(side_effect=mock_exec) # "press back" succeeds
|
||||
orchestrator.path_memory = MagicMock()
|
||||
orchestrator.path_memory.recall_path.return_value = None
|
||||
# To test the back-press circuit breaker, we just need to feed it 3 "press back" actions.
|
||||
|
||||
# Since achieve is complex, let's just test that the required logic
|
||||
# exists inside it. The circuit breaker is in the "EXECUTE" block of achieve.
|
||||
# We will mock the planner to return "press back" 3 times.
|
||||
orchestrator.planner.plan_next_step = MagicMock(side_effect=[["press back"], ["press back"], ["press back"], []])
|
||||
orchestrator.perceive = MagicMock(
|
||||
return_value={"screen_type": ScreenType.EXPLORE_GRID, "available_actions": ["mock"]}
|
||||
)
|
||||
|
||||
with patch("GramAddict.core.qdrant_memory.NavigationMemoryDB") as MockNavDB:
|
||||
mock_nav_instance = MockNavDB.return_value
|
||||
|
||||
# We need to simulate that `steps_taken` already had "tap explore tab"
|
||||
# However, achieve starts with an empty `steps_taken`.
|
||||
# So we mock the internal variables if possible, but they are local.
|
||||
# Alternatively, we make the planner return "tap explore tab", then 3 "press back"s.
|
||||
orchestrator.planner.plan_next_step = MagicMock(
|
||||
side_effect=["tap explore tab", "press back", "press back", "press back", "press back", None]
|
||||
)
|
||||
|
||||
# Act
|
||||
result = orchestrator.achieve(goal, max_steps=10)
|
||||
|
||||
# Assert (RED)
|
||||
assert result is False
|
||||
|
||||
# Did it forget the path? (learn_path with success=False)
|
||||
assert orchestrator.path_memory.learn_path.called
|
||||
|
||||
# Did it unlearn the transition?
|
||||
print("MockNavDB calls:", MockNavDB.mock_calls)
|
||||
print("NavInstance calls:", mock_nav_instance.mock_calls)
|
||||
mock_nav_instance.unlearn_transition.assert_called_once_with(ScreenType.EXPLORE_GRID.value, "tap explore tab")
|
||||
@@ -1,110 +0,0 @@
|
||||
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.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.engine = TelepathicEngine()
|
||||
|
||||
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": ""},
|
||||
},
|
||||
]
|
||||
|
||||
def test_first_call_returns_topmost_leftmost(self):
|
||||
"""Without skip_positions, Grid Fast-Path returns (178, 558)."""
|
||||
nodes = self._make_grid_nodes()
|
||||
result = self.engine._grid_fast_path("first image in explore grid", nodes)
|
||||
assert result is not None
|
||||
assert result["x"] == 178
|
||||
assert result["y"] == 558
|
||||
|
||||
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)})
|
||||
assert result is not None
|
||||
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)}
|
||||
)
|
||||
assert result is not None
|
||||
assert (result["x"], result["y"]) == (902, 558)
|
||||
|
||||
def test_all_positions_skipped_returns_none(self):
|
||||
"""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)
|
||||
assert result is None
|
||||
@@ -1,87 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import requests
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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):
|
||||
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,
|
||||
)
|
||||
|
||||
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"
|
||||
mock_resp = MagicMock()
|
||||
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
|
||||
)
|
||||
|
||||
assert result == '{"edge": "true"}'
|
||||
@@ -1,38 +0,0 @@
|
||||
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
|
||||
timeout down to the requests.post call, enabling long
|
||||
generation tasks like comments to complete without crashing.
|
||||
"""
|
||||
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,78 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
local Ollama models from the config and sends a POST request with keep_alive: 0
|
||||
to unload them from VRAM.
|
||||
"""
|
||||
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
|
||||
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,
|
||||
):
|
||||
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,67 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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:
|
||||
instance = mock.return_value
|
||||
instance.is_connected = True
|
||||
instance.collection_name = "goap_paths_v1"
|
||||
instance.client = MagicMock()
|
||||
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
|
||||
recalled path) because they share the same seed.
|
||||
"""
|
||||
pm = PathMemory()
|
||||
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
|
||||
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
|
||||
@@ -1,89 +0,0 @@
|
||||
"""
|
||||
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 GramAddict.core.physics.biomechanics import PhysicsBody
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singletons():
|
||||
PhysicsBody.reset()
|
||||
SendEventInjector.reset()
|
||||
yield
|
||||
PhysicsBody.reset()
|
||||
SendEventInjector.reset()
|
||||
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
def test_humanized_scroll_speeds(MockInjector):
|
||||
mock_injector = MagicMock()
|
||||
MockInjector.get_instance.return_value = mock_injector
|
||||
|
||||
device = MagicMock()
|
||||
device.get_info.return_value = {"displayHeight": 2400, "displayWidth": 1080}
|
||||
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
# First scroll
|
||||
humanized_scroll(device)
|
||||
assert mock_injector.inject_gesture.called
|
||||
|
||||
# Verify gesture data was passed correctly
|
||||
args = mock_injector.inject_gesture.call_args
|
||||
points = args[0][0]
|
||||
timing = args[0][1]
|
||||
|
||||
# Points must be valid (x, y, pressure) tuples
|
||||
for p in points:
|
||||
assert len(p) == 3, f"Expected (x, y, pressure), got {p}"
|
||||
|
||||
# Timing intervals must all be positive
|
||||
for t in timing:
|
||||
assert t > 0, f"Timing interval must be positive, got {t}"
|
||||
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
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
|
||||
biomechanical 'reading pauses'.
|
||||
"""
|
||||
mock_injector = MagicMock()
|
||||
MockInjector.get_instance.return_value = mock_injector
|
||||
|
||||
device = MagicMock()
|
||||
device.get_info.return_value = {"displayHeight": 2400, "displayWidth": 1080}
|
||||
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
# Run multiple times to overcome randomness in play_choice / do_correction
|
||||
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!"
|
||||
@@ -1,94 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
class TestProfileInteractionSync:
|
||||
"""
|
||||
TDD Tests: Reproduces the 'Already Followed -> Favorites' and 'No Story Ring'
|
||||
bugs from 2026-04-17 live run.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.engine = TelepathicEngine()
|
||||
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)
|
||||
|
||||
def test_prevent_tapping_following_button(self):
|
||||
"""
|
||||
If the intent is to follow, but the matching node says 'following' or 'gefolgt',
|
||||
the engine must skip the click to prevent opening the Favorites/Mute bottom sheet.
|
||||
"""
|
||||
# Simulate a profile where the user is already followed
|
||||
|
||||
# Test vector-based matching fallback
|
||||
self.engine._blacklist = {}
|
||||
|
||||
# Mock the parsing instead of old extraction
|
||||
self.engine._parser = MagicMock()
|
||||
self.engine._parser.parse.return_value = MagicMock()
|
||||
|
||||
# We must return SpatialNode objects for the new architecture
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
mock_node = SpatialNode(MagicMock())
|
||||
mock_node.resource_id = "com.instagram.android:id/button"
|
||||
mock_node.text = "Following"
|
||||
mock_node.bounds = [500, 600, 600, 650]
|
||||
self.engine._parser.get_clickable_nodes.return_value = [mock_node]
|
||||
|
||||
self.engine._structural_sanity_check = MagicMock(return_value=True)
|
||||
self.engine._is_instagram_context = MagicMock(return_value=True)
|
||||
|
||||
# Mock resolver to return our mock node
|
||||
self.engine._resolver = MagicMock()
|
||||
self.engine._resolver.resolve.return_value = mock_node
|
||||
|
||||
result = self.engine.find_best_node("<mock></mock>", "tap follow button on profile", device=self.mock_device)
|
||||
|
||||
assert result is not None, "Engine should return a skip result, not None"
|
||||
assert result.get("skip") is True, "Must return skip: True to prevent Favorites menu from opening"
|
||||
assert result.get("semantic") == "already_followed"
|
||||
|
||||
def test_story_ring_not_present_skips_click(self):
|
||||
"""
|
||||
If no story ring is explicitly in the XML, bot_flow should not execute
|
||||
the transition (simulated here by checking our XML evaluation logic).
|
||||
"""
|
||||
xml_without_story = """
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_profile_header_imageview" content-desc="Profile picture" />
|
||||
<node text="marisaundmarc" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
has_story = (
|
||||
"reel_ring" in xml_without_story
|
||||
or "unseen story" in xml_without_story.lower()
|
||||
or "story von" in xml_without_story.lower()
|
||||
)
|
||||
|
||||
assert has_story is False, "Logic falsely identified a story when there is only a generic profile picture"
|
||||
|
||||
def test_story_ring_present_allows_click(self):
|
||||
"""
|
||||
If a story ring is present, the logic should allow the interaction.
|
||||
"""
|
||||
xml_with_story = """
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/reel_ring" />
|
||||
<node resource-id="com.instagram.android:id/row_profile_header_imageview" content-desc="mercedesbenz_de's unseen story" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
has_story = (
|
||||
"reel_ring" in xml_with_story
|
||||
or "unseen story" in xml_with_story.lower()
|
||||
or "story von" in xml_with_story.lower()
|
||||
)
|
||||
|
||||
assert has_story is True, "Logic failed to identify active story ring"
|
||||
@@ -1,89 +0,0 @@
|
||||
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()
|
||||
self.args.follow_percentage = 100
|
||||
self.args.likes_percentage = 100
|
||||
self.args.stories_percentage = 0
|
||||
self.args.likes_count = "1-1"
|
||||
|
||||
def get_plugin_config(self, name):
|
||||
return {}
|
||||
|
||||
|
||||
def test_profile_grid_sync_delay_after_follow():
|
||||
"""
|
||||
Verifies that _interact_with_profile enforces a sleep delay
|
||||
after a successful follow and BEFORE searching for the grid,
|
||||
preventing UI automation from dumping mid-animation.
|
||||
It now tracks the autonomous QNavGraph calls.
|
||||
"""
|
||||
mock_device = MagicMock()
|
||||
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"),
|
||||
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
|
||||
MockQNavGraph.return_value = mock_nav_instance
|
||||
|
||||
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":
|
||||
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":
|
||||
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,47 +0,0 @@
|
||||
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
|
||||
@@ -1,79 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.situational_awareness import EscapeAction, SituationalAwarenessEngine, SituationEpisodeDB
|
||||
|
||||
|
||||
class TestSAETeslaUpgrade:
|
||||
def setup_method(self):
|
||||
self.device_mock = MagicMock()
|
||||
self.sae = SituationalAwarenessEngine(self.device_mock)
|
||||
|
||||
def test_sae_foreground_extraction(self):
|
||||
"""
|
||||
Ensures that modals/popups located at the END of the XML document
|
||||
(highest Z-index) are prioritized in the compressed signature.
|
||||
The old system truncated at elements[:50], missing the actual popup.
|
||||
"""
|
||||
# Create an XML with 60 background nodes, and 1 dialog node at the end
|
||||
xml_dump = "<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>\n<hierarchy rotation='0'>\n"
|
||||
for i in range(60):
|
||||
xml_dump += f' <node package="com.instagram.android" resource-id="id/feed_item_{i}" text="Feed {i}" bounds="[0,0][100,100]" />\n'
|
||||
|
||||
# The critical modal at the end
|
||||
xml_dump += ' <node package="com.instagram.android" resource-id="id/dialog_container" text="Not Now" bounds="[100,100][200,200]" />\n'
|
||||
xml_dump += "</hierarchy>"
|
||||
|
||||
compressed = self.sae._compress_xml(xml_dump)
|
||||
|
||||
# The compressed string MUST contain the dialog container
|
||||
assert "dialog_container" in compressed, "Foreground extraction failed: modal was truncated!"
|
||||
assert "Not Now" in compressed, "Foreground extraction failed: modal text was truncated!"
|
||||
|
||||
# It should prioritize the END of the document, so feed_item_0 should ideally be gone if capped at 50
|
||||
assert "feed_item_0" not in compressed, "Background elements are still being prioritized over foreground!"
|
||||
|
||||
def test_sae_structural_generalization(self):
|
||||
"""
|
||||
Ensures that dynamic user content is stripped to allow cross-post modal generalization,
|
||||
while short, static UI text (like "OK", "Cancel") is preserved.
|
||||
"""
|
||||
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation='0'>
|
||||
<node package="com.instagram.android" resource-id="id/comment" text="This is a very long user comment that changes every time we see this modal so it should be stripped!" />
|
||||
<node package="com.instagram.android" resource-id="id/button" text="OK" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
compressed = self.sae._compress_xml(xml_dump)
|
||||
|
||||
# Long dynamic text should be stripped or truncated to not pollute the vector space
|
||||
assert "This is a very long user comment" not in compressed, "Dynamic text > 20 chars was not stripped!"
|
||||
assert "text='<STRIPPED_DYNAMIC>'" in compressed or "This is a very lo" not in compressed
|
||||
# Short static text should be kept
|
||||
assert "OK" in compressed, "Short static UI text was incorrectly stripped!"
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new=True)
|
||||
def test_sae_negative_reinforcement(self):
|
||||
"""
|
||||
Ensures that failed escapes decay the confidence of the vector,
|
||||
and eventually purge it, instead of just storing a useless 0.0 vector alongside it.
|
||||
"""
|
||||
db = SituationEpisodeDB()
|
||||
|
||||
# We need to mock db._db.client directly since it's an instance property
|
||||
mock_client = MagicMock()
|
||||
db._db._client = mock_client
|
||||
db._db.client = mock_client
|
||||
|
||||
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)]
|
||||
|
||||
# Simulate a FAILURE
|
||||
action = EscapeAction("back")
|
||||
db.learn("some_signature", action, success=False)
|
||||
|
||||
# Verify that it fetched the current confidence and updated it, or deleted it if < 0.1
|
||||
# If confidence was 0.4 and delta is -0.5, it drops to -0.1 -> DELETED
|
||||
mock_client.delete.assert_called_once()
|
||||
@@ -1,22 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
|
||||
def test_sae_has_unlearn_current_state():
|
||||
"""Prove that SituationalAwarenessEngine exposes unlearn_current_state to heal from poisoned context."""
|
||||
device = MagicMock()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
|
||||
# Mocking internal compression and the screen_memory dependency
|
||||
sae._compress_xml = MagicMock(return_value="<compressed_feed/>")
|
||||
|
||||
with patch("GramAddict.core.qdrant_memory.ScreenMemoryDB") as MockScreenMemoryDB:
|
||||
mock_db_instance = MockScreenMemoryDB.return_value
|
||||
|
||||
# If unlearn_current_state does not exist, AttributeError (RED)
|
||||
sae.unlearn_current_state("<full_feed_xml/>")
|
||||
|
||||
# Verify it compresses and delegates to purge_screen
|
||||
sae._compress_xml.assert_called_once_with("<full_feed_xml/>")
|
||||
mock_db_instance.purge_screen.assert_called_once_with("<compressed_feed/>")
|
||||
@@ -1,50 +0,0 @@
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB, QdrantBase, ScreenMemoryDB
|
||||
|
||||
|
||||
class DummyQdrantBase(QdrantBase):
|
||||
def __init__(self):
|
||||
self.client = MagicMock()
|
||||
self.collection_name = "test_collection"
|
||||
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
|
||||
def test_qdrant_base_has_delete_point(mock_is_connected):
|
||||
"""Prove that QdrantBase implements delete_point for autonomous unlearning."""
|
||||
mock_is_connected.return_value = True
|
||||
db = DummyQdrantBase()
|
||||
# If delete_point does not exist, this will raise AttributeError (RED)
|
||||
db.generate_uuid = MagicMock(return_value="test-uuid-1234")
|
||||
|
||||
result = db.delete_point("test-seed")
|
||||
|
||||
# Assert
|
||||
db.client.delete.assert_called_once_with(collection_name="test_collection", points_selector=["test-uuid-1234"])
|
||||
assert result is True
|
||||
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
|
||||
def test_screen_memory_has_purge_screen(mock_is_connected):
|
||||
"""Prove that ScreenMemoryDB exposes purge_screen to heal poisoned classifications."""
|
||||
mock_is_connected.return_value = True
|
||||
db = ScreenMemoryDB()
|
||||
db.client = MagicMock()
|
||||
db.delete_point = MagicMock()
|
||||
|
||||
# If purge_screen does not exist, AttributeError (RED)
|
||||
db.purge_screen("<node class='feed' />")
|
||||
db.delete_point.assert_called_once_with("<node class='feed' />")
|
||||
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
|
||||
def test_navigation_memory_has_unlearn_transition(mock_is_connected):
|
||||
"""Prove that NavigationMemoryDB exposes unlearn_transition to destroy trap paths."""
|
||||
mock_is_connected.return_value = True
|
||||
db = NavigationMemoryDB()
|
||||
db.client = MagicMock()
|
||||
db.delete_point = MagicMock()
|
||||
|
||||
# If unlearn_transition does not exist, AttributeError (RED)
|
||||
db.unlearn_transition("HOME_FEED", "tap explore tab")
|
||||
db.delete_point.assert_called_once_with("HOME_FEED_tap explore tab")
|
||||
@@ -1,19 +0,0 @@
|
||||
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)
|
||||
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,247 +0,0 @@
|
||||
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.
|
||||
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,
|
||||
}
|
||||
]
|
||||
else:
|
||||
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,
|
||||
)
|
||||
|
||||
# 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)]
|
||||
)
|
||||
assert self.mock_device.back.call_count == 1
|
||||
|
||||
def test_unfollow_loop_keeps_high_resonance_profile(self, monkeypatch):
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
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)])
|
||||
assert self.mock_device.back.call_count == 1
|
||||
|
||||
def test_unfollow_loop_skips_close_friend(self, monkeypatch):
|
||||
"""
|
||||
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
|
||||
]
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
assert result == "BOREDOM_CHANGE_FEED"
|
||||
assert self.mock_session_state.totalUnfollowed == 0
|
||||
assert mock_click.call_count == 1
|
||||
assert self.mock_device.back.call_count == 1
|
||||
# Resonance shouldn't even be called for close friends
|
||||
self.mock_resonance.calculate_resonance.assert_not_called()
|
||||
|
||||
def test_unfollow_loop_scrolls_if_empty(self, monkeypatch):
|
||||
"""
|
||||
End of list path: If no following buttons are found, it should scroll _humanized_scroll_down.
|
||||
After 5 failed scrolls, it should gracefully return BOREDOM_CHANGE_FEED without crashing.
|
||||
"""
|
||||
# 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,
|
||||
)
|
||||
|
||||
assert result == "BOREDOM_CHANGE_FEED"
|
||||
# It should scroll exactly 6 times (failed_scrolls > 5)
|
||||
assert mock_scroll.call_count == 6
|
||||
assert self.mock_session_state.totalUnfollowed == 0
|
||||
|
||||
def test_unfollow_loop_respects_limits(self):
|
||||
"""
|
||||
Limit protection: If session limit is reached at the start, abort immediately.
|
||||
No clicks or UI dumps should occur.
|
||||
"""
|
||||
# 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,
|
||||
)
|
||||
|
||||
assert result == "BOREDOM_CHANGE_FEED"
|
||||
self.mock_device.dump_hierarchy.assert_not_called()
|
||||
self.mock_device.click.assert_not_called()
|
||||
self.mock_session_state.totalUnfollowed = 0
|
||||
Reference in New Issue
Block a user