diff --git a/tests/e2e/dump_legend.py b/tests/e2e/dump_legend.py index 980ea2a..0d199f5 100644 --- a/tests/e2e/dump_legend.py +++ b/tests/e2e/dump_legend.py @@ -19,19 +19,22 @@ def inspect_nodes(base_name): # We want to use the EXACT intent resolver logic resolver = IntentResolver() - # Let's mock the device - class DummyDeviceV2: + # Let's mock the device using DeviceFacade + from GramAddict.core.device_facade import DeviceFacade + + class MockU2Device: def __init__(self, img_path): self.img = Image.open(img_path) + self.info = {"sdkInt": 30, "displaySizeDpX": 400, "displayWidth": 1080, "screenOn": True} def screenshot(self): return self.img - class DummyDevice: - def __init__(self, img_path): - self.deviceV2 = DummyDeviceV2(img_path) - - device = DummyDevice(jpg_path) + device = object.__new__(DeviceFacade) + device.device_id = "test_device" + device.app_id = "com.instagram.android" + device.args = None + device.deviceV2 = MockU2Device(jpg_path) b64, box_map = resolver._annotate_screenshot_with_candidates(device, candidates) for idx in sorted(box_map.keys()): diff --git a/tests/e2e/test_follow_verification_integrity.py b/tests/e2e/test_follow_verification_integrity.py index a585fbe..33f0266 100644 --- a/tests/e2e/test_follow_verification_integrity.py +++ b/tests/e2e/test_follow_verification_integrity.py @@ -15,10 +15,10 @@ Root cause chain: Each test MUST fail (RED) before any production code is fixed. """ -from unittest.mock import MagicMock, patch - +from GramAddict.core.config import Config from GramAddict.core.perception.action_memory import ActionMemory from GramAddict.core.perception.spatial_parser import SpatialNode +from GramAddict.core.session_state import SessionState # ═══════════════════════════════════════════════════════ # TEST 1: verify_success MUST reject wrong-element clicks for follow @@ -35,7 +35,7 @@ class TestVerifySuccessRejectsWrongFollowElement: """ def setup_method(self): - self.memory = ActionMemory(ui_memory=MagicMock()) + self.memory = ActionMemory() def test_follow_toggle_rejects_when_clicked_element_is_photo(self): """ @@ -131,7 +131,7 @@ class TestQNavGraphDoBlocksFollowWithoutButton: when the current screen has no Follow button. """ - def test_do_rejects_follow_when_not_in_available_actions(self): + def test_do_rejects_follow_when_not_in_available_actions(self, make_real_device_with_xml): """ If the current screen's available_actions does not contain 'tap follow button', QNavGraph.do("tap 'Follow' button") MUST return False immediately. @@ -141,25 +141,23 @@ class TestQNavGraphDoBlocksFollowWithoutButton: """ from GramAddict.core.q_nav_graph import QNavGraph - device = MagicMock() - device.dump_hierarchy.return_value = "" - device.app_id = "com.instagram.android" + device = make_real_device_with_xml("") - # Mock GOAP perceive to return a screen without 'follow' in available_actions - mock_screen = { - "screen_type": MagicMock(value="OTHER_PROFILE"), - "available_actions": ["tap like button", "tap comment button", "scroll down"], - } + import types - with patch.object(QNavGraph, "__init__", lambda self, dev: None): - nav = QNavGraph.__new__(QNavGraph) - nav.device = device + from GramAddict.core.config import Config + from GramAddict.core.session_state import SessionState - mock_goap = MagicMock() - mock_goap.perceive.return_value = mock_screen - nav.goap = mock_goap + configs = Config(first_run=True) + configs.args = types.SimpleNamespace() + configs.args.disable_ai_messaging = False + configs.args.ai_condenser_model = "qwen3.5:latest" + configs.args.ai_condenser_url = "http://localhost:11434/api/generate" + SessionState(configs) - result = nav.do("tap 'Follow' button") + nav = QNavGraph(device) + + result = nav.do("tap 'Follow' button") assert result is False, ( "QNavGraph.do() allowed 'follow' to proceed without checking " @@ -189,9 +187,7 @@ class TestActionMemoryNeverConfirmsMismatch: Currently: confirm_click() blindly stores whatever was tracked, poisoning the memory DB. """ - mock_ui_memory = MagicMock() - mock_ui_memory.retrieve_memory.return_value = None - memory = ActionMemory(ui_memory=mock_ui_memory) + memory = ActionMemory() # Track a click on the WRONG element wrong_node = SpatialNode( @@ -209,7 +205,12 @@ class TestActionMemoryNeverConfirmsMismatch: # Qdrant store_memory should NOT have been called because # the element has nothing to do with 'follow' - assert not mock_ui_memory.store_memory.called, ( + # Since we use the real ActionMemory and Qdrant backend, we can verify + # that the memory wasn't stored by checking retrieve_memory directly. + from GramAddict.core.qdrant_memory import UIMemoryDB + + db = UIMemoryDB() + assert db.retrieve_memory("tap 'Follow' button", "") is None, ( "CRITICAL: ActionMemory.confirm_click() stored a PHOTO GRID ITEM " "as the successful click target for 'tap Follow button'! " "This poisons Qdrant and causes the same wrong click on every future run." @@ -232,7 +233,7 @@ class TestGOAPInteractionCrossCheck: and the intent BEFORE trusting the VLM verification. """ - def test_execute_action_rejects_when_clicked_node_doesnt_match_intent(self): + def test_execute_action_rejects_when_clicked_node_doesnt_match_intent(self, make_real_device_with_xml): """ If find_best_node returns a node with desc='3 photos by ...' for intent='tap Follow button', _execute_action MUST reject it @@ -241,39 +242,41 @@ class TestGOAPInteractionCrossCheck: Currently: _execute_action clicks first, then asks VLM to verify. The VLM verification is the fox guarding the henhouse. """ + import types + + from GramAddict.core.config import Config from GramAddict.core.goap import GoalExecutor - device = MagicMock() - device.dump_hierarchy.return_value = "" - device.app_id = "com.instagram.android" + configs = Config(first_run=True) + configs.args = types.SimpleNamespace() + configs.args.disable_ai_messaging = False + configs.args.ai_condenser_model = "qwen3.5:latest" + configs.args.ai_condenser_url = "http://localhost:11434/api/generate" + + xml_dump = """ + + +""" + + device = make_real_device_with_xml(xml_dump) + + # Track shell calls to verify no native click/swipe happened + device.shell_calls = [] + + def tracking_shell(cmd): + device.shell_calls.append(cmd) + + device.deviceV2.shell = tracking_shell executor = GoalExecutor(device, bot_username="testbot") - # Mock TelepathicEngine to return a photo node for a follow intent - mock_node = { - "x": 180, - "y": 580, - "text": "", - "description": "3 photos by Mission Green Energy at row 1, column 3", - "id": "com.instagram.android:id/image_button", - "class": "android.widget.ImageView", - "score": 0.7, - } + # No perceive mocking: the real ScreenIdentity will classify as OBSTACLE_FOREIGN_APP + # which means available_actions is empty. - with patch("GramAddict.core.telepathic_engine.TelepathicEngine") as MockTE: - mock_engine = MagicMock() - MockTE.get_instance.return_value = mock_engine - mock_engine.find_best_node.return_value = mock_node - - # Mock perceive to return a dummy screen state - executor.screen_id = MagicMock() - executor.screen_id.identify.return_value = { - "screen_type": MagicMock(value="OTHER_PROFILE"), - "available_actions": [], - "context": {}, - } - - result = executor._execute_action("tap 'Follow' button") + result = executor._execute_action("tap 'Follow' button") # The method should have rejected this node BEFORE clicking assert result is False, ( @@ -281,8 +284,8 @@ class TestGOAPInteractionCrossCheck: "There is no pre-click sanity check that the selected node " "semantically matches the intent." ) - # Verify that device.click was NOT called - device.click.assert_not_called() + # Verify that device.deviceV2.shell was NOT called + assert len(device.shell_calls) == 0 # ═══════════════════════════════════════════════════════ @@ -298,7 +301,7 @@ class TestFollowPluginEndToEnd: session state is corrupted. """ - def test_follow_plugin_does_not_count_follow_when_wrong_element_clicked(self): + def test_follow_plugin_does_not_count_follow_when_wrong_element_clicked(self, make_real_device_with_xml): """ If nav_graph.do() returns True but actually clicked a photo, the session_state.add_interaction(followed=True) poisons the stats. @@ -311,20 +314,40 @@ class TestFollowPluginEndToEnd: plugin = FollowPlugin() - # Build a minimal BehaviorContext - mock_session = MagicMock() - mock_configs = MagicMock() - mock_configs.args.follow_percentage = 100 - plugin.get_config = MagicMock(return_value={"percentage": "100"}) + import types - mock_nav = MagicMock() - # nav_graph.do() returns True (the lie) - mock_nav.do.return_value = True + configs = Config(first_run=True) + configs.args = types.SimpleNamespace() + configs.args.follow_percentage = 100 + configs.args.current_likes_limit = 300 + configs.config = {"plugins": {"follow": {"percentage": 100}}} + + session_state = SessionState(configs) + session_state.added_interactions = [] # Just add an array directly to the real SessionState to spy on it + + # Override add_interaction to spy on it + original_add_interaction = session_state.add_interaction + + def spy_add_interaction(source, succeed, followed, scraped): + session_state.added_interactions.append( + {"source": source, "succeed": succeed, "followed": followed, "scraped": scraped} + ) + original_add_interaction(source, succeed, followed, scraped) + + session_state.add_interaction = spy_add_interaction + + from GramAddict.core.q_nav_graph import QNavGraph + + mock_nav = QNavGraph(make_real_device_with_xml("")) + # Force do() to return True by monkeypatching the instance method just for the test's scope + import types + + mock_nav.do = types.MethodType(lambda self, intent: True, mock_nav) ctx = BehaviorContext( - device=MagicMock(), - session_state=mock_session, - configs=mock_configs, + device=make_real_device_with_xml(""), + session_state=session_state, + configs=configs, username="missiongreenenergy", cognitive_stack={"nav_graph": mock_nav}, ) @@ -342,9 +365,9 @@ class TestFollowPluginEndToEnd: # But HERE is the real assertion: the session state should NOT record # a follow if there's no structural proof the follow happened. # This proves the plugin has no independent verification. - mock_session.add_interaction.assert_called_once() - call_kwargs = mock_session.add_interaction.call_args - assert call_kwargs[1].get("followed") is True or call_kwargs.kwargs.get("followed") is True, ( + assert len(session_state.added_interactions) == 1 + interaction = session_state.added_interactions[0] + assert interaction["followed"] is True, ( "Plugin recorded followed=True — but it has NO independent verification! " "This test documents the architectural gap: FollowPlugin blindly trusts QNavGraph.do()." ) diff --git a/tests/e2e/test_nav_home_feed.py b/tests/e2e/test_nav_home_feed.py index e0d72a1..2a0c01a 100644 --- a/tests/e2e/test_nav_home_feed.py +++ b/tests/e2e/test_nav_home_feed.py @@ -5,7 +5,6 @@ the home feed using a REAL XML dump, without relying on legacy mocks. """ import pytest -from PIL import Image from GramAddict.core.perception.intent_resolver import IntentResolver from GramAddict.core.perception.spatial_parser import SpatialParser @@ -16,28 +15,8 @@ def _load_home_feed_xml(): return f.read() -def _make_device_with_real_image(img_path): - """ - Returns a mock device that provides the REAL screenshot captured directly from the device. - """ - img = Image.open(img_path) - - class DummyDeviceV2: - def __init__(self, img): - self.img = img - - def screenshot(self): - return self.img - - class DummyDevice: - def __init__(self, img): - self.deviceV2 = DummyDeviceV2(img) - - return DummyDevice(img) - - @pytest.mark.live_llm -def test_home_feed_like_button_extraction(): +def test_home_feed_like_button_extraction(make_real_device_with_image): """ Tests if the VLM can find the like button on a real home feed dump. """ @@ -46,7 +25,7 @@ def test_home_feed_like_button_extraction(): root = parser.parse(xml) candidates = parser.get_clickable_nodes(root) - device = _make_device_with_real_image("tests/fixtures/home_feed_with_ad.jpg") + device = make_real_device_with_image("tests/fixtures/home_feed_with_ad.jpg") resolver = IntentResolver() result = resolver._visual_discovery("tap like button", candidates, device) @@ -71,7 +50,7 @@ def test_home_feed_like_button_extraction(): @pytest.mark.live_llm -def test_home_feed_post_author_extraction(): +def test_home_feed_post_author_extraction(make_real_device_with_image): """ Tests if the VLM can identify the post author's header/username. """ @@ -80,7 +59,7 @@ def test_home_feed_post_author_extraction(): root = parser.parse(xml) candidates = parser.get_clickable_nodes(root) - device = _make_device_with_real_image("tests/fixtures/home_feed_with_ad.jpg") + device = make_real_device_with_image("tests/fixtures/home_feed_with_ad.jpg") resolver = IntentResolver() result = resolver._visual_discovery("tap post author username", candidates, device) @@ -93,7 +72,7 @@ def test_home_feed_post_author_extraction(): @pytest.mark.live_llm -def test_home_feed_comment_button_extraction(): +def test_home_feed_comment_button_extraction(make_real_device_with_image): """ Tests if the VLM can find the comment button to open the comment sheet. """ @@ -102,7 +81,7 @@ def test_home_feed_comment_button_extraction(): root = parser.parse(xml) candidates = parser.get_clickable_nodes(root) - device = _make_device_with_real_image("tests/fixtures/home_feed_with_ad.jpg") + device = make_real_device_with_image("tests/fixtures/home_feed_with_ad.jpg") resolver = IntentResolver() result = resolver._visual_discovery("tap comment button", candidates, device) diff --git a/tests/e2e/test_nav_reels.py b/tests/e2e/test_nav_reels.py index e95c1ae..532828d 100644 --- a/tests/e2e/test_nav_reels.py +++ b/tests/e2e/test_nav_reels.py @@ -22,33 +22,13 @@ def _load_reel_xml(): return f.read() -def _make_device_with_real_image(img_path): - """Creates a mock device that returns the REAL screenshot captured from the device.""" - from PIL import Image - - img = Image.open(img_path) - - class DummyDeviceV2: - def __init__(self, img): - self.img = img - - def screenshot(self): - return self.img - - class DummyDevice: - def __init__(self, img): - self.deviceV2 = DummyDeviceV2(img) - - return DummyDevice(img) - - # ═══════════════════════════════════════════════════════════════════════════ # TEST 1: "tap like button" must select the HEART, not the caption # ═══════════════════════════════════════════════════════════════════════════ @pytest.mark.live_llm -def test_reel_like_button_not_caption(): +def test_reel_like_button_not_caption(make_real_device_with_image): """ PRODUCTION BUG: VLM selected the caption ('would you like to try this...') instead of the heart icon for 'tap like button'. @@ -63,7 +43,7 @@ def test_reel_like_button_not_caption(): root = parser.parse(xml) candidates = parser.get_clickable_nodes(root) - device = _make_device_with_real_image("tests/fixtures/reels_feed_dump.jpg") + device = make_real_device_with_image("tests/fixtures/reels_feed_dump.jpg") resolver = IntentResolver() result = resolver._visual_discovery("tap like button", candidates, device) @@ -93,7 +73,7 @@ def test_reel_like_button_not_caption(): @pytest.mark.live_llm -def test_reel_follow_button_returns_none_when_absent(): +def test_reel_follow_button_returns_none_when_absent(make_real_device_with_image): """ PRODUCTION BUG: VLM selected the comment input field ('Add comment…') for 'tap follow button' because there IS no follow button on Reels. @@ -108,7 +88,7 @@ def test_reel_follow_button_returns_none_when_absent(): root = parser.parse(xml) candidates = parser.get_clickable_nodes(root) - device = _make_device_with_real_image("tests/fixtures/reels_feed_dump.jpg") + device = make_real_device_with_image("tests/fixtures/reels_feed_dump.jpg") resolver = IntentResolver() result = resolver._visual_discovery("tap follow button", candidates, device) @@ -139,7 +119,7 @@ def test_reel_follow_button_returns_none_when_absent(): @pytest.mark.live_llm -def test_reel_post_author_selects_username(): +def test_reel_post_author_selects_username(make_real_device_with_image): """ PRODUCTION BUG: VLM selected the action_bar container for 'post author header'. @@ -152,7 +132,7 @@ def test_reel_post_author_selects_username(): root = parser.parse(xml) candidates = parser.get_clickable_nodes(root) - device = _make_device_with_real_image("tests/fixtures/reels_feed_dump.jpg") + device = make_real_device_with_image("tests/fixtures/reels_feed_dump.jpg") resolver = IntentResolver() result = resolver._visual_discovery("tap post author username", candidates, device) @@ -172,7 +152,7 @@ def test_reel_post_author_selects_username(): # ═══════════════════════════════════════════════════════════════════════════ -def test_reel_dedup_preserves_like_button(): +def test_reel_dedup_preserves_like_button(make_real_device_with_image): """ The spatial dedup must NOT suppress the like_button. If the like_button is inside a parent container and gets deduped, @@ -183,7 +163,7 @@ def test_reel_dedup_preserves_like_button(): root = parser.parse(xml) candidates = parser.get_clickable_nodes(root) - device = _make_device_with_real_image("tests/fixtures/reels_feed_dump.jpg") + device = make_real_device_with_image("tests/fixtures/reels_feed_dump.jpg") resolver = IntentResolver() _, box_map = resolver._annotate_screenshot_with_candidates(device, candidates) @@ -201,7 +181,7 @@ def test_reel_dedup_preserves_like_button(): # ═══════════════════════════════════════════════════════════════════════════ -def test_reel_caption_with_like_word_is_not_like_button(): +def test_reel_caption_with_like_word_is_not_like_button(make_real_device_with_image): """ The reel fixture has a caption: 'would you like to try this line?' This text contains the word "like" but is NOT a like button. @@ -214,7 +194,7 @@ def test_reel_caption_with_like_word_is_not_like_button(): root = parser.parse(xml) candidates = parser.get_clickable_nodes(root) - device = _make_device_with_real_image("tests/fixtures/reels_feed_dump.jpg") + device = make_real_device_with_image("tests/fixtures/reels_feed_dump.jpg") resolver = IntentResolver() _, box_map = resolver._annotate_screenshot_with_candidates(device, candidates)