fix(sae): stabilize navigation engine, fix container filtering, and negative reinforcement logic
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import pytest
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from GramAddict.core.bot_flow import _wait_for_post_loaded, _run_zero_latency_feed_loop, FEED_MARKERS
|
||||
from GramAddict.core.device_facade import DeviceFacade
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS, _run_zero_latency_feed_loop, _wait_for_post_loaded
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DUMPS = {
|
||||
@@ -12,14 +13,17 @@ DUMPS = {
|
||||
}
|
||||
FIXTURE_DIR = os.path.join(ROOT_DIR, "fixtures")
|
||||
|
||||
|
||||
def mutate_xml_to_foreign(xml_content: str) -> str:
|
||||
"""Removes meaningful text content to simulate a language failure or empty state."""
|
||||
import re
|
||||
|
||||
# Strip text and content-desc
|
||||
xml = re.sub(r'text="[^"]*"', 'text=""', xml_content)
|
||||
xml = re.sub(r'content-desc="[^"]*"', 'content-desc=""', xml)
|
||||
return xml
|
||||
|
||||
|
||||
def mutate_xml_remove_feed_markers(xml_content: str) -> str:
|
||||
"""Removes all feed markers to simulate a grid view or random popup."""
|
||||
xml = xml_content
|
||||
@@ -27,21 +31,26 @@ def mutate_xml_remove_feed_markers(xml_content: str) -> str:
|
||||
xml = xml.replace(marker, "some_random_id")
|
||||
return xml
|
||||
|
||||
|
||||
class ConfigMock:
|
||||
def __init__(self):
|
||||
self.args = MagicMock()
|
||||
self.args.interact_percentage = 0
|
||||
self.args.comment_percentage = 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_dumps():
|
||||
dumps = {}
|
||||
with open(DUMPS["organic"], "r") as f:
|
||||
dumps["post"] = f.read()
|
||||
# Fake explore grid that lacks ALL feed markers
|
||||
dumps["grid"] = '<?xml version="1.0"?><hierarchy><node resource-id="com.instagram.android:id/explore_grid_container" /></hierarchy>'
|
||||
dumps["grid"] = (
|
||||
'<?xml version="1.0"?><hierarchy><node resource-id="com.instagram.android:id/explore_grid_container" /></hierarchy>'
|
||||
)
|
||||
return dumps
|
||||
|
||||
|
||||
def test_slow_loading_post_recovery(test_dumps):
|
||||
"""
|
||||
Test that _wait_for_post_loaded correctly handles a delay where the
|
||||
@@ -50,33 +59,35 @@ def test_slow_loading_post_recovery(test_dumps):
|
||||
device = MagicMock()
|
||||
# Simulate: Grid -> Grid -> Error -> Post
|
||||
device.dump_hierarchy.side_effect = [
|
||||
test_dumps["grid"],
|
||||
test_dumps["grid"],
|
||||
test_dumps["grid"],
|
||||
Exception("uiautomator2 temp failure"),
|
||||
test_dumps["post"]
|
||||
test_dumps["post"],
|
||||
]
|
||||
|
||||
|
||||
# We patch sleep to make the test super fast
|
||||
with patch('GramAddict.core.bot_flow.sleep', return_value=None):
|
||||
with patch("GramAddict.core.bot_flow.sleep", return_value=None):
|
||||
start = time.time()
|
||||
success = _wait_for_post_loaded(device, timeout=5)
|
||||
# Should return true when it hits the 4th element
|
||||
assert success is True
|
||||
assert device.dump_hierarchy.call_count == 4
|
||||
|
||||
|
||||
def test_wait_timeout_aborts_gracefully(test_dumps):
|
||||
"""Test what happens if the network is so slow it times out entirely."""
|
||||
device = MagicMock()
|
||||
# Always return grid
|
||||
device.dump_hierarchy.return_value = test_dumps["grid"]
|
||||
|
||||
|
||||
# Patch time.time to simulate 6 seconds passing immediately
|
||||
# We add sequence padding because python's logger internally uses time.time()
|
||||
with patch('GramAddict.core.bot_flow.time.time', side_effect=[0, 1, 6, 6, 6, 6, 6, 6, 6, 6]):
|
||||
with patch('GramAddict.core.bot_flow.sleep', return_value=None):
|
||||
with patch("time.time", side_effect=[0, 1, 6, 6, 6, 6, 6, 6, 6, 6]):
|
||||
with patch("GramAddict.core.bot_flow.sleep", return_value=None):
|
||||
success = _wait_for_post_loaded(device, timeout=5)
|
||||
assert success is False
|
||||
|
||||
|
||||
def test_empty_content_extraction_guard(test_dumps):
|
||||
"""
|
||||
Test that if a post is loaded, but it has strange empty text (foreign language or bug),
|
||||
@@ -85,38 +96,47 @@ def test_empty_content_extraction_guard(test_dumps):
|
||||
device = MagicMock()
|
||||
nav_graph = MagicMock()
|
||||
configs = ConfigMock()
|
||||
|
||||
|
||||
# We create a fake active inference engine to just break the loop after 1 iteration
|
||||
ai = MagicMock()
|
||||
# Dopamine engine controls loop exit
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.side_effect = [False, True] # Run once, then exit
|
||||
dopamine.is_app_session_over.side_effect = [False, True] # Run once, then exit
|
||||
dopamine.wants_to_change_feed.return_value = False
|
||||
dopamine.wants_to_doomscroll.return_value = False
|
||||
|
||||
|
||||
cognitive_stack = {
|
||||
"dopamine": dopamine,
|
||||
"active_inference": ai,
|
||||
"resonance": None, "growth_brain": None, "swarm": None, "darwin": None
|
||||
"resonance": None,
|
||||
"growth_brain": None,
|
||||
"swarm": None,
|
||||
"darwin": None,
|
||||
}
|
||||
|
||||
|
||||
# Mutate the post so it has NO text or description
|
||||
broken_xml = mutate_xml_to_foreign(test_dumps["post"])
|
||||
device.dump_hierarchy.return_value = broken_xml
|
||||
|
||||
|
||||
from GramAddict.core.situational_awareness import SituationType
|
||||
with patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
|
||||
patch('GramAddict.core.bot_flow.sleep'), \
|
||||
patch('GramAddict.core.situational_awareness.SituationalAwarenessEngine.perceive', return_value=SituationType.NORMAL):
|
||||
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll,
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch(
|
||||
"GramAddict.core.situational_awareness.SituationalAwarenessEngine.perceive",
|
||||
return_value=SituationType.NORMAL,
|
||||
),
|
||||
):
|
||||
result = _run_zero_latency_feed_loop(device, None, nav_graph, configs, MagicMock(), "HomeFeed", cognitive_stack)
|
||||
|
||||
|
||||
# Ensure scroll was called (the recovery mechanism)
|
||||
assert mock_scroll.called
|
||||
# Check that we never called resonance evaluation because we broke early
|
||||
assert not ai.predict_state.called
|
||||
assert result == "FEED_EXHAUSTED"
|
||||
|
||||
|
||||
def test_missing_feed_markers_guard(test_dumps):
|
||||
"""
|
||||
Test that if the UI is completely foreign (e.g., a system popup),
|
||||
@@ -124,23 +144,23 @@ def test_missing_feed_markers_guard(test_dumps):
|
||||
"""
|
||||
device = MagicMock()
|
||||
configs = ConfigMock()
|
||||
|
||||
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.side_effect = [False, True]
|
||||
dopamine.wants_to_change_feed.return_value = False
|
||||
dopamine.wants_to_doomscroll.return_value = False
|
||||
|
||||
|
||||
cognitive_stack = {"dopamine": dopamine, "growth_brain": None, "active_inference": None}
|
||||
|
||||
|
||||
# Mutate XML to remove all FEED MARKERS
|
||||
alien_xml = mutate_xml_remove_feed_markers(test_dumps["post"])
|
||||
device.dump_hierarchy.return_value = alien_xml
|
||||
|
||||
with patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
|
||||
patch('GramAddict.core.bot_flow.sleep'):
|
||||
|
||||
with patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll, patch("GramAddict.core.bot_flow.sleep"):
|
||||
_run_zero_latency_feed_loop(device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack)
|
||||
|
||||
@patch('GramAddict.core.device_facade.u2')
|
||||
|
||||
|
||||
@patch("GramAddict.core.device_facade.u2")
|
||||
def test_xpath_watcher_initialization(mock_u2):
|
||||
"""
|
||||
Test fixing the critical watcher API bug.
|
||||
@@ -148,21 +168,22 @@ def test_xpath_watcher_initialization(mock_u2):
|
||||
"""
|
||||
mock_d = MagicMock()
|
||||
mock_u2.connect.return_value = mock_d
|
||||
|
||||
|
||||
# Setup mock chain: deviceV2.watcher("crash_dialog").when(...)
|
||||
mock_watcher = MagicMock()
|
||||
mock_d.watcher.return_value = mock_watcher
|
||||
mock_when = MagicMock()
|
||||
mock_watcher.when.return_value = mock_when
|
||||
|
||||
|
||||
# Just init the facade
|
||||
from GramAddict.core.device_facade import create_device
|
||||
|
||||
device = create_device("fake_serial", "com.fake.app", MagicMock())
|
||||
|
||||
|
||||
# Verify exact API call structure for XPath
|
||||
mock_d.watcher.assert_any_call("crash_dialog")
|
||||
mock_d.watcher.assert_any_call("system_dialog")
|
||||
|
||||
|
||||
# We can't perfectly assert the chained arguments natively without a bit of inspection,
|
||||
# but we can verify it didn't crash and called start
|
||||
assert mock_d.watcher.start.called
|
||||
|
||||
69
tests/anomalies/test_telepathic_guards.py
Normal file
69
tests/anomalies/test_telepathic_guards.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
class TestTelepathicGuards:
|
||||
def setup_method(self):
|
||||
self.engine = TelepathicEngine()
|
||||
|
||||
def test_strict_story_ring_guard(self):
|
||||
"""
|
||||
TDD: Story rings MUST be physically near the top of the screen (y < 30%).
|
||||
Post profile headers that appear further down must be aggressively blocked
|
||||
when the intent is 'tap story ring avatar'.
|
||||
"""
|
||||
intent = "tap story ring avatar"
|
||||
screen_height = 2400
|
||||
|
||||
# Valid Story Ring (Top of screen, but below status bar)
|
||||
valid_story = {"resource_id": "reel_ring", "y": 300, "area": 100}
|
||||
assert self.engine._structural_sanity_check(valid_story, intent, screen_height) is True
|
||||
|
||||
# Invalid Story Ring (Hallucination: Post profile header in the feed)
|
||||
invalid_story = {"resource_id": "row_feed_profile_header", "y": 800, "area": 100}
|
||||
assert self.engine._structural_sanity_check(invalid_story, intent, screen_height) is False
|
||||
|
||||
def test_strict_button_guard(self):
|
||||
"""
|
||||
TDD: When explicitly looking for a 'button', nodes that declare themselves
|
||||
as profiles (e.g. 'go to profile') must be blocked, to prevent accidental
|
||||
profile visits when clicking 'like'.
|
||||
"""
|
||||
intent = "Heart like button for comment"
|
||||
screen_height = 2400
|
||||
|
||||
# Valid Like Button
|
||||
valid_btn = {"resource_id": "like_button", "semantic_string": "Like", "y": 1000, "area": 100}
|
||||
assert self.engine._structural_sanity_check(valid_btn, intent, screen_height) is True
|
||||
|
||||
# Invalid Profile Link masquerading as a match due to string proximity
|
||||
invalid_prof = {
|
||||
"resource_id": "username",
|
||||
"semantic_string": "Go to cayleighanddavid's profile",
|
||||
"y": 1000,
|
||||
"area": 100,
|
||||
}
|
||||
assert self.engine._structural_sanity_check(invalid_prof, intent, screen_height) is False
|
||||
|
||||
# However, if the intent *is* profile, it should pass
|
||||
intent_prof = "go to profile"
|
||||
assert self.engine._structural_sanity_check(invalid_prof, intent_prof, screen_height) is True
|
||||
|
||||
def test_like_semantic_verification(self):
|
||||
"""
|
||||
TDD: Verify that 'unlike' is treated as a successful 'Like' action,
|
||||
because tapping 'Like' changes the state to 'Unlike' in English Instagram.
|
||||
"""
|
||||
# Testing the specific regex logic inside verify_success
|
||||
import re
|
||||
|
||||
xml_dump_success = '<node class="android.widget.ImageView" content-desc="Unlike" />'
|
||||
intent = "tap like button"
|
||||
|
||||
marker_found = re.search(r"\b(liked|unlike|gefällt mir nicht mehr|gefällt mir am)\b", xml_dump_success.lower())
|
||||
assert marker_found is not None
|
||||
|
||||
xml_dump_fail = '<node class="android.widget.ImageView" content-desc="Like" />'
|
||||
marker_found_fail = re.search(
|
||||
r"\b(liked|unlike|gefällt mir nicht mehr|gefällt mir am)\b", xml_dump_fail.lower()
|
||||
)
|
||||
assert marker_found_fail is None
|
||||
Reference in New Issue
Block a user