chore: migrate autonomous navigation to GOAP and finalize 100% E2E test stabilization

- Delegate legacy BFS navigation to structure-based GOAP system
- Harden Situational Awareness Engine (SAE) for modal and obstacle clearance
- Fix device sizing calculations during mocked humanized scrolls
- Remove deprecated V8 test stubs and legacy debug entrypoints
- Stabilize Telepathic Engine context parsing thresholds
Result: 66/66 E2E and integration tests passing
This commit is contained in:
2026-04-19 22:14:56 +02:00
parent 0aeed11186
commit ba4d7ffda2
83 changed files with 4735 additions and 1873 deletions

View File

View File

@@ -34,13 +34,15 @@ class TestBotFlowEdgeCases:
res = _extract_post_content(xml)
assert res.get("description") == "some desc with more than 10 chars limits"
@patch('GramAddict.core.bot_flow.random.random', return_value=0.5)
@patch('GramAddict.core.bot_flow.random.uniform', return_value=1.5)
@patch('GramAddict.core.bot_flow.sleep')
@patch('GramAddict.core.bot_flow._humanized_scroll')
@patch('GramAddict.core.bot_flow.dump_ui_state')
@patch('GramAddict.core.bot_flow._detect_ad_structural')
@patch('GramAddict.core.bot_flow.is_ad')
@patch('GramAddict.core.bot_flow._align_active_post')
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
def test_zero_node_recovery(self, mock_get_telepathic, mock_align, mock_ad, mock_dump, mock_scroll, mock_sleep):
def test_zero_node_recovery(self, mock_get_telepathic, mock_align, mock_ad, mock_dump, mock_scroll, mock_sleep, mock_uniform, mock_random):
# Tests the explicit Zero-Node Recovery added previously
device = MagicMock()
zero_engine = MagicMock()
@@ -73,7 +75,7 @@ class TestBotFlowEdgeCases:
mock_engine._extract_semantic_nodes.return_value = []
mock_get_telepathic.return_value = mock_engine
device.deviceV2.dump_hierarchy.return_value = "<xml></xml>"
device.dump_hierarchy.return_value = "<xml></xml>"
# Execute the main loop
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
@@ -82,14 +84,16 @@ class TestBotFlowEdgeCases:
device.deviceV2.press.assert_called_with("back")
assert mock_scroll.call_count >= 1
@patch('GramAddict.core.bot_flow.random.random', return_value=0.5)
@patch('GramAddict.core.bot_flow.random.uniform', return_value=1.5)
@patch('GramAddict.core.bot_flow.sleep')
@patch('GramAddict.core.bot_flow._humanized_scroll')
@patch('GramAddict.core.bot_flow.dump_ui_state')
@patch('GramAddict.core.bot_flow._extract_post_content')
@patch('GramAddict.core.bot_flow._detect_ad_structural')
@patch('GramAddict.core.bot_flow.is_ad')
@patch('GramAddict.core.bot_flow._align_active_post')
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
def test_content_extraction_failed_recovery(self, mock_get_telepathic, mock_align, mock_ad, mock_extract, mock_dump, mock_scroll, mock_sleep):
def test_content_extraction_failed_recovery(self, mock_get_telepathic, mock_align, mock_ad, mock_extract, mock_dump, mock_scroll, mock_sleep, mock_uniform, mock_random):
device = MagicMock()
zero_engine = MagicMock()
nav_graph = MagicMock()
@@ -110,7 +114,7 @@ class TestBotFlowEdgeCases:
session_state.check_limit.return_value = [False]*10
# Ensure it HAS feed markers
device.deviceV2.dump_hierarchy.return_value = "<xml>row_feed_photo_profile_name</xml>"
device.dump_hierarchy.return_value = "<xml>row_feed_photo_profile_name</xml>"
# Ensure interactive_nodes is NOT zero
mock_engine = MagicMock()
@@ -126,3 +130,61 @@ class TestBotFlowEdgeCases:
mock_scroll.assert_called_once()
mock_dump.assert_called_with(device, "content_extraction_failed", {"feed": "HomeFeed"})
@patch('GramAddict.core.bot_flow.sleep')
@patch('GramAddict.core.bot_flow._humanized_scroll')
@patch('GramAddict.core.bot_flow.is_ad')
@patch('GramAddict.core.bot_flow._align_active_post')
@patch('GramAddict.core.bot_flow._extract_post_content')
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
@patch('GramAddict.core.llm_provider.query_llm')
def test_llm_timeout_handled_smoothly(self, mock_query_llm, mock_get_telepathic, mock_extract, mock_align, mock_ad, mock_scroll, mock_sleep):
"""
TDD Test: Verifies that if qwen3.5:latest times out during comment generation
(simulated by query_llm returning None after circuit breaker), the bot_flow
catches the empty response and continues gracefully without crashing.
"""
device = MagicMock()
zero_engine = MagicMock()
nav_graph = MagicMock()
configs = MagicMock()
session_state = MagicMock()
mock_ad.return_value = False
mock_align.return_value = False
# Make the LLM generation completely timeout and return None
mock_query_llm.return_value = None
cognitive_stack = {
"dopamine": MagicMock(),
"darwin": MagicMock(),
"resonance": MagicMock()
}
# break after 1 loop
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
# Emulate that dopamine WANTS to comment
cognitive_stack["dopamine"].get_action_desires.return_value = {"comment": True, "like": False}
# Avoid MagicMock comparison errors in Resonance Engine
cognitive_stack["resonance"].calculate_resonance.return_value = 0.8
session_state.check_limit.return_value = [False]*10
device.dump_hierarchy.return_value = "<xml>row_feed_photo_profile_name</xml>"
mock_engine = MagicMock()
mock_engine._extract_semantic_nodes.return_value = [{"x": 10}]
mock_get_telepathic.return_value = mock_engine
# Valid post content so it proceeds to comment generation
mock_extract.return_value = {"username": "test_user", "description": "a long enough description"}
# Run feed loop - MUST NOT CRASH
try:
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
except Exception as e:
pytest.fail(f"Feed loop crashed on LLM timeout with {e}")

