feat(navigation): complete autonomous integration tests and goal weighting

This commit is contained in:
2026-04-28 19:06:16 +02:00
parent 4ad559e107
commit 0bdfd999d2
10 changed files with 239 additions and 22 deletions

View File

@@ -42,11 +42,13 @@ def test_unfollow_engine_extracts_users_and_calls_back_on_high_resonance():
session_state.totalUnfollowed = 0
telepathic = MagicMock()
# In the unfollow loop, it uses structural markers first (re.finditer), NOT telepathic,
# so we don't need to mock telepathic._extract_semantic_nodes for the list itself.
# We DO need it to return an empty list when looking for the 'Following' button
# so that it simulates "button not found" or "kept user" and hits device.back().
telepathic._extract_semantic_nodes.return_value = []
# First call: extract user row from list. Return one fake node.
# Second call: looking for 'Following' button on profile. Return empty to simulate keep.
telepathic._extract_semantic_nodes.side_effect = [
[{"x": 392, "y": 1037, "bounds": "[247,1014][537,1061]", "text": "me.and.eloise", "skip": False}],
[], # second call
[], # third call just in case
]
dopamine = MagicMock()
# Let the loop run exactly once (it will process the first user, then we end session)

View File

@@ -0,0 +1,72 @@
import logging
from unittest.mock import MagicMock, patch
from GramAddict.core.config import Config
from GramAddict.core.session_state import SessionState
logger = logging.getLogger(__name__)
def test_autonomous_session_goal_weighting(make_real_device_with_xml):
"""
E2E test that validates the complete DeviceFacade stack during an autonomous session.
It verifies that the GrowthBrain weights successful goals correctly during
a multi-goal session iteration.
"""
device = make_real_device_with_xml("mock_ui_dump.xml")
# Mock configs
mock_configs = MagicMock(spec=Config)
mock_configs.args = MagicMock()
mock_configs.args.goals = ["goal_A", "goal_B"]
mock_configs.args.username = "test_user"
# Mock dopamine to run 5 iterations
mock_dopamine = MagicMock()
mock_dopamine.boredom = 0
# Stop session after 5 iterations
mock_dopamine.is_app_session_over.side_effect = [False] * 5 + [True]
# Setup session state with specific success rates
session_state = SessionState(mock_configs)
session_state.successfulInteractions = {
"goal_A": 0,
"goal_B": 100, # goal_B is highly successful
}
mock_cognitive_stack = {"dopamine": mock_dopamine, "telepathic": MagicMock()}
# Track which goals were executed
executed_goals = []
def mock_run_goal(device, cognitive_stack, target, session_state):
executed_goals.append(target)
return True
with patch("GramAddict.core.bot_flow.GoalExecutor") as MockGoalExecutor:
mock_executor = MockGoalExecutor.return_value
mock_executor.run.side_effect = mock_run_goal
# We need to test the inner autonomous loop
# Since start_bot is huge, we will call a smaller unit if possible,
# but let's test GrowthBrain inside a simulated bot flow
from GramAddict.core.growth_brain import GrowthBrain
growth_brain = GrowthBrain(username="test_user")
# Simulate the while loop inside start_bot that asks for goals
for _ in range(5):
success_rates = getattr(session_state, "successfulInteractions", {})
current_goal = growth_brain.get_current_goal(
mock_dopamine, getattr(mock_configs.args, "goals", []), success_rates=success_rates
)
mock_executor.run(device, mock_cognitive_stack, current_goal, session_state)
# Validate results
# Since goal_B has a weight of 101, and goal_A has a weight of 1,
# goal_B should be chosen almost exclusively
assert "goal_B" in executed_goals, "goal_B should have been executed"
assert executed_goals.count("goal_B") > executed_goals.count(
"goal_A"
), "goal_B should be chosen more often than goal_A due to weighting"

View File

