chore(test): Ruthless deletion of ALL remaining MagicMocks and patches across the entire test suite
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from GramAddict.core.qdrant_memory import ContentMemoryDB
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
|
||||
def test_ad_learning_flow():
|
||||
"""
|
||||
Integration test for the autonomous ad learning feedback loop.
|
||||
Verified by checking if 'Promotion' marker is learned and stored in ContentMemoryDB.
|
||||
"""
|
||||
# 1. Setup: A screen with a marker that is NOT currently known as an ad
|
||||
marker = "Promotion"
|
||||
xml = f'<hierarchy><node text="{marker}" resource-id="com.instagram.android:id/text_marker" class="android.widget.TextView" bounds="[0,0][100,100]" /></hierarchy>'
|
||||
|
||||
# We bypass the global MockTelepathicEngine from conftest.py
|
||||
# By creating a fresh REAL instance for this specific test
|
||||
real_engine = TelepathicEngine()
|
||||
cognitive_stack = {
|
||||
"telepathic": real_engine, # Fixed key to match is_ad
|
||||
}
|
||||
|
||||
# 2. Pre-check: Should NOT be recognized as an ad initially
|
||||
# We must also mock the internal embedding check for the pre-check
|
||||
with patch.object(ContentMemoryDB, "_get_embedding") as mock_embed:
|
||||
mock_embed.return_value = [0.1] * 768
|
||||
assert is_ad(xml, cognitive_stack) is False, f"Should not recognize '{marker}' yet"
|
||||
|
||||
# 3. Learning Phase: Store the evaluation
|
||||
with (
|
||||
patch.object(ContentMemoryDB, "get_cached_evaluation") as mock_get,
|
||||
patch.object(ContentMemoryDB, "_get_embedding") as mock_embed,
|
||||
):
|
||||
mock_get.return_value = {"classification": "sponsored", "reason": "test"}
|
||||
mock_embed.return_value = [0.1] * 768
|
||||
|
||||
# 4. Verification: Should now be recognized as an ad
|
||||
assert is_ad(xml, cognitive_stack) is True, "Should recognize 'Promotion' after learning"
|
||||
|
||||
print("✅ Autonomous Ad Learning Test Passed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_ad_learning_flow()
|
||||
@@ -1,595 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors import PluginRegistry, load_all_plugins
|
||||
from GramAddict.core.bot_flow import (
|
||||
_align_active_post,
|
||||
_extract_post_content,
|
||||
_run_zero_latency_feed_loop,
|
||||
_run_zero_latency_stories_loop,
|
||||
)
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_plugins():
|
||||
PluginRegistry.reset()
|
||||
load_all_plugins()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
device.deviceV2 = MagicMock()
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
# Link facade method to deviceV2 mock so old tests keep working
|
||||
device.dump_hierarchy = device.dump_hierarchy
|
||||
return device
|
||||
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
def test_extract_post_content(mock_get_telepathic):
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.find_best_node.side_effect = [
|
||||
{"original_attribs": {"text": "test_user"}},
|
||||
{"original_attribs": {"desc": "test description of image with more than 10 chars"}},
|
||||
]
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
xml = "<xml/>"
|
||||
res = _extract_post_content(xml)
|
||||
assert res["username"] == "test_user"
|
||||
assert "test description" in res["description"]
|
||||
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
def test_extract_post_content_fallback_caption(mock_get_telepathic):
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.find_best_node.side_effect = [{"original_attribs": {"text": "other_user"}}, None]
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
xml = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="" text="other_user this is a very long caption text that we want to extract as fallback" />
|
||||
</hierarchy>"""
|
||||
res = _extract_post_content(xml)
|
||||
assert res["username"] == "other_user"
|
||||
assert "this is a very long caption" in res["caption"]
|
||||
|
||||
|
||||
def testis_ad():
|
||||
assert is_ad('<node resource-id="com.instagram.android:id/ad_cta_button" />')
|
||||
assert is_ad('<node resource-id="com.instagram.android:id/clips_single_image_ads_media_content" />')
|
||||
assert is_ad('<node resource-id="com.instagram.android:id/secondary_label" text="Sponsored" />')
|
||||
assert not is_ad('<node resource-id="com.instagram.android:id/secondary_label" text="regular post" />')
|
||||
assert not is_ad('<node resource-id="com.instagram.android:id/normal_post" />')
|
||||
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
def test_align_active_post(mock_get_telepathic, mock_device):
|
||||
# Test snapping when post is far from ideal coordinates
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.find_best_node.return_value = {"bounds": "[0,800][1080,900]"}
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
mock_device.dump_hierarchy.return_value = "<xml/>"
|
||||
_align_active_post(mock_device)
|
||||
# The header is at 850px. Target is 250px. Diff is 600px. It should swipe.
|
||||
assert mock_device.swipe.called
|
||||
|
||||
|
||||
def test_feed_loop_boredom_change_feed(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
|
||||
mock_cognitive_stack["growth_brain"].evaluate_governance.return_value = "SHIFT_CONTEXT"
|
||||
|
||||
configs = MagicMock()
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.return_value = (False, False, False, False)
|
||||
|
||||
res = _run_zero_latency_feed_loop(
|
||||
mock_device,
|
||||
mock_cognitive_stack["zero_engine"],
|
||||
mock_cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
mock_cognitive_stack,
|
||||
)
|
||||
assert res == "BOREDOM_CHANGE_FEED"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Legacy integration test needs refactor for Plugin architecture")
|
||||
def test_feed_loop_context_lost(mock_device, mock_cognitive_stack):
|
||||
# Simulate not having any feed markers 3 times
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, False, False, True]
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
|
||||
mock_device.dump_hierarchy.return_value = "<hierarchy></hierarchy>" # Blind
|
||||
|
||||
# Needs telepathic engine mock
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
|
||||
patch("GramAddict.core.diagnostic_dump.dump_ui_state"),
|
||||
):
|
||||
mock_instance = MockTelepathic.get_instance.return_value
|
||||
mock_instance._extract_semantic_nodes.return_value = [
|
||||
{"x": 1, "y": 2}
|
||||
] # Pretend we have nodes so it doesn't trigger zero-node immediately
|
||||
|
||||
configs = MagicMock()
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.return_value = (False, False, False, False)
|
||||
|
||||
res = _run_zero_latency_feed_loop(
|
||||
mock_device,
|
||||
mock_cognitive_stack["zero_engine"],
|
||||
mock_cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
mock_cognitive_stack,
|
||||
)
|
||||
assert res == "CONTEXT_LOST"
|
||||
|
||||
|
||||
def test_feed_loop_zero_nodes(mock_device, mock_cognitive_stack):
|
||||
# Tests the Zero-Node recovery anomaly handler
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll,
|
||||
):
|
||||
mock_instance = MockTelepathic.get_instance.return_value
|
||||
mock_instance._extract_semantic_nodes.return_value = [] # Zero interactive nodes
|
||||
|
||||
configs = MagicMock()
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.return_value = (False, False, False, False)
|
||||
|
||||
_run_zero_latency_feed_loop(
|
||||
mock_device,
|
||||
mock_cognitive_stack["zero_engine"],
|
||||
mock_cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
mock_cognitive_stack,
|
||||
)
|
||||
|
||||
assert mock_device.press.called_with("back")
|
||||
assert mock_scroll.called
|
||||
|
||||
|
||||
def test_feed_loop_ad_skip(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
|
||||
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_like" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="ad_account" />
|
||||
<node resource-id="com.instagram.android:id/ad_cta_button" />
|
||||
</hierarchy>"""
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll,
|
||||
patch("GramAddict.core.bot_flow._align_active_post") as mock_align,
|
||||
):
|
||||
mock_instance = MockTelepathic.get_instance.return_value
|
||||
mock_instance._extract_semantic_nodes.return_value = [{"x": 1}]
|
||||
mock_align.return_value = False
|
||||
|
||||
configs = MagicMock()
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.return_value = (False, False, False, False)
|
||||
|
||||
_run_zero_latency_feed_loop(
|
||||
mock_device,
|
||||
mock_cognitive_stack["zero_engine"],
|
||||
mock_cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
mock_cognitive_stack,
|
||||
)
|
||||
assert mock_scroll.called
|
||||
|
||||
|
||||
def test_stories_loop_success(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
|
||||
configs = MagicMock()
|
||||
configs.args.stories = "1"
|
||||
session_state = MagicMock()
|
||||
|
||||
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/story_viewer" />
|
||||
</hierarchy>"""
|
||||
|
||||
with patch("GramAddict.core.bot_flow._humanized_click") as mock_click, patch("GramAddict.core.bot_flow.sleep"):
|
||||
res = _run_zero_latency_stories_loop(mock_device, configs, session_state, mock_cognitive_stack)
|
||||
assert res == "FEED_EXHAUSTED"
|
||||
assert mock_click.called
|
||||
|
||||
|
||||
def test_stories_loop_boredom(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = True
|
||||
|
||||
configs = MagicMock()
|
||||
session_state = MagicMock()
|
||||
|
||||
with patch("GramAddict.core.bot_flow.sleep"):
|
||||
res = _run_zero_latency_stories_loop(mock_device, configs, session_state, mock_cognitive_stack)
|
||||
assert res == "BOREDOM_CHANGE_FEED"
|
||||
assert mock_device.press.called_with("back")
|
||||
|
||||
|
||||
def test_start_bot_interrupt():
|
||||
from GramAddict.core.bot_flow import start_bot
|
||||
|
||||
# Mock all the heavy initialization
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.Config") as MockConfig,
|
||||
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.bot_flow.create_device"),
|
||||
patch("GramAddict.core.bot_flow.set_time_delta"),
|
||||
patch("GramAddict.core.bot_flow.SessionState") as MockSession,
|
||||
patch("GramAddict.core.bot_flow.open_instagram", side_effect=KeyboardInterrupt()),
|
||||
):
|
||||
MockConfig.return_value.args.feed = True
|
||||
MockConfig.return_value.args.explore = False
|
||||
MockConfig.return_value.args.reels = False
|
||||
MockConfig.return_value.args.stories = False
|
||||
MockConfig.return_value.args.capture_e2e_dumps = False
|
||||
MockConfig.return_value.args.working_hours = [10, 20]
|
||||
MockConfig.return_value.args.time_delta_session = 30
|
||||
MockConfig.return_value.args.username = "test_user"
|
||||
MockConfig.return_value.args.blank_start = False
|
||||
MockConfig.return_value.args.ai_embedding_url = "http://localhost:11434/api/chat"
|
||||
MockConfig.return_value.args.ai_embedding_model = "llama3"
|
||||
MockConfig.return_value.args.agent_strategy = "conservative"
|
||||
|
||||
MockSession.inside_working_hours.return_value = (True, 0)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
start_bot(username="test_user", device_id="123")
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Legacy integration test needs refactor for Plugin architecture")
|
||||
def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
|
||||
# This test hits the core interaction (Lines 900 - 1300)
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.85
|
||||
|
||||
configs = MagicMock()
|
||||
configs.args.likes_percentage = 100
|
||||
configs.args.follow_percentage = 100
|
||||
configs.args.comment_percentage = 100
|
||||
configs.args.ai_vibe = "friendly"
|
||||
configs.args.ai_condenser_model = "test-model"
|
||||
configs.args.ai_condenser_url = "test-url"
|
||||
configs.args.dry_run_comments = False
|
||||
|
||||
session_state = MagicMock()
|
||||
# If checking ALL, return a tuple of Falses. If specific limit like LIKES/COMMENTS, return False bool.
|
||||
session_state.check_limit.side_effect = (
|
||||
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
|
||||
)
|
||||
|
||||
# Needs to report a structure that has NO ad, HAS content, and HAS feed markers.
|
||||
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_like" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />
|
||||
<!-- Comment sheet structure for the sub-engagement logic -->
|
||||
<node class="android.widget.LinearLayout">
|
||||
<node resource-id="com.instagram.android:id/row_comment_textview_comment" text="This is a fantastic picture!" />
|
||||
<node resource-id="com.instagram.android:id/row_comment_button_like" bounds="[10,10][20,20]" />
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
|
||||
# Ensure radome doesn't destroy our XML string
|
||||
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
|
||||
# Simulate that nav_graph transitions work
|
||||
mock_cognitive_stack["nav_graph"].do.return_value = True
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
|
||||
patch("GramAddict.core.bot_flow._extract_post_content") as mock_extract,
|
||||
patch("GramAddict.core.llm_provider.query_llm") as mock_llm,
|
||||
patch("GramAddict.core.bot_flow.random.random", return_value=0.11),
|
||||
patch("GramAddict.core.bot_flow.random.uniform", return_value=1.5),
|
||||
patch("GramAddict.core.bot_flow.random.randint", return_value=1),
|
||||
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll"),
|
||||
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
|
||||
patch("GramAddict.core.stealth_typing.ghost_type", autospec=True) as mock_type,
|
||||
):
|
||||
mock_extract.return_value = {"username": "legit_user", "description": "test image", "caption": ""}
|
||||
mock_instance = MockTelepathic.get_instance.return_value
|
||||
mock_instance._extract_semantic_nodes.return_value = [
|
||||
{"x": 1, "y": 2, "original_attribs": {"text": "This is a fantastic picture!"}}
|
||||
]
|
||||
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
|
||||
mock_llm.return_value = {"response": "Great shot!"}
|
||||
|
||||
mock_cognitive_stack["telepathic"] = mock_instance
|
||||
|
||||
# We need to ensure that the configs allow interacting!
|
||||
configs.args.interact_percentage = 100
|
||||
|
||||
_run_zero_latency_feed_loop(
|
||||
mock_device,
|
||||
mock_cognitive_stack["zero_engine"],
|
||||
mock_cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
mock_cognitive_stack,
|
||||
)
|
||||
|
||||
assert mock_click.called
|
||||
assert mock_type.called
|
||||
|
||||
|
||||
def test_feed_loop_repost(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.85
|
||||
|
||||
configs = MagicMock()
|
||||
configs.args.repost_percentage = 100
|
||||
configs.args.likes_percentage = 0
|
||||
configs.args.comment_percentage = 0
|
||||
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.side_effect = (
|
||||
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
|
||||
)
|
||||
|
||||
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_like" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />
|
||||
</hierarchy>"""
|
||||
|
||||
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
|
||||
mock_cognitive_stack["nav_graph"].do.return_value = True
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
|
||||
patch("GramAddict.core.bot_flow.random.random", return_value=0.11),
|
||||
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll"),
|
||||
patch("GramAddict.core.bot_flow._humanized_click"),
|
||||
):
|
||||
mock_instance = MockTelepathic.get_instance.return_value
|
||||
mock_instance._extract_semantic_nodes.return_value = [{"x": 1, "y": 2}]
|
||||
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
|
||||
|
||||
mock_cognitive_stack["telepathic"] = mock_instance
|
||||
configs.args.interact_percentage = 100
|
||||
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
|
||||
_run_zero_latency_feed_loop(
|
||||
mock_device,
|
||||
mock_cognitive_stack["zero_engine"],
|
||||
mock_cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
mock_cognitive_stack,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Legacy integration test needs refactor for Plugin architecture")
|
||||
def test_profile_learning_percentage_trigger(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.50 # Not high enough to trigger default
|
||||
|
||||
configs = MagicMock()
|
||||
configs.args.profile_learning_percentage = 100 # Should force visit
|
||||
# In the new architecture, ProfileVisitPlugin uses profile_visit_percentage
|
||||
configs.args.profile_visit_percentage = 100
|
||||
configs.args.profile_learning_percentage = 100
|
||||
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.side_effect = (
|
||||
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
|
||||
)
|
||||
|
||||
mock_device.dump_hierarchy.return_value = """<?xml version='1.0' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />
|
||||
</hierarchy>"""
|
||||
|
||||
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
|
||||
mock_cognitive_stack["nav_graph"].do.return_value = True
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
|
||||
patch("GramAddict.core.bot_flow._extract_post_content") as mock_extract,
|
||||
patch("GramAddict.core.bot_flow.random.random", return_value=0.5),
|
||||
patch("GramAddict.core.behaviors.profile_visit.random.random", return_value=0.01),
|
||||
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll"),
|
||||
patch("GramAddict.core.bot_flow._interact_with_profile"),
|
||||
):
|
||||
mock_extract.return_value = {"username": "legit_user", "description": "test image", "caption": ""}
|
||||
mock_instance = MockTelepathic.get_instance.return_value
|
||||
mock_instance._extract_semantic_nodes.return_value = [{"x": 1, "y": 2, "original_attribs": {"text": "dummy"}}]
|
||||
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
|
||||
|
||||
mock_cognitive_stack["telepathic"] = mock_instance
|
||||
configs.args.interact_percentage = 100
|
||||
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
|
||||
_run_zero_latency_feed_loop(
|
||||
mock_device,
|
||||
mock_cognitive_stack["zero_engine"],
|
||||
mock_cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
mock_cognitive_stack,
|
||||
)
|
||||
|
||||
# In the new architecture, ProfileVisitPlugin calls nav_graph.do("tap post username")
|
||||
assert mock_cognitive_stack["nav_graph"].do.called
|
||||
# Check if 'tap post username' was one of the calls
|
||||
args_list = [call.args[0] for call in mock_cognitive_stack["nav_graph"].do.call_args_list]
|
||||
assert "tap post username" in args_list
|
||||
|
||||
|
||||
def test_ai_learn_own_profile_triggers_goap():
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.Config") as MockConfig,
|
||||
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"),
|
||||
patch("GramAddict.core.bot_flow.set_time_delta"),
|
||||
patch("GramAddict.core.bot_flow.SessionState") as MockSession,
|
||||
patch("GramAddict.core.bot_flow.open_instagram", return_value=True),
|
||||
patch("GramAddict.core.bot_flow.verify_and_switch_account", return_value=True),
|
||||
patch("GramAddict.core.bot_flow.get_instagram_version", return_value="1.0"),
|
||||
patch("GramAddict.core.goap.GoalExecutor") as MockGoalExecutor,
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
|
||||
patch("GramAddict.core.llm_provider.query_llm") as mock_query,
|
||||
patch("GramAddict.core.bot_flow.DojoEngine"),
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
):
|
||||
MockConfig.return_value.args.ai_learn_own_profile = True
|
||||
MockConfig.return_value.args.agent_strategy = "aggressive_growth"
|
||||
MockConfig.return_value.args.capture_e2e_dumps = False
|
||||
MockConfig.return_value.args.explore = False
|
||||
MockConfig.return_value.args.feed = False
|
||||
MockConfig.return_value.args.reels = False
|
||||
MockConfig.return_value.args.stories = False
|
||||
MockConfig.return_value.args.working_hours = [10, 20]
|
||||
MockConfig.return_value.args.time_delta_session = 30
|
||||
|
||||
MockSession.inside_working_hours.return_value = (True, 0)
|
||||
|
||||
mock_goap = MockGoalExecutor.get_instance.return_value
|
||||
mock_goap.achieve.return_value = True
|
||||
|
||||
mock_telepathic = MockTelepathic.return_value # the class constructor is called inside start_bot
|
||||
mock_telepathic._extract_semantic_nodes.return_value = [{"original_attribs": {"text": "my cool bio"}}]
|
||||
|
||||
mock_query.return_value = {"persona": "cool dev", "vibe": "chill"}
|
||||
|
||||
from GramAddict.core.bot_flow import start_bot
|
||||
|
||||
try:
|
||||
with patch("GramAddict.core.bot_flow.random_sleep", side_effect=KeyboardInterrupt()):
|
||||
start_bot(username="testuser", device_id="123")
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
mock_goap.achieve.assert_any_call("learn own profile")
|
||||
# resonance is created internally, so we can't easily assert on update_identity unless we patch ResonanceEngine too.
|
||||
# It's sufficient to know the GOAP goal was triggered.
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Legacy integration test needs refactor for Plugin architecture")
|
||||
def test_profile_mismatch_recovery(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.50
|
||||
|
||||
configs = MagicMock()
|
||||
configs.args.profile_learning_percentage = 100 # Should force visit
|
||||
configs.args.likes_percentage = 0
|
||||
configs.args.comment_percentage = 0
|
||||
configs.args.follow_percentage = 0
|
||||
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.side_effect = (
|
||||
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
|
||||
)
|
||||
|
||||
feed_xml = """<?xml version='1.0' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="amorextravel" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />
|
||||
</hierarchy>"""
|
||||
profile_xml = """<?xml version='1.0' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/action_bar_title" text="ryanresatka" />
|
||||
<node resource-id="com.instagram.android:id/row_profile_header_textview_biography" text="cool bio" />
|
||||
</hierarchy>"""
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def dump_hierarchy_mock(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
return feed_xml if call_count[0] == 1 else profile_xml
|
||||
|
||||
mock_device.dump_hierarchy.side_effect = dump_hierarchy_mock
|
||||
|
||||
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
|
||||
mock_cognitive_stack["nav_graph"].do.return_value = True
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine", autospec=True) as MockTelepathic,
|
||||
patch("GramAddict.core.bot_flow._extract_post_content") as mock_extract,
|
||||
patch("GramAddict.core.bot_flow.random.random", return_value=0.5),
|
||||
patch("GramAddict.core.behaviors.profile_visit.random.random", return_value=0.01),
|
||||
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll"),
|
||||
patch("GramAddict.core.bot_flow._interact_with_profile"),
|
||||
):
|
||||
mock_extract.return_value = {"username": "amorextravel", "description": "test image", "caption": ""}
|
||||
mock_instance = MockTelepathic.get_instance.return_value
|
||||
mock_instance._extract_semantic_nodes.side_effect = [
|
||||
[{"x": 1, "y": 2, "original_attribs": {"text": "amorextravel"}}], # 1st call at top of loop
|
||||
[{"x": 1, "y": 2, "original_attribs": {"text": "amorextravel"}}], # 2nd call before Targeted UX
|
||||
[
|
||||
{"x": 1, "y": 2, "text": "ryanresatka", "resource_id": "com.instagram.android:id/action_bar_title"}
|
||||
], # 3rd call on profile
|
||||
]
|
||||
mock_instance.find_best_node.return_value = {"x": 50, "y": 50, "bounds": "[10,10][20,20]", "skip": False}
|
||||
|
||||
mock_cognitive_stack["telepathic"] = mock_instance
|
||||
configs.args.interact_percentage = 100
|
||||
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
|
||||
_run_zero_latency_feed_loop(
|
||||
mock_device,
|
||||
mock_cognitive_stack["zero_engine"],
|
||||
mock_cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
mock_cognitive_stack,
|
||||
)
|
||||
|
||||
# In the new architecture, ProfileVisitPlugin calls nav_graph.do("tap post username")
|
||||
assert mock_cognitive_stack["nav_graph"].do.called
|
||||
args_list = [call.args[0] for call in mock_cognitive_stack["nav_graph"].do.call_args_list]
|
||||
assert "tap post username" in args_list
|
||||
@@ -1,96 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.bot_flow import start_bot
|
||||
|
||||
|
||||
@patch("GramAddict.core.persistent_list.PersistentList.persist")
|
||||
@patch("secrets.choice", return_value="HomeFeed")
|
||||
@patch("GramAddict.core.bot_flow._run_zero_latency_search_loop", return_value="SESSION_OVER")
|
||||
@patch("GramAddict.core.bot_flow._run_zero_latency_dm_loop", return_value="SESSION_OVER")
|
||||
@patch("GramAddict.core.bot_flow._run_zero_latency_unfollow_loop", return_value="SESSION_OVER")
|
||||
@patch("GramAddict.core.bot_flow._run_zero_latency_stories_loop", return_value="SESSION_OVER")
|
||||
@patch("GramAddict.core.bot_flow._run_zero_latency_feed_loop", return_value="SESSION_OVER")
|
||||
@patch("GramAddict.core.bot_flow.DojoEngine")
|
||||
@patch("GramAddict.core.bot_flow.HoneypotRadome")
|
||||
@patch("GramAddict.core.bot_flow.ParasocialCRMDB")
|
||||
@patch("GramAddict.core.bot_flow.GrowthBrain")
|
||||
@patch("GramAddict.core.bot_flow.ResonanceEngine")
|
||||
@patch("GramAddict.core.bot_flow.DopamineEngine")
|
||||
@patch("GramAddict.core.bot_flow.ZeroLatencyEngine")
|
||||
@patch("GramAddict.core.bot_flow.QNavGraph")
|
||||
@patch("GramAddict.core.bot_flow.TelepathicEngine")
|
||||
@patch("GramAddict.core.bot_flow.dump_ui_state")
|
||||
@patch("GramAddict.core.bot_flow.random_sleep")
|
||||
@patch("GramAddict.core.bot_flow.close_instagram")
|
||||
@patch("GramAddict.core.bot_flow.get_instagram_version", return_value="1.0")
|
||||
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
|
||||
@patch("GramAddict.core.bot_flow.SessionState")
|
||||
@patch("GramAddict.core.bot_flow.set_time_delta")
|
||||
@patch("GramAddict.core.bot_flow.create_device")
|
||||
@patch("GramAddict.core.llm_provider.log_openrouter_burn")
|
||||
@patch("GramAddict.core.benchmark_guard.check_model_benchmarks")
|
||||
@patch("GramAddict.core.bot_flow.check_if_updated")
|
||||
@patch("GramAddict.core.bot_flow.configure_logger")
|
||||
@patch("GramAddict.core.bot_flow.Config")
|
||||
def test_start_bot_normal_flow(
|
||||
MockConfig,
|
||||
mock_logger,
|
||||
mock_update,
|
||||
mock_benchmark,
|
||||
mock_burn,
|
||||
mock_create_device,
|
||||
mock_time_delta,
|
||||
MockSession,
|
||||
mock_open_ig,
|
||||
mock_ig_version,
|
||||
mock_close_ig,
|
||||
mock_sleep,
|
||||
mock_dump,
|
||||
mock_telepathic,
|
||||
mock_nav,
|
||||
mock_zero,
|
||||
mock_dopamine_class,
|
||||
mock_resonance,
|
||||
mock_growth,
|
||||
mock_crm,
|
||||
mock_radome,
|
||||
mock_dojo,
|
||||
mock_run_feed,
|
||||
mock_run_stories,
|
||||
mock_run_unfollow,
|
||||
mock_run_dm,
|
||||
mock_run_search,
|
||||
mock_choice,
|
||||
mock_persist,
|
||||
):
|
||||
MockConfig.return_value.args.username = "test"
|
||||
MockConfig.return_value.args.feed = True
|
||||
MockConfig.return_value.args.explore = False
|
||||
MockConfig.return_value.args.reels = True
|
||||
MockConfig.return_value.args.stories = False
|
||||
MockConfig.return_value.args.capture_e2e_dumps = False
|
||||
MockConfig.return_value.args.working_hours = [10, 20]
|
||||
MockConfig.return_value.args.time_delta_session = 30
|
||||
|
||||
device = mock_create_device.return_value
|
||||
device.dump_hierarchy.return_value = '<?xml version="1.0" encoding="UTF-8" ?><hierarchy><node resource-id="com.instagram.android:id/action_bar_title" text="test" /></hierarchy>'
|
||||
mock_nav.return_value.navigate_to.return_value = True
|
||||
mock_nav.return_value.do.return_value = True
|
||||
|
||||
MockSession.inside_working_hours.return_value = (True, 0)
|
||||
|
||||
# Simulate dopamine session over after one loop
|
||||
mock_dopamine = mock_dopamine_class.return_value
|
||||
mock_dopamine.is_app_session_over.side_effect = [False, True]
|
||||
mock_dopamine.boredom = 10.0
|
||||
|
||||
# We need to intentionally throw an exception to break the "while True" loop
|
||||
MockSession.side_effect = [MagicMock(), Exception("Break infinite loop")]
|
||||
|
||||
try:
|
||||
start_bot(username="test", device_id="123")
|
||||
except Exception as e:
|
||||
if str(e) != "Break infinite loop":
|
||||
raise e
|
||||
|
||||
assert mock_run_feed.called
|
||||
@@ -1,137 +0,0 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.bot_flow import _extract_post_content
|
||||
from GramAddict.core.growth_brain import GrowthBrain
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
|
||||
# Path to the real XML dumps in the root directory
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DUMPS = {
|
||||
"organic": os.path.join(ROOT_DIR, "fixtures", "organic_post.xml"),
|
||||
"ad": os.path.join(ROOT_DIR, "fixtures", "peugeot_ad.xml"),
|
||||
"explore": os.path.join(ROOT_DIR, "fixtures", "explore_feed_reel.xml"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_engines():
|
||||
"""Mock database connections but keep logic intact."""
|
||||
with (
|
||||
patch("GramAddict.core.resonance_engine.ContentMemoryDB") as mock_cm_cls,
|
||||
patch("GramAddict.core.resonance_engine.PersonaMemoryDB"),
|
||||
patch("GramAddict.core.growth_brain.PersonaMemoryDB"),
|
||||
):
|
||||
# Consistent mock instance
|
||||
mock_cm = MagicMock()
|
||||
mock_cm_cls.return_value = mock_cm
|
||||
mock_cm.get_cached_evaluation.return_value = None
|
||||
mock_cm._get_embedding.return_value = [0.1] * 1536
|
||||
|
||||
resonance = ResonanceEngine(my_username="test_bot", persona_interests=["fitness", "travel"])
|
||||
growth = GrowthBrain(username="test_bot", persona_interests=["fitness", "travel"])
|
||||
|
||||
# Explicit inject
|
||||
resonance._persona_vector = [0.1] * 1536
|
||||
resonance.content_memory = mock_cm
|
||||
|
||||
# Reset mock after bootstrap
|
||||
mock_cm._get_embedding.reset_mock()
|
||||
mock_cm._get_embedding.return_value = [0.1] * 1536
|
||||
|
||||
return resonance, growth
|
||||
|
||||
|
||||
def test_full_content_to_resonance_flow(mock_engines):
|
||||
"""
|
||||
REALITY CHECK: Tests the flow from RAW XML -> EXTRACED CONTENT -> RESONANCE SCORE.
|
||||
Using 'dump.xml' which contains an organic post and an ad.
|
||||
"""
|
||||
resonance, _ = mock_engines
|
||||
|
||||
with open(DUMPS["organic"], "r") as f:
|
||||
xml_content = f.read()
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get_telepathic:
|
||||
mock_engine = MagicMock()
|
||||
|
||||
def mock_find_best_node(xml, intent, *args, **kwargs):
|
||||
if "author" in intent:
|
||||
return {"original_attribs": {"text": "steves_movies", "desc": ""}}
|
||||
elif "media" in intent or "content" in intent:
|
||||
return {"original_attribs": {"text": "", "desc": "This is an organic post description"}}
|
||||
return None
|
||||
|
||||
mock_engine.find_best_node.side_effect = mock_find_best_node
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
|
||||
# 1. Extraction (The Bot's Eyes)
|
||||
post_data = _extract_post_content(xml_content)
|
||||
|
||||
# Verify extraction from organic dump
|
||||
assert len(post_data["username"]) > 3
|
||||
assert len(post_data["description"]) > 10
|
||||
|
||||
# 2. Resonance (The Bot's Brain)
|
||||
# Ensure it's not being blocked by an accidental ad detection on organic content
|
||||
resonance.content_memory._get_embedding.return_value = [0.1] * 1536
|
||||
|
||||
score = resonance.calculate_resonance(post_data)
|
||||
assert score == 1.0 # (1.0 - 0.15) / 0.30 -> capped to 1.0
|
||||
assert resonance.judge_interaction(score) is True
|
||||
|
||||
|
||||
def test_ad_detection_integration():
|
||||
"""Verify that is_ad works on the actual ad_dump.xml."""
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
with open(DUMPS["ad"], "r") as f:
|
||||
ad_xml = f.read()
|
||||
|
||||
# ad_dump.xml should contain nodes that trigger structural ad detection
|
||||
is_ad = is_ad(ad_xml)
|
||||
assert is_ad is True or "secondary_label" in ad_xml
|
||||
|
||||
|
||||
def test_circadian_pacing_logic(mock_engines):
|
||||
"""Verify GrowthBrain adjusts pacing across artificial time shifts."""
|
||||
_, growth = mock_engines
|
||||
|
||||
# Simulate Deep Sleep (03:00)
|
||||
with patch("GramAddict.core.growth_brain.datetime") as mock_date:
|
||||
mock_date.now.return_value = datetime(2026, 4, 13, 3, 0, 0)
|
||||
pacing = growth.get_circadian_pacing()
|
||||
assert pacing == 0.1
|
||||
|
||||
# Simulate Peak Hours (14:00)
|
||||
with patch("GramAddict.core.growth_brain.datetime") as mock_date:
|
||||
mock_date.now.return_value = datetime(2026, 4, 13, 14, 0, 0)
|
||||
pacing = growth.get_circadian_pacing()
|
||||
assert pacing == 1.0
|
||||
|
||||
|
||||
def test_extract_explore_reel():
|
||||
"""Verify extraction logic works on the Explore Grid/Reels dump."""
|
||||
with open(DUMPS["explore"], "r") as f:
|
||||
xml = f.read()
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get_telepathic:
|
||||
mock_engine = MagicMock()
|
||||
|
||||
def mock_find_best_node(xml, intent, *args, **kwargs):
|
||||
if "author" in intent:
|
||||
return {"original_attribs": {"text": "steves_movies", "desc": ""}}
|
||||
elif "media" in intent or "content" in intent:
|
||||
return {"original_attribs": {"text": "", "desc": "steves_movies Reel by user"}}
|
||||
return None
|
||||
|
||||
mock_engine.find_best_node.side_effect = mock_find_best_node
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
|
||||
post_data = _extract_post_content(xml)
|
||||
|
||||
assert "steves_movies" in post_data["description"]
|
||||
assert "Reel by" in post_data["description"]
|
||||
@@ -1,158 +0,0 @@
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.darwin_engine import DarwinEngine
|
||||
from GramAddict.core.dojo_engine import DojoEngine
|
||||
from GramAddict.core.growth_brain import GrowthBrain
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
# Import under test
|
||||
from GramAddict.core.qdrant_memory import (
|
||||
ContentMemoryDB,
|
||||
HeuristicMemoryDB,
|
||||
NavigationMemoryDB,
|
||||
ParasocialCRMDB,
|
||||
PersonaMemoryDB,
|
||||
)
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
from GramAddict.core.swarm_protocol import SwarmProtocol
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_PointStruct():
|
||||
with patch("GramAddict.core.qdrant_memory.PointStruct") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_qdrant(mock_PointStruct):
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.collection_exists.return_value = True
|
||||
yield client_instance
|
||||
|
||||
|
||||
# --- ORIGINAL CORE AUDIT ---
|
||||
|
||||
|
||||
def test_parasocial_crm_logging(mock_qdrant, mock_PointStruct):
|
||||
"""Verify that CRM interaction logging actually attempts to persist to Qdrant."""
|
||||
crm = ParasocialCRMDB()
|
||||
crm.client = mock_qdrant
|
||||
with patch.object(ParasocialCRMDB, "_get_embedding", return_value=[0.1] * 1536):
|
||||
crm.log_interaction("test_user_alpha", "like")
|
||||
|
||||
assert mock_qdrant.upsert.called
|
||||
kwargs = mock_PointStruct.call_args[1]
|
||||
assert kwargs["payload"]["username"] == "test_user_alpha"
|
||||
assert kwargs["payload"]["interactions"][0]["type"] == "like"
|
||||
|
||||
|
||||
def test_darwin_reward_signaling(mock_qdrant, mock_PointStruct):
|
||||
"""Verify that Darwin Engine correctly records session rewards for reinforcement learning."""
|
||||
engine = DarwinEngine("test_bot")
|
||||
engine.client = mock_qdrant
|
||||
engine.current_behavior = {"initial_dwell_sec": 4.5}
|
||||
engine.emit_reward_signal(followers_gained=5, block_warnings_seen=0)
|
||||
|
||||
assert mock_qdrant.upsert.called
|
||||
kwargs = mock_PointStruct.call_args[1]
|
||||
assert kwargs["payload"]["reward"] == 5
|
||||
|
||||
|
||||
def test_swarm_pheromone_emission(mock_qdrant, mock_PointStruct):
|
||||
"""Verify that Swarm Protocol shares UI state outcomes with the fleet."""
|
||||
swarm = SwarmProtocol("test_bot")
|
||||
swarm.client = mock_qdrant
|
||||
swarm.emit_pheromone("feed_scroll_A", "success")
|
||||
|
||||
assert mock_qdrant.upsert.called
|
||||
kwargs = mock_PointStruct.call_args[1]
|
||||
assert kwargs["payload"]["path_hash"] == "feed_scroll_A"
|
||||
|
||||
|
||||
def test_dojo_background_learning(mock_qdrant, mock_PointStruct):
|
||||
"""Verify that Dojo Engine processes snapshots and updates the heuristic memory."""
|
||||
device = MagicMock()
|
||||
dojo = DojoEngine(device)
|
||||
dojo.db.client = mock_qdrant
|
||||
with patch.object(dojo.compiler, "generate_heuristic", return_value={"rule_type": "regex", "pattern": ".*"}):
|
||||
with patch.object(HeuristicMemoryDB, "_get_embedding", return_value=[0.1] * 1536):
|
||||
dojo.start()
|
||||
dojo.submit_snapshot("test_button_intent", "<xml/>", "Tap the like button")
|
||||
start = time.time()
|
||||
while mock_qdrant.upsert.call_count == 0 and time.time() - start < 3.0:
|
||||
time.sleep(0.1)
|
||||
dojo.stop()
|
||||
|
||||
assert mock_qdrant.upsert.called
|
||||
kwargs = mock_PointStruct.call_args[1]
|
||||
assert kwargs["payload"]["intent"] == "test_button_intent"
|
||||
|
||||
|
||||
# --- EXTENDED ULTRA-AUDIT (PHASE 2) ---
|
||||
|
||||
|
||||
def test_growth_brain_persona_learning(mock_qdrant, mock_PointStruct):
|
||||
"""Verify that GrowthBrain persists persona insights derived from interactions."""
|
||||
brain = GrowthBrain("test_user")
|
||||
brain.persona_memory.client = mock_qdrant # Inject client into the wrapped DB
|
||||
|
||||
outcomes = [{"username": "niche_influencer", "action": "like", "resonance": 0.9}]
|
||||
with patch.object(PersonaMemoryDB, "_get_embedding", return_value=[0.1] * 1536):
|
||||
brain.refine_persona(outcomes)
|
||||
|
||||
assert mock_qdrant.upsert.called
|
||||
kwargs = mock_PointStruct.call_args[1]
|
||||
assert "High-resonance" in kwargs["payload"]["insight"]
|
||||
|
||||
|
||||
def test_resonance_oracle_cross_talk(mock_qdrant, mock_PointStruct):
|
||||
"""Verify that ResonanceEngine evaluation triggers both ContentMemory and ParasocialCRM updates."""
|
||||
crm = ParasocialCRMDB()
|
||||
crm.client = mock_qdrant
|
||||
engine = ResonanceEngine("my_user", persona_interests=["cyberpunk", "tech"], crm=crm)
|
||||
engine.content_memory.client = mock_qdrant
|
||||
|
||||
post = {"username": "cyber_artist", "description": "New neon artwork #cyberpunk", "caption": ""}
|
||||
|
||||
with (
|
||||
patch.object(ContentMemoryDB, "_get_embedding", return_value=[0.1] * 1536),
|
||||
patch.object(ParasocialCRMDB, "_get_embedding", return_value=[0.1] * 1536),
|
||||
patch.object(NavigationMemoryDB, "_get_embedding", return_value=[0.1] * 1536),
|
||||
patch.object(engine, "_cosine_similarity", return_value=0.9),
|
||||
):
|
||||
# We need to mock scroll for get_relationship_stage inside log_interaction
|
||||
mock_qdrant.scroll.return_value = ([], None)
|
||||
|
||||
score = engine.calculate_resonance(post)
|
||||
assert score > 0.7
|
||||
|
||||
# Should call upsert twice: 1 for content_memory, 1 for crm (inside ResonanceEngine)
|
||||
assert mock_qdrant.upsert.call_count >= 2
|
||||
|
||||
# Verify ContentMemory storage
|
||||
calls = [mock_PointStruct.call_args_list[i][1] for i in range(len(mock_PointStruct.call_args_list))]
|
||||
content_storage = any("classification" in c["payload"] for c in calls)
|
||||
crm_storage = any("stage" in c["payload"] for c in calls)
|
||||
|
||||
assert content_storage, "ResonanceEngine failed to cache evaluation in ContentMemory!"
|
||||
assert crm_storage, "ResonanceEngine failed to update user profile in ParasocialCRM!"
|
||||
|
||||
|
||||
def test_nav_graph_topological_persistence(mock_qdrant, mock_PointStruct):
|
||||
"""Verify that QNavGraph shares learned navigation anchors with the fleet."""
|
||||
device = MagicMock()
|
||||
graph = QNavGraph(device)
|
||||
graph.nav_memory.client = mock_qdrant
|
||||
|
||||
# Simulate discovering a transition
|
||||
graph.nav_memory.store_transition("ExploreFeed", "tap_home_tab", "HomeFeed")
|
||||
|
||||
assert mock_qdrant.upsert.called
|
||||
kwargs = mock_PointStruct.call_args[1]
|
||||
assert kwargs["payload"]["from"] == "ExploreFeed"
|
||||
assert kwargs["payload"]["action"] == "tap_home_tab"
|
||||
assert kwargs["payload"]["to"] == "HomeFeed"
|
||||
@@ -1,117 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.darwin_engine import DarwinEngine
|
||||
|
||||
|
||||
class DummyArgs:
|
||||
def __init__(self):
|
||||
self.interact_percentage = 0
|
||||
self.follow_percentage = 0
|
||||
|
||||
|
||||
def test_darwin_engine_explore_exploit():
|
||||
"""Test the Multi-Armed Bandit Epsilon-Greedy logic without a real Qdrant server."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient"):
|
||||
engine = DarwinEngine("test_user")
|
||||
|
||||
# Override epsilon to force exploitation (Greedy)
|
||||
# Wait, inside synthesize_interaction_profile epsilon is hardcoded to 0.15
|
||||
|
||||
mock_record_1 = MagicMock()
|
||||
mock_record_1.payload = {"params": {"initial_dwell_sec": 2.0}, "reward": 10.0}
|
||||
|
||||
mock_record_2 = MagicMock()
|
||||
mock_record_2.payload = {"params": {"initial_dwell_sec": 10.0}, "reward": 50.0}
|
||||
|
||||
engine.client.scroll.return_value = ([mock_record_1, mock_record_2], None)
|
||||
|
||||
# We patch random.random to force Exploit
|
||||
with patch("random.random", return_value=0.99):
|
||||
profile = engine.synthesize_interaction_profile(0.5)
|
||||
|
||||
# Just ensure it generated something valid within bounds
|
||||
assert 1.0 <= profile["initial_dwell_sec"] <= 20.0
|
||||
|
||||
# We patch random.random to force Explore
|
||||
with patch("random.random", return_value=0.01):
|
||||
profile_explore = engine.synthesize_interaction_profile(0.5)
|
||||
# Just ensure it generated something valid
|
||||
assert "initial_dwell_sec" in profile_explore
|
||||
|
||||
|
||||
def test_evaluate_session_end_short_session():
|
||||
"""Ensure short sessions are not recorded to avoid polluting RoI metrics."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient"):
|
||||
engine = DarwinEngine("test_user")
|
||||
engine.current_behavior = {"initial_dwell_sec": 5.0} # Set behavior
|
||||
# But wait, evaluate_session_end short sessions still emit, we didn't block it in the engine except by default math
|
||||
engine.emit_reward_signal(followers_gained=10, block_warnings_seen=0)
|
||||
|
||||
# It should upsert
|
||||
engine.client.upsert.assert_called_once()
|
||||
|
||||
|
||||
def test_evaluate_session_end_upsert():
|
||||
"""Ensure valid sessions are successfully logged to the database."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient"):
|
||||
engine = DarwinEngine("test_user")
|
||||
engine.current_behavior = {"initial_dwell_sec": 5.0}
|
||||
engine.evaluate_session_end(60.0, 100) # 60 minutes
|
||||
|
||||
engine.client.upsert.assert_called_once()
|
||||
|
||||
|
||||
def test_execute_proof_of_resonance_close_comments():
|
||||
"""Verify that Darwin correctly closes the comments section even if 'bottom_sheet_container' is missing."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient"):
|
||||
engine = DarwinEngine("test_user")
|
||||
device = MagicMock()
|
||||
nav_graph = MagicMock()
|
||||
zero_engine = MagicMock()
|
||||
configs = MagicMock()
|
||||
|
||||
# Make the profile decide to read comments for 2s
|
||||
fake_profile = {
|
||||
"initial_dwell_sec": 1.0,
|
||||
"scroll_velocity": 1.0,
|
||||
"scroll_depth_clicks": 0,
|
||||
"back_swipe_prob": 0.0,
|
||||
"comment_read_dwell": 2.0,
|
||||
}
|
||||
with patch.object(engine, "synthesize_interaction_profile", return_value=fake_profile):
|
||||
# Mock opening comments success
|
||||
nav_graph._execute_transition.return_value = True
|
||||
|
||||
# Simulated UI Dump: No 'bottom_sheet_container', but neither 'row_feed' nor 'button_like'
|
||||
# Which occurs when IG renames it to 'fragment_container_view' or similar wrapper
|
||||
device.dump_hierarchy.return_value = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy>
|
||||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||||
<!-- Random UI generic sheet classes the Bot doesn't track -->
|
||||
<node class="androidx.appcompat.widget.LinearLayoutCompat" text="Reply" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
# Act
|
||||
with patch("random.random", return_value=0.0): # Force comment block entry
|
||||
with patch("GramAddict.core.darwin_engine.DarwinEngine._has_comments", return_value=True):
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_telepathic:
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.find_best_node.return_value = None
|
||||
mock_telepathic.return_value = mock_engine
|
||||
|
||||
engine.execute_proof_of_resonance(
|
||||
device=device,
|
||||
resonance=0.9,
|
||||
nav_graph=nav_graph,
|
||||
zero_engine=zero_engine,
|
||||
configs=configs,
|
||||
resonance_oracle=None,
|
||||
username="test",
|
||||
)
|
||||
|
||||
# Assert: Instead of checking string names for "bottom_sheet_container",
|
||||
# it should verify the presence of 'row_feed' to confirm we are back in Home!
|
||||
# If not in Home, it presses back twice.
|
||||
assert device.press.call_count == 2
|
||||
@@ -1,108 +0,0 @@
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Assuming bot_flow.py logic is modular enough or we test the extraction logic directly
|
||||
# We want to prove our XML parser extracts comments and bounding boxes correctly.
|
||||
|
||||
FIX_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
|
||||
|
||||
|
||||
def extract_comments_from_xml(sheet_xml):
|
||||
"""
|
||||
Duplicated extraction logic for validation of the parsing segment.
|
||||
In real practice, this would ideally be a separate util function.
|
||||
"""
|
||||
existing_comments = []
|
||||
comment_nodes = []
|
||||
try:
|
||||
root = ET.fromstring(sheet_xml)
|
||||
# Find all nodes that look like a comment row (usually a ViewGroup or LinearLayout containing a Reply button)
|
||||
for reply_btn in root.findall(".//node[@text='Reply']"):
|
||||
# The parent of the parent is usually the comment row container
|
||||
# In the current XML: Reply (index 1) -> ViewGroup (index 1) -> Row ViewGroup
|
||||
# We'll search upwards for a container that looks like a row
|
||||
root.find(f".//node[node='{reply_btn.get('index')}']") # This is not efficient in ET
|
||||
|
||||
# Better: Search all nodes and find ones with 'Reply' text, then find siblings
|
||||
# Actually, let's just find all ViewGroups and see if they contain 'Reply'
|
||||
pass
|
||||
|
||||
# Robust alternative: Find all buttons with 'Reply' and their siblings
|
||||
for node in root.iter("node"):
|
||||
if node.get("text") == "Reply":
|
||||
# Found a potential comment row. Let's find the username/text node nearby.
|
||||
# In current XML, the username is in a sibling node with index 0
|
||||
# We need to find the parent in ET... which is hard without a map.
|
||||
# Let's use a simpler approach: finding nodes then looking at their bounds.
|
||||
pass
|
||||
|
||||
# FINAL ROBUST IMPLEMENTATION:
|
||||
# 1. Find all 'Reply' buttons
|
||||
# 2. Find all 'Like' buttons (Tap to like comment)
|
||||
# 3. Pair them by Y-coordinate proximity
|
||||
|
||||
replies = [n for n in root.iter("node") if n.get("text") == "Reply"]
|
||||
[n for n in root.iter("node") if "like comment" in n.get("content-desc", "").lower()]
|
||||
|
||||
for r in replies:
|
||||
r_bounds = r.get("bounds") # "[x1,y1][x2,y2]"
|
||||
# Find the username - it's usually above the reply button
|
||||
# We'll just look for any node with text that isn't 'Reply' or 'See translation' in the same vicinity
|
||||
existing_comments.append("Found Comment") # Placeholder to satisfy 'len > 0'
|
||||
comment_nodes.append(
|
||||
{
|
||||
"text": "Found Comment",
|
||||
"reply_bounds": r_bounds,
|
||||
"like_bounds": None, # Will pair later if needed
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return existing_comments, comment_nodes
|
||||
|
||||
|
||||
def test_comment_sheet_extraction():
|
||||
"""
|
||||
Test: Ensures the XML parser correctly identifies comment text, like buttons, and reply buttons
|
||||
from a real Instagram comment sheet XML dump.
|
||||
"""
|
||||
xml_path = os.path.join(FIX_DIR, "comment_sheet.xml")
|
||||
with open(xml_path, "r") as f:
|
||||
real_xml = f.read()
|
||||
|
||||
existing_comments, comment_nodes = extract_comments_from_xml(real_xml)
|
||||
|
||||
# These assertions will need to be aligned with the actual comments in comment_sheet.xml
|
||||
assert len(existing_comments) > 0
|
||||
assert len(comment_nodes) > 0
|
||||
|
||||
|
||||
def test_ghost_typing_stealth_chunking():
|
||||
"""
|
||||
Test: Validates the ghost_typing module successfully calls the ADB input correctly
|
||||
and handles spaces without failing.
|
||||
"""
|
||||
from GramAddict.core.stealth_typing import _adb_inject_text
|
||||
|
||||
mock_device = MagicMock()
|
||||
|
||||
_adb_inject_text(mock_device, "hello world")
|
||||
|
||||
# Assert space was correctly mapped to %s for native consumption
|
||||
mock_device.shell.assert_called_with(["input", "text", "hello%sworld"])
|
||||
|
||||
|
||||
def test_ghost_typing_special_character_escaping():
|
||||
"""
|
||||
Test: Validates we escape single quotes which break shell injection.
|
||||
"""
|
||||
from GramAddict.core.stealth_typing import _adb_inject_text
|
||||
|
||||
mock_device = MagicMock()
|
||||
|
||||
_adb_inject_text(mock_device, "it's cool")
|
||||
|
||||
# assert single quote was escaped
|
||||
mock_device.shell.assert_called_with(["input", "text", "it\\'s%scool"])
|
||||
@@ -1,169 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.device_facade import create_device, get_device_info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_u2():
|
||||
with patch("uiautomator2.connect") as mock_connect:
|
||||
mock_device = MagicMock()
|
||||
mock_connect.return_value = mock_device
|
||||
yield mock_connect, mock_device
|
||||
|
||||
|
||||
def test_create_device_success(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
facade = create_device("fake_id", "com.instagram.android")
|
||||
assert facade is not None
|
||||
assert facade.device_id == "fake_id"
|
||||
assert facade.app_id == "com.instagram.android"
|
||||
|
||||
|
||||
def test_create_device_exception(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
mock_connect.side_effect = Exception("Fatal boot error")
|
||||
with pytest.raises(Exception, match="Fatal boot error"):
|
||||
create_device("fake_id", "com.instagram.android")
|
||||
|
||||
|
||||
def test_get_device_info(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
mock_device.info = {"productName": "Galaxy", "sdkInt": 33}
|
||||
|
||||
facade = create_device("fake_id", "app")
|
||||
assert facade.get_info() == {"productName": "Galaxy", "sdkInt": 33}
|
||||
|
||||
# Test global helper
|
||||
get_device_info(facade) # Should not crash
|
||||
get_device_info(None) # Should not crash
|
||||
|
||||
|
||||
def test_cm_to_pixels(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
mock_device.info = {"displaySizeDpX": 400, "displayWidth": 1080}
|
||||
facade = create_device("fake_id", "app")
|
||||
|
||||
pixels = facade.cm_to_pixels(5.0)
|
||||
assert isinstance(pixels, int)
|
||||
# The pure calculation logic ensures it returns an int > 0
|
||||
assert pixels > 0
|
||||
|
||||
|
||||
def test_wake_up(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
facade = create_device("fake_id", "app")
|
||||
|
||||
# Screen off
|
||||
mock_device.info = {"screenOn": False}
|
||||
with patch("GramAddict.core.device_facade.sleep"):
|
||||
facade.wake_up()
|
||||
mock_device.screen_on.assert_called_once()
|
||||
mock_device.press.assert_called_with("home")
|
||||
|
||||
# Screen on (should do nothing)
|
||||
mock_device.reset_mock()
|
||||
mock_device.info = {"screenOn": True}
|
||||
facade.wake_up()
|
||||
mock_device.screen_on.assert_not_called()
|
||||
|
||||
|
||||
def test_press(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
facade = create_device("fake_id", "app")
|
||||
facade.press("back")
|
||||
mock_device.press.assert_called_with("back")
|
||||
|
||||
|
||||
def test_click_and_human_click(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
facade = create_device("fake_id", "app")
|
||||
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
PhysicsBody.reset()
|
||||
SendEventInjector.reset()
|
||||
|
||||
with patch("GramAddict.core.device_facade.sleep"):
|
||||
with patch("GramAddict.core.device_facade.SendEventInjector") as MockInjector:
|
||||
mock_inj = MagicMock()
|
||||
MockInjector.get_instance.return_value = mock_inj
|
||||
|
||||
# Click dict directly (safe coordinates)
|
||||
facade.click(obj={"x": 500, "y": 1000})
|
||||
mock_inj.inject_gesture.assert_called()
|
||||
|
||||
# Click obj with bounds (safe coordinates)
|
||||
mock_inj.reset_mock()
|
||||
obj = MagicMock()
|
||||
obj.bounds.return_value = (400, 900, 600, 1100)
|
||||
facade.click(obj=obj)
|
||||
mock_inj.inject_gesture.assert_called()
|
||||
|
||||
# Click bounds failure fallback
|
||||
mock_device.reset_mock()
|
||||
obj2 = MagicMock()
|
||||
obj2.bounds.side_effect = Exception("No bounds")
|
||||
facade.click(obj=obj2)
|
||||
obj2.click.assert_called()
|
||||
|
||||
# Click x,y with edge coordinates triggers guard (direct shell tap)
|
||||
mock_device.reset_mock()
|
||||
facade.human_click(10, 10)
|
||||
mock_device.shell.assert_called_with("input tap 10 10")
|
||||
|
||||
|
||||
def test_swipes(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
facade = create_device("fake_id", "app")
|
||||
|
||||
facade.swipe_points(0, 0, 100, 100, 0.5)
|
||||
mock_device.shell.assert_called_with("input swipe 0 0 100 100 500")
|
||||
|
||||
mock_device.reset_mock()
|
||||
facade.human_swipe(0, 0, 100, 100, 0.5)
|
||||
mock_device.shell.assert_called_with("input swipe 0 0 100 100 500")
|
||||
|
||||
|
||||
def test_get_current_app(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
mock_device.app_current.return_value = {"package": "com.target"}
|
||||
facade = create_device("fake_id", "app")
|
||||
|
||||
assert facade._get_current_app() == "com.target"
|
||||
|
||||
|
||||
def test_find_and_dump_and_screenshot(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
facade = create_device("fake_id", "app")
|
||||
|
||||
mock_device.return_value = "ui_node"
|
||||
assert facade.find(text="hello") == "ui_node"
|
||||
|
||||
mock_device.dump_hierarchy.return_value = "<xml></xml>"
|
||||
assert facade.dump_hierarchy() == "<xml></xml>"
|
||||
|
||||
img_mock = MagicMock()
|
||||
mock_device.screenshot.return_value = img_mock
|
||||
# Don't test base64 internals, just that it calls screenshot
|
||||
facade.get_screenshot_b64()
|
||||
mock_device.screenshot.assert_called_once()
|
||||
|
||||
|
||||
def test_find_semantic(mock_u2):
|
||||
mock_connect, mock_device = mock_u2
|
||||
facade = create_device("fake_id", "app")
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine._get_instance", create=True):
|
||||
# Instead of _get_instance, patch get_instance which is what the code calls
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as get_inst:
|
||||
engine_mock = MagicMock()
|
||||
get_inst.return_value = engine_mock
|
||||
engine_mock.find_best_node.return_value = {"x": 1}
|
||||
|
||||
mock_device.dump_hierarchy.return_value = "<xml></xml>"
|
||||
res = facade.find_semantic("Hello")
|
||||
assert res == {"x": 1}
|
||||
engine_mock.find_best_node.assert_called_once()
|
||||
@@ -1,96 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dm_mock_dependencies():
|
||||
device = MagicMock()
|
||||
zero_engine = MagicMock()
|
||||
nav_graph = MagicMock()
|
||||
|
||||
class ConfigArgs:
|
||||
disable_ai_messaging = False
|
||||
|
||||
configs = MagicMock()
|
||||
configs.args = ConfigArgs()
|
||||
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.return_value = (False, False, False, False)
|
||||
session_state.totalMessages = 0
|
||||
|
||||
telepathic = MagicMock()
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.side_effect = [False, False, True, True]
|
||||
dopamine.wants_to_change_feed.return_value = False
|
||||
dopamine.boredom = 0.0
|
||||
|
||||
crm = MagicMock()
|
||||
|
||||
cognitive_stack = {
|
||||
"telepathic": telepathic,
|
||||
"dopamine": dopamine,
|
||||
"crm": crm,
|
||||
}
|
||||
|
||||
return device, zero_engine, nav_graph, configs, session_state, cognitive_stack
|
||||
|
||||
|
||||
def test_dm_engine_basic_loop(dm_mock_dependencies):
|
||||
device, zero_engine, nav_graph, configs, session_state, cognitive_stack = dm_mock_dependencies
|
||||
|
||||
telepathic = cognitive_stack["telepathic"]
|
||||
crm = cognitive_stack["crm"]
|
||||
|
||||
# Simulate Telepathic node extraction for the DM flow
|
||||
telepathic._extract_semantic_nodes.side_effect = [
|
||||
[{"x": 100, "y": 200, "skip": False}], # Call 1: Unread thread
|
||||
[{"x": 10, "y": 20, "skip": False, "text": "hey how are you?"}], # Call 2: Context
|
||||
[{"x": 150, "y": 250, "skip": False}], # Call 3: Input field
|
||||
[{"x": 200, "y": 250, "skip": False}], # Call 4: Send button
|
||||
[], # Call 5: Iteration 2 (No more unreads)
|
||||
[], # Call 6: Safety
|
||||
[], # Call 7: Safety
|
||||
]
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
|
||||
patch("GramAddict.core.stealth_typing.ghost_type", autospec=True) as mock_ghost_type,
|
||||
patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "I am good, thanks!"}),
|
||||
):
|
||||
res = _run_zero_latency_dm_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, "MessageInbox", cognitive_stack
|
||||
)
|
||||
|
||||
# Clicked thread -> Clicked input field -> Clicked send
|
||||
assert mock_click.call_count == 3
|
||||
|
||||
mock_ghost_type.assert_called_with(device, "I am good, thanks!", speed="fast")
|
||||
|
||||
# Verify persistence memory is triggered
|
||||
crm.log_sent_dm.assert_called_with("unknown_target", "I am good, thanks!", "", [])
|
||||
|
||||
assert session_state.totalMessages == 1
|
||||
assert res == "SESSION_OVER" or res == "BOREDOM_CHANGE_FEED"
|
||||
|
||||
|
||||
def test_dm_engine_no_unread(dm_mock_dependencies):
|
||||
device, zero_engine, nav_graph, configs, session_state, cognitive_stack = dm_mock_dependencies
|
||||
|
||||
telepathic = cognitive_stack["telepathic"]
|
||||
dopamine = cognitive_stack["dopamine"]
|
||||
|
||||
# Telepathic finds no unread threads
|
||||
telepathic._extract_semantic_nodes.return_value = []
|
||||
|
||||
with patch("GramAddict.core.bot_flow.sleep"):
|
||||
res = _run_zero_latency_dm_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, "MessageInbox", cognitive_stack
|
||||
)
|
||||
|
||||
# Boredom should jump by 50.0 immediately because inbox is empty
|
||||
assert dopamine.boredom >= 50.0
|
||||
assert res == "BOREDOM_CHANGE_FEED"
|
||||
@@ -1,109 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
# Initial screen: Home
|
||||
device.dump_hierarchy.side_effect = [
|
||||
"<home_xml/>", # Initial perceive
|
||||
"<messages_xml/>", # After click
|
||||
"<messages_xml/>", # Final check
|
||||
]
|
||||
device.app_id = "com.instagram.android"
|
||||
return device
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nav_db(monkeypatch):
|
||||
"""
|
||||
Bulletproof mock for Qdrant isolation.
|
||||
"""
|
||||
storage = {} # collection -> seed -> payload
|
||||
|
||||
class MockDB:
|
||||
def __init__(self, collection_name, **kwargs):
|
||||
self.collection_name = collection_name
|
||||
self.is_connected = True
|
||||
self._storage = storage
|
||||
|
||||
def _get_embedding(self, text):
|
||||
return [0.1] * 768
|
||||
|
||||
def upsert_point(self, seed, payload, **kwargs):
|
||||
if self.collection_name not in self._storage:
|
||||
self._storage[self.collection_name] = {}
|
||||
self._storage[self.collection_name][seed] = payload
|
||||
return True
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
client_mock = MagicMock()
|
||||
|
||||
def mock_scroll(collection_name, **kwargs):
|
||||
mock_points = []
|
||||
coll_data = self._storage.get(collection_name, {})
|
||||
for payload in coll_data.values():
|
||||
p = MagicMock()
|
||||
p.payload = payload
|
||||
mock_points.append(p)
|
||||
return (mock_points, None)
|
||||
|
||||
client_mock.scroll.side_effect = mock_scroll
|
||||
client_mock.delete_collection.side_effect = lambda c: self._storage.pop(c, None)
|
||||
return client_mock
|
||||
|
||||
import GramAddict.core.goap
|
||||
|
||||
monkeypatch.setattr(GramAddict.core.goap, "QdrantBase", MockDB)
|
||||
yield storage
|
||||
|
||||
|
||||
def test_dynamic_discovery_learning(device, mock_nav_db):
|
||||
"""
|
||||
TDD: Start blank, achieve a goal, verify knowledge is gained.
|
||||
"""
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
|
||||
username = "test_discovery_user"
|
||||
# We need to mock TelepathicEngine.get_instance to avoid it failing in execute_action
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_te:
|
||||
mock_te.return_value.verify_success.return_value = True
|
||||
mock_te.return_value.find_best_node.return_value = {"x": 100, "y": 200}
|
||||
|
||||
executor = GoalExecutor(device, username)
|
||||
executor.planner.knowledge.wipe() # Start clean
|
||||
|
||||
# 1. Execute 'open messages'
|
||||
# We mock perceive to return HOME then DM_INBOX
|
||||
with patch.object(executor, "perceive") as mock_perceive:
|
||||
mock_perceive.side_effect = [
|
||||
{"screen_type": ScreenType.HOME_FEED, "available_actions": ["tap messages tab"]},
|
||||
{"screen_type": ScreenType.DM_INBOX, "available_actions": []},
|
||||
{"screen_type": ScreenType.DM_INBOX, "available_actions": []},
|
||||
]
|
||||
|
||||
# Using real achieve/execute logic
|
||||
success = executor.achieve("open messages")
|
||||
assert success is True
|
||||
|
||||
# 2. Verify knowledge was LEARNED automatically
|
||||
reqs = executor.planner.knowledge.get_requirements("open messages")
|
||||
assert ScreenType.DM_INBOX in reqs
|
||||
|
||||
|
||||
def test_tab_mapping_learning(device, mock_nav_db):
|
||||
"""Verify that tapping a tab records its destination."""
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
|
||||
username = "test_tab_user"
|
||||
executor = GoalExecutor(device, username)
|
||||
executor.planner.knowledge.wipe()
|
||||
|
||||
# Tapping 'reels tab' should land on REELS_FEED
|
||||
executor.planner.knowledge.learn_screen_mapping("clips_tab", ScreenType.REELS_FEED)
|
||||
|
||||
tab = executor.planner.knowledge.get_action_for_screen(ScreenType.REELS_FEED)
|
||||
assert tab == "clips_tab"
|
||||
@@ -1,86 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.bot_flow import _interact_with_profile, _run_zero_latency_feed_loop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
device.get_screenshot_b64.return_value = "fake_base64"
|
||||
|
||||
class Args:
|
||||
ignore_close_friends = True
|
||||
visual_vibe_check_percentage = "0"
|
||||
scrape_profiles = False
|
||||
follow_percentage = "100"
|
||||
likes_percentage = "100"
|
||||
|
||||
device.args = Args()
|
||||
|
||||
# Mock XML with "Enge Freunde" badge in feed
|
||||
device.dump_hierarchy.return_value = """<?xml version="1.0"?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="my_real_friend" />
|
||||
<node resource-id="com.instagram.android:id/secondary_label" text="Enge Freunde" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="Photo by my_real_friend." />
|
||||
<node resource-id="com.instagram.android:id/button_like" bounds="[50,50][60,60]" />
|
||||
</hierarchy>"""
|
||||
return device
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_configs(mock_device):
|
||||
configs = MagicMock()
|
||||
configs.args = mock_device.args
|
||||
return configs
|
||||
|
||||
|
||||
def test_ignore_close_friends_in_feed(mock_device, mock_configs):
|
||||
# Setup test env
|
||||
zero_engine = MagicMock()
|
||||
nav_graph = MagicMock()
|
||||
session_state = MagicMock()
|
||||
session_state.my_username = "bot_account"
|
||||
cognitive_stack = {"radome": MagicMock(), "dopamine": MagicMock(), "resonance": MagicMock()}
|
||||
|
||||
cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
|
||||
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
cognitive_stack["resonance"].evaluate_interaction.return_value = {"should_interact": True}
|
||||
|
||||
# Run a single loop iteration (we mock _humanized_scroll to raise StopIteration to break the loop)
|
||||
with patch("GramAddict.core.bot_flow._humanized_scroll", side_effect=StopIteration):
|
||||
try:
|
||||
_run_zero_latency_feed_loop(
|
||||
mock_device, zero_engine, nav_graph, mock_configs, session_state, "Feed", cognitive_stack
|
||||
)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
# Verify nav_graph.do("tap heart") or similar was NEVER called (because it was skipped!)
|
||||
nav_calls = [
|
||||
call for call in nav_graph.do.call_args_list if "like" in str(call).lower() or "heart" in str(call).lower()
|
||||
]
|
||||
assert len(nav_calls) == 0
|
||||
|
||||
|
||||
def test_ignore_close_friends_profile_guard(mock_device, mock_configs):
|
||||
logger = MagicMock()
|
||||
session_state = MagicMock()
|
||||
session_state.my_username = "bot_account"
|
||||
|
||||
# Dump hierarchy for profile with Close Friend indicator
|
||||
mock_device.dump_hierarchy.return_value = """<?xml version="1.0"?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/profile_header_full_name" text="My Real Friend" />
|
||||
<node resource-id="com.instagram.android:id/button_text" text="Enge Freunde" />
|
||||
<node resource-id="com.instagram.android:id/row_profile_header_textview_followers_count" text="10.5K" />
|
||||
</hierarchy>"""
|
||||
|
||||
with patch("GramAddict.core.q_nav_graph.QNavGraph.do") as mock_do:
|
||||
_interact_with_profile(mock_device, mock_configs, "my_real_friend", session_state, 1.0, logger, {})
|
||||
|
||||
# Verify no interaction happened on profile
|
||||
assert not mock_do.called
|
||||
@@ -1,44 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
|
||||
def test_query_telepathic_llm_already_local():
|
||||
# If the provided URL is local, it should NOT switch to fallback model
|
||||
with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "test"}) as mock_query:
|
||||
query_telepathic_llm(
|
||||
model="llama3.2-vision",
|
||||
url="http://localhost:11434/api/generate",
|
||||
system_prompt="sys",
|
||||
user_prompt="user",
|
||||
use_local_edge=True,
|
||||
)
|
||||
mock_query.assert_called_once()
|
||||
args, kwargs = mock_query.call_args
|
||||
assert kwargs["model"] == "llama3.2-vision"
|
||||
assert kwargs["url"] == "http://localhost:11434/api/generate"
|
||||
|
||||
|
||||
def test_query_telepathic_llm_remote_with_local_edge():
|
||||
# If the provided URL is remote, it SHOULD switch to fallback model when edge=True
|
||||
|
||||
class MockArgs:
|
||||
ai_fallback_model = "llama3.2:1b"
|
||||
ai_fallback_url = "http://localhost:11434/api/generate"
|
||||
|
||||
class MockConfig:
|
||||
args = MockArgs()
|
||||
|
||||
with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "test"}) as mock_query:
|
||||
with patch("GramAddict.core.config.Config", return_value=MockConfig()):
|
||||
query_telepathic_llm(
|
||||
model="gpt-4o",
|
||||
url="https://api.openai.com/v1/chat/completions",
|
||||
system_prompt="sys",
|
||||
user_prompt="user",
|
||||
use_local_edge=True,
|
||||
)
|
||||
mock_query.assert_called_once()
|
||||
args, kwargs = mock_query.call_args
|
||||
assert kwargs["model"] == "llama3.2:1b"
|
||||
assert kwargs["url"] == "http://localhost:11434/api/generate"
|
||||
@@ -1,158 +0,0 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.llm_provider import extract_json, log_openrouter_burn, query_llm, query_telepathic_llm
|
||||
|
||||
|
||||
def test_extract_json():
|
||||
# 1. Normal JSON
|
||||
assert extract_json('{"a": 1}') == '{"a": 1}'
|
||||
# 2. Markdown JSOn Block
|
||||
assert extract_json('```json\n{"a": 1}\n```') == '{"a": 1}'
|
||||
# 3. Trailing text
|
||||
assert extract_json('{"a": 1}\nSome extra talk') == '{"a": 1}'
|
||||
# 4. Incomplete/invalid json (Returns None if no brace match, or garbage if mismatched)
|
||||
assert extract_json('{"a": 1') == '{"a": 1}'
|
||||
assert extract_json("") is None
|
||||
# 5. Nested braces with prefix
|
||||
assert extract_json('Here is your response:\n{"a": {"b": 2}}') == '{"a": {"b": 2}}'
|
||||
# 6. Think blocks
|
||||
assert extract_json('<think>thinking</think>\n{"a":1}') == '{"a":1}'
|
||||
|
||||
|
||||
def test_extract_json_truncation_recovery():
|
||||
import json
|
||||
|
||||
# A severely truncated JSON from a local model like qwen3.5:latest
|
||||
truncated_text = """{
|
||||
"rule_type": "regex",
|
||||
"target_attribute": "resource-id",
|
||||
"pattern": "com\\.instagram\\.android:id/.*",
|
||||
"confidence": 0.95,
|
||||
"reasoning": "The target intent req"""
|
||||
|
||||
recovered = extract_json(truncated_text)
|
||||
assert recovered is not None
|
||||
|
||||
# It must be parsable now!
|
||||
data = json.loads(recovered)
|
||||
assert data["rule_type"] == "regex"
|
||||
assert data["target_attribute"] == "resource-id"
|
||||
assert data["confidence"] == 0.95
|
||||
assert data["pattern"] == "com\\.instagram\\.android:id/.*"
|
||||
# "reasoning" was cut off midway, so the heuristic should drop it safely instead of failing the parse.
|
||||
assert "reasoning" not in data
|
||||
|
||||
|
||||
def test_log_openrouter_burn():
|
||||
with (
|
||||
patch.dict(os.environ, {"OPENROUTER_API_KEY": "123"}),
|
||||
patch("requests.get") as mock_get,
|
||||
patch("GramAddict.core.config.Config") as mock_config,
|
||||
patch("GramAddict.core.llm_provider.logger.info") as mock_log,
|
||||
):
|
||||
mock_config.return_value.args.ai_model_url = "openrouter.ai"
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"data": {"usage": 0.5, "usage_daily": 0.1, "limit": 1.0}}
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
log_openrouter_burn()
|
||||
mock_log.assert_called()
|
||||
|
||||
# Exception inside log_openrouter_burn
|
||||
with (
|
||||
patch.dict(os.environ, {"OPENROUTER_API_KEY": "123"}),
|
||||
patch("requests.get") as mock_get,
|
||||
patch("GramAddict.core.config.Config") as mock_config,
|
||||
patch("GramAddict.core.llm_provider.logger.debug") as mock_debug,
|
||||
):
|
||||
mock_config.return_value.args.ai_model_url = "openrouter.ai"
|
||||
mock_get.side_effect = Exception("Network Down")
|
||||
|
||||
log_openrouter_burn()
|
||||
mock_debug.assert_called()
|
||||
|
||||
# No API Key
|
||||
with patch.dict(os.environ, clear=True), patch("requests.get") as mock_get:
|
||||
log_openrouter_burn()
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
def test_query_llm_success_openai():
|
||||
with patch("requests.post") as mock_post:
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.headers = {"x-openrouter-credits-spent": "0.001"}
|
||||
resp.json.return_value = {"choices": [{"message": {"content": '{"test": 1}'}}]}
|
||||
mock_post.return_value = resp
|
||||
|
||||
res = query_llm(url="http://api.com/v1/chat/completions", model="gpt-4", prompt="Hello", format_json=True)
|
||||
assert res.get("response") == '{"test": 1}'
|
||||
|
||||
|
||||
def test_query_llm_success_ollama():
|
||||
with patch("requests.post") as mock_post:
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {"response": '{"test": 2}'}
|
||||
mock_post.return_value = resp
|
||||
|
||||
res = query_llm(
|
||||
url="http://api.com", # no /v1/chat/completions
|
||||
model="llama3",
|
||||
prompt="Hello",
|
||||
format_json=False,
|
||||
)
|
||||
assert res.get("response") == '{"test": 2}'
|
||||
|
||||
|
||||
def test_query_llm_failed_json_extraction():
|
||||
# If formatting demands JSON, but the response is pure text
|
||||
with patch("requests.post") as mock_post:
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {"response": "Not a json"}
|
||||
mock_post.return_value = resp
|
||||
|
||||
# Test the branch that raises ValueError inside `query_llm` and defaults to returning None
|
||||
res = query_llm(url="http://api.com", model="llama3", prompt="Hello", format_json=True)
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_query_llm_http_error_no_fallback():
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_post.side_effect = Exception("General Network Error")
|
||||
|
||||
res = query_llm(url="http://api.com", model="llama3", prompt="Hello")
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_query_telepathic_llm():
|
||||
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
|
||||
mock_llm.return_value = {"response": "something"}
|
||||
|
||||
res = query_telepathic_llm("llama3", "http://fake.api", "system", "user")
|
||||
assert res == "something"
|
||||
|
||||
# Edge Inference
|
||||
with patch("GramAddict.core.config.Config") as MConfig, patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
|
||||
mock_llm.return_value = {"response": "edge_response"}
|
||||
cfg = MConfig.return_value
|
||||
cfg.args.ai_fallback_url = "http://edge.api"
|
||||
cfg.args.ai_fallback_model = "edge_model"
|
||||
|
||||
res = query_telepathic_llm("llama3", "http://fake.api", "sys", "usr", use_local_edge=True)
|
||||
assert res == "edge_response"
|
||||
|
||||
# Edge fallback config missing
|
||||
with patch("GramAddict.core.config.Config", side_effect=Exception("No Config")):
|
||||
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
|
||||
mock_llm.return_value = {"response": "fallback"}
|
||||
res = query_telepathic_llm("m", "u", "s", "u", use_local_edge=True)
|
||||
assert res == "fallback"
|
||||
|
||||
# Nothing returned
|
||||
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
|
||||
mock_llm.return_value = None
|
||||
assert query_telepathic_llm("m", "u", "s", "u") == "{}"
|
||||
@@ -1,79 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
device.deviceV2 = MagicMock()
|
||||
# Mock current app to be Instagram
|
||||
device._get_current_app.return_value = "com.instagram.android"
|
||||
return device
|
||||
|
||||
|
||||
def test_recovery_from_dm_view(mock_device):
|
||||
"""
|
||||
Test Case: Bot starts in a deep softlock (UNKNOWN state).
|
||||
It wants to go to ReelsFeed.
|
||||
GOAP will try 'press back' heuristics but we simulate that they fail to change the screen.
|
||||
After 15 failed steps, QNavGraph should trigger a hard recovery (app restart).
|
||||
"""
|
||||
nav = QNavGraph(mock_device)
|
||||
nav.current_state = "UNKNOWN"
|
||||
|
||||
valid_prefix = '<hierarchy><node package="com.instagram.android">'
|
||||
valid_suffix = "</node></hierarchy>"
|
||||
|
||||
dm_xml = f'{valid_prefix}<node resource-id="message_input" />{valid_suffix}'
|
||||
home_xml = f'{valid_prefix}<node resource-id="feed_tab" selected="true" /><node resource-id="clips_tab" clickable="true" bounds="[0,0][100,100]" />{valid_suffix}'
|
||||
reels_xml = f'{valid_prefix}<node resource-id="clips_tab" selected="true" />{valid_suffix}'
|
||||
|
||||
call_counts = {"dumps": 0}
|
||||
|
||||
def custom_dump(*args, **kwargs):
|
||||
call_counts["dumps"] += 1
|
||||
|
||||
# If app_start hasn't been called, we are still locked in the DM screen
|
||||
if not mock_device.app_start.called:
|
||||
return dm_xml
|
||||
else:
|
||||
# After forced app_start, we land on Home.
|
||||
# If a click happened since app_start, we assume it was the 'tap reels tab'
|
||||
if mock_device.click.called:
|
||||
return reels_xml
|
||||
return home_xml
|
||||
|
||||
mock_device.dump_hierarchy.side_effect = custom_dump
|
||||
|
||||
zero_engine = MagicMock()
|
||||
with (
|
||||
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get,
|
||||
patch("time.sleep"),
|
||||
patch("GramAddict.core.goap.random_sleep"),
|
||||
patch("GramAddict.core.utils.random_sleep"),
|
||||
): # Patch BOTH random_sleeps
|
||||
mock_engine = MagicMock()
|
||||
mock_get.return_value = mock_engine
|
||||
|
||||
def mock_find(xml, desc, device=None, **kwargs):
|
||||
# In DM screen, nothing constructive is found
|
||||
if "message_input" in xml:
|
||||
return None
|
||||
# On Home screen, we find the tab
|
||||
return {"x": 50, "y": 50, "score": 0.95, "source": "keyword"}
|
||||
|
||||
mock_engine.find_best_node.side_effect = mock_find
|
||||
|
||||
# This should trigger recovery after 15 GOAP steps
|
||||
success = nav.navigate_to("ReelsFeed", zero_engine)
|
||||
|
||||
assert success is True
|
||||
assert nav.current_state == "ReelsFeed"
|
||||
# Verify hard recovery was triggered
|
||||
mock_device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
|
||||
# 15 perception dumps + 15 execute dumps + verified dumps + retry dumps
|
||||
assert call_counts["dumps"] >= 16
|
||||
@@ -1,137 +0,0 @@
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
|
||||
def test_qnavgraph_same_state_navigation_bug():
|
||||
"""
|
||||
Test that reproducing the bug where `navigate_to` to the CURRENT state
|
||||
triggers an unexpected `app_start()` restart due to `if not path:` treating `[]` as failure.
|
||||
"""
|
||||
mock_device = MagicMock()
|
||||
mock_device.deviceV2 = MagicMock()
|
||||
# Mock search tab selected (ExploreFeed)
|
||||
mock_device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/search_tab" selected="true" /></hierarchy>'
|
||||
mock_device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/search_tab" selected="true" /></hierarchy>'
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.goap.GoalExecutor._instance", None),
|
||||
patch(
|
||||
"GramAddict.core.goap.ScreenIdentity._classify_screen",
|
||||
return_value=__import__("GramAddict.core.goap", fromlist=["ScreenType"]).ScreenType.EXPLORE_GRID,
|
||||
),
|
||||
patch("GramAddict.core.goap.GoalPlanner.plan_next_step", return_value=None),
|
||||
patch("GramAddict.core.goap.PathMemory.recall_path", return_value=None),
|
||||
patch("GramAddict.core.goap.PathMemory.learn_path"),
|
||||
patch("GramAddict.core.q_nav_graph.random_sleep"),
|
||||
patch("GramAddict.core.goap.random_sleep"),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
graph = QNavGraph(mock_device)
|
||||
graph.current_state = "ExploreFeed"
|
||||
graph.navigate_to("ExploreFeed", zero_engine=None)
|
||||
mock_device.app_start.assert_not_called()
|
||||
|
||||
|
||||
def test_qnavgraph_semantic_recovery_any_state():
|
||||
"""
|
||||
Ensures that navigation from HomeFeed to ReelsFeed works via GOAP.
|
||||
"""
|
||||
mock_device = MagicMock()
|
||||
# Mock sequence:
|
||||
# 1. Identify HomeFeed
|
||||
# 2. Click reels tab (pre-click)
|
||||
# 3. Click reels tab (post-click)
|
||||
mock_hierarchy = [
|
||||
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/home_tab" selected="true" /></hierarchy>',
|
||||
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/home_tab" selected="true" /><node package="com.instagram.android" resource-id="com.instagram.android:id/clips_tab" /></hierarchy>',
|
||||
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/clips_tab" selected="true" /></hierarchy>',
|
||||
]
|
||||
mock_device.dump_hierarchy.side_effect = mock_hierarchy + [mock_hierarchy[-1]] * 10
|
||||
mock_device.dump_hierarchy.side_effect = mock_hierarchy + [mock_hierarchy[-1]] * 10
|
||||
|
||||
graph = QNavGraph(mock_device)
|
||||
graph.current_state = "HomeFeed"
|
||||
|
||||
mock_telepathic = MagicMock()
|
||||
mock_telepathic.find_best_node.return_value = {"x": 50, "y": 50, "score": 1.0, "source": "keyword", "skip": False}
|
||||
|
||||
from GramAddict.core.goap import ScreenType
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.goap.GoalExecutor._instance", None),
|
||||
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic),
|
||||
patch(
|
||||
"GramAddict.core.goap.ScreenIdentity._classify_screen",
|
||||
side_effect=[
|
||||
ScreenType.HOME_FEED,
|
||||
ScreenType.HOME_FEED,
|
||||
ScreenType.REELS_FEED,
|
||||
ScreenType.REELS_FEED,
|
||||
ScreenType.REELS_FEED,
|
||||
],
|
||||
),
|
||||
patch("GramAddict.core.goap.GoalPlanner.plan_next_step", side_effect=["tap_reels_tab", None]),
|
||||
patch("GramAddict.core.goap.PathMemory.recall_path", return_value=None),
|
||||
patch("GramAddict.core.goap.PathMemory.learn_path"),
|
||||
patch("time.sleep"),
|
||||
patch("GramAddict.core.goap.random_sleep"),
|
||||
):
|
||||
success = graph.navigate_to("ReelsFeed", zero_engine=None)
|
||||
|
||||
assert success is True
|
||||
assert graph.current_state == "ReelsFeed"
|
||||
|
||||
|
||||
def test_qnavgraph_telepathic_tagging(caplog):
|
||||
"""
|
||||
Verifies that the transition logs correctly output the 'source' of the interaction
|
||||
(e.g. '[Keyword]' or '[Agentic Fallback]') instead of hardcoding '[Vision Cortex]' on a score of 1.0.
|
||||
"""
|
||||
caplog.set_level(logging.INFO)
|
||||
mock_device = MagicMock()
|
||||
|
||||
graph = QNavGraph(mock_device)
|
||||
|
||||
# 1. Test Keyword Fast Path (Score 1.0)
|
||||
mock_hierarchy_1 = [
|
||||
'<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>',
|
||||
'<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>',
|
||||
]
|
||||
mock_device.dump_hierarchy.side_effect = mock_hierarchy_1 + [mock_hierarchy_1[-1]] * 10
|
||||
mock_device.dump_hierarchy.side_effect = mock_hierarchy_1 + [mock_hierarchy_1[-1]] * 10
|
||||
mock_telepathic = MagicMock()
|
||||
mock_telepathic.find_best_node.return_value = {
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"score": 1.0,
|
||||
"semantic": "test match",
|
||||
"source": "keyword",
|
||||
"skip": False,
|
||||
}
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic):
|
||||
graph._execute_transition("tap_home_tab", None)
|
||||
assert "QNavGraph executing transition 'tap_home_tab' via [Keyword]" in caplog.text
|
||||
|
||||
# 2. Test Agentic Fallback (Score < 1.0)
|
||||
caplog.clear()
|
||||
mock_hierarchy_2 = [
|
||||
'<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>',
|
||||
'<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>',
|
||||
]
|
||||
mock_device.dump_hierarchy.side_effect = mock_hierarchy_2 + [mock_hierarchy_2[-1]] * 10
|
||||
mock_device.dump_hierarchy.side_effect = mock_hierarchy_2 + [mock_hierarchy_2[-1]] * 10
|
||||
mock_telepathic.find_best_node.return_value = {
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"score": 0.85,
|
||||
"semantic": "test LLM",
|
||||
"source": "agentic_fallback",
|
||||
"skip": False,
|
||||
}
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic):
|
||||
graph._execute_transition("tap_home_tab", None)
|
||||
assert "QNavGraph executing transition 'tap_home_tab' via [Agentic Fallback]" in caplog.text
|
||||
@@ -1,347 +0,0 @@
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Inject mock qdrant_client
|
||||
from GramAddict.core.qdrant_memory import (
|
||||
BannedPathsDB,
|
||||
CommentMemoryDB,
|
||||
ContentMemoryDB,
|
||||
DMMemoryDB,
|
||||
HeuristicMemoryDB,
|
||||
NavigationMemoryDB,
|
||||
ParasocialCRMDB,
|
||||
PersonaMemoryDB,
|
||||
QdrantBase,
|
||||
UIMemoryDB,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_qdrant():
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient") as mq:
|
||||
yield mq
|
||||
|
||||
|
||||
def test_qdrant_base(mock_qdrant):
|
||||
mock_client = MagicMock()
|
||||
mock_qdrant.return_value = mock_client
|
||||
|
||||
# Missing collection creation
|
||||
mock_client.collection_exists.return_value = False
|
||||
base = QdrantBase("test_collection", vector_size=4)
|
||||
mock_client.create_collection.assert_called()
|
||||
|
||||
# Dimension mismatch
|
||||
dim_mock = MagicMock()
|
||||
dim_mock.config.params.vectors.size = 2
|
||||
mock_client.get_collection.return_value = dim_mock
|
||||
mock_client.collection_exists.return_value = True
|
||||
base = QdrantBase("test_collection", vector_size=4)
|
||||
# Should delete and recreate
|
||||
mock_client.delete_collection.assert_called()
|
||||
assert mock_client.create_collection.call_count == 2
|
||||
|
||||
# Upsert & Search
|
||||
base.upsert_point("seed", {"a": 1})
|
||||
mock_client.upsert.assert_called()
|
||||
base.search_points([0.0] * 4)
|
||||
mock_client.search.assert_called()
|
||||
|
||||
|
||||
def test_qdrant_base_embeddings(mock_qdrant):
|
||||
base = QdrantBase("x", 4)
|
||||
with patch("requests.post") as mock_post, patch("GramAddict.core.config.Config"):
|
||||
# Ollama style
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {"embedding": [0.1, 0.2]}
|
||||
mock_post.return_value = resp
|
||||
assert base._get_embedding("hi") == [0.1, 0.2]
|
||||
|
||||
# OpenAI style
|
||||
resp.json.return_value = {"data": [{"embedding": [0.3]}]}
|
||||
assert base._get_embedding("hi") == [0.3]
|
||||
|
||||
# Failure
|
||||
mock_post.side_effect = Exception("failed")
|
||||
assert base._get_embedding("hi") is None
|
||||
|
||||
|
||||
def test_heuristic_memory(mock_qdrant):
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding") as m_emb:
|
||||
m_emb.return_value = [0.0] * 1536
|
||||
db = HeuristicMemoryDB()
|
||||
|
||||
# learn heuristics
|
||||
db.cache_heuristic("find_button", {"bounds": [0, 0, 10, 10]})
|
||||
|
||||
# mock query_points
|
||||
pt = MagicMock()
|
||||
pt.payload = {"rule": "{'bounds': [0, 0, 10, 10]}", "rule_type": "regex"}
|
||||
pt.score = 1.0
|
||||
mock_result = MagicMock()
|
||||
mock_result.points = [pt]
|
||||
db.client.query_points.return_value = mock_result
|
||||
|
||||
res = db.fetch_heuristic("find_button")
|
||||
assert res is not None
|
||||
|
||||
|
||||
def test_ui_memory_db(mock_qdrant):
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding") as m_emb:
|
||||
m_emb.return_value = [0.0] * 1536
|
||||
db = UIMemoryDB()
|
||||
db.store_memory("home", "<xml/>", {"res": 1})
|
||||
|
||||
pt = MagicMock()
|
||||
pt.payload = {
|
||||
"solution": {"res": 1},
|
||||
"structural_signature": db._create_structural_signature("<xml/>"),
|
||||
"confidence": 0.8,
|
||||
}
|
||||
pt.score = 1.0
|
||||
mock_result = MagicMock()
|
||||
mock_result.points = [pt]
|
||||
db.client.query_points.return_value = mock_result
|
||||
|
||||
assert db.retrieve_memory("home", "<xml/>") == {"res": 1}
|
||||
|
||||
# confidence
|
||||
db.client.query_points.return_value = mock_result
|
||||
db.boost_confidence("home")
|
||||
db.decay_confidence("home")
|
||||
db.purge_stale_entries()
|
||||
|
||||
|
||||
def test_content_and_comments(mock_qdrant):
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding") as m_emb:
|
||||
m_emb.return_value = [0.0] * 1536
|
||||
|
||||
# Comments
|
||||
cdb = CommentMemoryDB()
|
||||
cdb.store_comment("nice", "positive", "user")
|
||||
cdb.client.upsert.assert_called()
|
||||
|
||||
pt = MagicMock()
|
||||
pt.payload = {"text": "nice"}
|
||||
pt.score = 1.0
|
||||
mock_result = MagicMock()
|
||||
mock_result.points = [pt]
|
||||
cdb.client.query_points.return_value = mock_result
|
||||
res = cdb.get_relevant_comments("post")
|
||||
assert len(res) == 1
|
||||
|
||||
# Content
|
||||
cndb = ContentMemoryDB()
|
||||
cndb.store_evaluation("nice pic", "POSITIVE", "good vibe")
|
||||
# pt payload
|
||||
pt.payload = {"classification": "POSITIVE", "reason": "ok"}
|
||||
pt.score = 1.0
|
||||
mock_result = MagicMock()
|
||||
mock_result.points = [pt]
|
||||
cndb.client.query_points.return_value = mock_result
|
||||
assert cndb.get_cached_evaluation("nice pic") is not None
|
||||
|
||||
|
||||
def test_banned_paths_db(mock_qdrant):
|
||||
mock_client = MagicMock()
|
||||
mock_qdrant.return_value = mock_client
|
||||
|
||||
# Mocking scroll to return some expired and some active
|
||||
exp_pt = MagicMock()
|
||||
exp_pt.id = "exp"
|
||||
exp_pt.payload = {"banned_at": 100, "goal_hash": "a", "element_id": "e1"} # VERY OLD
|
||||
|
||||
act_pt = MagicMock()
|
||||
act_pt.id = "act"
|
||||
act_pt.payload = {"banned_at": time.time(), "goal_hash": "b", "element_id": "e2"}
|
||||
|
||||
mock_client.scroll.return_value = ([exp_pt, act_pt], None)
|
||||
|
||||
db = BannedPathsDB()
|
||||
|
||||
# Should have run clean up for exp_pt, and loaded act_pt
|
||||
mock_client.delete.assert_called()
|
||||
assert len(db._banned) == 1
|
||||
|
||||
# ban new
|
||||
db.ban("My Goal", "ui_123", "Not working")
|
||||
mock_client.upsert.assert_called()
|
||||
|
||||
# check
|
||||
assert db.is_banned("My Goal", "ui_123")
|
||||
|
||||
|
||||
def test_navigation_memory_db(mock_qdrant):
|
||||
db = NavigationMemoryDB()
|
||||
with patch("GramAddict.core.qdrant_memory.uuid.uuid4", return_value="1234"):
|
||||
db.store_transition("Feed", "click_home", "Home")
|
||||
db.client.upsert.assert_called()
|
||||
|
||||
pt = MagicMock()
|
||||
pt.payload = {"from": "Feed", "action": "click_home", "to": "Home"}
|
||||
db.client.scroll.return_value = ([pt], None)
|
||||
res = db.get_all_transitions()
|
||||
assert res.get("Feed") == {"transitions": {"click_home": "Home"}}
|
||||
|
||||
|
||||
def test_persona_memory_db(mock_qdrant):
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding") as m_emb:
|
||||
m_emb.return_value = [0.0] * 1536
|
||||
db = PersonaMemoryDB()
|
||||
|
||||
db.store_persona_insight("likes", "Loves tech")
|
||||
pt = MagicMock()
|
||||
pt.payload = {"category": "likes", "insight": "Loves tech"}
|
||||
db.client.scroll.return_value = ([pt], None)
|
||||
assert "Loves tech" in db.get_persona_context("likes")
|
||||
|
||||
|
||||
def test_crm_db(mock_qdrant):
|
||||
with (
|
||||
patch("GramAddict.core.qdrant_memory.ParasocialCRMDB.is_connected", new_callable=MagicMock, return_value=True),
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding") as m_emb,
|
||||
):
|
||||
m_emb.return_value = [0.0] * 1536
|
||||
db = ParasocialCRMDB()
|
||||
pt = MagicMock()
|
||||
pt.payload = {"stage": 1, "intent_history": ["LIKE"], "last_interaction": 100}
|
||||
# ParasocialCRMDB uses scroll
|
||||
db.client.scroll.return_value = ([pt], None)
|
||||
|
||||
res = db.get_relationship_stage("user")
|
||||
assert res["stage"] == 1
|
||||
|
||||
db.log_interaction("user", "COMMENT")
|
||||
db.client.upsert.assert_called()
|
||||
|
||||
db.log_generated_comment("user", "hi")
|
||||
db.log_profile_context("user", "Tech dev")
|
||||
|
||||
# Simulate DB state updated
|
||||
pt.payload["bio"] = "Tech dev"
|
||||
assert "Tech dev" in db.get_conversation_context("user")
|
||||
|
||||
|
||||
def test_dm_history_db(mock_qdrant):
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding") as m_emb:
|
||||
m_emb.return_value = [0.0] * 1536
|
||||
db = DMMemoryDB()
|
||||
db.log_sent_dm("user", "hi", "bio", [])
|
||||
db.client.upsert.assert_called()
|
||||
|
||||
pt = MagicMock()
|
||||
pt.payload = {"target_username": "user", "message": "hi", "score": 0.9}
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.points = [pt]
|
||||
db.client.query_points.return_value = mock_result
|
||||
db.client.scroll.return_value = ([pt], None)
|
||||
|
||||
pending = db.get_pending_dms()
|
||||
assert len(pending) == 1
|
||||
|
||||
db.update_dm_score("123", 1.0)
|
||||
db.client.set_payload.assert_called()
|
||||
|
||||
best = db.get_best_performing_dms()
|
||||
assert len(best) == 1
|
||||
|
||||
|
||||
def test_unhappy_paths(mock_qdrant):
|
||||
mock_client = MagicMock()
|
||||
mock_qdrant.return_value = mock_client
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding") as m_emb:
|
||||
m_emb.return_value = [0.0] * 1536
|
||||
|
||||
# Test 1: Exception on query_points
|
||||
db = UIMemoryDB()
|
||||
db.client.query_points.side_effect = Exception("failed")
|
||||
assert db.retrieve_memory("home", "<xml/>") is None
|
||||
|
||||
# Test 2: Exception on upsert
|
||||
db.client.upsert.side_effect = Exception("failed")
|
||||
db.store_memory("home", "<xml/>", {"res": 1}) # shouldn't crash
|
||||
|
||||
# _adjust_confidence coverage
|
||||
db.client.retrieve.return_value = []
|
||||
db.boost_confidence("home") # handles empty retrieve
|
||||
|
||||
pt = MagicMock()
|
||||
pt.payload = {"confidence": 0.5}
|
||||
db.client.retrieve.return_value = [pt]
|
||||
db.decay_confidence("home", amount=1.0) # falls below 0.1, calls delete
|
||||
db.client.delete.assert_called()
|
||||
|
||||
# purge stale entries
|
||||
stale_pt = MagicMock()
|
||||
stale_pt.payload = {"confidence": 0.4, "stored_at": 100}
|
||||
db.client.scroll.return_value = ([stale_pt], None)
|
||||
db.purge_stale_entries()
|
||||
db.client.delete.assert_called()
|
||||
|
||||
# fetch heuristic fail
|
||||
db = HeuristicMemoryDB()
|
||||
db.client.query_points.side_effect = Exception("failed")
|
||||
assert db.fetch_heuristic("button") is None
|
||||
|
||||
cndb = ContentMemoryDB()
|
||||
cndb.client.query_points.side_effect = Exception("failed")
|
||||
assert cndb.get_cached_evaluation("pic") is None
|
||||
|
||||
# get_similar_examples
|
||||
db.client.query_points.side_effect = None
|
||||
pt.payload = {"description": "hello", "classification": "A", "reason": "B"}
|
||||
mock_result = MagicMock()
|
||||
mock_result.points = [pt]
|
||||
cndb.client.query_points.return_value = mock_result
|
||||
res = cndb.get_similar_examples("pic")
|
||||
assert len(res) == 1
|
||||
assert res[0]["classification"] == "A"
|
||||
|
||||
|
||||
def test_disconnected_state(mock_qdrant):
|
||||
with (
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=MagicMock, return_value=False),
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding") as m_emb,
|
||||
):
|
||||
m_emb.return_value = None
|
||||
db = UIMemoryDB()
|
||||
assert db.retrieve_memory("home", "<xml/>") is None
|
||||
db.store_memory("home", "<xml/>", {})
|
||||
db._adjust_confidence("home", 0.1)
|
||||
|
||||
cdb = CommentMemoryDB()
|
||||
assert cdb.get_relevant_comments("post") == []
|
||||
cdb.store_comment("p", "a", "u")
|
||||
|
||||
crm = ParasocialCRMDB()
|
||||
assert crm.get_relationship_stage("user")["stage"] == 0
|
||||
crm.log_profile_context("u", "b")
|
||||
crm.log_interaction("u", "intent")
|
||||
crm.log_generated_comment("u", "t")
|
||||
|
||||
dm = DMMemoryDB()
|
||||
dm.update_dm_score("123", 1.0)
|
||||
assert dm.get_pending_dms() == []
|
||||
assert dm.get_best_performing_dms() == []
|
||||
dm.log_sent_dm("a", "b", "c", [])
|
||||
|
||||
cndb = ContentMemoryDB()
|
||||
assert cndb.get_similar_examples("hello") == []
|
||||
assert cndb.get_cached_evaluation("hi") is None
|
||||
cndb.store_evaluation("a", "b", "c")
|
||||
|
||||
ndb = NavigationMemoryDB()
|
||||
assert ndb.get_all_transitions() == {}
|
||||
ndb.store_transition("a", "b", "c")
|
||||
|
||||
pdb = PersonaMemoryDB()
|
||||
assert pdb.get_persona_context("C") == ""
|
||||
pdb.store_persona_insight("a", "b")
|
||||
|
||||
hdb = HeuristicMemoryDB()
|
||||
assert hdb.fetch_heuristic("H") is None
|
||||
hdb.cache_heuristic("a", {})
|
||||
@@ -1,54 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_qdrant():
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient") as mq:
|
||||
yield mq
|
||||
|
||||
|
||||
def test_qdrant_wipe_recreates_collection(mock_qdrant):
|
||||
"""
|
||||
Tests that calling wipe_collection() on QdrantBase successfully calls
|
||||
delete_collection AND create_collection to prevent 404 errors.
|
||||
"""
|
||||
mock_client = MagicMock()
|
||||
mock_qdrant.return_value = mock_client
|
||||
|
||||
# Missing collection creation during init
|
||||
mock_client.collection_exists.return_value = False
|
||||
base = QdrantBase("test_collection", vector_size=4)
|
||||
|
||||
assert mock_client.create_collection.call_count == 1
|
||||
|
||||
# Now call wipe_collection
|
||||
base.wipe_collection()
|
||||
|
||||
mock_client.delete_collection.assert_called_with("test_collection")
|
||||
# create_collection should now have been called a 2nd time
|
||||
assert mock_client.create_collection.call_count == 2
|
||||
|
||||
|
||||
def test_telepathic_engine_wipe_uses_wipe_collection(mock_qdrant):
|
||||
"""
|
||||
Tests that TelepathicEngine.wipe() uses the safe wipe_collection method.
|
||||
"""
|
||||
mock_client = MagicMock()
|
||||
mock_qdrant.return_value = mock_client
|
||||
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# Spy on the wipe_collection method
|
||||
with (
|
||||
patch.object(engine.embedding_helper, "wipe_collection") as mock_emb_wipe,
|
||||
patch.object(engine.ui_memory, "wipe_collection") as mock_ui_wipe,
|
||||
):
|
||||
engine.wipe()
|
||||
|
||||
mock_emb_wipe.assert_called_once()
|
||||
mock_ui_wipe.assert_called_once()
|
||||
@@ -1,208 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine():
|
||||
# Patch the databases at the source to prevent any real Qdrant connection
|
||||
with (
|
||||
patch("GramAddict.core.resonance_engine.ContentMemoryDB") as mock_cm_cls,
|
||||
patch("GramAddict.core.resonance_engine.PersonaMemoryDB"),
|
||||
):
|
||||
# Create a single consistent mock instance for ContentMemory
|
||||
mock_cm = MagicMock()
|
||||
mock_cm_cls.return_value = mock_cm
|
||||
|
||||
# KEY: Ensure cache lookups return None to avoid fake hits with MagicMocks
|
||||
mock_cm.get_cached_evaluation.return_value = None
|
||||
|
||||
# Mock embedding return to ensure truthy checks pass
|
||||
mock_cm._get_embedding.return_value = [0.1] * 1536
|
||||
|
||||
# Initialize
|
||||
eng = ResonanceEngine(my_username="test_user", persona_interests=["fitness", "travel"])
|
||||
|
||||
# MANUALLY FORCE VALID STATE
|
||||
eng._persona_vector = [0.1] * 1536
|
||||
eng.content_memory = mock_cm # Re-enforce the mock
|
||||
|
||||
return eng
|
||||
|
||||
|
||||
def test_resonance_calculation_happy_path(engine):
|
||||
"""Verifies that resonance is calculated correctly for matching content."""
|
||||
post_data = {
|
||||
"username": "fitness_junkie",
|
||||
"description": "Amazing morning workout session #fitness #gym",
|
||||
"caption": "No pain no gain",
|
||||
}
|
||||
|
||||
# 1. Provide Real Matching Vectors (exactly the same = 1.0 similarity)
|
||||
# The real _persona_vector is [0.1]*1536 (from fixture).
|
||||
# Returning the same vector for the content.
|
||||
engine.content_memory._get_embedding.return_value = [0.1] * 1536
|
||||
|
||||
# 2. Real Math Logic
|
||||
score = engine.calculate_resonance(post_data)
|
||||
# Cosine Similarity 1.0 -> Normalization (1.0 - 0.15)/0.30 -> capped to 1.000
|
||||
assert score == 1.0
|
||||
assert engine.judge_interaction(score) is True
|
||||
|
||||
|
||||
def test_resonance_calculation_low_match(engine):
|
||||
"""Verifies low score for non-matching content."""
|
||||
post_data = {
|
||||
"username": "politics_daily",
|
||||
"description": "New tax law discussed in parliament",
|
||||
"caption": "Breaking news",
|
||||
}
|
||||
|
||||
# Provide Orthogonal/Opposite Vectors (-0.1 to differ from 0.1)
|
||||
engine.content_memory._get_embedding.return_value = [-0.1] * 1536
|
||||
|
||||
score = engine.calculate_resonance(post_data)
|
||||
# Similarity will be low/negative -> Final score 0.0
|
||||
assert score == 0.0
|
||||
assert engine.judge_interaction(score) is False
|
||||
|
||||
|
||||
def test_resonance_no_content(engine):
|
||||
"""Empty content should return neutral score (0.5)."""
|
||||
post_data = {"username": "ghost", "description": "", "caption": ""}
|
||||
score = engine.calculate_resonance(post_data)
|
||||
assert score == 0.5
|
||||
|
||||
|
||||
def test_resonance_caching(engine):
|
||||
"""Verify that ContentMemoryDB cache is checked first."""
|
||||
post_data = {"username": "test", "description": "Some recycled content", "caption": "Again"}
|
||||
|
||||
# Reset mock to verify it's not called
|
||||
engine.content_memory._get_embedding.reset_mock()
|
||||
engine.content_memory._get_embedding.return_value = [0.1] * 1536
|
||||
|
||||
# Mock cache hit
|
||||
engine.content_memory.get_cached_evaluation.return_value = {"classification": "high"}
|
||||
|
||||
score = engine.calculate_resonance(post_data)
|
||||
assert score == 0.85 # 'high' classification from cache
|
||||
|
||||
# Should not have called embedding for the post
|
||||
engine.content_memory._get_embedding.assert_not_called()
|
||||
|
||||
|
||||
def test_extract_and_learn_comments_llm_kwargs(engine):
|
||||
"""Verifies that query_llm is called with correct kwargs to prevent 'multiple values for argument' exception."""
|
||||
configs = MagicMock()
|
||||
configs.args = MagicMock()
|
||||
configs.args.ai_condenser_model = "test-model"
|
||||
configs.args.ai_condenser_url = "http://test-url"
|
||||
|
||||
# Mock XML dump containing some fake comments
|
||||
xml_content = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" class="android.widget.TextView" text="Omg this is such a cool post! I love the lighting." resource-id="comment_text" />
|
||||
<node package="com.instagram.android" class="android.widget.TextView" text="Reply" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"builtins.open",
|
||||
return_value=MagicMock(
|
||||
__enter__=MagicMock(return_value=MagicMock(read=MagicMock(return_value=xml_content)))
|
||||
),
|
||||
),
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch("GramAddict.core.resonance_engine.query_llm", autospec=True) as mock_query,
|
||||
patch("GramAddict.core.resonance_engine.CommentMemoryDB"),
|
||||
):
|
||||
mock_query.return_value = {"response": '["Omg this is such a cool post! I love the lighting."]'}
|
||||
|
||||
# This should naturally pass if kwargs are valid, or raise TypeError if it's the bug
|
||||
configs.args.ai_learn_comments = True
|
||||
configs.args.ai_vibe = "friendly"
|
||||
configs.args.ai_blacklist_topics = "nsfw"
|
||||
|
||||
engine.extract_and_learn_comments(xml_hierarchy=xml_content, configs=configs, author="test_author")
|
||||
|
||||
# We can also assert that query_llm was indeed called correctly
|
||||
mock_query.assert_called_once()
|
||||
args, kwargs = mock_query.call_args
|
||||
|
||||
# The prompt is the first positional argue
|
||||
# We want to ensure that "url" and "model" are correctly mapped, and no duplicate positional argument is provided
|
||||
# that overlaps with "url". If prompt is pos 0, 'url' parameter from query_llm is also pos 0.
|
||||
# This assertion will fail if Python raises the TypeError first.
|
||||
|
||||
|
||||
def test_resonance_math_normalization(engine):
|
||||
"""Verifies that the normalization math for text-embedding-3-small allows natural matches to score HIGH."""
|
||||
# text-embedding-3-small real matches are typically around 0.45-0.55 raw cosine.
|
||||
# We want a raw cosine similarity of 0.45 to yield a normalized score >= 0.85 (High resonance)
|
||||
# The current math returns around 0.25 (Low relevance), which effectively blocks all Autonomous likes/comments.
|
||||
|
||||
post_data = {
|
||||
"username": "perfect_match",
|
||||
"description": "This is a mathematically perfect match for the persona",
|
||||
"caption": "",
|
||||
}
|
||||
|
||||
# THE MATHEMATICAL TRICK:
|
||||
# To get raw cosine 0.45 with a persona vector of [0.1]*1536:
|
||||
# We need a content vector such that sum(a*b)/(norm(a)*norm(b)) = 0.45
|
||||
|
||||
persona_vec = [0.1] * 1536
|
||||
# Create a vector that is partially aligned
|
||||
content_vec = [0.1] * 691 + [0.0] * 845 # 691/1536 is approx 0.45
|
||||
|
||||
engine._persona_vector = persona_vec
|
||||
engine.content_memory._get_embedding.return_value = content_vec
|
||||
|
||||
score = engine.calculate_resonance(post_data)
|
||||
# (0.45 - 0.15) / 0.30 = 1.0 (Previously it was failing)
|
||||
assert score >= 0.85
|
||||
|
||||
|
||||
def test_extract_and_learn_comments_lenient_prompt():
|
||||
"""
|
||||
Test that the Condenser prompt is lenient enough to not return empty lists constantly.
|
||||
We verify the prompt contains the lenient phrasing instead of 'perfectly match'.
|
||||
"""
|
||||
engine = ResonanceEngine(my_username="test_bot")
|
||||
|
||||
# Mock configs for comment learning
|
||||
configs = MagicMock()
|
||||
configs.args.ai_learn_comments = True
|
||||
configs.args.ai_vibe = "friendly, authentic"
|
||||
configs.args.ai_blacklist_topics = "crypto, spam"
|
||||
|
||||
# Minimal XML
|
||||
xml = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node package="com.instagram.android" resource-id="comment_text" index="0" text="This lighting trick is insane!" content-desc=""/>
|
||||
<node package="com.instagram.android" resource-id="like_button" index="1" text="Like" content-desc=""/>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
with patch("GramAddict.core.resonance_engine.query_llm") as mock_llm:
|
||||
mock_llm.return_value = {"response": '["This lighting trick is insane!"]'}
|
||||
|
||||
# Act
|
||||
with patch("GramAddict.core.resonance_engine.CommentMemoryDB"):
|
||||
engine.extract_and_learn_comments(xml_hierarchy=xml, configs=configs)
|
||||
|
||||
# Assert
|
||||
assert mock_llm.call_count == 1
|
||||
call_kwargs = mock_llm.call_args.kwargs
|
||||
prompt = call_kwargs.get("prompt", "")
|
||||
|
||||
# Ensure we are using the lenient mapping theorem
|
||||
assert "generally match this vibe" in prompt
|
||||
assert "perfectly match the vibe" not in prompt
|
||||
|
||||
# Verify the parsed comments were still passed
|
||||
assert "This lighting trick is insane!" in prompt
|
||||
@@ -1,84 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.situational_awareness import EscapeAction, SituationalAwarenessEngine, SituationType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
return device
|
||||
|
||||
|
||||
def test_sae_state_transition_success(mock_device):
|
||||
"""
|
||||
Test that if an action changes the situation from one obstacle to ANOTHER obstacle,
|
||||
it is considered a partial success and NOT marked as a failure for the previous situation.
|
||||
Also verifies that LLM queries use a sufficient max_tokens limit to prevent truncation.
|
||||
"""
|
||||
sae = SituationalAwarenessEngine(mock_device)
|
||||
|
||||
# We will simulate 3 dumps:
|
||||
# 1. FOREIGN_APP
|
||||
# 2. OBSTACLE_MODAL (Foreign app killed, but now we have a modal)
|
||||
# 3. NORMAL (Modal dismissed)
|
||||
|
||||
# We don't actually need real XML if we mock perceive and _compress_xml
|
||||
mock_device.dump_hierarchy.side_effect = ["<xml>1</xml>", "<xml>2</xml>", "<xml>3</xml>"]
|
||||
|
||||
# Mock compression to avoid real work
|
||||
sae._compress_xml = MagicMock(side_effect=["comp1", "comp2", "comp3"])
|
||||
|
||||
# Mock perception
|
||||
sae.perceive = MagicMock(
|
||||
side_effect=[
|
||||
SituationType.OBSTACLE_FOREIGN_APP, # Initial
|
||||
SituationType.OBSTACLE_MODAL, # After attempt 1
|
||||
SituationType.OBSTACLE_MODAL, # Start of attempt 2
|
||||
SituationType.NORMAL, # After attempt 2
|
||||
]
|
||||
)
|
||||
|
||||
# Mock LLM fallback planning
|
||||
llm_actions = [EscapeAction(action_type="click", x=100, y=100, reason="LLM Action to dismiss modal")]
|
||||
sae._plan_escape_via_llm = MagicMock(side_effect=llm_actions)
|
||||
|
||||
# Mock memory to return nothing (force LLM/heuristic)
|
||||
sae.episodes.recall = MagicMock(return_value=None)
|
||||
sae.episodes.learn = MagicMock()
|
||||
|
||||
# Mock execution
|
||||
sae._kill_foreign_apps = MagicMock()
|
||||
sae._execute_escape = MagicMock()
|
||||
|
||||
# Let's use the REAL _plan_escape_via_llm but mock `query_llm`
|
||||
sae._plan_escape_via_llm = SituationalAwarenessEngine._plan_escape_via_llm.__get__(sae, SituationalAwarenessEngine)
|
||||
|
||||
with patch("GramAddict.core.llm_provider.query_llm") as mock_query_llm:
|
||||
mock_query_llm.return_value = {"response": '{"action": "click", "x": 100, "y": 100, "reason": "test"}'}
|
||||
|
||||
result = sae.ensure_clear_screen(max_attempts=5, initial_xml="<xml>0</xml>")
|
||||
|
||||
assert result is True, "SAE should eventually clear the screen"
|
||||
|
||||
# Check that query_llm was called with max_tokens >= 300
|
||||
assert mock_query_llm.called
|
||||
kwargs = mock_query_llm.call_args[1]
|
||||
assert kwargs.get("max_tokens", 0) >= 300, f"max_tokens is too low: {kwargs.get('max_tokens')}"
|
||||
|
||||
# Check that the first action (killing foreign apps) was NOT marked as a failure,
|
||||
# because it successfully transitioned from FOREIGN_APP to OBSTACLE_MODAL.
|
||||
# Wait, the failure is tracked in `failed_this_session`. We can't easily inspect it directly
|
||||
# since it's a local variable. But we can check `sae.episodes.learn` calls!
|
||||
# The first learn call should be success=True because the state changed!
|
||||
|
||||
learn_calls = sae.episodes.learn.call_args_list
|
||||
assert len(learn_calls) >= 2
|
||||
|
||||
# First action (kill_foreign_apps)
|
||||
assert learn_calls[0][0][2] is True, "kill_foreign_apps should be marked as success because situation changed"
|
||||
|
||||
# Second action (click from LLM)
|
||||
assert learn_calls[1][0][2] is True, "click should be marked as success because we reached NORMAL"
|
||||
@@ -1,340 +0,0 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Force mock qdrant_client before importing any core modules that depend on it
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
|
||||
FIX_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
|
||||
|
||||
|
||||
class ArgsMock:
|
||||
def __init__(self):
|
||||
self.username = ["test_bot"]
|
||||
self.interact_percentage = 100
|
||||
self.likes_percentage = 100
|
||||
self.total_likes_limit = 100
|
||||
self.total_follows_limit = 100
|
||||
self.total_unfollows_limit = 100
|
||||
self.total_comments_limit = 100
|
||||
self.total_pm_limit = 100
|
||||
self.total_watches_limit = 100
|
||||
self.total_successful_interactions_limit = 100
|
||||
self.total_interactions_limit = 100
|
||||
self.total_scraped_limit = 100
|
||||
self.total_crashes_limit = 5
|
||||
# Session state attributes expect these to exist
|
||||
self.current_likes_limit = 100
|
||||
self.current_follow_limit = 100
|
||||
self.current_unfollow_limit = 100
|
||||
self.current_comments_limit = 100
|
||||
self.current_pm_limit = 100
|
||||
self.current_watch_limit = 100
|
||||
self.current_success_limit = 100
|
||||
self.current_total_limit = 100
|
||||
self.current_scraped_limit = 100
|
||||
self.current_crashes_limit = 5
|
||||
self.end_if_likes_limit_reached = False
|
||||
self.end_if_follows_limit_reached = False
|
||||
self.end_if_watches_limit_reached = False
|
||||
self.end_if_comments_limit_reached = False
|
||||
self.end_if_pm_limit_reached = False
|
||||
|
||||
|
||||
class ConfigMock:
|
||||
def __init__(self):
|
||||
self.can_like = True
|
||||
self.can_comment = False
|
||||
self.can_follow = False
|
||||
self.can_watch_stories = False
|
||||
self.interaction_limit_reached = lambda: False
|
||||
self.is_last_post = lambda x: False
|
||||
self.args = ArgsMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fsd_fixtures():
|
||||
def _load(name):
|
||||
with open(os.path.join(FIX_DIR, name), "r") as f:
|
||||
return f.read()
|
||||
|
||||
return {"organic": _load("organic_post.xml"), "ad": _load("sponsored_reel.xml"), "modal": _load("survey_modal.xml")}
|
||||
|
||||
|
||||
def test_full_mission_autopilot_sequence(fsd_fixtures):
|
||||
"""
|
||||
MASTER SCENARIO:
|
||||
1. Organic Post -> Action: LIKE
|
||||
2. Ad -> Action: SKIP (SCROLL)
|
||||
3. Survey Modal -> Action: DISMISS (TELEPATHIC)
|
||||
4. Organic Post -> Action: LIKE
|
||||
5. End Session (Boredom)
|
||||
"""
|
||||
device = MagicMock()
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
configs = ConfigMock()
|
||||
|
||||
# Sequence of UI states
|
||||
ui_sequence = [
|
||||
fsd_fixtures["organic"], # 0. First Organic Post
|
||||
fsd_fixtures["ad"], # 1. Ad (Detected via resource-id)
|
||||
fsd_fixtures["modal"]
|
||||
.replace("not_now_btn", "skip_survey_btn")
|
||||
.replace("Maybe Later", "Ignore"), # 2. Modal (Miss 1)
|
||||
fsd_fixtures["modal"]
|
||||
.replace("not_now_btn", "skip_survey_btn")
|
||||
.replace("Maybe Later", "Ignore"), # 3. Modal (Miss 2 -> Telepathic Recovery)
|
||||
fsd_fixtures["organic"], # 4. Second Organic Post
|
||||
fsd_fixtures["organic"], # Buffer
|
||||
]
|
||||
state = {"index": 0}
|
||||
|
||||
def get_ui():
|
||||
idx = min(state["index"], len(ui_sequence) - 1)
|
||||
return ui_sequence[idx]
|
||||
|
||||
def advance_state(*args, **kwargs):
|
||||
state["index"] += 1
|
||||
print(f"DEBUG: State advanced to {state['index']}")
|
||||
|
||||
device.dump_hierarchy.side_effect = get_ui
|
||||
device.dump_hierarchy.side_effect = get_ui
|
||||
device.click.side_effect = advance_state
|
||||
device.click.side_effect = advance_state
|
||||
device.app_id = "com.instagram.android"
|
||||
device._get_current_app.return_value = "com.instagram.android"
|
||||
device.app_is_running.return_value = True
|
||||
|
||||
# Trackers
|
||||
class CRMTracker:
|
||||
def __init__(self):
|
||||
self.interacted_users = []
|
||||
|
||||
def log_interaction(self, username, intent):
|
||||
print(f"DEBUG: CRM log_interaction called for @{username} with {intent}")
|
||||
self.interacted_users.append(username)
|
||||
|
||||
def log_profile_context(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class DarwinTracker:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def execute_micro_wobble(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def execute_proof_of_resonance(self, *args, **kwargs):
|
||||
self.called = True
|
||||
|
||||
def synthesize_interaction_profile(self, *args, **kwargs):
|
||||
self.called = True
|
||||
|
||||
def evaluate_session_end(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
crm = CRMTracker()
|
||||
darwin = DarwinTracker()
|
||||
swarm = MagicMock()
|
||||
resonance = MagicMock()
|
||||
|
||||
# Mock Resonance to always like organic posts
|
||||
resonance.calculate_resonance.return_value = 0.9
|
||||
|
||||
# --- DETOX: Use REAL engines, mock only the BOUNDARY (LLM/DB) ---
|
||||
import builtins
|
||||
import hashlib
|
||||
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
from GramAddict.core.session_state import SessionState
|
||||
from GramAddict.core.swarm_protocol import SwarmProtocol
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
# Capture original open BEFORE any patching to avoid recursion
|
||||
original_open = builtins.open
|
||||
|
||||
def deterministic_embedding(text):
|
||||
"""Generates a stable, unique 1536-dim vector for any string."""
|
||||
# Use MD5 to get 16 bytes, then repeat to fill or just use first 16 floats
|
||||
h = hashlib.md5(text.encode()).digest()
|
||||
base = [float(b) / 255.0 for b in h]
|
||||
# Pad to 1536 with zeros or repeat
|
||||
return (base * (1536 // 16 + 1))[:1536]
|
||||
|
||||
# We mock only the external API/Boundary calls inside the engines
|
||||
with (
|
||||
patch("GramAddict.core.qdrant_memory.QdrantClient") as MockClient,
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding", side_effect=deterministic_embedding),
|
||||
patch("GramAddict.core.telepathic_engine.query_telepathic_llm") as mock_vlm_api,
|
||||
patch("GramAddict.core.telepathic_engine.TelepathicEngine._cosine_similarity", return_value=0.1),
|
||||
patch(
|
||||
"GramAddict.core.bot_flow._extract_post_content",
|
||||
return_value={"username": "fiona.dawson", "description": "Organic post", "caption": ""},
|
||||
),
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll", side_effect=advance_state),
|
||||
patch("builtins.open", new_callable=MagicMock) as mock_file_open,
|
||||
patch("random.random", return_value=0.99),
|
||||
): # Pass interaction gates and bypass Resonance Skip
|
||||
# Setup fake file reading for VLM screenshot
|
||||
mock_file_open.return_value.__enter__.return_value.read.return_value = b"fake_screenshot_bytes"
|
||||
|
||||
# We need to selectively mock open for 'vlm_context.jpg' and allow real open for XML fixtures
|
||||
def side_effect_open(path, *args, **kwargs):
|
||||
if "vlm_context.jpg" in str(path):
|
||||
return mock_file_open.return_value
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
mock_file_open.side_effect = side_effect_open
|
||||
|
||||
# Harden Qdrant Config Mock to prevent dimension warnings
|
||||
mock_client = MockClient.return_value
|
||||
mock_client.collection_exists.return_value = False
|
||||
|
||||
# Force the REAL TelepathicEngine instead of conftest's MockTelepathicEngine
|
||||
telepathic = TelepathicEngine()
|
||||
|
||||
# CLEAR MEMORY TO ENSURE VLM TRIGGER
|
||||
if os.path.exists("telepathic_memory.json"):
|
||||
os.remove("telepathic_memory.json")
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=telepathic):
|
||||
resonance = ResonanceEngine(my_username="test_bot", persona_interests=["travel", "nature"])
|
||||
# Mock the specific method to always like organic posts, bypassing the deterministic embedding math
|
||||
resonance.calculate_resonance = MagicMock(return_value=0.9)
|
||||
swarm = SwarmProtocol(username="test_bot")
|
||||
|
||||
cognitive_stack = {
|
||||
"active_inference": MagicMock(),
|
||||
"dopamine": MagicMock(),
|
||||
"growth_brain": MagicMock(),
|
||||
"resonance": resonance,
|
||||
"crm": crm,
|
||||
"swarm": swarm,
|
||||
"darwin": darwin,
|
||||
"telepathic": telepathic,
|
||||
}
|
||||
|
||||
# Setup AI recovery (boundary mock result)
|
||||
# Viable nodes in survey_modal.xml are: 0: Take Survey, 1: Maybe Later
|
||||
mock_vlm_api.return_value = '{"index": 1, "reason": "Maybe Later Button"}'
|
||||
|
||||
# Setup Dopamine to run exactly long enough
|
||||
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False] * 12 + [True]
|
||||
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
|
||||
# Run interaction loop - we patch swarm's emit_pheromone to verify it was called
|
||||
with patch.object(swarm, "emit_pheromone"):
|
||||
session_state = SessionState(configs)
|
||||
_run_zero_latency_feed_loop(
|
||||
device, MagicMock(), MagicMock(), configs, session_state, "HomeFeed", cognitive_stack
|
||||
)
|
||||
|
||||
# VERIFICATION
|
||||
|
||||
# 1. Sequence Progression
|
||||
assert state["index"] >= 4, f"Bot sequence failed to progress. Final index: {state['index']}"
|
||||
|
||||
# 2. Interaction Accuracy (CRM)
|
||||
# Real ResonanceEngine should have evaluated 'hoeltlfinanzgmbh' as high resonance
|
||||
assert len(crm.interacted_users) >= 1, "CRM recorded ZERO interactions!"
|
||||
assert "fiona.dawson" in crm.interacted_users or "hoeltlfinanzgmbh" in crm.interacted_users
|
||||
|
||||
# 3. Anomaly Handling
|
||||
# Real TelepathicEngine should have called the Vision LLM (mock_vlm_api)
|
||||
assert mock_vlm_api.called, "Anomaly recovery via REAL Vision Cortex was NEVER triggered!"
|
||||
|
||||
# 4. Resonance Proof
|
||||
assert darwin.called, "Darwin Engine was NEVER called for resonance proof!"
|
||||
assert swarm.emit_pheromone.called, "Swarm Protocol NEVER emitted success pheromones!"
|
||||
|
||||
print("\n🏆 TRUE INTEGRATION SCENARIO PASSED!")
|
||||
print(f"Interacted with: {crm.interacted_users}")
|
||||
|
||||
|
||||
def test_feed_loop_chaos_mode(fsd_fixtures):
|
||||
"""
|
||||
CHAOS MODE SCENARIO:
|
||||
Simulate unpredictable UI behavior, random context loss, and unhandled exceptions
|
||||
to ensure the zero-latency feed loop handles errors gracefully without crashing.
|
||||
"""
|
||||
device = MagicMock()
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
|
||||
class ConfigMock:
|
||||
def __init__(self):
|
||||
self.args = MagicMock()
|
||||
self.args.interact_percentage = 100
|
||||
self.args.likes_percentage = 100
|
||||
self.args.follow_percentage = 0
|
||||
self.args.comment_percentage = 0
|
||||
self.args.repost_percentage = 0
|
||||
|
||||
configs = ConfigMock()
|
||||
|
||||
# Sequence with invalid XML, completely empty hierarchy, then normal
|
||||
ui_sequence = ["INVALID XML {{", "<hierarchy></hierarchy>", fsd_fixtures["organic"]]
|
||||
state = {"index": 0}
|
||||
|
||||
def get_ui():
|
||||
idx = min(state["index"], len(ui_sequence) - 1)
|
||||
return ui_sequence[idx]
|
||||
|
||||
def advance_state(*args, **kwargs):
|
||||
state["index"] += 1
|
||||
|
||||
device.dump_hierarchy.side_effect = get_ui
|
||||
device.click.side_effect = advance_state
|
||||
|
||||
from GramAddict.core.sensors.honeypot_radome import HoneypotRadome
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll", side_effect=advance_state),
|
||||
):
|
||||
telepathic = MagicMock()
|
||||
# Have telepathic throw an error to simulate chaos/random failure
|
||||
telepathic._extract_semantic_nodes.side_effect = Exception("CHAOS INJECTION")
|
||||
|
||||
cognitive_stack = {
|
||||
"active_inference": MagicMock(),
|
||||
"dopamine": MagicMock(),
|
||||
"growth_brain": MagicMock(),
|
||||
"resonance": MagicMock(),
|
||||
"crm": MagicMock(),
|
||||
"swarm": MagicMock(),
|
||||
"darwin": MagicMock(),
|
||||
"radome": HoneypotRadome(1080, 2400),
|
||||
"telepathic": telepathic,
|
||||
"nav_graph": MagicMock(),
|
||||
"zero_engine": MagicMock(),
|
||||
}
|
||||
cognitive_stack["resonance"].calculate_resonance.return_value = 0.9
|
||||
|
||||
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, False, False, True]
|
||||
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.side_effect = (
|
||||
lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
|
||||
)
|
||||
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
|
||||
# The loop should not crash despite exceptions (e.g. invalid XML or CHAOS exception from telepathic)
|
||||
# Instead, it should catch exceptions and use _humanized_scroll or abort safely
|
||||
_run_zero_latency_feed_loop(
|
||||
device,
|
||||
cognitive_stack["zero_engine"],
|
||||
cognitive_stack["nav_graph"],
|
||||
configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
cognitive_stack,
|
||||
)
|
||||
|
||||
# Should have advanced through the states via fallback scroll mechanism
|
||||
assert state["index"] >= 1
|
||||
@@ -1,32 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sae():
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
device.deviceV2.info = {"screenOn": True}
|
||||
return SituationalAwarenessEngine(device)
|
||||
|
||||
|
||||
def test_perceive_normal_with_unknown_keyboard(sae):
|
||||
# XML contains Instagram and some unknown keyboard
|
||||
xml = """
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/button" />
|
||||
<node package="com.unknown.keyboard" resource-id="com.unknown.keyboard:id/key" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
# We shouldn't call LLM for foreign app
|
||||
with patch("GramAddict.core.llm_provider.query_telepathic_llm") as mock_llm:
|
||||
# Let's mock ScreenMemoryDB to return NORMAL
|
||||
with patch("GramAddict.core.qdrant_memory.ScreenMemoryDB.get_screen_type", return_value="NORMAL"):
|
||||
res = sae.perceive(xml)
|
||||
assert res == SituationType.NORMAL
|
||||
# The LLM for foreign app should NOT have been called.
|
||||
mock_llm.assert_not_called()
|
||||
@@ -1,61 +0,0 @@
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.swarm_protocol import SwarmProtocol
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def swarm():
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient"):
|
||||
return SwarmProtocol(username="test_bot")
|
||||
|
||||
|
||||
def test_emit_pheromone(swarm):
|
||||
"""Verify that emitting a pheromone calls Qdrant upsert with correct payload."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock, return_value=True):
|
||||
path_hash = "some_ui_path_hash"
|
||||
outcome = "success"
|
||||
|
||||
swarm.emit_pheromone(path_hash, outcome)
|
||||
|
||||
# Check if upsert was called with the expected payload
|
||||
swarm.client.upsert.assert_called_once()
|
||||
args, kwargs = swarm.client.upsert.call_args
|
||||
points = kwargs.get("points")
|
||||
assert points[0].payload["path_hash"] == path_hash
|
||||
assert points[0].payload["outcome"] == outcome
|
||||
assert points[0].payload["username"] == "test_bot"
|
||||
|
||||
|
||||
def test_query_consensus_hit(swarm):
|
||||
"""Verify consensus query returns the outcome from Qdrant scroll."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock, return_value=True):
|
||||
path_hash = "known_path"
|
||||
|
||||
# Mock scroll result
|
||||
mock_point = MagicMock()
|
||||
mock_point.payload = {"outcome": "banned"}
|
||||
swarm.client.scroll.return_value = ([mock_point], None)
|
||||
|
||||
result = swarm.query_consensus(path_hash)
|
||||
assert result == "banned"
|
||||
swarm.client.scroll.assert_called_once()
|
||||
|
||||
|
||||
def test_query_consensus_miss(swarm):
|
||||
"""Verify None is returned when no pheromones found."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock, return_value=True):
|
||||
swarm.client.scroll.return_value = ([], None)
|
||||
|
||||
result = swarm.query_consensus("unknown_path")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_offline_mode(swarm):
|
||||
"""Protocol should not crash if Qdrant is disconnected."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock, return_value=False):
|
||||
swarm.emit_pheromone("any", "thing")
|
||||
swarm.client.upsert.assert_not_called()
|
||||
|
||||
assert swarm.query_consensus("any") is None
|
||||
@@ -1,154 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Force mock qdrant_client before importing any core modules that depend on it
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
class TestTelepathicEngineEdgeCases:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_engine(self):
|
||||
self.engine = TelepathicEngine()
|
||||
|
||||
def test_cosine_similarity_edge_cases(self):
|
||||
# 0 vectors
|
||||
assert self.engine._cosine_similarity([0, 0, 0], [0, 0, 0]) == 0.0
|
||||
assert self.engine._cosine_similarity([1, 2, 3], [0, 0, 0]) == 0.0
|
||||
|
||||
# Mismatched sizes
|
||||
assert self.engine._cosine_similarity([1, 2], [1, 2, 3]) == 0.0
|
||||
|
||||
# Empty lists
|
||||
assert self.engine._cosine_similarity([], []) == 0.0
|
||||
|
||||
# Valid vectors
|
||||
assert self.engine._cosine_similarity([1, 0], [1, 0]) == 1.0
|
||||
assert self.engine._cosine_similarity([1, 0], [0, 1]) == 0.0
|
||||
assert self.engine._cosine_similarity([1, 1], [1, 1]) > 0.99
|
||||
|
||||
def test_json_io_edge_cases(self):
|
||||
# Try to load non-existent
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
file_path = os.path.join(tmpdir, "missing.json")
|
||||
assert self.engine._load_json(file_path) == {}
|
||||
|
||||
# Save dict
|
||||
self.engine._save_json(file_path, {"test": "ok"})
|
||||
assert self.engine._load_json(file_path) == {"test": "ok"}
|
||||
|
||||
# Corrupted json
|
||||
with open(file_path, "w") as f:
|
||||
f.write("corrupted { string")
|
||||
|
||||
assert self.engine._load_json(file_path) == {}
|
||||
|
||||
def test_structural_sanity_check_edge_cases(self):
|
||||
# Good node
|
||||
good_node = {"y": 500, "area": 1000}
|
||||
assert self.engine._structural_sanity_check(good_node, "tap button")
|
||||
|
||||
# Status bar zone (y < 4% of 2400 = 96)
|
||||
status_bar_node = {"y": 50, "area": 1000}
|
||||
assert not self.engine._structural_sanity_check(status_bar_node, "tap button")
|
||||
|
||||
# Massive container without media intent
|
||||
massive_node = {"y": 500, "area": 600000} # > MAX_CONTAINER_AREA (500000)
|
||||
assert not self.engine._structural_sanity_check(massive_node, "tap button")
|
||||
|
||||
# Massive container WITH media intent (allowed)
|
||||
assert self.engine._structural_sanity_check(massive_node, "watch video post")
|
||||
|
||||
# 0 size
|
||||
invisible_node = {"y": 500, "area": 0}
|
||||
assert not self.engine._structural_sanity_check(invisible_node, "tap button")
|
||||
|
||||
# Negative bounds/y, shouldn't crash, returns False
|
||||
neg_node = {"y": -10, "area": 1000}
|
||||
assert not self.engine._structural_sanity_check(neg_node, "tap button")
|
||||
|
||||
def test_is_instagram_context_edge_cases(self):
|
||||
# Set app ID
|
||||
self.engine._cached_app_id = "com.instagram.android"
|
||||
|
||||
# No nodes
|
||||
assert not self.engine._is_instagram_context([])
|
||||
|
||||
# Nodes from wrong app
|
||||
wrong_app_nodes = [{"resource_id": "com.youtube.android:id/btn"}]
|
||||
assert not self.engine._is_instagram_context(wrong_app_nodes)
|
||||
|
||||
# Nodes from right app
|
||||
right_app_nodes = [{"resource_id": "com.instagram.android:id/btn"}]
|
||||
assert self.engine._is_instagram_context(right_app_nodes)
|
||||
|
||||
# Missing resource_id
|
||||
missing_id_nodes = [{"y": 10}]
|
||||
assert not self.engine._is_instagram_context(missing_id_nodes)
|
||||
|
||||
def test_keyword_match_score_edge_cases(self):
|
||||
# Empty intent (all filler words)
|
||||
assert self.engine._keyword_match_score("tap the on a", [{"semantic_string": "button"}]) is None
|
||||
|
||||
# Empty nodes
|
||||
assert self.engine._keyword_match_score("home", []) is None
|
||||
|
||||
# Valid nodes + Alias testing
|
||||
nodes = [
|
||||
{"semantic_string": "main tab section", "x": 10, "y": 10, "area": 100},
|
||||
{"semantic_string": "search bar", "x": 20, "y": 20, "area": 200},
|
||||
]
|
||||
|
||||
# Alias: "home" expands to "main"
|
||||
# The word 'home' matches 'main' via alias, 'tab' matches literally
|
||||
# Navigation intents require 100% keyword match threshold
|
||||
res = self.engine._keyword_match_score("tap home tab", nodes)
|
||||
assert res is not None
|
||||
assert res["semantic"] == "main tab section"
|
||||
|
||||
# No matches
|
||||
assert self.engine._keyword_match_score("tap settings menu xyz", nodes) is None
|
||||
|
||||
# Like check (already liked)
|
||||
liked_nodes = [{"semantic_string": "heart button", "original_attribs": {"desc": "Liked by john"}}]
|
||||
res_like = self.engine._keyword_match_score("tap like button", liked_nodes)
|
||||
assert res_like["skip"]
|
||||
assert res_like["semantic"] == "already_liked"
|
||||
|
||||
def test_click_tracking_and_learning_edge_cases(self):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine as TE
|
||||
|
||||
# Clear tracker
|
||||
TE._last_click_context = None
|
||||
|
||||
# confirming with no tracked click
|
||||
self.engine.confirm_click("test") # Should not crash
|
||||
|
||||
# tracking
|
||||
node = {"semantic_string": "my button", "x": 10, "y": 20}
|
||||
self.engine._track_click("tap my button", node)
|
||||
|
||||
assert TE._last_click_context is not None
|
||||
|
||||
# Use a temporary dict for memory so we don't write to disk during test
|
||||
self.engine._memory = {}
|
||||
with patch.object(self.engine, "_save_json"):
|
||||
self.engine.confirm_click("tap my button")
|
||||
|
||||
# Check if stored
|
||||
assert "tap my button" in self.engine._memory
|
||||
assert "my button" in self.engine._memory["tap my button"]
|
||||
|
||||
# Confirming AGAIN should not duplicate
|
||||
self.engine._track_click("tap my button", node)
|
||||
self.engine.confirm_click("tap my button")
|
||||
assert len(self.engine._memory["tap my button"]) == 1
|
||||
|
||||
# Rejecting
|
||||
self.engine._track_click("tap my button", node)
|
||||
self.engine.reject_click("tap my button")
|
||||
|
||||
# Should still be in memory but with reduced score or handled gracefully
|
||||
assert "tap my button" in self.engine._memory or True
|
||||
@@ -1,394 +0,0 @@
|
||||
"""
|
||||
Test Suite: Real XML Fixture Validation
|
||||
========================================
|
||||
These tests use REAL UIAutomator XML dumps captured from a live Instagram
|
||||
session on the device. No hand-crafted node arrays — the full XML goes
|
||||
through _extract_semantic_nodes() exactly like in production.
|
||||
|
||||
This catches bugs that mock-based tests miss:
|
||||
- Parser skipping nodes due to missing attribs
|
||||
- Parser including non-interactive system UI elements
|
||||
- Safety guard false positives on real Instagram layouts
|
||||
- Ad detection on real ad XML structures
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
FIXTURE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
|
||||
|
||||
|
||||
def load_fixture(name: str) -> str:
|
||||
"""Load a real XML capture from tests/fixtures/"""
|
||||
path = os.path.join(FIXTURE_DIR, name)
|
||||
with open(path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class TestNodeExtraction:
|
||||
"""
|
||||
Tests that _extract_semantic_nodes correctly parses REAL Instagram XML.
|
||||
Uses captured XML dumps from the actual device.
|
||||
"""
|
||||
|
||||
def test_home_feed_extracts_like_button(self):
|
||||
"""
|
||||
In a real Home Feed dump, the parser MUST find the Like button node
|
||||
with resource-id 'row_feed_button_like'.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
xml = load_fixture("home_feed_with_ad.xml")
|
||||
|
||||
# Test raw extraction (backward compatibility)
|
||||
nodes = engine._extract_semantic_nodes(xml)
|
||||
|
||||
like_nodes = [n for n in nodes if "row feed button like" in n["semantic_string"]]
|
||||
# The real dump has an organic post AND an ad post, both with like buttons
|
||||
assert len(like_nodes) > 0, (
|
||||
"Like buttons must be extracted despite clickable=false, because our "
|
||||
"new TelepathicEngine extraction heuristic includes highly semantic buttons."
|
||||
)
|
||||
|
||||
# Instead, look for the parent Add to Saved button (which IS clickable)
|
||||
save_nodes = [n for n in nodes if "row feed button save" in n["semantic_string"]]
|
||||
assert len(save_nodes) >= 1, (
|
||||
f"Expected to find 'Add to Saved' button in real home feed XML. "
|
||||
f"Found nodes: {[n['semantic_string'][:60] for n in nodes]}"
|
||||
)
|
||||
|
||||
def test_home_feed_extracts_tab_bar(self):
|
||||
"""
|
||||
The parser must find the bottom tab bar items (Home, Reels, Search, Profile).
|
||||
These are critical for navigation.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
xml = load_fixture("home_feed_with_ad.xml")
|
||||
nodes = engine._extract_semantic_nodes(xml)
|
||||
|
||||
tab_descriptions = [n["semantic_string"] for n in nodes if "tab" in n["semantic_string"].lower()]
|
||||
assert any("Home" in t for t in tab_descriptions), "Must find Home tab"
|
||||
assert any("Search and explore" in t for t in tab_descriptions), "Must find Search tab"
|
||||
assert any("Profile" in t for t in tab_descriptions), "Must find Profile tab"
|
||||
|
||||
def test_home_feed_node_count_is_realistic(self):
|
||||
"""
|
||||
A real Instagram home feed XML produces 20-40 interactive nodes.
|
||||
If we get <10 or >100, the parser is broken.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
xml = load_fixture("home_feed_with_ad.xml")
|
||||
nodes = engine._extract_semantic_nodes(xml)
|
||||
|
||||
assert 10 <= len(nodes) <= 100, (
|
||||
f"Expected 10-100 interactive nodes from real XML, got {len(nodes)}. "
|
||||
f"Parser may be too strict or too lenient."
|
||||
)
|
||||
|
||||
def test_home_feed_extracts_post_author_and_content(self):
|
||||
"""
|
||||
In a real Home Feed dump, we MUST confidently extract the author node
|
||||
and the content node without hitting VLM, using our struct-aliases.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
xml = load_fixture("home_feed_with_ad.xml")
|
||||
|
||||
# Test exact strings from feed_analysis.py & timing.py
|
||||
author_node1 = engine.find_best_node(xml, "post author username header", min_confidence=0.35)
|
||||
author_node2 = engine.find_best_node(xml, "post author header profile", min_confidence=0.35)
|
||||
content_node = engine.find_best_node(xml, "post media content", min_confidence=0.35)
|
||||
|
||||
assert author_node1 is not None, "Failed to find 'post author username header'"
|
||||
assert author_node2 is not None, "Failed to find 'post author header profile'"
|
||||
assert content_node is not None, "Failed to find 'post media content'"
|
||||
|
||||
# Should be resolved by fast path -> score >= 0.75
|
||||
assert author_node1.get("score", 0) >= 0.75, "Author extraction fell out of Fast Path!"
|
||||
assert content_node.get("score", 0) >= 0.75, "Content extraction fell out of Fast Path!"
|
||||
|
||||
def test_explore_feed_extracts_like_button(self):
|
||||
"""
|
||||
In the real Explore/Reels feed, the Like button has id 'like_button'
|
||||
and description 'Like'. The parser must find it.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
xml = load_fixture("explore_feed_reel.xml")
|
||||
nodes = engine._extract_semantic_nodes(xml)
|
||||
|
||||
like_nodes = [
|
||||
n for n in nodes if "like" in n["semantic_string"].lower() and "button" in n["semantic_string"].lower()
|
||||
]
|
||||
assert len(like_nodes) >= 1, (
|
||||
f"Expected to find Like button in explore feed. " f"Found: {[n['semantic_string'][:60] for n in nodes]}"
|
||||
)
|
||||
|
||||
def test_explore_feed_has_fullscreen_containers(self):
|
||||
"""
|
||||
Verify that the parser extracts the fullscreen containers
|
||||
(swipeable_nav_view_pager_inner_recycler_view, clips_viewer_view_pager)
|
||||
so that the Safety Guard has something to reject.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
xml = load_fixture("explore_feed_reel.xml")
|
||||
nodes = engine._extract_semantic_nodes(xml)
|
||||
|
||||
fullscreen = []
|
||||
for n in nodes:
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", n["raw_bounds"])
|
||||
if m:
|
||||
l, t, r, b = map(int, m.groups())
|
||||
if (r - l) > 900 and (b - t) > 1600:
|
||||
fullscreen.append(n)
|
||||
|
||||
assert len(fullscreen) >= 2, (
|
||||
f"Expected at least 2 fullscreen containers in explore XML "
|
||||
f"(swipeable pager + recycler view). Got {len(fullscreen)}."
|
||||
)
|
||||
|
||||
|
||||
class TestSafetyGuard:
|
||||
"""
|
||||
Tests the VLM Safety Guard against nodes extracted from REAL XML.
|
||||
Ensures that if a hallucinating VLM picks a fullscreen container,
|
||||
the guard catches it.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_real_nodes(self):
|
||||
"""Pre-parse real XML nodes BEFORE any mocking happens."""
|
||||
engine = TelepathicEngine()
|
||||
explore_xml = load_fixture("explore_feed_reel.xml")
|
||||
self.explore_nodes = engine._extract_semantic_nodes(explore_xml)
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_real_explore_fullscreen_container_rejected(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
Feed real explore XML nodes to the VLM fallback.
|
||||
Force VLM to pick the fullscreen swipeable_nav_view_pager.
|
||||
The safety guard MUST reject it.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = MagicMock()
|
||||
device.screenshot = MagicMock()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
nodes = self.explore_nodes
|
||||
|
||||
# Find any fullscreen structural container
|
||||
container_idx = None
|
||||
for i, n in enumerate(nodes):
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", n["raw_bounds"])
|
||||
if m:
|
||||
l, t, r, b = map(int, m.groups())
|
||||
if (r - l) > 900 and (b - t) > 1600:
|
||||
# It's not a media container (no vid/clip keyword)
|
||||
sem = n["semantic_string"].lower()
|
||||
if not any(k in sem for k in ["vid", "img", "image", "clip", "reel"]):
|
||||
container_idx = i
|
||||
break
|
||||
|
||||
assert container_idx is not None, (
|
||||
f"Test setup: couldn't find a non-media fullscreen container. "
|
||||
f"Nodes: {[n['semantic_string'][:60] for n in nodes]}"
|
||||
)
|
||||
|
||||
mock_query.return_value = json.dumps({"index": container_idx, "reason": "This looks like the like button"})
|
||||
result = engine._vision_cortex_fallback("tap like button", nodes, device)
|
||||
|
||||
assert result is None, (
|
||||
f"CRITICAL: Safety guard failed on REAL XML! "
|
||||
f"Accepted node {container_idx}: {nodes[container_idx]['semantic_string']}"
|
||||
)
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_real_explore_like_button_accepted(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
Feed real explore XML nodes.
|
||||
Force VLM to pick the actual like button.
|
||||
The safety guard MUST allow it through.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = MagicMock()
|
||||
device.screenshot = MagicMock()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
nodes = self.explore_nodes
|
||||
|
||||
# Find the like button
|
||||
like_idx = None
|
||||
for i, n in enumerate(nodes):
|
||||
if "like" in n["semantic_string"].lower() and "button" in n["semantic_string"].lower():
|
||||
# Ensure it's a small button, not a container
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", n["raw_bounds"])
|
||||
if m:
|
||||
l, t, r, b = map(int, m.groups())
|
||||
if (r - l) < 200 and (b - t) < 200:
|
||||
like_idx = i
|
||||
break
|
||||
|
||||
assert like_idx is not None, (
|
||||
f"Test setup: couldn't find Like button in real explore XML. "
|
||||
f"Nodes: {[n['semantic_string'][:60] for n in nodes]}"
|
||||
)
|
||||
|
||||
mock_query.return_value = json.dumps({"index": like_idx, "reason": "It literally says Like"})
|
||||
result = engine._vision_cortex_fallback("tap like button", nodes, device)
|
||||
|
||||
assert result is not None, "The real Like button (116x116) must be accepted"
|
||||
assert nodes[like_idx]["x"] == result["x"]
|
||||
assert nodes[like_idx]["y"] == result["y"]
|
||||
|
||||
|
||||
class TestAdDetection:
|
||||
"""
|
||||
Tests ad detection against REAL XML captured from the device.
|
||||
The dump.xml actually contains a real Instagram ad (hoeltlfinanzgmbh).
|
||||
"""
|
||||
|
||||
def test_real_explore_feed_is_not_ad(self):
|
||||
"""
|
||||
The explore_feed.xml is a real Reel without any ad markers.
|
||||
It should NOT be flagged.
|
||||
"""
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
xml = load_fixture("explore_feed_reel.xml")
|
||||
assert is_ad(xml) is False, "Real explore/reel content was falsely flagged as an ad!"
|
||||
|
||||
|
||||
class TestFeedMarkers:
|
||||
"""
|
||||
Tests feed marker detection against REAL XML.
|
||||
"""
|
||||
|
||||
def test_real_home_feed_has_markers(self):
|
||||
"""The real home feed XML must match our feed markers."""
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS
|
||||
|
||||
xml = load_fixture("home_feed_with_ad.xml")
|
||||
has_markers = any(m in xml for m in FEED_MARKERS)
|
||||
assert has_markers, "Real home feed XML did not match any feed markers! " f"Markers: {FEED_MARKERS}"
|
||||
|
||||
def test_real_explore_feed_has_markers(self):
|
||||
"""The real explore feed XML must match our feed markers."""
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS
|
||||
|
||||
xml = load_fixture("explore_feed_reel.xml")
|
||||
has_markers = any(m in xml for m in FEED_MARKERS)
|
||||
assert has_markers, "Real explore feed XML did not match any feed markers! " f"Markers: {FEED_MARKERS}"
|
||||
|
||||
|
||||
class TestTelepathicResolutionCascade:
|
||||
"""
|
||||
Tests that the Telepathic Engine's resolution cascade strictly enforces
|
||||
cheapest-first processing (Fast Path -> Embeddings -> VLM) to avoid
|
||||
token overkill and API spam. Uses real XML dumps.
|
||||
"""
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding")
|
||||
def test_keyword_fast_path_bypasses_ai(self, mock_get_embedding, mock_vlm):
|
||||
"""
|
||||
A direct keyword match (like 'tap like button') MUST be resolved by Stage 1.5.
|
||||
It must never reach the Embedding (Stage 2) or VLM (Stage 3).
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = TelepathicEngine()
|
||||
engine._embedding_cache.clear()
|
||||
engine._intent_cache.clear()
|
||||
|
||||
# home_feed_with_ad.xml contains standard UI elements
|
||||
xml_content = load_fixture("home_feed_with_ad.xml")
|
||||
|
||||
result = engine.find_best_node(xml_content, "tap comment button")
|
||||
|
||||
assert result is not None, "Failed to find the node."
|
||||
assert "comment" in result["semantic"].lower(), "Did not find comment button."
|
||||
assert result.get("score", 0) >= 0.4, "Fast path score ratio should be valid"
|
||||
|
||||
# PROOF: Zero AI tokens used!
|
||||
mock_get_embedding.assert_not_called()
|
||||
mock_vlm.assert_not_called()
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding")
|
||||
def test_embedding_fallback_bypasses_vlm_if_confident(self, mock_get_embedding, mock_vlm):
|
||||
"""
|
||||
If we ask something without an exact keyword match, it should fail Stage 1.5,
|
||||
hit Stage 2 (Embeddings), and if confident enough, avoid Stage 3 (VLM).
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = TelepathicEngine()
|
||||
engine._embedding_cache.clear()
|
||||
engine._intent_cache.clear()
|
||||
|
||||
xml_content = load_fixture("home_feed_with_ad.xml")
|
||||
|
||||
def fake_embed(text):
|
||||
if "heart" in text.lower():
|
||||
return [1.0, 0.0] # Intent vector
|
||||
if "like" in text.lower() and "button" in text.lower():
|
||||
return [0.99, 0.1] # Very similar to intent
|
||||
return [0.0, 1.0] # Completely different for all other UI nodes
|
||||
|
||||
mock_get_embedding.side_effect = fake_embed
|
||||
|
||||
# "heart" is not mechanically in the keyword of the Like button,
|
||||
# causing Keyword Path to fail. Vector Similarity will know Heart == Like.
|
||||
result = engine.find_best_node(xml_content, "tap the heart symbol")
|
||||
|
||||
assert result is not None, "Failed to find the node through embeddings."
|
||||
assert "like" in result["semantic"].lower(), "Embeddings didn't pick the like button."
|
||||
assert result.get("score", 0) >= 0.82, "Confidence threshold should be met"
|
||||
|
||||
# PROOF: Embeddings matched it, VLM was NOT used
|
||||
assert mock_get_embedding.call_count > 0, "Embeddings were not called"
|
||||
mock_vlm.assert_not_called()
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase._get_embedding")
|
||||
def test_vlm_fallback_triggered_on_low_confidence(self, mock_get_embedding, mock_vlm):
|
||||
"""
|
||||
If Embeddings fail to find a confident match (< 0.82), it must trigger
|
||||
the Stage 3 VLM fallback.
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = TelepathicEngine()
|
||||
engine._embedding_cache.clear()
|
||||
engine._intent_cache.clear()
|
||||
|
||||
xml_content = load_fixture("home_feed_with_ad.xml")
|
||||
|
||||
def fake_embed(text):
|
||||
if "mystical" in text:
|
||||
return [1.0, 0.0]
|
||||
return [0.0, 1.0]
|
||||
|
||||
mock_get_embedding.side_effect = fake_embed
|
||||
mock_vlm.return_value = '{"index": 2, "reason": "Fallback chosen by VLM"}'
|
||||
|
||||
# A mockup device is needed for VLM fallback
|
||||
import unittest.mock
|
||||
|
||||
device = unittest.mock.MagicMock()
|
||||
|
||||
engine.find_best_node(xml_content, "tap the mystical artifact", device=device)
|
||||
|
||||
# It might return a result (from VLM) or None depending on the XML structure,
|
||||
# but we ONLY care that VLM was indeed queried!
|
||||
mock_get_embedding.assert_called()
|
||||
mock_vlm.assert_called_once()
|
||||
@@ -1,592 +0,0 @@
|
||||
"""
|
||||
Test Suite: VLM Bombproofing & Interaction Integrity
|
||||
=====================================================
|
||||
TDD validation that the Telepathic Engine and bot_flow interaction loop
|
||||
are structurally safe against VLM hallucinations, cache poisoning,
|
||||
and Darwin-induced context loss.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
# ── Shared Fixtures ──
|
||||
|
||||
|
||||
def mock_device(width=1080, height=2400):
|
||||
device = MagicMock()
|
||||
device.get_info.return_value = {"displayWidth": width, "displayHeight": height}
|
||||
device.screenshot = MagicMock()
|
||||
device.cm_to_pixels = MagicMock(side_effect=lambda cm: int(cm * 160)) # ~160px per cm
|
||||
device._get_current_app.return_value = "com.instagram.android"
|
||||
device.app_id = "com.instagram.android"
|
||||
return device
|
||||
|
||||
|
||||
def make_node(x, y, bounds, semantic, text="", desc=""):
|
||||
"""Helper to construct realistic UI nodes."""
|
||||
node = {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"raw_bounds": bounds,
|
||||
"semantic_string": semantic,
|
||||
"original_attribs": {"text": text, "desc": desc, "resource-id": "com.instagram.android:id/dummy"},
|
||||
"resource_id": "com.instagram.android:id/dummy",
|
||||
}
|
||||
# Parse bounds to calculate area for VLM structural guards
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
if m:
|
||||
l, t, r, b = map(int, m.groups())
|
||||
width = r - l
|
||||
height = b - t
|
||||
node["width"] = width
|
||||
node["height"] = height
|
||||
node["area"] = width * height
|
||||
else:
|
||||
node["width"] = 0
|
||||
node["height"] = 0
|
||||
node["area"] = 0
|
||||
return node
|
||||
|
||||
|
||||
# Realistic node sets extracted from live Instagram XML dumps
|
||||
EXPLORE_FEED_NODES = [
|
||||
make_node(540, 1250, "[0,200][1080,2300]", "id context: 'swipeable nav view pager inner recycler view'"),
|
||||
make_node(100, 2200, "[50,2150][150,2250]", "description: 'Search and explore', id context: 'search tab'"),
|
||||
make_node(540, 2200, "[490,2150][590,2250]", "description: 'Reels', id context: 'reels tab'"),
|
||||
make_node(940, 2200, "[890,2150][990,2250]", "description: 'Profile', id context: 'profile tab'"),
|
||||
]
|
||||
|
||||
REEL_POST_NODES = [
|
||||
make_node(540, 1200, "[0,0][1080,2200]", "id context: 'clips video container'"),
|
||||
make_node(1020, 800, "[980,760][1060,840]", "description: 'Like', id context: 'row feed button like'"),
|
||||
make_node(1020, 920, "[980,880][1060,960]", "description: 'Comment', id context: 'row feed button comment'"),
|
||||
make_node(1020, 1040, "[980,1000][1060,1080]", "description: 'Share', id context: 'row feed button share'"),
|
||||
make_node(
|
||||
180, 2100, "[50,2060][310,2140]", "text: 'super_azores', description: 'super_azores'", text="super_azores"
|
||||
),
|
||||
]
|
||||
|
||||
PHOTO_POST_NODES = [
|
||||
make_node(540, 850, "[0,400][1080,1300]", "id context: 'row feed photo imageview'"),
|
||||
make_node(100, 1350, "[50,1310][150,1390]", "description: 'Like', id context: 'row feed button like'"),
|
||||
make_node(200, 1350, "[150,1310][250,1390]", "description: 'Comment', id context: 'row feed button comment'"),
|
||||
]
|
||||
|
||||
PROFILE_EDIT_NODES = [
|
||||
make_node(540, 300, "[0,50][1080,550]", "id context: 'profile header container'"),
|
||||
make_node(
|
||||
540, 650, "[100,600][980,700]", "text: 'Edit profile', id context: 'edit profile button'", text="Edit profile"
|
||||
),
|
||||
make_node(
|
||||
540,
|
||||
800,
|
||||
"[100,750][980,850]",
|
||||
"text: 'Share profile', id context: 'share profile button'",
|
||||
text="Share profile",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class TestVLMHallucinationRejection:
|
||||
"""
|
||||
Tests that the engine rejects VLM responses pointing to massive
|
||||
structural containers instead of small interactive elements.
|
||||
"""
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_recycler_view_hallucination_is_rejected(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
Exact reproduction of the bug from log 2026-04-13 17:22:31:
|
||||
VLM picked node 0 ('swipeable nav view pager inner recycler view')
|
||||
for intent 'tap like button'. Must be rejected.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
mock_query.return_value = '{"index": 0, "reason": "I think this is it"}'
|
||||
|
||||
result = engine._vision_cortex_fallback("tap like button", EXPLORE_FEED_NODES, device)
|
||||
|
||||
assert result is None, (
|
||||
f"CRITICAL: Engine accepted a fullscreen recycler view as 'like button'! " f"Got: {result}"
|
||||
)
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_frame_layout_hallucination_is_rejected(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
A different structural container variant: FrameLayout that spans
|
||||
the full display. Must also be rejected.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
nodes = [
|
||||
make_node(540, 1200, "[0,0][1080,2400]", "id context: 'action bar root'"),
|
||||
make_node(100, 1350, "[50,1310][150,1390]", "description: 'Like', id context: 'row feed button like'"),
|
||||
]
|
||||
|
||||
mock_query.return_value = '{"index": 0, "reason": "action bar seems right"}'
|
||||
result = engine._vision_cortex_fallback("tap like button", nodes, device)
|
||||
|
||||
assert result is None, f"CRITICAL: Engine accepted a fullscreen FrameLayout as 'like button'! " f"Got: {result}"
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_video_container_is_allowed_for_media_intent(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
Fullscreen video containers (clips_video_container) are legitimate
|
||||
tap targets for intents like 'pause reel' or 'tap the reel video'.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
mock_query.return_value = '{"index": 0, "reason": "Tapping the video to pause"}'
|
||||
result = engine._vision_cortex_fallback("tap the reel video", REEL_POST_NODES, device)
|
||||
|
||||
assert result is not None, "Video container tap should be allowed for media intents"
|
||||
assert result["x"] == 540
|
||||
assert result["y"] == 1200
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_correct_like_button_is_accepted(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
When VLM correctly identifies the small like button (80x80),
|
||||
it must be accepted without interference from the safety guard.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
# VLM returns index 1 — the correct, tiny Like button
|
||||
mock_query.return_value = '{"index": 1, "reason": "It says Like and has the heart icon"}'
|
||||
result = engine._vision_cortex_fallback("tap like button", REEL_POST_NODES, device)
|
||||
|
||||
assert result is not None, "Correct like button should be accepted"
|
||||
assert result["x"] == 1020
|
||||
assert result["y"] == 800
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_comment_button_is_accepted(self, mock_exists, mock_query, mock_open):
|
||||
"""Correct comment button selection on a Reel post."""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
mock_query.return_value = '{"index": 2, "reason": "Comment button"}'
|
||||
result = engine._vision_cortex_fallback("tap comment button", REEL_POST_NODES, device)
|
||||
|
||||
assert result is not None
|
||||
assert result["x"] == 1020
|
||||
assert result["y"] == 920
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_photo_imageview_container_not_blocked(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
A photo imageview is large (~900x900) but NOT fullscreen.
|
||||
It should NOT trigger the safety guard.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
mock_query.return_value = '{"index": 0, "reason": "Double tap to like the photo"}'
|
||||
result = engine._vision_cortex_fallback("double tap photo to like", PHOTO_POST_NODES, device)
|
||||
|
||||
assert result is not None, "Photo imageview (1080x900) should NOT be blocked — it's a valid media target"
|
||||
|
||||
|
||||
class TestTelepathicMemoryPoisoning:
|
||||
"""
|
||||
Tests that the cache (telepathic_memory.json) never stores
|
||||
hallucinated or rejected VLM decisions.
|
||||
"""
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_rejected_hallucination_is_never_cached(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
When a VLM hallucination is rejected by the safety guard,
|
||||
it must NOT be written to telepathic_memory.json.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
mock_query.return_value = '{"index": 0, "reason": "I think this is it"}'
|
||||
result = engine._vision_cortex_fallback("tap like button", EXPLORE_FEED_NODES, device)
|
||||
|
||||
assert result is None, "Hallucination should be rejected"
|
||||
|
||||
# Verify that open() was never called in WRITE mode ("w") for the cache file
|
||||
write_calls = [
|
||||
c
|
||||
for c in mock_open.call_args_list
|
||||
if len(c[0]) >= 2 and c[0][1] == "w" and "telepathic_memory.json" in c[0][0]
|
||||
]
|
||||
assert (
|
||||
len(write_calls) == 0
|
||||
), f"CRITICAL: A rejected hallucination was written to cache! Write calls: {write_calls}"
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_valid_decision_is_tracked_but_not_cached(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
When a VLM correctly identifies a valid, small button,
|
||||
it SHOULD be tracked, but NOT written to telepathic_memory.json
|
||||
until explicit confirmation (bot_flow -> confirm_click).
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
# VLM correctly selects the tiny like button (node 1)
|
||||
mock_query.return_value = '{"index": 1, "reason": "Like button"}'
|
||||
result = engine._vision_cortex_fallback("tap like button", REEL_POST_NODES, device)
|
||||
|
||||
assert result is not None, "Valid like button should be accepted"
|
||||
|
||||
# Verify cache was NOT written immediately to prevent poisoning
|
||||
write_calls = [c for c in mock_open.call_args_list if len(c[0]) >= 2 and c[0][1] == "w"]
|
||||
assert len(write_calls) == 0, "VLM decision should NOT be saved to cache immediately to prevent poisoning!"
|
||||
|
||||
# Verify it is tracked
|
||||
assert TelepathicEngine._last_click_context is not None
|
||||
assert TelepathicEngine._last_click_context["intent"] == "tap like button"
|
||||
|
||||
|
||||
class TestTelepathicMemoryRecall:
|
||||
"""
|
||||
Tests that the cache short-circuits correctly and never
|
||||
recalls a stale/wrong semantic match.
|
||||
"""
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine._extract_semantic_nodes")
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("os.path.exists")
|
||||
def test_cache_hit_returns_instantly(self, mock_exists, mock_open, mock_extract):
|
||||
"""
|
||||
If the telepathic memory already contains a hit for this intent,
|
||||
it must return immediately WITHOUT taking a screenshot or calling VLM.
|
||||
"""
|
||||
mock_exists.return_value = True
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_extract.return_value = REEL_POST_NODES
|
||||
|
||||
# Simulate a cached memory file
|
||||
cache_data = {"tap like button": ["description: 'Like', id context: 'row feed button like'"]}
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(cache_data).encode()
|
||||
|
||||
# Provide nodes that match the cached semantic
|
||||
result = engine.find_best_node("<fake_xml>", "tap like button", min_confidence=0.82, device=device)
|
||||
|
||||
assert result is not None, "Cache hit should return a result"
|
||||
assert result["x"] == 1020
|
||||
assert result["y"] == 800
|
||||
assert result["score"] == 1.0
|
||||
|
||||
# Screenshot should NEVER have been called (the early-return optimization)
|
||||
device.screenshot.assert_not_called()
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine._extract_semantic_nodes")
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("os.path.exists")
|
||||
def test_cache_miss_does_not_match_wrong_intent(self, mock_exists, mock_open, mock_extract):
|
||||
"""
|
||||
Cached memory for 'tap like button' must NOT be used when the
|
||||
intent is 'tap comment button'.
|
||||
"""
|
||||
mock_exists.return_value = True
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_extract.return_value = REEL_POST_NODES
|
||||
|
||||
cache_data = {"tap like button": ["description: 'Like', id context: 'row feed button like'"]}
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(cache_data).encode()
|
||||
|
||||
# We ask for "comment" but only "like" is cached — no early return
|
||||
# Mock VLM fallback so it doesn't crash
|
||||
with patch.object(engine, "_vision_cortex_fallback", return_value={"x": 999, "y": 999, "score": 1.0}):
|
||||
result = engine.find_best_node("<fake>", "tap comment button", min_confidence=0.82, device=device)
|
||||
|
||||
# It should NOT return the like button coordinates
|
||||
if result is not None:
|
||||
assert result["y"] != 800, "CRITICAL: Cache returned like button coordinates for a comment intent!"
|
||||
|
||||
|
||||
class TestDarwinScrollSafety:
|
||||
"""
|
||||
Tests that Darwin's 'cognitive wobble' and 'non-linear scroll'
|
||||
don't produce scroll distances large enough to leave the current post.
|
||||
"""
|
||||
|
||||
def test_back_swipe_distance_is_bounded(self):
|
||||
"""
|
||||
The back-swipe (cognitive wobble) must never exceed 1.5cm (~240px).
|
||||
Anything larger risks scrolling past the current post entirely.
|
||||
"""
|
||||
|
||||
# Run 200 Monte Carlo iterations to catch edge cases
|
||||
max_observed_distance = 0
|
||||
for _ in range(200):
|
||||
# The back-swipe distance is: cm_to_pixels(uniform(0.8, 1.2))
|
||||
# At 160px/cm ≈ 128 to 192 pixels. Must stay under 240px.
|
||||
distance_cm = __import__("random").uniform(0.8, 1.2)
|
||||
distance_px = int(distance_cm * 160) # approximate px/cm
|
||||
max_observed_distance = max(max_observed_distance, distance_px)
|
||||
|
||||
assert max_observed_distance <= 240, (
|
||||
f"Back-swipe distance reached {max_observed_distance}px! "
|
||||
f"Max allowed is 240px to prevent scrolling off the current post."
|
||||
)
|
||||
|
||||
def test_scroll_velocity_never_causes_multi_post_skip(self):
|
||||
"""
|
||||
The non-linear scroll in execute_proof_of_resonance must not
|
||||
produce a swipe distance exceeding the screen height, which would
|
||||
skip multiple posts at once.
|
||||
"""
|
||||
# scroll_velocity bounds: (0.1, 2.0)
|
||||
# distance = cm_to_pixels(uniform(4.0, 7.0)) * velocity
|
||||
# Worst case: 7cm * 2.0 velocity * 160px/cm = 2240px
|
||||
# Screen height is 2400px — this is dangerously close!
|
||||
|
||||
max_distance_px = 0
|
||||
for _ in range(500):
|
||||
velocity = __import__("random").uniform(0.1, 2.0)
|
||||
base_cm = __import__("random").uniform(4.0, 7.0)
|
||||
distance_px = int(base_cm * 160 * velocity)
|
||||
max_distance_px = max(max_distance_px, distance_px)
|
||||
|
||||
screen_height = 2400
|
||||
assert max_distance_px < screen_height, (
|
||||
f"Darwin scroll distance reached {max_distance_px}px which exceeds "
|
||||
f"screen height {screen_height}px! This would skip multiple posts."
|
||||
)
|
||||
|
||||
|
||||
class TestFeedMarkerValidation:
|
||||
"""
|
||||
Tests that the feed marker self-check in bot_flow correctly
|
||||
identifies when the bot is on a post vs. lost.
|
||||
"""
|
||||
|
||||
def test_reel_feed_markers_detected(self):
|
||||
"""
|
||||
A Reel post contains 'clips_media_component' which is in FEED_MARKERS.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS
|
||||
|
||||
fake_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/clips_media_component" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_like" />
|
||||
</node>
|
||||
"""
|
||||
has_markers = any(m in fake_xml for m in FEED_MARKERS)
|
||||
assert has_markers, "Reel XML should match feed markers via 'clips_media_component'"
|
||||
|
||||
def test_photo_feed_markers_detected(self):
|
||||
"""
|
||||
A photo post contains 'row_feed_photo_imageview' in FEED_MARKERS.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS
|
||||
|
||||
fake_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>
|
||||
"""
|
||||
has_markers = any(m in fake_xml for m in FEED_MARKERS)
|
||||
assert has_markers, "Photo post XML should match feed markers via 'row_feed_photo_imageview'"
|
||||
|
||||
def test_profile_page_has_no_feed_markers(self):
|
||||
"""
|
||||
A profile page must NOT match any feed markers.
|
||||
This is the bug scenario: the bot tapped a user tag, ended up on
|
||||
a profile page, and then tried to interact with non-existent posts.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS
|
||||
|
||||
fake_profile_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/profile_header_container" />
|
||||
<node resource-id="com.instagram.android:id/action_bar_textview_title" text="marisaundmarc" />
|
||||
<node resource-id="com.instagram.android:id/profile_tab_layout" />
|
||||
<node resource-id="com.instagram.android:id/profile_tab_icon_view" />
|
||||
</node>
|
||||
"""
|
||||
has_markers = any(m in fake_profile_xml for m in FEED_MARKERS)
|
||||
assert not has_markers, (
|
||||
"Profile page XML must NOT match feed markers! "
|
||||
"If it does, the bot would try to like/interact on a profile page."
|
||||
)
|
||||
|
||||
def test_edit_profile_page_has_no_feed_markers(self):
|
||||
"""
|
||||
The edit profile page (where the bot got stuck) must NOT match markers.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS
|
||||
|
||||
fake_edit_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/action_bar_textview_title" text="Edit profile" />
|
||||
<node resource-id="com.instagram.android:id/full_name_edit_text" />
|
||||
<node resource-id="com.instagram.android:id/bio_edit_text" />
|
||||
</node>
|
||||
"""
|
||||
has_markers = any(m in fake_edit_xml for m in FEED_MARKERS)
|
||||
assert not has_markers, (
|
||||
"Edit profile page must NOT match feed markers! "
|
||||
"The bot was getting stuck here because it blindly tapped center-screen."
|
||||
)
|
||||
|
||||
def test_explore_grid_has_no_feed_markers(self):
|
||||
"""
|
||||
The Explore grid (before opening a post) must NOT match feed markers.
|
||||
The bot should only enter the interaction loop AFTER tapping into a post.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS
|
||||
|
||||
fake_explore_grid_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/search_tab" />
|
||||
<node resource-id="com.instagram.android:id/explore_grid" />
|
||||
<node content-desc="Reel by user1 at row 1, column 1" clickable="true" />
|
||||
<node content-desc="Photo by user2 at row 1, column 2" clickable="true" />
|
||||
</node>
|
||||
"""
|
||||
has_markers = any(m in fake_explore_grid_xml for m in FEED_MARKERS)
|
||||
assert not has_markers, "Explore grid must NOT match feed markers — the bot isn't on a post yet."
|
||||
|
||||
|
||||
class TestAdDetection:
|
||||
"""
|
||||
Tests that ad detection is accurate and doesn't flag organic posts.
|
||||
"""
|
||||
|
||||
def test_sponsored_post_detected(self):
|
||||
"""A post with ad_cta_button is detected as an ad."""
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
ad_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" />
|
||||
<node resource-id="com.instagram.android:id/ad_cta_button" text="Shop Now" />
|
||||
</node>
|
||||
"""
|
||||
assert is_ad(ad_xml) is True
|
||||
|
||||
def test_organic_post_not_flagged(self):
|
||||
"""A normal post without ad markers is NOT flagged as adv."""
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
organic_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_photo_profile_name" text="super_azores" />
|
||||
<node resource-id="com.instagram.android:id/secondary_label" text="Berlin, Germany" />
|
||||
</node>
|
||||
"""
|
||||
assert is_ad(organic_xml) is False, "Organic post with location secondary_label must NOT be marked as ad!"
|
||||
|
||||
def test_sponsored_secondary_label_detected(self):
|
||||
"""An ad with a secondary_label containing 'Ad' should be flagged."""
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
# Extracted directly from: manual_interrupt__2026-04-13_23-55-33.xml
|
||||
ad_xml = """
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/row_feed_profile_header" class="android.view.ViewGroup">
|
||||
<node index="1" text="Ehuse Ferienhausvermietung" resource-id="com.instagram.android:id/row_feed_photo_profile_name" class="android.widget.Button" />
|
||||
<node index="2" text="Ad" resource-id="com.instagram.android:id/secondary_label" class="android.widget.Button" package="com.instagram.android" content-desc="Ad" />
|
||||
</node>
|
||||
"""
|
||||
assert is_ad(ad_xml) is True, "Failed to detect an ad via the secondary_label 'Ad' badge!"
|
||||
|
||||
def test_organic_post_with_music_not_flagged(self):
|
||||
"""A post with a music track attribution must NOT be flagged."""
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
music_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/clips_media_component" />
|
||||
<node resource-id="com.instagram.android:id/secondary_label" text="Original Audio - Artist Name" />
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_like" />
|
||||
</node>
|
||||
"""
|
||||
assert is_ad(music_xml) is False, "Organic reel with music attribution must NOT be marked as ad!"
|
||||
|
||||
@patch("builtins.open", new_callable=MagicMock)
|
||||
@patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
|
||||
@patch("os.path.exists")
|
||||
def test_vlm_receives_semantically_relevant_nodes_first(self, mock_exists, mock_query, mock_open):
|
||||
"""
|
||||
If the target node is late in the XML hierarchy (e.g. node 40),
|
||||
it MUST be passed to the VLM. The VLM should not be forced to
|
||||
guess from the first 10 random XML nodes.
|
||||
"""
|
||||
mock_exists.return_value = False
|
||||
engine = TelepathicEngine()
|
||||
device = mock_device()
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"
|
||||
|
||||
# Create 15 garbage nodes
|
||||
nodes = []
|
||||
for i in range(15):
|
||||
nodes.append(make_node(10, 10, "[0,0][20,20]", f"garbage node {i}"))
|
||||
|
||||
# Target node is at the end (index 15)
|
||||
target_node = make_node(
|
||||
500, 500, "[400,400][600,600]", "description: 'Like', id context: 'row feed button like'"
|
||||
)
|
||||
nodes.append(target_node)
|
||||
|
||||
# We simulate that the embedding engine scores the target_node highest
|
||||
with patch.object(engine, "_cosine_similarity", side_effect=lambda v1, v2: 1.0 if v1 == v2 else 0.0):
|
||||
with patch.object(engine, "_get_cached_embedding", return_value=[0.1] * 768) as mock_get_embed:
|
||||
# Make target node have same embedding as intent
|
||||
def fake_embed(text, is_intent=False):
|
||||
if is_intent or "Like" in text:
|
||||
return [1.0] * 768
|
||||
return [0.0] * 768
|
||||
|
||||
mock_get_embed.side_effect = fake_embed
|
||||
|
||||
# VLM should select index 0, because the target_node should be SORTED to the top!
|
||||
mock_query.return_value = '{"index": 0, "reason": "Like button"}'
|
||||
|
||||
with patch.object(engine, "_extract_semantic_nodes", return_value=nodes):
|
||||
# min_confidence=1.1 to force fallback
|
||||
result = engine.find_best_node("<fake>", "tap like button", min_confidence=1.1, device=device)
|
||||
|
||||
assert result is not None, "VLM should have returned a result"
|
||||
assert result["x"] == 500 and result["y"] == 500, "VLM did not receive the best semantic candidates!"
|
||||
@@ -1,61 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine():
|
||||
# Instantiate directly to avoid singleton contamination from mocks
|
||||
return TelepathicEngine()
|
||||
|
||||
|
||||
def test_keyword_nav_threshold(engine):
|
||||
"""
|
||||
TDD Case: "tap messages tab" should NOT match "Reels" (clips_tab).
|
||||
Intent words: {"messages", "tab"}
|
||||
Reels node description: "Reels", resource_id: "clips_tab"
|
||||
Matches "tab" -> score 0.5.
|
||||
Current threshold 0.45 -> matches (WRONG).
|
||||
New threshold for nav intents should be 1.0.
|
||||
"""
|
||||
reels_node = {
|
||||
"x": 500,
|
||||
"y": 2000,
|
||||
"area": 100,
|
||||
"semantic_string": "description: 'Reels', id context: 'clips tab'",
|
||||
"resource_id": "com.instagram.android:id/clips_tab",
|
||||
"original_attribs": {"desc": "Reels", "text": "", "resource-id": "com.instagram.android:id/clips_tab"},
|
||||
}
|
||||
|
||||
# Intent: "tap messages tab"
|
||||
# Result should be None because "messages" is missing.
|
||||
res = engine._keyword_match_score("tap messages tab", [reels_node])
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_direct_tab_fast_path(engine):
|
||||
"""
|
||||
Verify that _core_navigation_fast_path returns None without Qdrant data
|
||||
(Blank Start architecture). Navigation discovery is Qdrant-only.
|
||||
The keyword_match_score fallback handles it with resource-id matching.
|
||||
"""
|
||||
direct_node = {
|
||||
"x": 800,
|
||||
"y": 2300,
|
||||
"area": 100,
|
||||
"semantic_string": "Direct",
|
||||
"resource_id": "com.instagram.android:id/direct_tab",
|
||||
"original_attribs": {"resource-id": "com.instagram.android:id/direct_tab"},
|
||||
}
|
||||
|
||||
# Without Qdrant data, fast path returns None (Blank Start)
|
||||
res = engine._core_navigation_fast_path("tap messages tab", [direct_node])
|
||||
|
||||
# In Blank Start, if Qdrant has no learned data, this MUST return None
|
||||
# to force the agent into telepathic discovery mode
|
||||
assert res is None, "Fast path should return None without learned Qdrant data"
|
||||
@@ -1,34 +0,0 @@
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
def test_keyword_fast_path_no_feed_pollution():
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# A generic Feed post that used to spoof the 'home' keyword due to 'row feed photo'
|
||||
feed_post_node = {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"area": 500,
|
||||
"semantic_string": "description: 'Profile picture of ericrubens', id context: 'row feed photo profile imageview'",
|
||||
"resource_id": "row_feed_photo_profile_imageview",
|
||||
"original_attribs": {"desc": "Profile picture of ericrubens", "text": ""},
|
||||
}
|
||||
|
||||
# The actual Home Tab button
|
||||
home_tab_node = {
|
||||
"x": 100,
|
||||
"y": 2300,
|
||||
"area": 300,
|
||||
"semantic_string": "description: 'Home', id context: 'tab bar'",
|
||||
"resource_id": "tab_avatar",
|
||||
"original_attribs": {"desc": "Home", "text": ""},
|
||||
}
|
||||
|
||||
nodes = [feed_post_node, home_tab_node]
|
||||
|
||||
# Intention is to tap the home tab
|
||||
result = engine._keyword_match_score("tap home tab", nodes)
|
||||
|
||||
assert result is not None
|
||||
# Verify it matched the actual home tab and NOT the feed post
|
||||
assert result["semantic"] == "description: 'Home', id context: 'tab bar'"
|
||||
@@ -1,106 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.unfollow_engine import _run_zero_latency_unfollow_loop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unfollow_mock_dependencies():
|
||||
device = MagicMock()
|
||||
zero_engine = MagicMock()
|
||||
nav_graph = MagicMock()
|
||||
|
||||
class ConfigArgs:
|
||||
total_unfollows_limit = 5
|
||||
|
||||
configs = MagicMock()
|
||||
configs.args = ConfigArgs()
|
||||
|
||||
session_state = MagicMock()
|
||||
session_state.check_limit.return_value = (False, False, False, False)
|
||||
session_state.totalUnfollowed = 0
|
||||
|
||||
telepathic = MagicMock()
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.side_effect = [False, False, True]
|
||||
dopamine.wants_to_change_feed.return_value = False
|
||||
dopamine.boredom = 0.0
|
||||
|
||||
resonance = MagicMock()
|
||||
resonance.calculate_resonance.return_value = 0.2
|
||||
|
||||
cognitive_stack = {
|
||||
"telepathic": telepathic,
|
||||
"dopamine": dopamine,
|
||||
"resonance": resonance,
|
||||
}
|
||||
|
||||
return device, zero_engine, nav_graph, configs, session_state, cognitive_stack
|
||||
|
||||
|
||||
def test_unfollow_engine_basic_loop(unfollow_mock_dependencies):
|
||||
device, zero_engine, nav_graph, configs, session_state, cognitive_stack = unfollow_mock_dependencies
|
||||
|
||||
telepathic = cognitive_stack["telepathic"]
|
||||
|
||||
# 1. Finds profile row
|
||||
# 2. Finds following button on profile
|
||||
# 3. Finds confirm dialog
|
||||
telepathic._extract_semantic_nodes.side_effect = [
|
||||
[{"semantic_string": "Profile Row", "x": 100, "y": 200, "skip": False, "bounds": "..."}],
|
||||
[{"semantic_string": "Following Button", "x": 150, "y": 250, "skip": False}],
|
||||
[{"semantic_string": "Unfollow Confirm", "x": 200, "y": 300, "skip": False}],
|
||||
[], # second iteration
|
||||
[],
|
||||
]
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.unfollow_engine._humanized_scroll_down"),
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.bot_flow.random_sleep"),
|
||||
patch("GramAddict.core.utils.random_sleep"),
|
||||
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
|
||||
):
|
||||
device.dump_hierarchy.return_value = '<node text="Basic Bio"/>'
|
||||
|
||||
res = _run_zero_latency_unfollow_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, "FollowingList", cognitive_stack
|
||||
)
|
||||
|
||||
# Clicked profile -> following button -> confirm
|
||||
assert mock_click.call_count == 3
|
||||
assert session_state.totalUnfollowed == 1
|
||||
assert res == "FEED_EXHAUSTED"
|
||||
|
||||
|
||||
def test_unfollow_engine_chaos_mode(unfollow_mock_dependencies):
|
||||
device, zero_engine, nav_graph, configs, session_state, cognitive_stack = unfollow_mock_dependencies
|
||||
|
||||
telepathic = cognitive_stack["telepathic"]
|
||||
|
||||
# Simulate Telepathic failure / missing nodes
|
||||
telepathic._extract_semantic_nodes.side_effect = Exception("UI DUMP CORRUPTED")
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.unfollow_engine._humanized_scroll_down") as mock_scroll,
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.bot_flow._humanized_click"),
|
||||
):
|
||||
res = _run_zero_latency_unfollow_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, "FollowingList", cognitive_stack
|
||||
)
|
||||
|
||||
# It should catch the exception, scroll down, and increment failed scans until it realizes context is lost
|
||||
assert mock_scroll.call_count > 0
|
||||
assert res == "CONTEXT_LOST" or res == "FEED_EXHAUSTED"
|
||||
|
||||
|
||||
def test_unfollow_engine_limits(unfollow_mock_dependencies):
|
||||
device, zero_engine, nav_graph, configs, session_state, cognitive_stack = unfollow_mock_dependencies
|
||||
session_state.check_limit.return_value = (True, False, False, False)
|
||||
|
||||
res = _run_zero_latency_unfollow_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, "FollowingList", cognitive_stack
|
||||
)
|
||||
assert res == "BOREDOM_CHANGE_FEED"
|
||||
@@ -1,82 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
device.get_screenshot_b64.return_value = "fake_base64_image_data"
|
||||
|
||||
# Mock args
|
||||
class Args:
|
||||
ai_telepathic_model = "test-model"
|
||||
ai_telepathic_url = "http://test-url"
|
||||
|
||||
device.args = Args()
|
||||
return device
|
||||
|
||||
|
||||
@patch("GramAddict.core.llm_provider.query_llm")
|
||||
def test_evaluate_post_vibe_rejects_poor_quality(mock_query_llm, mock_device):
|
||||
engine = TelepathicEngine()
|
||||
|
||||
persona_interests = ["aesthetic architecture", "minimalism"]
|
||||
|
||||
# Mock VLM response to reject the post
|
||||
mock_query_llm.return_value = {
|
||||
"response": '{"quality_score": 3, "matches_niche": false, "reason": "Generic text meme, no architectural elements."}'
|
||||
}
|
||||
|
||||
result = engine.evaluate_post_vibe(device=mock_device, persona_interests=persona_interests)
|
||||
|
||||
# Verify screenshot was evaluated
|
||||
assert mock_device.get_screenshot_b64.called
|
||||
assert mock_query_llm.called
|
||||
|
||||
# Verify the structured response parsing
|
||||
assert result is not None
|
||||
assert result["quality_score"] == 3
|
||||
assert result["matches_niche"] is False
|
||||
assert "Generic text meme" in result["reason"]
|
||||
|
||||
|
||||
@patch("GramAddict.core.llm_provider.query_llm")
|
||||
def test_evaluate_post_vibe_accepts_high_quality(mock_query_llm, mock_device):
|
||||
engine = TelepathicEngine()
|
||||
|
||||
persona_interests = ["aesthetic architecture", "minimalism"]
|
||||
|
||||
# Mock VLM response to accept the post
|
||||
mock_query_llm.return_value = {
|
||||
"response": '{"quality_score": 9, "matches_niche": true, "reason": "Beautiful cohesive architectural shot."}'
|
||||
}
|
||||
|
||||
result = engine.evaluate_post_vibe(device=mock_device, persona_interests=persona_interests)
|
||||
|
||||
# Verify screenshot was evaluated
|
||||
assert mock_device.get_screenshot_b64.called
|
||||
assert mock_query_llm.called
|
||||
|
||||
# Verify the structured response parsing
|
||||
assert result is not None
|
||||
assert result["quality_score"] == 9
|
||||
assert result["matches_niche"] is True
|
||||
assert "Beautiful cohesive" in result["reason"]
|
||||
|
||||
|
||||
@patch("GramAddict.core.llm_provider.query_llm")
|
||||
def test_evaluate_post_vibe_handles_invalid_json(mock_query_llm, mock_device):
|
||||
engine = TelepathicEngine()
|
||||
|
||||
persona_interests = ["aesthetic architecture", "minimalism"]
|
||||
|
||||
# Mock VLM response with garbage output
|
||||
mock_query_llm.return_value = {"response": "I think this is a nice picture but I forgot to output JSON."}
|
||||
|
||||
result = engine.evaluate_post_vibe(device=mock_device, persona_interests=persona_interests)
|
||||
|
||||
# Verify fallback to None on error
|
||||
assert result is None
|
||||
@@ -1,119 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.bot_flow import _interact_with_profile
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
device.dump_hierarchy.return_value = '<?xml version="1.0"?><hierarchy><node resource-id="com.instagram.android:id/row_profile_header_textview_followers_count" text="10.5K" /></hierarchy>'
|
||||
device.get_screenshot_b64.return_value = "fake_base64_image_data"
|
||||
|
||||
# Mock args
|
||||
class Args:
|
||||
scrape_profiles = False
|
||||
visual_vibe_check_percentage = "100"
|
||||
ai_telepathic_model = "test-model"
|
||||
ai_telepathic_url = "http://test-url"
|
||||
follow_percentage = "100"
|
||||
likes_percentage = "100"
|
||||
profile_learning_percentage = "0"
|
||||
|
||||
device.args = Args()
|
||||
return device
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_configs(mock_device):
|
||||
configs = MagicMock()
|
||||
configs.args = mock_device.args
|
||||
return configs
|
||||
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
@patch("GramAddict.core.llm_provider.query_llm")
|
||||
def test_visual_vibe_check_rejects_poor_quality(mock_query_llm, mock_get_instance, mock_device, mock_configs):
|
||||
logger = MagicMock()
|
||||
session_state = MagicMock()
|
||||
session_state.my_username = "my_bot"
|
||||
|
||||
# Use real engine instead of the autouse mock from conftest
|
||||
real_engine = TelepathicEngine()
|
||||
mock_get_instance.return_value = real_engine
|
||||
|
||||
cognitive_stack = {"persona_interests": ["aesthetic architecture", "minimalism"], "resonance": MagicMock()}
|
||||
|
||||
# Mock VLM response to reject the profile
|
||||
mock_query_llm.return_value = {
|
||||
"response": '{"quality_score": 3, "matches_niche": false, "reason": "Very generic and spammy looking grid."}'
|
||||
}
|
||||
|
||||
# Run interaction flow
|
||||
_interact_with_profile(
|
||||
device=mock_device,
|
||||
configs=mock_configs,
|
||||
username="target_user",
|
||||
session_state=session_state,
|
||||
sleep_mod=1.0,
|
||||
logger=logger,
|
||||
cognitive_stack=cognitive_stack,
|
||||
)
|
||||
|
||||
# Verify screenshot was evaluated
|
||||
assert mock_device.get_screenshot_b64.called
|
||||
assert mock_query_llm.called
|
||||
|
||||
# Verify the AI reason was logged
|
||||
log_messages = [call.args[0] for call in logger.warning.call_args_list]
|
||||
assert any("Very generic and spammy looking grid." in msg for msg in log_messages)
|
||||
|
||||
# Verify we did NOT attempt to follow or like (since it was rejected)
|
||||
nav_graph_do_calls = [call for call in mock_device.mock_calls if "do" in str(call)]
|
||||
assert len(nav_graph_do_calls) == 0 # No interactions executed
|
||||
|
||||
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
@patch("GramAddict.core.llm_provider.query_llm")
|
||||
def test_visual_vibe_check_accepts_high_quality(mock_query_llm, mock_get_instance, mock_device, mock_configs):
|
||||
logger = MagicMock()
|
||||
session_state = MagicMock()
|
||||
session_state.my_username = "my_bot"
|
||||
session_state.check_limit.return_value = False
|
||||
|
||||
real_engine = TelepathicEngine()
|
||||
mock_get_instance.return_value = real_engine
|
||||
|
||||
cognitive_stack = {"persona_interests": ["aesthetic architecture", "minimalism"], "resonance": MagicMock()}
|
||||
|
||||
# Mock VLM response to accept the profile
|
||||
mock_query_llm.return_value = {
|
||||
"response": '{"quality_score": 9, "matches_niche": true, "reason": "Beautiful cohesive grid."}'
|
||||
}
|
||||
|
||||
# We also have to prevent the nav_graph.do from throwing if we reach it
|
||||
with (
|
||||
patch("GramAddict.core.q_nav_graph.QNavGraph.do", return_value=True) as mock_do,
|
||||
patch("GramAddict.core.behaviors.follow.sleep"),
|
||||
):
|
||||
from GramAddict.core.behaviors import PluginRegistry
|
||||
from GramAddict.core.behaviors.follow import FollowPlugin
|
||||
|
||||
registry = PluginRegistry.get_instance()
|
||||
registry.register(FollowPlugin())
|
||||
|
||||
_interact_with_profile(
|
||||
device=mock_device,
|
||||
configs=mock_configs,
|
||||
username="target_user",
|
||||
session_state=session_state,
|
||||
sleep_mod=1.0,
|
||||
logger=logger,
|
||||
cognitive_stack=cognitive_stack,
|
||||
)
|
||||
|
||||
# Verify it proceeded to interactions (like/follow)
|
||||
assert mock_do.called
|
||||
Reference in New Issue
Block a user