View File

@@ -49,7 +49,7 @@ def test_slow_loading_post_recovery(test_dumps):
"""
device = MagicMock()
# Simulate: Grid -> Grid -> Error -> Post
device.deviceV2.dump_hierarchy.side_effect = [
device.dump_hierarchy.side_effect = [
test_dumps["grid"],
test_dumps["grid"],
Exception("uiautomator2 temp failure"),
@@ -62,13 +62,13 @@ def test_slow_loading_post_recovery(test_dumps):
success = _wait_for_post_loaded(device, timeout=5)
# Should return true when it hits the 4th element
assert success is True
assert device.deviceV2.dump_hierarchy.call_count == 4
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.deviceV2.dump_hierarchy.return_value = test_dumps["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()
@@ -102,7 +102,7 @@ def test_empty_content_extraction_guard(test_dumps):
# Mutate the post so it has NO text or description
broken_xml = mutate_xml_to_foreign(test_dumps["post"])
device.deviceV2.dump_hierarchy.return_value = broken_xml
device.dump_hierarchy.return_value = broken_xml
with patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
patch('GramAddict.core.bot_flow.sleep'):
@@ -113,7 +113,7 @@ def test_empty_content_extraction_guard(test_dumps):
assert mock_scroll.called
# Check that we never called resonance evaluation because we broke early
assert not ai.predict_state.called
assert result == "SESSION_OVER"
assert result == "FEED_EXHAUSTED"
def test_missing_feed_markers_guard(test_dumps):
"""
@@ -132,7 +132,7 @@ def test_missing_feed_markers_guard(test_dumps):
# Mutate XML to remove all FEED MARKERS
alien_xml = mutate_xml_remove_feed_markers(test_dumps["post"])
device.deviceV2.dump_hierarchy.return_value = alien_xml
device.dump_hierarchy.return_value = alien_xml
with patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
patch('GramAddict.core.bot_flow.sleep'):