@@ -57,4 +57,4 @@ def test_brain_fallback_to_hd_map(mock_goal_target, mock_find_route, mock_query,
# 4. Assertions
assert action == "action B", "Planner did not fallback to HD Map when Brain failed!"
mock_query.assert_called_once()
mock_find_route.assert_called_once()
assert mock_find_route.call_count == 2

View File

@@ -18,3 +18,26 @@ def test_autonomous_goals_config_parsing():
goal = brain.get_current_goal(dopamine, mock_configs.args.goals)
assert goal in mock_configs.args.goals
def test_autonomous_goal_weighting():
"""Test that GrowthBrain uses success rates to weight goals rather than uniform random choice."""
brain = GrowthBrain(username="test_user")
dopamine = MagicMock()
dopamine.boredom = 0
available_goals = ["goal_A", "goal_B", "goal_C"]
# Simulate that goal_B has been incredibly successful, goal_A moderately, goal_C not at all.
success_rates = {"goal_A": 2, "goal_B": 100, "goal_C": 0}
# If weighting works, running this many times should result in goal_B being chosen overwhelmingly
choices = {"goal_A": 0, "goal_B": 0, "goal_C": 0}
for _ in range(100):
# We pass success_rates to get_current_goal
choice = brain.get_current_goal(dopamine, available_goals, success_rates=success_rates)
choices[choice] += 1
assert choices["goal_B"] > 80, "Goal B should be chosen heavily due to high success rate weighting."
assert choices["goal_A"] < 20, "Goal A should be chosen rarely."
assert choices["goal_A"] > choices["goal_C"], "Goal A should still be chosen more than C."

View File

@@ -0,0 +1,85 @@
from unittest.mock import MagicMock, patch
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
@patch("GramAddict.core.llm_provider.query_llm")
def test_dm_engine_escapes_thread_without_hardcoded_strings(mock_query_llm):
mock_query_llm.return_value = {"response": "Hi!"}
"""
Test that dm_engine successfully presses 'back' a second time if it is
still trapped in a thread, without relying on hardcoded resource-ids.
"""
mock_device = MagicMock()
mock_zero_engine = MagicMock()
mock_nav_graph = MagicMock()
mock_configs = MagicMock()
mock_session_state = MagicMock()
# Setup cognitive stack
mock_telepathic = MagicMock()
mock_dopamine = MagicMock()
mock_cognitive_stack = {"telepathic": mock_telepathic, "dopamine": mock_dopamine}
# We only want one iteration
mock_dopamine.is_app_session_over.side_effect = [False] + [True] * 10
mock_dopamine.wants_to_change_feed.return_value = False
mock_dopamine.boredom = 0
mock_session_state.check_limit.return_value = False
# Simulate an inbox with one unread thread, and then a valid message to pass the context guard
mock_telepathic._extract_semantic_nodes.side_effect = [
[{"x": 100, "y": 200, "bounds": "[50,150][150,250]", "semantic": "unread thread"}],
[{"x": 100, "y": 200, "bounds": "[50,150][150,250]", "text": "Hello there"}],
[{"x": 100, "y": 200, "bounds": "[50,150][150,250]", "semantic": "input field"}],
[{"x": 100, "y": 200, "bounds": "[50,150][150,250]", "semantic": "send button"}],
]
# We simulate a "Thread" view XML but WITHOUT the hardcoded instagram IDs
# Instead, we give it enough structural info to be parsed as a thread by ScreenIdentity.
inbox_xml = """
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy>
<node package="com.instagram.android" class="android.widget.FrameLayout" text="" resource-id="com.instagram.android:id/some_new_inbox_container" content-desc="Inbox">
<node package="com.instagram.android" class="android.widget.TextView" text="Messages" resource-id="" content-desc="" />
<node package="com.instagram.android" class="android.widget.ImageView" text="" resource-id="com.instagram.android:id/direct_tab" selected="true" content-desc="direct" />
</node>
</hierarchy>
"""
# The thread XML lacks 'direct_thread_header' and 'row_thread_composer_edittext'
# but still has message inputs (which ScreenIdentity should use).
thread_xml = """
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy>
<node package="com.instagram.android" class="android.widget.FrameLayout" text="">
<node package="com.instagram.android" class="android.widget.EditText" text="Message..." resource-id="com.instagram.android:id/some_new_message_input" content-desc="" />
<node package="com.instagram.android" class="android.widget.ImageView" text="" resource-id="com.instagram.android:id/some_new_back_button" content-desc="Back" />
</node>
</hierarchy>
"""
# Sequence of XML dumps:
# 1. Main loop (Inbox)
# 2. After clicking thread, we check what it is (Thread) -> Wait, telepathic handles replying.
# 3. After replying (or skipping), it checks if we are still in thread (Thread XML again).
mock_device.dump_hierarchy.side_effect = [inbox_xml] + [thread_xml] * 20
_run_zero_latency_dm_loop(
mock_device,
mock_zero_engine,
mock_nav_graph,
mock_configs,
mock_session_state,
"MessageInbox",
mock_cognitive_stack,
)
print(f"PRESS CALLS: {mock_device.press.call_args_list}")
# The device.press("back") should be called TWICE to escape the thread:
# Once at the end of thread processing (line 213).
# Once more because we are STILL in the thread (line 222).
assert (
mock_device.press.call_count == 2
), f"Expected 2 presses, got {mock_device.press.call_count}: {mock_device.press.call_args_list}"