chore: FSD stabilization, strict TDD enforcement, and unlearn mechanism
- implemented self-healing unlearn for Qdrant false positives - centralized testing logic in conftest - documented core rules, ai standards, and goap philosophy - purged old dev scratchpads
This commit is contained in:
@@ -20,20 +20,26 @@ def mock_device():
|
||||
return device
|
||||
|
||||
|
||||
def test_extract_post_content():
|
||||
xml = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="test_user"/>
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test description of image with more than 10 chars" />
|
||||
</hierarchy>'''
|
||||
@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"]
|
||||
|
||||
def test_extract_post_content_fallback_caption():
|
||||
@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="com.instagram.android:id/row_feed_photo_profile_name" text="other_user"/>
|
||||
<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)
|
||||
@@ -47,12 +53,13 @@ def testis_ad():
|
||||
assert is_ad('<node resource-id="com.instagram.android:id/secondary_label" text="regular post" />') == False
|
||||
assert is_ad('<node resource-id="com.instagram.android:id/normal_post" />') == False
|
||||
|
||||
def test_align_active_post(mock_device):
|
||||
@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_device.dump_hierarchy.return_value = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_feed_profile_header" bounds="[0,800][1080,900]" />
|
||||
</hierarchy>'''
|
||||
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/>"
|
||||
res = _align_active_post(mock_device)
|
||||
# The header is at 850px. Target is 250px. Diff is 600px. It should swipe.
|
||||
assert mock_device.swipe.called
|
||||
@@ -176,8 +183,7 @@ def test_start_bot_interrupt():
|
||||
patch('GramAddict.core.bot_flow.create_device') as mock_create_device, \
|
||||
patch('GramAddict.core.bot_flow.set_time_delta') as mock_time_delta, \
|
||||
patch('GramAddict.core.bot_flow.SessionState') as MockSession, \
|
||||
patch('GramAddict.core.bot_flow.open_instagram', side_effect=KeyboardInterrupt()), \
|
||||
patch('GramAddict.core.bot_flow.dump_ui_state') as mock_dump:
|
||||
patch('GramAddict.core.bot_flow.open_instagram', side_effect=KeyboardInterrupt()):
|
||||
|
||||
MockConfig.return_value.args.feed = True
|
||||
MockConfig.return_value.args.explore = False
|
||||
@@ -186,13 +192,16 @@ def test_start_bot_interrupt():
|
||||
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", device_id="123")
|
||||
|
||||
assert mock_dump.called
|
||||
start_bot(username="test_user", device_id="123")
|
||||
|
||||
def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
|
||||
# This test hits the core interaction (Lines 900 - 1300)
|
||||
@@ -233,6 +242,7 @@ def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["nav_graph"].do.return_value = True
|
||||
|
||||
with patch('GramAddict.core.bot_flow.TelepathicEngine') 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), \
|
||||
@@ -242,6 +252,7 @@ def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
|
||||
patch('GramAddict.core.bot_flow._humanized_click') as mock_click, \
|
||||
patch('GramAddict.core.stealth_typing.ghost_type') 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}
|
||||
@@ -321,11 +332,13 @@ def test_profile_learning_percentage_trigger(mock_device, mock_cognitive_stack):
|
||||
mock_cognitive_stack["nav_graph"].do.return_value = True
|
||||
|
||||
with patch('GramAddict.core.bot_flow.TelepathicEngine') 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.bot_flow._align_active_post', return_value=False), \
|
||||
patch('GramAddict.core.bot_flow._humanized_scroll'), \
|
||||
patch('GramAddict.core.bot_flow._interact_with_profile') as mock_interact:
|
||||
|
||||
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}
|
||||
@@ -391,3 +404,62 @@ def test_ai_learn_own_profile_triggers_goap():
|
||||
# It's sufficient to know the GOAP goal was triggered.
|
||||
|
||||
|
||||
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') 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.bot_flow._align_active_post', return_value=False), \
|
||||
patch('GramAddict.core.bot_flow._humanized_scroll'), \
|
||||
patch('GramAddict.core.bot_flow._interact_with_profile') as mock_interact:
|
||||
|
||||
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)
|
||||
|
||||
assert mock_interact.call_args[0][2] == "ryanresatka", f"Expected ryanresatka but got {mock_interact.call_args[0][2]}"
|
||||
|
||||
@@ -51,8 +51,19 @@ def test_full_content_to_resonance_flow(mock_engines):
|
||||
with open(DUMPS["organic"], "r") as f:
|
||||
xml_content = f.read()
|
||||
|
||||
# 1. Extraction (The Bot's Eyes)
|
||||
post_data = _extract_post_content(xml_content)
|
||||
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
|
||||
@@ -98,6 +109,18 @@ def test_extract_explore_reel():
|
||||
with open(DUMPS["explore"], "r") as f:
|
||||
xml = f.read()
|
||||
|
||||
post_data = _extract_post_content(xml)
|
||||
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,40 +0,0 @@
|
||||
import os
|
||||
import pytest
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
DUMP_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "debug", "xml_dumps", "manual_interrupt__2026-04-17_15-44-56.xml")
|
||||
|
||||
def test_core_nav_username_fast_path():
|
||||
if not os.path.exists(DUMP_PATH):
|
||||
pytest.skip("Dump not found.")
|
||||
|
||||
with open(DUMP_PATH, "r") as f:
|
||||
xml_content = f.read()
|
||||
|
||||
engine = TelepathicEngine()
|
||||
engine._is_modal_active = lambda *args, **kwargs: False
|
||||
intent = "tap_post_username"
|
||||
|
||||
result = engine.find_best_node(xml_content, intent)
|
||||
|
||||
assert result is not None, "Should have found a node"
|
||||
assert result["source"] == "core_nav", "Should have bypassed VLM and hit core_nav"
|
||||
assert "row feed photo profile name" in result["semantic"] or "media header user" in result["semantic"], "Should target the exact username resource ID"
|
||||
|
||||
|
||||
def test_core_nav_dm_fast_path():
|
||||
if not os.path.exists(DUMP_PATH):
|
||||
pytest.skip("Dump not found.")
|
||||
|
||||
with open(DUMP_PATH, "r") as f:
|
||||
xml_content = f.read()
|
||||
|
||||
engine = TelepathicEngine()
|
||||
engine._is_modal_active = lambda *args, **kwargs: False
|
||||
intent = "tap direct message icon inbox"
|
||||
|
||||
result = engine.find_best_node(xml_content, intent)
|
||||
|
||||
assert result is not None, "Should have found a node"
|
||||
assert result["source"] == "core_nav", "Should have bypassed VLM and hit core_nav"
|
||||
assert "direct tab" in result["semantic"] or "action bar" in result["semantic"] or "direct" in result["semantic"], "Should target the DM button/tab"
|
||||
@@ -90,8 +90,14 @@ def test_execute_proof_of_resonance_close_comments():
|
||||
|
||||
# Act
|
||||
with patch('random.random', return_value=0.0): # Force comment block entry
|
||||
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")
|
||||
|
||||
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.
|
||||
|
||||
@@ -70,30 +70,38 @@ 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'):
|
||||
# Click dict directly (safe coordinates)
|
||||
facade.click(obj={"x": 500, "y": 1000})
|
||||
mock_device.touch.down.assert_called()
|
||||
mock_device.touch.up.assert_called()
|
||||
|
||||
# Click obj with bounds (safe coordinates)
|
||||
mock_device.reset_mock()
|
||||
obj = MagicMock()
|
||||
obj.bounds.return_value = (400, 900, 600, 1100)
|
||||
facade.click(obj=obj)
|
||||
mock_device.touch.down.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 fallback (using coordinates that trigger the guard)
|
||||
mock_device.reset_mock()
|
||||
facade.human_click(10, 10)
|
||||
mock_device.shell.assert_called_with("input tap 10 10")
|
||||
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
|
||||
|
||||
@@ -38,17 +38,15 @@ def mock_nav_db(monkeypatch):
|
||||
@property
|
||||
def client(self):
|
||||
client_mock = MagicMock()
|
||||
def mock_query(collection_name, query, **kwargs):
|
||||
mock_points = MagicMock()
|
||||
points_list = []
|
||||
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
|
||||
points_list.append(p)
|
||||
mock_points.points = points_list
|
||||
return mock_points
|
||||
client_mock.query_points.side_effect = mock_query
|
||||
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
|
||||
|
||||
@@ -91,11 +89,11 @@ 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(mock_device, username)
|
||||
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_tab_for_screen(ScreenType.REELS_FEED)
|
||||
tab = executor.planner.knowledge.get_action_for_screen(ScreenType.REELS_FEED)
|
||||
assert tab == "clips_tab"
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
import os
|
||||
|
||||
DUMP_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "debug", "xml_dumps", "post_load_timeout__2026-04-19_00-36-11.xml")
|
||||
|
||||
def test_explore_grid_targeting_from_dump():
|
||||
"""
|
||||
Integration test: verify that the first image in the explore grid is correctly
|
||||
targeted despite potential layout overlays.
|
||||
"""
|
||||
if not os.path.exists(DUMP_PATH):
|
||||
pytest.skip("Diagnostic dump for grid failure not found.")
|
||||
|
||||
with open(DUMP_PATH, "r") as f:
|
||||
xml_content = f.read()
|
||||
|
||||
# Bypass the global autouse=True mock from conftest.py by instantiating directly
|
||||
engine = TelepathicEngine()
|
||||
intent = "first image in explore grid"
|
||||
|
||||
# Debug the nodes
|
||||
nodes = engine._extract_semantic_nodes(xml_content)
|
||||
grid_nodes = []
|
||||
for node in nodes:
|
||||
if node.get("resource_id") in ["com.instagram.android:id/grid_card_layout_container", "com.instagram.android:id/image_button"]:
|
||||
grid_nodes.append(node)
|
||||
|
||||
# Apply our sorting logic manually to see what happens
|
||||
grid_nodes.sort(key=lambda n: (
|
||||
round(n["y"] / 5) * 5,
|
||||
n["x"],
|
||||
n["naf"],
|
||||
-n["area"]
|
||||
))
|
||||
|
||||
print("\nSorted Grid Nodes (Manual Test):")
|
||||
for n in grid_nodes:
|
||||
print(f"Y={n['y']} (rounded={round(n['y']/5)*5}), NAF={n['naf']}, Area={n['area']}, ID={n['resource_id']}")
|
||||
|
||||
result = engine.find_best_node(xml_content, intent)
|
||||
|
||||
assert result is not None
|
||||
assert "grid card" in result["semantic"].lower() or "image button" in result["semantic"].lower()
|
||||
|
||||
def test_verify_success_grid_logic():
|
||||
"""
|
||||
Verifies that the success verification logic correctly identifies if a post opened.
|
||||
"""
|
||||
if not os.path.exists(DUMP_PATH):
|
||||
pytest.skip("Diagnostic dump for grid failure not found.")
|
||||
|
||||
with open(DUMP_PATH, "r") as f:
|
||||
xml_content = f.read()
|
||||
|
||||
# Bypass the global autouse=True mock from conftest.py by instantiating directly
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# CRITICAL: We MUST set a mock click context to prevent early True return
|
||||
TelepathicEngine._last_click_context = {"x": 178, "y": 558}
|
||||
|
||||
# Intent category: grid interaction
|
||||
intent = "first image in explore grid"
|
||||
|
||||
# Verification should return False because the XML is just the grid again
|
||||
success = engine.verify_success(intent, xml_content)
|
||||
assert success is False, "Verification should fail when the UI remains on the grid."
|
||||
@@ -1,35 +0,0 @@
|
||||
import pytest
|
||||
import os
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("RUN_LIVE_HARDWARE_TESTS"), reason="Requires physical hardware and RUN_LIVE_HARDWARE_TESTS=1")
|
||||
def test_autonomous_navigation_to_messages(device):
|
||||
"""
|
||||
E2E Hardness Test:
|
||||
1. Start from Home screen.
|
||||
2. Command 'open messages'.
|
||||
3. Verify bot autonomous discovers the path and executes it.
|
||||
4. Verify final state is DM_INBOX.
|
||||
"""
|
||||
username = "marcmintel" # use current config user
|
||||
executor = GoalExecutor(device, username)
|
||||
executor.planner.knowledge.wipe() # Start with 'Blank Start' to force discovery
|
||||
|
||||
# Ensure we are in Instagram
|
||||
# device.start_app("com.instagram.android")
|
||||
|
||||
print("🚀 Initializing Autonomous Discovery on Hardware...")
|
||||
|
||||
# The achieve loop will:
|
||||
# - Perceive HOME_FEED (hopefully)
|
||||
# - See 'messages' intent -> tap dm_tab or top-right icon
|
||||
# - Verify DM_INBOX
|
||||
success = executor.achieve("open messages")
|
||||
|
||||
assert success is True, "Autonomous navigation failed to reach DMs on live device"
|
||||
|
||||
# Final check of the state
|
||||
screen = executor.perceive()
|
||||
assert screen["screen_type"] == ScreenType.DM_INBOX, f"Expected DM_INBOX, but bot thinks it is on {screen['screen_type']}"
|
||||
|
||||
print("✅ Autonomous hardware test PASSED. Bot discovered and navigated to DMs.")
|
||||
@@ -1,48 +0,0 @@
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
from GramAddict.core.device_facade import DeviceFacade
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("RUN_LIVE_AI_TESTS"), reason="Requires a running local LLM/Ollama instance and RUN_LIVE_AI_TESTS=1")
|
||||
def test_real_llm_pathfinding_on_golden_fixture():
|
||||
"""
|
||||
Validates that the REAL telepathic engine (hitting the real LLM endpoint)
|
||||
can successfully parse a real IG XML dump and locate standard elements
|
||||
without hallucinating or crashing.
|
||||
"""
|
||||
# 1. Load a real XML dump from our golden fixtures
|
||||
fixture_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
|
||||
xml_path = os.path.join(fixture_dir, "explore_feed_dump.xml")
|
||||
|
||||
assert os.path.exists(xml_path), "Golden fixture explore_feed_dump.xml is missing. Make sure sync_fixtures.py was run."
|
||||
|
||||
with open(xml_path, "r", encoding="utf-8") as f:
|
||||
xml_data = f.read()
|
||||
|
||||
# 2. Setup a fresh real engine
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# Mute the actual screencap logic, but provide valid display bounds
|
||||
device = MagicMock(spec=DeviceFacade)
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
|
||||
# Mock screenshot to prevent hitting the adb shell during this specific offline text-parsing test
|
||||
device.screenshot = MagicMock()
|
||||
|
||||
# 3. Fire the intent against the real LLM endpoint.
|
||||
# We force min_confidence=1.1 so it ALWAYS hits the LLM Vision Cortex Fallback
|
||||
# and bypasses the local vector DB cache (to ensure we test the prompt & JSON extraction).
|
||||
result = engine.find_best_node(xml_data, "tap reels tab", min_confidence=1.1, device=device)
|
||||
|
||||
# 4. Assert it actually found something sane instead of hallucinating
|
||||
assert result is not None, "Real LLM failed to understand the XML and return a valid action json."
|
||||
assert "x" in result and "y" in result, "Coordinates missing from VLM payload"
|
||||
|
||||
# The Reels tab on standard IG UI is in the bottom navigation bar
|
||||
# usually around y=2200-2400 and roughly x=540
|
||||
assert result["y"] > 2000, f"Expected reels tab to be at the bottom screen, but got y={result['y']}"
|
||||
assert 400 < result["x"] < 700, f"Expected reels tab to be in bottom middle, but got x={result['x']}"
|
||||
|
||||
print(f"SUCCESS: LLM detected reels tab correctly at x={result['x']}, y={result['y']}")
|
||||
80
tests/integration/test_sae_fallback.py
Normal file
80
tests/integration/test_sae_fallback.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType, EscapeAction
|
||||
|
||||
@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"
|
||||
@@ -81,8 +81,8 @@ def test_full_mission_autopilot_sequence(fsd_fixtures):
|
||||
ui_sequence = [
|
||||
fsd_fixtures["organic"], # 0. First Organic Post
|
||||
fsd_fixtures["ad"], # 1. Ad (Detected via resource-id)
|
||||
fsd_fixtures["modal"], # 2. Modal (Miss 1)
|
||||
fsd_fixtures["modal"], # 3. Modal (Miss 2 -> Telepathic Recovery)
|
||||
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
|
||||
]
|
||||
@@ -151,6 +151,7 @@ def test_full_mission_autopilot_sequence(fsd_fixtures):
|
||||
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, \
|
||||
|
||||
@@ -100,16 +100,16 @@ class TestTelepathicEngineEdgeCases:
|
||||
|
||||
# Valid nodes + Alias testing
|
||||
nodes = [
|
||||
{"semantic_string": "main view section", "x": 10, "y": 10, "area": 100},
|
||||
{"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' is checked against 'main view section' and gets a hit
|
||||
# Threshold: 0.45 for short intents (2 words)
|
||||
# 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 view section"
|
||||
assert res["semantic"] == "main tab section"
|
||||
|
||||
# No matches
|
||||
assert self.engine._keyword_match_score("tap settings menu xyz", nodes) == None
|
||||
|
||||
@@ -87,6 +87,27 @@ class TestNodeExtraction:
|
||||
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'
|
||||
|
||||
@@ -40,7 +40,9 @@ def test_keyword_nav_threshold(engine):
|
||||
|
||||
def test_direct_tab_fast_path(engine):
|
||||
"""
|
||||
Verify that "tap messages tab" now hits the core_nav_fast_path.
|
||||
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,
|
||||
@@ -51,7 +53,9 @@ def test_direct_tab_fast_path(engine):
|
||||
}
|
||||
}
|
||||
|
||||
# Without Qdrant data, fast path returns None (Blank Start)
|
||||
res = engine._core_navigation_fast_path("tap messages tab", [direct_node])
|
||||
assert res is not None
|
||||
assert res["source"] == "core_nav"
|
||||
assert res["x"] == 800
|
||||
|
||||
# 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"
|
||||
|
||||
78
tests/integration/test_vision_post_eval.py
Normal file
78
tests/integration/test_vision_post_eval.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
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
|
||||
@@ -95,7 +95,14 @@ def test_visual_vibe_check_accepts_high_quality(mock_query_llm, mock_get_instanc
|
||||
}
|
||||
|
||||
# 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:
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user