View File

@@ -19,11 +19,21 @@ class DummyDevice:
return "fake_screenshot"
def __init__(self):
import unittest
self.deviceV2 = self.DeviceV2()
self.app_id = "com.instagram.android"
self.args = unittest.mock.MagicMock()
self.args.ai_telepathic_model = "qwen2.5:3b"
self.args.ai_telepathic_url = "http://localhost:11434/api/generate"
def _get_current_app(self):
return "com.instagram.android"
def get_info(self):
return {"displayHeight": 2400, "displayWidth": 1080}
def screenshot(self, path=None):
return "fake_screenshot"
class TestHumanHesitation(unittest.TestCase):
def setUp(self):
@@ -51,7 +61,8 @@ class TestHumanHesitation(unittest.TestCase):
result = self.telepathic.find_best_node(
synthetic_dump,
"Discard or Verwerfen popup button to cancel comment",
device=self.device
device=self.device,
min_confidence=0.5
)
# Assert (Should hit the [600,1200][800,1300] box, which centers to (700, 1250))

View File

@@ -12,6 +12,8 @@ class TestQNavGraphEdgeCases:
def setup_graph(self):
self.device = MagicMock()
self.device.app_id = "com.instagram.android"
self.device.deviceV2.info = {"screenOn": True}
self.device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" /></hierarchy>'
self.device._get_current_app = MagicMock(return_value="com.instagram.android")
# Prevent Dojo engine instantiation during tests
@@ -52,44 +54,60 @@ class TestQNavGraphEdgeCases:
# BFS should find shortest path (len 2)
assert len(self.graph._find_path("Start", "End")) == 2
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
@patch('GramAddict.core.situational_awareness.random_sleep', return_value=None)
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
def test_execute_transition_edge_cases(self, mock_get_telepathic):
def test_execute_transition_edge_cases(self, mock_get_telepathic, mock_sae_sleep, mock_q_rand_sleep, mock_q_sleep):
from GramAddict.core.telepathic_engine import TelepathicEngine
mock_engine = MagicMock(spec=TelepathicEngine)
mock_get_telepathic.return_value = mock_engine
zero_engine = MagicMock()
# Case 1: Telepathic engine finds nothing
mock_engine.find_best_node.return_value = None
# If still in Instagram, it returns False
self.device._get_current_app.return_value = "com.instagram.android"
assert self.graph._execute_transition("unknown_action", zero_engine) == False
assert self.graph._execute_transition("unknown_action", mock_engine) == False
# If app is different, it returns "CONTEXT_LOST"
self.device._get_current_app.return_value = "com.android.launcher3"
assert self.graph._execute_transition("unknown_action", zero_engine) == "CONTEXT_LOST"
assert self.graph._execute_transition("unknown_action", mock_engine) == "CONTEXT_LOST"
# Case 2: Best node has skip flag
mock_engine.find_best_node.return_value = {"skip": True}
assert self.graph._execute_transition("already_done_action", zero_engine) == True
assert self.graph._execute_transition("already_done_action", mock_engine) == True
# Case 3: Proper interaction, but XML doesn't change (verification fail)
mock_engine.find_best_node.return_value = {"x": 10, "y": 10, "score": 0.9}
self.device.deviceV2.dump_hierarchy.side_effect = ["<xml>same</xml>", "<xml>same</xml>"]
assert self.graph._execute_transition("click_action", zero_engine) == False
mock_engine.reject_click.assert_called_once()
same_xml = '<hierarchy><node package="com.instagram.android" class="same" /></hierarchy>'
self.device.dump_hierarchy.side_effect = None
self.device.dump_hierarchy.return_value = same_xml
assert self.graph._execute_transition("click_action", mock_engine) == False
assert mock_engine.reject_click.call_count == 3
# Case 4: Proper interaction, XML changes (verification pass)
mock_engine.reset_mock()
mock_engine.find_best_node.return_value = {"x": 10, "y": 10, "score": 0.9}
self.device.deviceV2.dump_hierarchy.side_effect = ["<xml>before</xml>", "<xml>after</xml>"]
assert self.graph._execute_transition("click_action", zero_engine) == True
before_xml = '<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>'
after_xml = '<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>'
initial_clicks = self.device.click.call_count
def dynamic_xml(*args, **kwargs):
return after_xml if self.device.click.call_count > initial_clicks else before_xml
self.device.dump_hierarchy.side_effect = dynamic_xml
# Explicitly ensure verify_success is truthy
mock_engine.verify_success.return_value = True
assert self.graph._execute_transition("click_action", mock_engine) == True
mock_engine.confirm_click.assert_called_once()
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
@patch('GramAddict.core.situational_awareness.random_sleep', return_value=None)
@patch('GramAddict.core.dojo_engine.DojoEngine.get_instance')
def test_navigate_to_recovery_edge_cases(self, mock_dojo):
def test_navigate_to_recovery_edge_cases(self, mock_dojo, mock_sae_sleep, mock_q_rand_sleep, mock_q_sleep):
# We test the deepest recovery logic: when everything fails
zero_engine = MagicMock()

View File

@@ -0,0 +1,69 @@
import sys
import os
import unittest
import types
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.telepathic_engine import TelepathicEngine
class TestTrapEscape(unittest.TestCase):
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
@patch('GramAddict.core.situational_awareness.random_sleep', return_value=None)
def test_trap_guard_autonomous_ai_escape(self, mock_sae_sleep, mock_q_rand_sleep, mock_q_sleep):
print("Starting TDD: Testing autonomous Trap Escape with semantic bypass...")
# 1. Setup mocks
mock_device = MagicMock()
mock_device.app_id = "com.instagram.android"
mock_device._get_current_app.return_value = "com.instagram.android"
dump_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../debug/xml_dumps/manual_interrupt__2026-04-18_16-09-11.xml'))
with open(dump_path, 'r', encoding='utf-8') as f:
trap_xml = f.read()
current_xml = [trap_xml]
# Dynamic dump that changes after click
def dynamic_dump():
return current_xml[0]
def dynamic_click(**kwargs):
if kwargs.get('obj') and kwargs['obj'].get('semantic') and "done" in kwargs['obj'].get('semantic').lower():
current_xml[0] = "<html><node text='Reels'/><node text='Home'/></html>"
mock_device.dump_hierarchy.side_effect = dynamic_dump
mock_device.click.side_effect = dynamic_click
nav_graph = QNavGraph(device=mock_device)
engine = TelepathicEngine.get_instance()
engine.confirm_click = MagicMock()
engine.reject_click = MagicMock()
original_find_best_node = engine.find_best_node
def spy_find_best_node(xml_hierarchy, intent_description, **kwargs):
if "tap home tab" in intent_description.lower():
return None
return original_find_best_node(xml_hierarchy, intent_description, **kwargs)
engine.find_best_node = spy_find_best_node
nav_graph.engine = engine # explicitly enforce
# 2. Execute transition
# Mock engine finds nothing, triggering the final fallback escape
result = nav_graph._execute_transition("tap_home_tab", max_retries=1, mock_semantic_engine=engine)
# 3. Assertions
# The new SAE/nav_graph behavior explicitly presses BACK when 'tap_home_tab' fails after all retries
self.assertTrue(mock_device.deviceV2.press.called, "Trap guard did not autonomously press BACK to escape the sub-view!")
called_key = mock_device.deviceV2.press.call_args_list[0][0][0]
self.assertEqual(called_key, "back")
print("TDD SUCCESS: Autonomous Backend fallback confirmed.")
if __name__ == '__main__':
unittest.main()