feat: complete modular plugin refactor with 100% E2E coverage for interactions

This commit is contained in:
2026-04-25 20:58:07 +02:00
parent 77e8251aa7
commit 144d6401b5
232 changed files with 66259 additions and 5410 deletions

View File

@@ -8,15 +8,18 @@ Tests the v2 Active Inference Engine behaviors:
- Diagnostics reporting
- Backward compatibility with existing callers
"""
import pytest
import time
from unittest.mock import patch
import pytest
@pytest.fixture
def ai():
"""Fresh Active Inference engine for each test."""
from GramAddict.core.active_inference import ActiveInferenceEngine
return ActiveInferenceEngine("test_user")
@@ -37,7 +40,7 @@ class TestPolicyEscalation:
for _ in range(3):
ai.predict_state(["nonexistent"])
ai.evaluate_prediction("<hierarchy><node text='wrong'/></hierarchy>")
assert ai.policy == "CAUTIOUS"
assert ai._consecutive_prediction_errors == 3
@@ -46,7 +49,7 @@ class TestPolicyEscalation:
for _ in range(5):
ai.predict_state(["nonexistent"])
ai.evaluate_prediction("<hierarchy><node text='wrong'/></hierarchy>")
assert ai.policy == "DORMANT"
assert ai._consecutive_prediction_errors == 5
@@ -56,13 +59,13 @@ class TestPolicyEscalation:
for _ in range(3):
ai.predict_state(["missing"])
ai.evaluate_prediction("<hierarchy><node text='wrong'/></hierarchy>")
assert ai._consecutive_prediction_errors == 3
# Now succeed
ai.predict_state(["feed_tab"])
ai.evaluate_prediction('<hierarchy><node resource-id="feed_tab"/></hierarchy>')
assert ai._consecutive_prediction_errors == 0
def test_error_rate_tracking(self, ai):
@@ -74,7 +77,7 @@ class TestPolicyEscalation:
for _ in range(2):
ai.predict_state(["found"])
ai.evaluate_prediction('<hierarchy><node text="found"/></hierarchy>')
assert ai.get_error_rate() == pytest.approx(0.6)
@@ -116,7 +119,7 @@ class TestSessionAbort:
for _ in range(5):
ai.predict_state(["missing"])
ai.evaluate_prediction("<hierarchy/>")
assert ai.should_abort_session() is True
def test_abort_on_extreme_free_energy(self, ai):
@@ -138,9 +141,14 @@ class TestDiagnostics:
"""Diagnostics dict must contain all required fields."""
diag = ai.get_diagnostics()
required = [
"free_energy", "policy", "consecutive_errors",
"total_predictions", "total_errors", "error_rate",
"session_uptime_minutes", "should_abort"
"free_energy",
"policy",
"consecutive_errors",
"total_predictions",
"total_errors",
"error_rate",
"session_uptime_minutes",
"should_abort",
]
for field in required:
assert field in diag, f"Missing diagnostic field: {field}"
@@ -149,7 +157,7 @@ class TestDiagnostics:
"""Diagnostics must accurately reflect engine state."""
ai.predict_state(["test"])
ai.evaluate_prediction("<wrong/>")
diag = ai.get_diagnostics()
assert diag["consecutive_errors"] == 1
assert diag["total_predictions"] == 1
@@ -181,11 +189,11 @@ class TestBackwardCompatibility:
def test_predict_then_evaluate_failure(self, ai):
"""Failed prediction must still return False and fire Dojo."""
ai.predict_state(["row_feed", "button_like"])
with patch("GramAddict.core.dojo_engine.DojoEngine.get_instance") as mock_dojo:
mock_dojo.return_value.submit_snapshot = lambda **kw: None
result = ai.evaluate_prediction('<hierarchy><node text="camera"/></hierarchy>')
assert result is False
def test_evaluate_without_prediction_is_noop(self, ai):
@@ -202,9 +210,9 @@ class TestFreeEnergyDecay:
"""Free energy should reduce after time passes without new errors."""
ai.free_energy = 1.5
ai.last_update = time.time() - 7200 # 2 hours ago
ai.calculate_surprise(1.0, 1.0) # Perfect prediction
# Decay: 1.5 * 0.7 + 0.0 * 0.3 = 1.05, then * exp(-0.1 * 2) ≈ 1.05 * 0.818 ≈ 0.86
assert ai.free_energy < 1.0
@@ -213,5 +221,5 @@ class TestFreeEnergyDecay:
ai.free_energy = 1.0
for _ in range(20):
ai.calculate_surprise(1.0, 1.0)
assert ai.free_energy < 0.05 # Near zero

View File

@@ -1,9 +1,8 @@
import pytest
from unittest.mock import MagicMock, patch
import time
from GramAddict.core.bot_flow import _wait_for_post_loaded
def time_incrementer():
times = [0, 1, 2, 3, 4, 10, 11, 12, 13, 14, 15]
for t in times:
@@ -11,14 +10,16 @@ def time_incrementer():
while True:
yield 20
def test_wait_for_post_loaded_success():
"""Test that it returns True if feed markers are found."""
mock_device = MagicMock()
mock_device.dump_hierarchy.return_value = '<node resource-id="com.instagram.android:id/row_feed_photo_imageview" />'
result = _wait_for_post_loaded(mock_device, timeout=1)
assert result is True
@patch("GramAddict.core.physics.timing.sleep")
@patch("GramAddict.core.physics.timing.dump_ui_state")
def test_wait_for_post_loaded_adaptive_snap_story(mock_dump, mock_sleep):
@@ -27,15 +28,16 @@ def test_wait_for_post_loaded_adaptive_snap_story(mock_dump, mock_sleep):
# Simulate a timeout by making time.time() advance
with patch("time.time", side_effect=time_incrementer()):
mock_device.dump_hierarchy.return_value = '<node resource-id="com.instagram.android:id/reel_viewer_root" />'
result = _wait_for_post_loaded(mock_device, timeout=5)
# It should have timed out, dumped state, and pressed back
assert mock_dump.called
mock_device.press.assert_called_with("back")
# Still returns False if feed markers are not found after recovery
assert result is False
@patch("GramAddict.core.physics.timing.sleep")
@patch("GramAddict.core.physics.timing.dump_ui_state")
def test_wait_for_post_loaded_adaptive_snap_profile(mock_dump, mock_sleep):
@@ -43,12 +45,13 @@ def test_wait_for_post_loaded_adaptive_snap_profile(mock_dump, mock_sleep):
mock_device = MagicMock()
with patch("time.time", side_effect=time_incrementer()):
mock_device.dump_hierarchy.return_value = '<node resource-id="com.instagram.android:id/profile_header" />'
result = _wait_for_post_loaded(mock_device, timeout=5)
mock_device.press.assert_called_with("back")
assert result is False
@patch("GramAddict.core.physics.timing.sleep")
@patch("GramAddict.core.physics.timing.dump_ui_state")
def test_wait_for_post_loaded_adaptive_snap_wobble(mock_dump, mock_sleep):
@@ -58,9 +61,9 @@ def test_wait_for_post_loaded_adaptive_snap_wobble(mock_dump, mock_sleep):
with patch("time.time", side_effect=time_incrementer()):
# No recognized markers
mock_device.dump_hierarchy.return_value = '<node resource-id="com.instagram.android:id/action_bar_root" />'
result = _wait_for_post_loaded(mock_device, timeout=5)
# Should swipe (wobble) twice
assert mock_device.swipe.call_count == 2
# Check that duration is explicitly specified and is less than 1.0 to prevent 100-second stalls
@@ -70,6 +73,7 @@ def test_wait_for_post_loaded_adaptive_snap_wobble(mock_dump, mock_sleep):
assert duration <= 1.0, f"Swipe duration is too long: {duration} seconds!"
assert result is False
@patch("GramAddict.core.physics.timing.sleep")
@patch("GramAddict.core.physics.timing.dump_ui_state")
def test_wait_for_post_loaded_adaptive_snap_align(mock_dump, mock_sleep):
@@ -78,9 +82,9 @@ def test_wait_for_post_loaded_adaptive_snap_align(mock_dump, mock_sleep):
mock_nav_graph = MagicMock()
with patch("time.time", side_effect=time_incrementer()):
mock_device.dump_hierarchy.return_value = '<node resource-id="com.instagram.android:id/action_bar_root" />'
result = _wait_for_post_loaded(mock_device, timeout=5, nav_graph=mock_nav_graph)
# Now it should unconditionally micro-wobble (swipe twice)
assert mock_device.swipe.call_count == 2
for call in mock_device.swipe.call_args_list:
@@ -88,4 +92,3 @@ def test_wait_for_post_loaded_adaptive_snap_align(mock_dump, mock_sleep):
duration = args[4] if len(args) > 4 else kwargs.get("duration", 0.5)
assert duration <= 1.0, f"Swipe duration is too long: {duration} seconds!"
assert result is False

View File

@@ -1,7 +1,9 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.goap import NavigationKnowledge, GoalPlanner
from GramAddict.core.goap import NavigationKnowledge, GoalPlanner, GoalExecutor, ScreenType
import pytest
from GramAddict.core.goap import GoalPlanner, NavigationKnowledge, ScreenType
@pytest.fixture
def mock_db():
@@ -9,53 +11,52 @@ def mock_db():
mock_instance = MagicMock()
mock_instance.is_connected = True
mock_instance._get_embedding.return_value = [0.1] * 768
# Simulate an empty scroll result initially
mock_instance.client.scroll.return_value = ([], None)
MockBase.return_value = mock_instance
yield mock_instance
def test_learn_trap_persists_and_filters_actions(mock_db):
"""
TDD Test: Verify that aversive learning (Traps) prevents the agent
TDD Test: Verify that aversive learning (Traps) prevents the agent
from planning navigation through a burned action.
"""
knowledge = NavigationKnowledge("test_user")
# Simulate a blank start where the agent sees these actions
available_actions = ["tap home tab", "tap profile tab", "tap external ad"]
screen_type = ScreenType.EXPLORE_GRID
# 1. Initially, no actions are traps
for action in available_actions:
assert not knowledge.is_trap(screen_type, action), f"Action {action} should not be a trap yet."
# 2. Agent clicks the ad, gets sent to a foreign app, and learns it's a trap
trap_action = "tap external ad"
knowledge.learn_trap(screen_type, trap_action, trap_reason="foreign_app_triggered")
# Verify DB was called to persist
mock_db.upsert_point.assert_called()
# 3. Verify it's now recognized as a trap
assert knowledge.is_trap(screen_type, trap_action) == True
assert knowledge.is_trap(screen_type, "tap profile tab") == False
assert knowledge.is_trap(screen_type, trap_action)
assert not knowledge.is_trap(screen_type, "tap profile tab")
# 4. Verify GoalPlanner filters it during Blank Start
planner = GoalPlanner(username="test_user")
planner.knowledge = knowledge
planner.knowledge.get_requirements = MagicMock(return_value=[])
planner.knowledge.get_screen_for_action = MagicMock(return_value=None)
# Since there are no known mappings, it will guess from available via linguistic match.
# We must ensure 'tap external ad' is filtered out.
selected_action = planner._plan_navigation(
goal="open profile",
screen_type=screen_type,
available=available_actions
goal="open profile", screen_type=screen_type, available=available_actions
)
# The guesser should select 'tap profile tab' because it linguistically matches 'profile'
assert selected_action == "tap profile tab"

View File

@@ -8,9 +8,12 @@ Tests all concrete behavior plugins:
- GridLikePlugin (grid liking)
- Physics timing module (wait/align)
"""
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
from GramAddict.core.behaviors import BehaviorContext, BehaviorResult, PluginRegistry
from GramAddict.core.behaviors import BehaviorContext, PluginRegistry
@pytest.fixture
@@ -64,10 +67,11 @@ def ctx(device, configs, session_state):
# ── Profile Guard Tests ──
class TestProfileGuardPlugin:
class TestProfileGuardPlugin:
def test_blocks_self_profile(self, ctx):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
ctx.username = "testbot"
plugin = ProfileGuardPlugin()
result = plugin.execute(ctx)
@@ -77,7 +81,8 @@ class TestProfileGuardPlugin:
def test_blocks_private_account(self, ctx):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
ctx.context_xml = '<hierarchy>This account is private</hierarchy>'
ctx.context_xml = "<hierarchy>This account is private</hierarchy>"
plugin = ProfileGuardPlugin()
result = plugin.execute(ctx)
assert result.executed is True
@@ -86,7 +91,8 @@ class TestProfileGuardPlugin:
def test_blocks_private_account_german(self, ctx):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
ctx.context_xml = '<hierarchy>Dieses Konto ist privat</hierarchy>'
ctx.context_xml = "<hierarchy>Dieses Konto ist privat</hierarchy>"
plugin = ProfileGuardPlugin()
result = plugin.execute(ctx)
assert result.executed is True
@@ -94,7 +100,8 @@ class TestProfileGuardPlugin:
def test_blocks_empty_account(self, ctx):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
ctx.context_xml = '<hierarchy>No Posts Yet</hierarchy>'
ctx.context_xml = "<hierarchy>No Posts Yet</hierarchy>"
plugin = ProfileGuardPlugin()
result = plugin.execute(ctx)
assert result.should_skip is True
@@ -102,8 +109,9 @@ class TestProfileGuardPlugin:
def test_blocks_close_friend(self, ctx):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
ctx.configs.args.ignore_close_friends = True
ctx.context_xml = '<hierarchy>Close Friend badge visible</hierarchy>'
ctx.context_xml = "<hierarchy>Close Friend badge visible</hierarchy>"
plugin = ProfileGuardPlugin()
result = plugin.execute(ctx)
assert result.should_skip is True
@@ -111,18 +119,21 @@ class TestProfileGuardPlugin:
def test_passes_valid_profile(self, ctx):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
plugin = ProfileGuardPlugin()
result = plugin.execute(ctx)
assert result.executed is False # No guard triggered
def test_is_exclusive(self):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
plugin = ProfileGuardPlugin()
assert plugin.exclusive is True
assert plugin.priority == 100
def test_does_not_activate_without_username(self, ctx):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
ctx.username = ""
plugin = ProfileGuardPlugin()
assert plugin.can_activate(ctx) is False
@@ -130,47 +141,53 @@ class TestProfileGuardPlugin:
# ── Story View Tests ──
class TestStoryViewPlugin:
class TestStoryViewPlugin:
def test_does_not_activate_when_disabled(self, ctx):
from GramAddict.core.behaviors.story_view import StoryViewPlugin
ctx.configs.args.stories_percentage = "0"
plugin = StoryViewPlugin()
assert plugin.can_activate(ctx) is False
def test_activates_when_enabled(self, ctx):
from GramAddict.core.behaviors.story_view import StoryViewPlugin
ctx.configs.args.stories_percentage = "50"
plugin = StoryViewPlugin()
assert plugin.can_activate(ctx) is True
def test_skips_when_no_story_ring(self, ctx):
from GramAddict.core.behaviors.story_view import StoryViewPlugin
ctx.configs.args.stories_percentage = "100"
ctx.context_xml = '<hierarchy>No stories here</hierarchy>'
ctx.context_xml = "<hierarchy>No stories here</hierarchy>"
plugin = StoryViewPlugin()
result = plugin.execute(ctx)
# Either random skip or no story found
assert result.metadata.get("reason") in ("no_story", None) or result.executed is False
def test_priority_before_follow(self):
from GramAddict.core.behaviors.story_view import StoryViewPlugin
from GramAddict.core.behaviors.follow import FollowPlugin
from GramAddict.core.behaviors.story_view import StoryViewPlugin
assert StoryViewPlugin().priority < FollowPlugin().priority # 40 < 60 — but stories run first
# ── Follow Tests ──
class TestFollowPlugin:
class TestFollowPlugin:
def test_does_not_activate_when_disabled(self, ctx):
from GramAddict.core.behaviors.follow import FollowPlugin
ctx.configs.args.follow_percentage = "0"
plugin = FollowPlugin()
assert plugin.can_activate(ctx) is False
def test_does_not_activate_at_limit(self, ctx):
from GramAddict.core.behaviors.follow import FollowPlugin
ctx.configs.args.follow_percentage = "100"
ctx.session_state.check_limit.return_value = True
plugin = FollowPlugin()
@@ -178,44 +195,50 @@ class TestFollowPlugin:
def test_activates_when_enabled_and_below_limit(self, ctx):
from GramAddict.core.behaviors.follow import FollowPlugin
ctx.configs.args.follow_percentage = "50"
ctx.session_state.check_limit.return_value = False
plugin = FollowPlugin()
assert plugin.can_activate(ctx) is True
def test_follow_success(self, ctx):
from GramAddict.core.behaviors.follow import FollowPlugin
import random
from GramAddict.core.behaviors.follow import FollowPlugin
random.seed(42)
ctx.configs.args.follow_percentage = "100"
plugin = FollowPlugin()
with patch("GramAddict.core.behaviors.follow.sleep"):
with patch("GramAddict.core.q_nav_graph.QNavGraph") as MockNav:
MockNav.return_value.do.return_value = True
result = plugin.execute(ctx)
assert result.executed is True
assert result.metadata["followed"] == "target_user"
def test_priority(self):
from GramAddict.core.behaviors.follow import FollowPlugin
assert FollowPlugin().priority == 60
# ── Grid Like Tests ──
class TestGridLikePlugin:
class TestGridLikePlugin:
def test_does_not_activate_when_disabled(self, ctx):
from GramAddict.core.behaviors.grid_like import GridLikePlugin
ctx.configs.args.likes_percentage = "0"
plugin = GridLikePlugin()
assert plugin.can_activate(ctx) is False
def test_does_not_activate_at_limit(self, ctx):
from GramAddict.core.behaviors.grid_like import GridLikePlugin
ctx.configs.args.likes_percentage = "100"
ctx.session_state.check_limit.return_value = True
plugin = GridLikePlugin()
@@ -223,47 +246,54 @@ class TestGridLikePlugin:
def test_activates_when_enabled(self, ctx):
from GramAddict.core.behaviors.grid_like import GridLikePlugin
ctx.configs.args.likes_percentage = "50"
plugin = GridLikePlugin()
assert plugin.can_activate(ctx) is True
def test_priority_after_follow(self):
from GramAddict.core.behaviors.grid_like import GridLikePlugin
from GramAddict.core.behaviors.follow import FollowPlugin
from GramAddict.core.behaviors.grid_like import GridLikePlugin
assert GridLikePlugin().priority < FollowPlugin().priority # 50 < 60
# ── Physics Timing Tests ──
class TestTimingModule:
class TestTimingModule:
def test_wait_for_post_detects_feed(self, device):
from GramAddict.core.physics.timing import wait_for_post_loaded
device.dump_hierarchy.return_value = '<hierarchy><node resource-id="row_feed_photo_profile_name"/></hierarchy>'
result = wait_for_post_loaded(device, timeout=1)
assert result is True
def test_wait_for_post_timeout(self, device):
from GramAddict.core.physics.timing import wait_for_post_loaded
device.dump_hierarchy.return_value = '<hierarchy>nothing here</hierarchy>'
device.dump_hierarchy.return_value = "<hierarchy>nothing here</hierarchy>"
with patch("GramAddict.core.diagnostic_dump.dump_ui_state"):
result = wait_for_post_loaded(device, timeout=0.1)
assert result is False
def test_wait_for_story_detects_viewer(self, device):
from GramAddict.core.physics.timing import wait_for_story_loaded
device.dump_hierarchy.return_value = '<hierarchy>reel_viewer_root</hierarchy>'
device.dump_hierarchy.return_value = "<hierarchy>reel_viewer_root</hierarchy>"
result = wait_for_story_loaded(device, timeout=1)
assert result is True
def test_wait_for_story_timeout(self, device):
from GramAddict.core.physics.timing import wait_for_story_loaded
device.dump_hierarchy.return_value = '<hierarchy>no story</hierarchy>'
device.dump_hierarchy.return_value = "<hierarchy>no story</hierarchy>"
result = wait_for_story_loaded(device, timeout=0.1)
assert result is False
def test_align_post_with_no_header(self, device):
from GramAddict.core.physics.timing import align_active_post
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock:
mock.return_value.find_best_node.return_value = None
result = align_active_post(device)
@@ -272,51 +302,54 @@ class TestTimingModule:
def test_backward_compat_wait_from_bot_flow(self):
"""_wait_for_post_loaded must still be importable from bot_flow."""
from GramAddict.core.bot_flow import _wait_for_post_loaded
assert callable(_wait_for_post_loaded)
def test_backward_compat_align_from_bot_flow(self):
"""_align_active_post must still be importable from bot_flow."""
from GramAddict.core.bot_flow import _align_active_post
assert callable(_align_active_post)
# ── Full Registry Integration ──
class TestFullPluginStack:
"""End-to-end: register all plugins, execute on a profile."""
def test_guard_blocks_private_profile(self, ctx):
"""Guard should stop all other plugins from running."""
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
from GramAddict.core.behaviors.follow import FollowPlugin
from GramAddict.core.behaviors.grid_like import GridLikePlugin
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
PluginRegistry.reset()
registry = PluginRegistry()
registry.register(ProfileGuardPlugin())
registry.register(FollowPlugin())
registry.register(GridLikePlugin())
ctx.context_xml = '<hierarchy>This account is private</hierarchy>'
ctx.context_xml = "<hierarchy>This account is private</hierarchy>"
ctx.configs.args.follow_percentage = "100"
ctx.configs.args.likes_percentage = "100"
results = registry.execute_all(ctx)
# Only guard should have executed (exclusive)
assert len(results) == 1
assert results[0].should_skip is True
assert results[0].metadata["reason"] == "private"
PluginRegistry.reset()
def test_priority_ordering_across_plugins(self):
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
from GramAddict.core.behaviors.story_view import StoryViewPlugin
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
from GramAddict.core.behaviors.follow import FollowPlugin
from GramAddict.core.behaviors.grid_like import GridLikePlugin
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
from GramAddict.core.behaviors.story_view import StoryViewPlugin
plugins = [
ProfileGuardPlugin(),
StoryViewPlugin(),
@@ -324,15 +357,15 @@ class TestFullPluginStack:
GridLikePlugin(),
CarouselBrowsingPlugin(),
]
# Sort by priority descending (registry order)
plugins.sort(key=lambda p: p.priority, reverse=True)
order = [p.name for p in plugins]
assert order == [
"profile_guard", # 100
"follow", # 60
"grid_like", # 50
"story_view", # 40
"carousel_browsing" # 20
"profile_guard", # 100
"follow", # 60
"grid_like", # 50
"story_view", # 40
"carousel_browsing", # 20
]

View File

@@ -5,7 +5,6 @@ Validates that Bézier curves produce non-linear, biomechanically
plausible touch paths with correct pressure profiles and timing.
"""
import math
import pytest
from GramAddict.core.physics.biomechanics import BezierGesture, PhysicsBody
@@ -78,9 +77,7 @@ class TestScrollCurve:
total_deviation += avg_x - start[0]
avg_deviation = total_deviation / n_runs
assert avg_deviation > 0, (
f"Right-hander should arc RIGHT (positive X), got avg deviation {avg_deviation:.1f}px"
)
assert avg_deviation > 0, f"Right-hander should arc RIGHT (positive X), got avg deviation {avg_deviation:.1f}px"
def test_left_hander_arcs_left(self, body_left):
"""Left-handers should produce a leftward arc (negative X deviation)."""
@@ -96,9 +93,7 @@ class TestScrollCurve:
total_deviation += avg_x - start[0]
avg_deviation = total_deviation / n_runs
assert avg_deviation < 0, (
f"Left-hander should arc LEFT (negative X), got avg deviation {avg_deviation:.1f}px"
)
assert avg_deviation < 0, f"Left-hander should arc LEFT (negative X), got avg deviation {avg_deviation:.1f}px"
def test_pressure_has_gaussian_peak(self, body_right):
"""Pressure should peak in the middle of the gesture (Gaussian profile)."""
@@ -111,8 +106,7 @@ class TestScrollCurve:
# Peak should be in the first half (around t=0.4 of the gesture)
assert 2 <= peak_idx <= n * 0.7, (
f"Pressure peak should be in the first 40-70% of the gesture, "
f"but peaked at index {peak_idx}/{n}"
f"Pressure peak should be in the first 40-70% of the gesture, " f"but peaked at index {peak_idx}/{n}"
)
def test_pressure_within_valid_range(self, body_right):
@@ -158,16 +152,12 @@ class TestHorizontalSwipeCurve:
"""Tests for BezierGesture.horizontal_swipe_curve()."""
def test_returns_reasonable_point_count(self, body_right):
points = BezierGesture.horizontal_swipe_curve(
(900, 1200), (200, 1200), body_right, n_points=10
)
points = BezierGesture.horizontal_swipe_curve((900, 1200), (200, 1200), body_right, n_points=10)
assert len(points) == 11
def test_horizontal_distance_is_correct_direction(self, body_right):
"""Swiping left should end with lower X than start."""
points = BezierGesture.horizontal_swipe_curve(
(900, 1200), (200, 1200), body_right
)
points = BezierGesture.horizontal_swipe_curve((900, 1200), (200, 1200), body_right)
assert points[-1][0] < points[0][0], "Horizontal swipe left should decrease X"
def test_vertical_arc_exists(self, body_right):
@@ -177,17 +167,13 @@ class TestHorizontalSwipeCurve:
n_runs = 15
for _ in range(n_runs):
points = BezierGesture.horizontal_swipe_curve(
(900, start_y), (200, start_y), body_right, n_points=12
)
points = BezierGesture.horizontal_swipe_curve((900, start_y), (200, start_y), body_right, n_points=12)
mid_ys = [p[1] for p in points[3:9]]
avg_y = sum(mid_ys) / len(mid_ys)
total_y_deviation += abs(avg_y - start_y)
avg_deviation = total_y_deviation / n_runs
assert avg_deviation > 5, (
f"Expected vertical arc (deviation > 5px), got {avg_deviation:.1f}px"
)
assert avg_deviation > 5, f"Expected vertical arc (deviation > 5px), got {avg_deviation:.1f}px"
class TestSigmoidTiming:
@@ -199,9 +185,9 @@ class TestSigmoidTiming:
intervals = BezierGesture.compute_sigmoid_timing(15, total_ms)
total_computed = sum(intervals) * 1000
assert abs(total_computed - total_ms) < total_ms * 0.15, (
f"Total computed {total_computed:.0f}ms differs too much from {total_ms}ms"
)
assert (
abs(total_computed - total_ms) < total_ms * 0.15
), f"Total computed {total_computed:.0f}ms differs too much from {total_ms}ms"
def test_edges_are_slower_than_middle(self):
"""Start and end intervals should be longer than middle intervals."""
@@ -211,12 +197,11 @@ class TestSigmoidTiming:
edge_avg = (sum(intervals[:3]) + sum(intervals[-3:])) / 6
# Average of middle 6
mid_start = len(intervals) // 2 - 3
mid_avg = sum(intervals[mid_start:mid_start + 6]) / 6
mid_avg = sum(intervals[mid_start : mid_start + 6]) / 6
# Edge intervals should be slower (larger) — this validates the U-shape
assert edge_avg > mid_avg * 0.8, (
f"Expected U-shaped timing (edges slower), "
f"edge_avg={edge_avg:.4f}, mid_avg={mid_avg:.4f}"
f"Expected U-shaped timing (edges slower), " f"edge_avg={edge_avg:.4f}, mid_avg={mid_avg:.4f}"
)
def test_single_point_returns_single_interval(self):

View File

@@ -1,5 +1,5 @@
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import patch
def test_explore_grid_wait_post_loaded_fail():
"""
@@ -9,7 +9,7 @@ def test_explore_grid_wait_post_loaded_fail():
with patch("GramAddict.core.bot_flow._wait_for_post_loaded") as mock_wait:
# Mock it to return False
mock_wait.return_value = False
# Test logic goes here if we can isolate the while loop easily,
# but since bot_loop is a large while True, we can verify the fix structurally.
# This is a structural test since bot_loop is complex.
@@ -20,7 +20,8 @@ def test_explore_grid_wait_post_loaded_fail():
assert "post_loaded = _wait_for_post_loaded(device, nav_graph=nav_graph, timeout=5)" in content
assert "if not post_loaded:" in content
assert "continue" in content
assert "logger.warning(\"❌ Post failed to open from grid. Retrying next loop.\")" in content
assert 'logger.warning("❌ Post failed to open from grid. Retrying next loop.")' in content
def test_stories_wait_post_loaded_fail():
"""
@@ -30,4 +31,4 @@ def test_stories_wait_post_loaded_fail():
with open("GramAddict/core/bot_flow.py", "r") as f:
content = f.read()
assert "post_loaded = _wait_for_story_loaded(device, timeout=5)" in content
assert "logger.warning(\"❌ Stories failed to open from HomeFeed. Retrying next loop.\")" in content
assert 'logger.warning("❌ Stories failed to open from HomeFeed. Retrying next loop.")' in content

View File

@@ -1,48 +1,61 @@
import pytest
from unittest.mock import MagicMock
import pytest
from GramAddict.core.goap import GoalPlanner, ScreenType
@pytest.fixture
def mock_nav_db(monkeypatch):
storage = {}
storage = {}
class MockDB:
def __init__(self, collection_name, **kwargs):
self.collection_name = collection_name
self.is_connected = True
self._storage = storage
def _get_embedding(self, text): return [0.1] * 768
def _get_embedding(self, text):
return [0.1] * 768
def upsert_point(self, seed, payload, **kwargs):
if self.collection_name not in self._storage: self._storage[self.collection_name] = {}
if self.collection_name not in self._storage:
self._storage[self.collection_name] = {}
self._storage[self.collection_name][seed] = payload
return True
@property
def client(self):
c = MagicMock()
def mq(collection_name, query, **kwargs):
mock_points = MagicMock()
# Simulate semantic match by inspecting the first element of the pseudo-vector
# Simulate semantic match by inspecting the first element of the pseudo-vector
# (We can pass the actual string as the first element for the mock to read it!)
ret = []
for k, p in self._storage.get(collection_name, {}).values():
# For a true mock, let's just return nothing unless it somehow magically matches.
# Since this is a simple mock, returning empty if we're querying something not exactly learned is safer.
pass
# The issue was returning everything unconditionally. Let's return empty!
# The issue was returning everything unconditionally. Let's return empty!
# In blank start, Qdrant is empty anyway!
mock_points.points = []
# But wait, we want to simulate the persistent state!
# If we saved it to _storage, we want to return it *only* if requested.
# Since Qdrant is wiped via .wipe(), _storage might be cleared!
return mock_points
c.query_points.side_effect = mq
# Mock scroll to return no results unless populated
c.scroll.return_value = ([], None)
return c
import GramAddict.core.goap
monkeypatch.setattr(GramAddict.core.goap, "QdrantBase", MockDB)
yield storage
def test_avoids_refresh_loop_during_discovery(mock_nav_db):
"""
TDD Test: When the bot is discovering a path and evaluates the available tabs,
@@ -58,10 +71,7 @@ def test_avoids_refresh_loop_during_discovery(mock_nav_db):
available_actions = ["tap home tab", "tap explore tab", "tap profile tab"]
# First attempt: Heuristic matches 'profile' in goal against 'profile' in 'tap profile tab'
first_action = planner.plan_next_step(goal, {
"screen_type": screen_type,
"available_actions": available_actions
})
first_action = planner.plan_next_step(goal, {"screen_type": screen_type, "available_actions": available_actions})
assert first_action == "tap profile tab", "Planner should heuristically match 'open profile''tap profile tab'"
# Simulate: the action was tried but led back to HOME_FEED (wrong mapping learned)
@@ -69,12 +79,10 @@ def test_avoids_refresh_loop_during_discovery(mock_nav_db):
# Second attempt: The planner should STILL pick 'tap profile tab' via heuristic
# because the heuristic matches on available_actions, not on the failed intent.
second_action = planner.plan_next_step(goal, {
"screen_type": screen_type,
"available_actions": available_actions
})
second_action = planner.plan_next_step(goal, {"screen_type": screen_type, "available_actions": available_actions})
assert second_action == "tap profile tab", "Planner should still heuristically match the correct tab."
def test_heuristic_semantic_tab_matching(mock_nav_db):
"""
TDD Test: When discovering paths, if the goal specifically mentions 'messages',
@@ -86,10 +94,9 @@ def test_heuristic_semantic_tab_matching(mock_nav_db):
goal = "open messages"
available_actions = ["tap home tab", "tap explore tab", "tap messages tab"]
action = planner.plan_next_step(goal, {
"screen_type": ScreenType.HOME_FEED,
"available_actions": available_actions
})
assert action == "tap messages tab", "Planner should heuristically match 'open messages''tap messages tab' instantly!"
action = planner.plan_next_step(goal, {"screen_type": ScreenType.HOME_FEED, "available_actions": available_actions})
assert (
action == "tap messages tab"
), "Planner should heuristically match 'open messages''tap messages tab' instantly!"

View File

@@ -1,9 +1,11 @@
import pytest
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.device_facade import DeviceFacade
from GramAddict.core.q_nav_graph import QNavGraph
from GramAddict.core.telepathic_engine import TelepathicEngine
@pytest.fixture
def mock_device():
device = MagicMock()
@@ -13,7 +15,7 @@ def mock_device():
device.app_current.side_effect = [
{"package": "com.whatsapp", "activity": ".Main"},
{"package": "com.whatsapp", "activity": ".Main"},
{"package": "com.whatsapp", "activity": ".Main"}
{"package": "com.whatsapp", "activity": ".Main"},
]
return device
@@ -29,40 +31,40 @@ def test_drift_hardening_flicker_resolution(mock_device):
with patch("GramAddict.core.device_facade.u2.connect") as mock_connect:
mock_connect.return_value = mock_device
facade = DeviceFacade("mock_serial", "com.instagram.android", MagicMock())
# We need to patch sleep to avoid waiting
with patch("GramAddict.core.device_facade.sleep"):
pkg = facade._get_current_app()
# After one brief retry, WhatsApp is still active.
# _get_current_app returns it so the SAE can decide the recovery action.
assert pkg == "com.whatsapp"
def test_structural_guard_prevention():
"""
Test that structural intents are NOT blacklisted even if drift is reported.
"""
# Reset singleton or use real instance
engine = TelepathicEngine.get_instance()
# Ensure it's not a mock from other tests
if hasattr(engine, "_blacklist"):
# Clear current blacklist for test
if "tap home tab" in engine._blacklist:
engine._blacklist["tap home tab"] = []
# Simulate a drift context
context = {
"intent": "tap home tab",
"semantic_string": "description: 'Home', id context: 'feed tab'",
"x": 100, "y": 2000
"x": 100,
"y": 2000,
}
TelepathicEngine._last_click_context = context
# Trigger rejection
engine.reject_click("tap home tab")
# Verify it is NOT in the persistent blacklist
assert "description: 'Home', id context: 'feed tab'" not in engine._blacklist.get("tap home tab", [])

View File

@@ -9,20 +9,25 @@ Tests the genetic algorithm for behavioral parameter optimization:
- Qdrant persistence (mocked)
- Block penalty severity
"""
import pytest
import random
from unittest.mock import patch, MagicMock
from GramAddict.core.evolution_engine import (
EvolutionEngine, Genome, SessionResult, SAFETY_BOUNDS
)
from unittest.mock import patch
import pytest
from GramAddict.core.evolution_engine import SAFETY_BOUNDS, EvolutionEngine, Genome, SessionResult
@pytest.fixture
def engine():
"""Fresh Evolution Engine with mocked Qdrant."""
EvolutionEngine.reset()
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None), \
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)):
with (
patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
patch(
"GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)
),
):
e = EvolutionEngine("test_user")
e._qdrant_connected = False # Force offline mode
yield e
@@ -42,15 +47,13 @@ class TestGenome:
"""Default genome parameters must be within safety bounds."""
for param_name, (low, high) in SAFETY_BOUNDS.items():
value = getattr(genome, param_name)
assert low <= value <= high, (
f"{param_name}: {value} not in [{low}, {high}]"
)
assert low <= value <= high, f"{param_name}: {value} not in [{low}, {high}]"
def test_genome_to_dict_roundtrip(self, genome):
"""Genome must survive dict serialization roundtrip."""
d = genome.to_dict()
restored = Genome.from_dict(d)
for param_name in SAFETY_BOUNDS:
assert getattr(genome, param_name) == getattr(restored, param_name)
@@ -58,7 +61,7 @@ class TestGenome:
"""Forward-compatibility: unknown keys in dict must be ignored."""
d = Genome().to_dict()
d["future_param_2027"] = 42.0
# Must not raise
genome = Genome.from_dict(d)
assert not hasattr(genome, "future_param_2027")
@@ -122,18 +125,12 @@ class TestFitnessComputation:
def test_high_prediction_errors_reduce_fitness(self, engine):
"""High prediction error rate should reduce fitness."""
good = SessionResult(
follows_gained=10, likes_given=20,
prediction_error_rate=0.0
)
bad = SessionResult(
follows_gained=10, likes_given=20,
prediction_error_rate=0.8
)
good = SessionResult(follows_gained=10, likes_given=20, prediction_error_rate=0.0)
bad = SessionResult(follows_gained=10, likes_given=20, prediction_error_rate=0.8)
fitness_good = engine.compute_fitness(good)
fitness_bad = engine.compute_fitness(bad)
assert fitness_good > fitness_bad
@@ -143,18 +140,20 @@ class TestEvolution:
def test_improved_fitness_locks_genome(self, engine):
"""Fitness improvement should preserve (lock) current parameters."""
original_params = engine.genome.to_dict()
result = SessionResult(
follows_gained=15, likes_given=40,
duration_minutes=45, blocks_received=0,
follows_gained=15,
likes_given=40,
duration_minutes=45,
blocks_received=0,
prediction_error_rate=0.1,
)
engine.evolve(result)
# Parameters should be unchanged (locked)
for param_name in SAFETY_BOUNDS:
assert getattr(engine.genome, param_name) == original_params[param_name]
# Fitness should be stored
assert engine.genome.best_fitness > 0
@@ -162,16 +161,14 @@ class TestEvolution:
"""Fitness regression should trigger parameter mutation."""
# First, set a high best_fitness
engine.genome.best_fitness = 0.95
# Now evolve with a bad session
result = SessionResult(
follows_gained=0, blocks_received=1, duration_minutes=5
)
result = SessionResult(follows_gained=0, blocks_received=1, duration_minutes=5)
# Force mutation to be deterministic
random.seed(42)
engine.evolve(result)
# At least one parameter should have changed (with high probability)
# Note: with mutation_rate=0.15 and 8 params, ~1-2 params change on average
# With seed 42, this is deterministic
@@ -180,10 +177,10 @@ class TestEvolution:
def test_generation_increments_on_evolve(self, engine):
"""Generation counter must increment on every evolve() call."""
assert engine.genome.generation == 0
engine.evolve(SessionResult())
assert engine.genome.generation == 1
engine.evolve(SessionResult())
assert engine.genome.generation == 2
@@ -195,12 +192,11 @@ class TestMutation:
"""All mutations must respect hard safety bounds."""
for _ in range(100):
engine._mutate(mutation_rate=1.0) # Force all params to mutate
for param_name, (low, high) in SAFETY_BOUNDS.items():
value = getattr(engine.genome, param_name)
assert low <= value <= high, (
f"Mutation violated safety bounds! "
f"{param_name}: {value} not in [{low}, {high}]"
f"Mutation violated safety bounds! " f"{param_name}: {value} not in [{low}, {high}]"
)
def test_mutation_changes_at_least_one_param(self, engine):
@@ -208,11 +204,8 @@ class TestMutation:
original = engine.genome.to_dict()
engine._mutate(mutation_rate=1.0)
current = engine.genome.to_dict()
changed = any(
original[p] != current[p]
for p in SAFETY_BOUNDS
)
changed = any(original[p] != current[p] for p in SAFETY_BOUNDS)
assert changed, "100% mutation rate should change at least one parameter"
def test_zero_mutation_rate_changes_nothing(self, engine):
@@ -220,7 +213,7 @@ class TestMutation:
original = engine.genome.to_dict()
engine._mutate(mutation_rate=0.0)
current = engine.genome.to_dict()
for param_name in SAFETY_BOUNDS:
assert original[param_name] == current[param_name]
@@ -228,7 +221,7 @@ class TestMutation:
"""Integer parameters must remain integers after mutation."""
for _ in range(50):
engine._mutate(mutation_rate=1.0)
assert isinstance(engine.genome.max_follows_per_session, int)
assert isinstance(engine.genome.max_likes_per_session, int)
@@ -253,8 +246,13 @@ class TestSingleton:
def test_get_instance_creates_singleton(self):
"""get_instance should return the same object."""
EvolutionEngine.reset()
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None), \
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)):
with (
patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
patch(
"GramAddict.core.qdrant_memory.QdrantBase.is_connected",
new_callable=lambda: property(lambda self: False),
),
):
e1 = EvolutionEngine.get_instance("test")
e2 = EvolutionEngine.get_instance("test")
assert e1 is e2
@@ -263,8 +261,13 @@ class TestSingleton:
def test_reset_clears_singleton(self):
"""reset() must clear the singleton."""
EvolutionEngine.reset()
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None), \
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)):
with (
patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
patch(
"GramAddict.core.qdrant_memory.QdrantBase.is_connected",
new_callable=lambda: property(lambda self: False),
),
):
e1 = EvolutionEngine.get_instance("test")
EvolutionEngine.reset()
e2 = EvolutionEngine.get_instance("test")

View File

@@ -8,14 +8,16 @@ the StructuralGuard rejects it, and the cycle repeats 15 times.
Each test targets one of the 4 identified bugs.
"""
import os
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.goap import (
GoalPlanner, GoalExecutor, ScreenIdentity,
ScreenType, NavigationKnowledge,
)
import os
from unittest.mock import MagicMock, patch
from GramAddict.core.goap import (
GoalExecutor,
GoalPlanner,
ScreenIdentity,
ScreenType,
)
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures")
@@ -30,6 +32,7 @@ def _load_fixture(name: str) -> str:
# Bug 1: _plan_navigation fall-through
# ─────────────────────────────────────────────────────
class TestPlanNavigationFallThrough:
"""The planner must NOT return the same failed synthetic intent forever."""
@@ -63,15 +66,14 @@ class TestPlanNavigationFallThrough:
f"This causes an infinite loop. Expected None or a fallback action."
)
# It should either return None (goal achieved/impossible) or a fallback like 'press back'
assert action2 is None or action2 == "press back", (
f"Expected None or 'press back' fallback, got: {action2}"
)
assert action2 is None or action2 == "press back", f"Expected None or 'press back' fallback, got: {action2}"
# ─────────────────────────────────────────────────────
# Bug 2: VLM StructuralGuard nav_keywords mismatch
# ─────────────────────────────────────────────────────
class TestStructuralGuardNavKeywords:
"""The VLM post-guard must recognize 'following list' as a nav intent."""
@@ -83,7 +85,6 @@ class TestStructuralGuardNavKeywords:
This tests the VLM post-guard's is_nav_intent classification.
"""
from GramAddict.core.telepathic_engine import NAV_BAR_ZONE
# The intent is "open following list"
intent = "open following list"
@@ -92,10 +93,16 @@ class TestStructuralGuardNavKeywords:
# The VLM guard's nav keywords (this is what we're testing)
# This is the list from line 1594 of telepathic_engine.py
nav_keywords_vlm = [
"tab", "navigation", "reels tab", "profile tab",
"home tab", "message tab",
"tab",
"navigation",
"reels tab",
"profile tab",
"home tab",
"message tab",
# These MUST be present to fix the bug:
"following", "follower", "followers",
"following",
"follower",
"followers",
]
is_nav_intent = any(k in low_intent for k in nav_keywords_vlm)
@@ -111,6 +118,7 @@ class TestStructuralGuardNavKeywords:
# Bug 3: Synthetic intent masking
# ─────────────────────────────────────────────────────
class TestSyntheticIntentTracking:
"""GoalExecutor must stop retrying synthetic intents that fail."""
@@ -139,6 +147,7 @@ class TestSyntheticIntentTracking:
"available_actions": ["tap home tab", "press back", "tap profile tab"],
"context": {},
}
executor.perceive = MagicMock(side_effect=fake_perceive)
# Mock _execute_action to always fail for the synthetic intent
@@ -150,6 +159,7 @@ class TestSyntheticIntentTracking:
if action == "press back":
return True
return False
monkeypatch.setattr(executor, "_execute_action", fake_execute)
# Speed up sleeps
@@ -172,6 +182,7 @@ class TestSyntheticIntentTracking:
# Bug 4: _extract_available_actions for own profile
# ─────────────────────────────────────────────────────
class TestAvailableActionsOwnProfile:
"""available_actions must include 'tap following list' on own profile."""
@@ -185,9 +196,7 @@ class TestAvailableActionsOwnProfile:
identity = ScreenIdentity("marisaundmarc")
result = identity.identify(xml)
assert result["screen_type"] == ScreenType.OWN_PROFILE, (
f"Expected OWN_PROFILE but got {result['screen_type']}"
)
assert result["screen_type"] == ScreenType.OWN_PROFILE, f"Expected OWN_PROFILE but got {result['screen_type']}"
available = result["available_actions"]
assert "tap following list" in available, (
@@ -211,12 +220,12 @@ class TestAvailableActionsOwnProfile:
result = identity.identify(xml)
screen_type = result["screen_type"]
assert screen_type in (ScreenType.OWN_PROFILE, ScreenType.OTHER_PROFILE), (
f"Expected profile screen, got {screen_type}"
)
assert screen_type in (
ScreenType.OWN_PROFILE,
ScreenType.OTHER_PROFILE,
), f"Expected profile screen, got {screen_type}"
available = result["available_actions"]
assert "tap following list" in available, (
f"'tap following list' not in available_actions on English profile! "
f"Available: {available}"
f"'tap following list' not in available_actions on English profile! " f"Available: {available}"
)

View File

@@ -1,71 +1,76 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.goap import GoalExecutor, ScreenType
def test_goal_executor_masks_failed_actions(monkeypatch):
"""
TDD Test: Verifiziert, dass der GoalExecutor eine Aktion, die mehrmals
TDD Test: Verifiziert, dass der GoalExecutor eine Aktion, die mehrmals
fehlschlägt, temporär aus den available_actions entfernt, um Loops zu verhindern.
"""
device = MagicMock()
executor = GoalExecutor(device, "test_user")
# Mock perceive so we always return a static screen that has 'tap follow button' available.
perceive_mock = MagicMock()
MagicMock()
def fake_perceive(*args, **kwargs):
# We must return a NEW dict each time so masking doesn't permanently modify the mock's template
return {
'screen_type': ScreenType.OWN_PROFILE,
'available_actions': ['tap follow button', 'press back'],
'context': {}
"screen_type": ScreenType.OWN_PROFILE,
"available_actions": ["tap follow button", "press back"],
"context": {},
}
executor.perceive = MagicMock(side_effect=fake_perceive)
# Original planner behavior or mock:
# 'plan_next_step' naturally suggests 'tap follow button' if 'follow' is in goal.
# We will just verify the raw call to _execute_action.
# We mock _execute_action to ALWAYS fail for 'tap follow button',
# We mock _execute_action to ALWAYS fail for 'tap follow button',
# and if 'press back' is called, we return True and artificially complete the goal.
executor.execute_calls = []
def fake_execute(action, **kwargs):
executor.execute_calls.append(action)
if action == 'tap follow button':
if action == "tap follow button":
return False
if action == 'press back':
if action == "press back":
# Simulated exit to end the loop
executor.goal_achieved = True
return True
return False
monkeypatch.setattr(executor, "_execute_action", fake_execute)
# Modify the loop so it breaks if goal_achieved is set
original_plan = executor.planner.plan_next_step
def hooked_plan(goal, screen, *args, **kwargs):
if getattr(executor, 'goal_achieved', False):
return None # Stop GOAP
if getattr(executor, "goal_achieved", False):
return None # Stop GOAP
return original_plan(goal, screen, *args, **kwargs)
executor.planner.plan_next_step = MagicMock(side_effect=hooked_plan)
# Speed up sleep in the loop
monkeypatch.setattr("GramAddict.core.goap.random_sleep", lambda x, y: None)
# Set max_steps
executor.max_steps = 10
# Mock PathMemory to avoid real DB access which adds a recall attempt
executor.path_memory.recall_path = MagicMock(return_value=[])
# Execute
executor.achieve("follow user")
# Ohne Loop-Prevention würde execute_calls 10 mal 'tap follow button' enthalten
# Mit Loop-Prevention sollte er <= 2 mal 'tap follow button' versuchen, dann es maskieren,
# und dann den Fallback ('press back') versuchen, was then finishes the goal.
count_follow = executor.execute_calls.count('tap follow button')
assert count_follow <= 2, f"GoalExecutor ist in einem Loop gefangen! Versuchte die fehlgeschlagene Aktion {count_follow} mal anstatt sie zu maskieren."
count_follow = executor.execute_calls.count("tap follow button")
assert (
count_follow <= 2
), f"GoalExecutor ist in einem Loop gefangen! Versuchte die fehlgeschlagene Aktion {count_follow} mal anstatt sie zu maskieren."

View File

@@ -1,10 +1,13 @@
import pytest
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
def test_feed_markers_missing_prevents_back_button_trap():
"""
TDD Test: When the bot is on the feed but no feed markers (like buttons) are visible
TDD Test: When the bot is on the feed but no feed markers (like buttons) are visible
(e.g., due to a tall image or mid-scroll), it must NOT press the Android 'back' button,
because pressing back on the Home Feed forces a jump to the top of the feed and a refresh.
It should only press back if it explicitly detects an obstacle (e.g., a bottom sheet).
@@ -12,42 +15,50 @@ def test_feed_markers_missing_prevents_back_button_trap():
device = MagicMock()
# Return XML that has NO feed markers and NO obstacles
device.dump_hierarchy.return_value = '<?xml version="1.0"?><hierarchy><node text="some tall post" /></hierarchy>'
configs = MagicMock()
configs.args.ignore_close_friends = False
configs.args.carousel_percentage = 0
configs.args.interaction_users_amount = "1"
# We want to break the loop after one pass. We can patch _humanized_scroll to raise an Exception.
class LoopBreak(Exception): pass
class LoopBreak(Exception):
pass
with patch("GramAddict.core.bot_flow._humanized_scroll", side_effect=LoopBreak) as mock_scroll:
with patch("GramAddict.core.bot_flow.sleep"):
with patch("GramAddict.core.bot_flow.is_ad", return_value=False):
with patch("GramAddict.core.bot_flow.TelepathicEngine") as MockEng:
MockEng.get_instance.return_value._extract_semantic_nodes.return_value = [1] # prevent zero-node crash
MockEng.get_instance.return_value._extract_semantic_nodes.return_value = [
1
] # prevent zero-node crash
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.wants_to_doomscroll.return_value = False
cog_stack = {"dopamine": dopamine}
try:
zero_engine = MagicMock()
nav_graph = MagicMock()
session_state = MagicMock()
session_state.check_limit.return_value = [False, False]
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "home_feed", cog_stack)
_run_zero_latency_feed_loop(
device, zero_engine, nav_graph, configs, session_state, "home_feed", cog_stack
)
except LoopBreak:
pass
# It must NOT press back, because it's just lost in the feed without explicit obstacles.
try:
device.press.assert_not_called()
except AssertionError:
pytest.fail("Agent incorrectly pressed BACK when no obstacle was present. This triggers the scroll-to-top trap!")
pytest.fail(
"Agent incorrectly pressed BACK when no obstacle was present. This triggers the scroll-to-top trap!"
)
mock_scroll.assert_called_once()
def test_explicit_obstacle_triggers_back_button():
"""
TDD Test: When the bot detects an explicit obstacle (e.g., dialog_container),
@@ -55,13 +66,16 @@ def test_explicit_obstacle_triggers_back_button():
"""
device = MagicMock()
# Return XML that HAS an obstacle
device.dump_hierarchy.return_value = '<?xml version="1.0"?><hierarchy><node resource-id="dialog_container" /></hierarchy>'
device.dump_hierarchy.return_value = (
'<?xml version="1.0"?><hierarchy><node resource-id="dialog_container" /></hierarchy>'
)
configs = MagicMock()
configs.args.ignore_close_friends = False
class LoopBreak(Exception): pass
class LoopBreak(Exception):
pass
with patch("GramAddict.core.bot_flow.sleep"):
with patch("GramAddict.core.bot_flow.is_ad", return_value=False):
with patch("GramAddict.core.bot_flow.TelepathicEngine") as MockEng:
@@ -70,7 +84,7 @@ def test_explicit_obstacle_triggers_back_button():
dopamine.is_app_session_over.return_value = False
dopamine.wants_to_doomscroll.return_value = False
cog_stack = {"dopamine": dopamine}
try:
zero_engine = MagicMock()
nav_graph = MagicMock()
@@ -78,9 +92,11 @@ def test_explicit_obstacle_triggers_back_button():
session_state.check_limit.return_value = [False, False]
# Make device.press raise LoopBreak so we can verify it was called and break the infinite loop
device.press.side_effect = LoopBreak
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "home_feed", cog_stack)
_run_zero_latency_feed_loop(
device, zero_engine, nav_graph, configs, session_state, "home_feed", cog_stack
)
except LoopBreak:
pass
# device.press("back") SHOULD be called
device.press.assert_called_with("back")

View File

@@ -1,7 +1,8 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.telepathic_engine import TelepathicEngine
def test_learnable_fast_paths_use_qdrant(monkeypatch):
"""
TDD Test: The TelepathicEngine must NOT rely solely on hardcoded fast paths.
@@ -11,34 +12,34 @@ def test_learnable_fast_paths_use_qdrant(monkeypatch):
# Use direct instantiation to bypass any singleton mock leakage from previous tests
TelepathicEngine.reset()
engine = TelepathicEngine()
# Mock UIMemoryDB
mock_memory = MagicMock()
monkeypatch.setattr(engine, "ui_memory", mock_memory, raising=False)
# 1. When Qdrant HAS a mapping for 'tap profile tab', it should use it.
mock_memory.retrieve_memory.return_value = {
"resource_id": "com.instagram.android:id/profile_tab_learned",
"action": "tap",
"confidence": 0.95
"confidence": 0.95,
}
viable_nodes = [
{"resource_id": "com.instagram.android:id/profile_tab_learned", "x": 10, "y": 20, "semantic_string": "profile"},
{"resource_id": "com.instagram.android:id/feed_tab", "x": 30, "y": 40, "semantic_string": "feed"}
{"resource_id": "com.instagram.android:id/feed_tab", "x": 30, "y": 40, "semantic_string": "feed"},
]
# We pass viable_nodes because the core_nav fast path scans nodes
result = engine._core_navigation_fast_path("tap profile tab", viable_nodes)
assert result is not None, "Should use learned path from memory"
assert result["x"] == 10, "Should select the node matching LEARNED resource-id, not hardcoded!"
assert result["source"] == "qdrant_nav", "Source should be marked as Qdrant memory"
# 2. When Qdrant does NOT have a mapping, it MUST NOT fall back to hardcoded defaults.
# It must return None to force the system to evaluate semantics autonomously (Blank Start).
mock_memory.retrieve_memory.return_value = None
result2 = engine._core_navigation_fast_path("tap home tab", viable_nodes)
assert result2 is None, "Should NOT fall back to default seed; must enforce Blank Start!"

View File

@@ -1,38 +1,41 @@
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from GramAddict.core.telepathic_engine import TelepathicEngine
import os
FAILED_XML_PATH = "/Volumes/Alpha SSD/Coding/bot/debug/xml_dumps/manual_interrupt__2026-04-17_13-16-14.xml"
def test_modal_guard_blocks_nav_intent_on_failed_xml():
"""
Test that the Modal Guard correctly identifies the bottom sheet in the failed XML
and prevents searching for the 'Home Tab'.
"""
# Replace hardcoded dump file with inline XML containing a modal to prevent skipping
xml_content = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
xml_content = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" resource-id="com.instagram.android:id/bottom_sheet_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,900][1080,2400]" visible-to-user="true">
<node index="0" text="Add comment" resource-id="com.instagram.android:id/comment_composer" class="android.widget.EditText" bounds="[42,2224][1038,2350]" visible-to-user="true" />
</node>
<node index="1" resource-id="com.instagram.android:id/tab_bar" bounds="[0,2200][1080,2400]" visible-to-user="false" />
</hierarchy>'''
</hierarchy>"""
engine = TelepathicEngine()
# Intent that SHOULD be blocked because Home Tab is obscured by the comment sheet
intent = "tap home tab"
# We don't want to trigger actual LLM/VLM calls during the test
with patch("GramAddict.core.telepathic_engine.query_telepathic_llm") as mock_vlm:
result = engine.find_best_node(xml_content, intent)
# 1. Verify that no VLM call was even attempted because the Modal Guard should have caught it early
assert mock_vlm.called is False, "VLM should not be called when a modal obscures the target zone."
# 2. Result should be {'blocked_by_modal': True} (meaning 'Target blocked/missing')
assert result == {'blocked_by_modal': True}, "Modal Guard should return block status for navigation intents when a sheet is open."
assert result == {
"blocked_by_modal": True
}, "Modal Guard should return block status for navigation intents when a sheet is open."
def test_zone_enforcement_blocks_mid_screen_tab_hallucination():
"""
@@ -40,33 +43,39 @@ def test_zone_enforcement_blocks_mid_screen_tab_hallucination():
any result for a 'tab' intent that is in the middle of the screen is rejected.
"""
engine = TelepathicEngine()
# Minimal XML
xml = "<?xml version='1.0' ?><hierarchy><node index='0' text='Add comment' bounds='[42,2224][1038,2350]' visible-to-user='true' /></hierarchy>"
intent = "tap home tab"
# Mock VLM to return the 'Add comment' field which is at Y=2224 (0.917 of 2424)
# Our guard enforces Tabs MUST be in the bottom 10% (Y > 0.90 * Height).
# Wait, Y=2224 on H=2424 is 0.917. That IS in the bottom 10%.
# Let's mock a node at Y=1000 (middle of screen) to test the guard.
with patch("GramAddict.core.telepathic_engine.query_telepathic_llm") as mock_vlm:
# Mock VLM returning a middle-screen element (Index 0)
mock_vlm.return_value = '{"index": 0, "reason": "hallucination test"}'
# Injected node at middle screen
with patch.object(TelepathicEngine, "_extract_semantic_nodes") as mock_extract:
mock_extract.return_value = [{
"index": 0,
"x": 500, "y": 1000, "width": 100, "height": 100, "area": 10000,
"raw_bounds": "[450,950][550,1050]",
"semantic_string": "text: 'Fake Home', id context: 'fake_tab'"
}]
mock_extract.return_value = [
{
"index": 0,
"x": 500,
"y": 1000,
"width": 100,
"height": 100,
"area": 10000,
"raw_bounds": "[450,950][550,1050]",
"semantic_string": "text: 'Fake Home', id context: 'fake_tab'",
}
]
# We also need to patch _is_modal_active to False so it GETS to the VLM step
with patch.object(TelepathicEngine, "_is_modal_active", return_value=False):
result = engine.find_best_node(xml, intent)
# Should be rejected because navigation tabs should be in the nav bar zone
assert result is None, "Structural Guard should reject mid-screen navigation tab candidates."

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.goap import GoalExecutor, GoalPlanner, ScreenType
from GramAddict.core.goap import GoalExecutor, ScreenType
def test_goal_executor_prevents_infinite_tab_loops(monkeypatch):
"""
@@ -11,63 +12,60 @@ def test_goal_executor_prevents_infinite_tab_loops(monkeypatch):
device = MagicMock()
executor = GoalExecutor(device, "test_user")
executor.planner.knowledge.wipe()
# We want to achieve "open following list", which requires ScreenType.FOLLOW_LIST
# Currently on HOME_FEED
# The heuristic might guess "tap reels tab" because of a fallback
# Track executed actions
executed_actions = []
# Mock perceive to alternate between HOME_FEED and REELS_FEED
# If we press a tab on HOME, we go to REELS.
# If we press back on REELS, we go to HOME.
current_screen = ScreenType.HOME_FEED
def fake_perceive(*args, **kwargs):
if current_screen == ScreenType.HOME_FEED:
return {
'screen_type': ScreenType.HOME_FEED,
'available_actions': ['tap reels tab', 'tap explore tab'],
'context': {}
"screen_type": ScreenType.HOME_FEED,
"available_actions": ["tap reels tab", "tap explore tab"],
"context": {},
}
else:
return {
'screen_type': ScreenType.REELS_FEED,
'available_actions': ['press back'],
'context': {}
}
return {"screen_type": ScreenType.REELS_FEED, "available_actions": ["press back"], "context": {}}
executor.perceive = MagicMock(side_effect=fake_perceive)
def fake_execute(action, **kwargs):
nonlocal current_screen
executed_actions.append(action)
if action == 'open following list' and current_screen == ScreenType.HOME_FEED:
if action == "open following list" and current_screen == ScreenType.HOME_FEED:
current_screen = ScreenType.REELS_FEED
return True
elif action == 'press back' and current_screen == ScreenType.REELS_FEED:
elif action == "press back" and current_screen == ScreenType.REELS_FEED:
current_screen = ScreenType.HOME_FEED
return True
return False
monkeypatch.setattr(executor, "_execute_action", fake_execute)
# Speed up sleep
monkeypatch.setattr("GramAddict.core.goap.random_sleep", lambda x, y: None)
# Execute the goal
executor.max_steps = 10
result = executor.achieve("open following list")
# Assert it failed (we never reached FOLLOW_LIST)
assert result is False
# Assert we didn't loop endlessly.
# Assert we didn't loop endlessly.
# Try 1: tap reels tab
# Try 2: press back
# Try 3: It should NOT try 'tap reels tab' again.
count_open_following = executed_actions.count('open following list')
assert count_open_following == 1, f"Bot is stuck in a loop! It tried to open following list {count_open_following} times."
count_open_following = executed_actions.count("open following list")
assert (
count_open_following == 1
), f"Bot is stuck in a loop! It tried to open following list {count_open_following} times."

View File

@@ -1,45 +1,43 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.goap import GoalPlanner, NavigationKnowledge, GoalExecutor, ScreenType
from unittest.mock import MagicMock
from GramAddict.core.goap import GoalExecutor, ScreenType
def test_null_action_escalates_to_trap():
"""
TDD Test: Verify that when an action is executed but the screen state does not change
(null-action / dead button), the GOAP loop tracks the failure and eventually burns
TDD Test: Verify that when an action is executed but the screen state does not change
(null-action / dead button), the GOAP loop tracks the failure and eventually burns
the action as a trap to prevent infinite loops.
"""
device_mock = MagicMock()
# Mock dump_hierarchy to simulate no UI change
device_mock.dump_hierarchy.return_value = "<hierarchy></hierarchy>"
nav = GoalExecutor(device=device_mock, bot_username="test_user")
# Mock perceive to always return the SAME screen (EXPLORE_GRID)
nav.perceive = MagicMock(return_value={
"screen_type": ScreenType.EXPLORE_GRID,
"available_actions": ["tap broken button"]
})
nav.perceive = MagicMock(
return_value={"screen_type": ScreenType.EXPLORE_GRID, "available_actions": ["tap broken button"]}
)
# Mock _execute_action to return False (which is what happens when ui_changed is False)
nav._execute_action = MagicMock(return_value=False)
# Mock plan_next_step to repeatedly suggest the same action
nav.planner.plan_next_step = MagicMock(return_value="tap broken button")
# Mock learn_trap to verify it gets called
nav.planner.knowledge.learn_trap = MagicMock()
# Run a short loop (max 3 steps)
nav.achieve(goal="open profile", max_steps=3)
# Verify that the action was executed multiple times
assert nav._execute_action.call_count == 3
# The action failed on step 1 -> fail count = 1
# The action failed on step 2 -> fail count = 2
# At fail count 2, learn_trap MUST be called.
nav.planner.knowledge.learn_trap.assert_called_with(
ScreenType.EXPLORE_GRID,
"tap broken button",
"repeated_failure_or_null_action"
ScreenType.EXPLORE_GRID, "tap broken button", "repeated_failure_or_null_action"
)

View File

@@ -3,7 +3,6 @@ TDD: Perception Module Tests.
Tests the extracted feed analysis functions in isolation.
"""
import pytest
class TestFeedMarkers:
@@ -12,24 +11,28 @@ class TestFeedMarkers:
def test_feed_markers_is_list(self):
"""FEED_MARKERS must be a list."""
from GramAddict.core.perception.feed_analysis import FEED_MARKERS
assert isinstance(FEED_MARKERS, list)
assert len(FEED_MARKERS) >= 4
def test_has_feed_markers_detects_feed(self):
"""has_feed_markers must return True when markers are present."""
from GramAddict.core.perception.feed_analysis import has_feed_markers
xml = '<hierarchy><node resource-id="row_feed_photo_profile_name"/></hierarchy>'
assert has_feed_markers(xml) is True
def test_has_feed_markers_detects_reels(self):
"""has_feed_markers must detect Reels markers."""
from GramAddict.core.perception.feed_analysis import has_feed_markers
xml = '<hierarchy><node resource-id="clips_media_component"/></hierarchy>'
assert has_feed_markers(xml) is True
def test_has_feed_markers_rejects_empty(self):
"""has_feed_markers must return False on empty/unrelated XML."""
from GramAddict.core.perception.feed_analysis import has_feed_markers
assert has_feed_markers("") is False
assert has_feed_markers("<hierarchy/>") is False
@@ -40,18 +43,21 @@ class TestCarouselDetection:
def test_detects_carousel_indicator(self):
"""Must detect carousel_page_indicator."""
from GramAddict.core.perception.feed_analysis import has_carousel_in_view
xml = '<node resource-id="com.instagram.android:id/carousel_page_indicator"/>'
assert has_carousel_in_view(xml) is True
def test_detects_carousel_group(self):
"""Must detect carousel_media_group."""
from GramAddict.core.perception.feed_analysis import has_carousel_in_view
xml = '<node resource-id="com.instagram.android:id/carousel_media_group"/>'
assert has_carousel_in_view(xml) is True
def test_rejects_non_carousel(self):
"""Must not false-positive on non-carousel XML."""
from GramAddict.core.perception.feed_analysis import has_carousel_in_view
xml = '<node resource-id="com.instagram.android:id/action_bar_button"/>'
assert has_carousel_in_view(xml) is False
@@ -61,14 +67,15 @@ class TestExtractPostContent:
def test_returns_dict_with_required_keys(self):
"""Must always return dict with username, description, caption."""
from unittest.mock import MagicMock, patch
from GramAddict.core.perception.feed_analysis import extract_post_content
from unittest.mock import patch, MagicMock
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock:
instance = MagicMock()
instance.find_best_node.return_value = None
mock.return_value = instance
result = extract_post_content("<hierarchy/>")
assert "username" in result
assert "description" in result
@@ -76,14 +83,15 @@ class TestExtractPostContent:
def test_handles_garbage_xml_gracefully(self):
"""Must not crash on corrupted XML."""
from unittest.mock import MagicMock, patch
from GramAddict.core.perception.feed_analysis import extract_post_content
from unittest.mock import patch, MagicMock
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock:
instance = MagicMock()
instance.find_best_node.return_value = None
mock.return_value = instance
result = extract_post_content("garbage<<>>not xml at all")
assert isinstance(result, dict)
assert result["username"] == ""
@@ -95,15 +103,18 @@ class TestBackwardCompatibility:
def test_feed_markers_importable_from_bot_flow(self):
"""FEED_MARKERS must be importable from bot_flow for existing tests."""
from GramAddict.core.bot_flow import FEED_MARKERS
assert isinstance(FEED_MARKERS, list)
assert len(FEED_MARKERS) >= 4
def test_has_carousel_importable_from_bot_flow(self):
"""has_carousel_in_view must be importable from bot_flow."""
from GramAddict.core.bot_flow import has_carousel_in_view
assert callable(has_carousel_in_view)
def test_extract_post_content_importable_from_bot_flow(self):
"""_extract_post_content must be importable from bot_flow."""
from GramAddict.core.bot_flow import _extract_post_content
assert callable(_extract_post_content)

View File

@@ -8,9 +8,10 @@ Validates that the PhysicsBody correctly models:
- Gaussian jitter on start positions
"""
import pytest
import time
import pytest
from GramAddict.core.physics.biomechanics import PhysicsBody
@@ -24,18 +25,12 @@ def reset_singleton():
@pytest.fixture
def body_right():
return PhysicsBody(
handedness="right",
device_info={"displayWidth": 1080, "displayHeight": 2400}
)
return PhysicsBody(handedness="right", device_info={"displayWidth": 1080, "displayHeight": 2400})
@pytest.fixture
def body_left():
return PhysicsBody(
handedness="left",
device_info={"displayWidth": 1080, "displayHeight": 2400}
)
return PhysicsBody(handedness="left", device_info={"displayWidth": 1080, "displayHeight": 2400})
class TestHandedness:
@@ -43,31 +38,27 @@ class TestHandedness:
def test_right_hander_anchor_is_right(self, body_right):
"""Right-hander anchor should be on the right side of the screen."""
assert body_right.anchor_x > body_right.w * 0.6, (
f"Right-hander anchor_x should be > 60% of width, got {body_right.anchor_x}"
)
assert (
body_right.anchor_x > body_right.w * 0.6
), f"Right-hander anchor_x should be > 60% of width, got {body_right.anchor_x}"
def test_left_hander_anchor_is_left(self, body_left):
"""Left-hander anchor should be on the left side of the screen."""
assert body_left.anchor_x < body_left.w * 0.4, (
f"Left-hander anchor_x should be < 40% of width, got {body_left.anchor_x}"
)
assert (
body_left.anchor_x < body_left.w * 0.4
), f"Left-hander anchor_x should be < 40% of width, got {body_left.anchor_x}"
def test_right_hander_scroll_starts_right(self, body_right):
"""Right-hander scroll positions should cluster on the right."""
xs = [body_right.get_scroll_start()[0] for _ in range(50)]
avg_x = sum(xs) / len(xs)
assert avg_x > body_right.w * 0.55, (
f"Right-hander avg scroll X should be > 55% of width, got {avg_x:.0f}"
)
assert avg_x > body_right.w * 0.55, f"Right-hander avg scroll X should be > 55% of width, got {avg_x:.0f}"
def test_left_hander_scroll_starts_left(self, body_left):
"""Left-hander scroll positions should cluster on the left."""
xs = [body_left.get_scroll_start()[0] for _ in range(50)]
avg_x = sum(xs) / len(xs)
assert avg_x < body_left.w * 0.45, (
f"Left-hander avg scroll X should be < 45% of width, got {avg_x:.0f}"
)
assert avg_x < body_left.w * 0.45, f"Left-hander avg scroll X should be < 45% of width, got {avg_x:.0f}"
class TestThumbArcBias:
@@ -101,21 +92,15 @@ class TestSessionDrift:
# Drift applies every 15-25 gestures (randomized), so 500 guarantees multiple triggers
total_drift = abs(body_right.drift_x) + abs(body_right.drift_y)
assert total_drift > 0, (
"Expected non-zero drift after 500 gestures"
)
assert total_drift > 0, "Expected non-zero drift after 500 gestures"
def test_drift_is_bounded(self, body_right):
"""Drift should never wander off-screen."""
for _ in range(500):
body_right.get_scroll_start()
assert abs(body_right.drift_x) <= body_right.w * 0.1 + 1, (
f"Drift X too large: {body_right.drift_x}"
)
assert abs(body_right.drift_y) <= body_right.h * 0.06 + 1, (
f"Drift Y too large: {body_right.drift_y}"
)
assert abs(body_right.drift_x) <= body_right.w * 0.1 + 1, f"Drift X too large: {body_right.drift_x}"
assert abs(body_right.drift_y) <= body_right.h * 0.06 + 1, f"Drift Y too large: {body_right.drift_y}"
class TestStartPositions:
@@ -224,9 +209,7 @@ class TestPressureAndTouchMajor:
avg_fresh = sum(pressures_fresh) / len(pressures_fresh)
avg_tired = sum(pressures_tired) / len(pressures_tired)
assert avg_tired > avg_fresh, (
f"Fatigued pressure ({avg_tired:.3f}) should exceed fresh ({avg_fresh:.3f})"
)
assert avg_tired > avg_fresh, f"Fatigued pressure ({avg_tired:.3f}) should exceed fresh ({avg_fresh:.3f})"
def test_touch_major_in_range(self, body_right):
for _ in range(50):
@@ -244,9 +227,7 @@ class TestPressureAndTouchMajor:
avg_fresh = sum(tm_fresh) / len(tm_fresh)
avg_tired = sum(tm_tired) / len(tm_tired)
assert avg_tired > avg_fresh, (
f"Fatigued touch_major ({avg_tired:.1f}) should exceed fresh ({avg_fresh:.1f})"
)
assert avg_tired > avg_fresh, f"Fatigued touch_major ({avg_tired:.1f}) should exceed fresh ({avg_fresh:.1f})"
class TestSingleton:

View File

@@ -9,9 +9,11 @@ These tests mock the SendEventInjector at the injection boundary to
validate that the humanized functions correctly generate gesture data
and delegate to the injector.
"""
import pytest
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.physics.biomechanics import PhysicsBody
@@ -20,6 +22,7 @@ def reset_singletons():
"""Reset singletons between tests for isolation."""
PhysicsBody.reset()
from GramAddict.core.physics.sendevent_injector import SendEventInjector
SendEventInjector.reset()
yield
PhysicsBody.reset()
@@ -46,12 +49,13 @@ class TestHumanizedScroll:
MockInjector.get_instance.return_value = mock_injector
from GramAddict.core.physics.humanized_input import humanized_scroll
humanized_scroll(device)
mock_injector.inject_gesture.assert_called()
args = mock_injector.inject_gesture.call_args
points = args[0][0]
timing = args[0][1]
args[0][1]
# Validate gesture data structure
assert len(points) >= 5, f"Expected at least 5 points, got {len(points)}"
@@ -80,11 +84,13 @@ class TestHumanizedScroll:
def test_skip_scroll_calls_injector(self, MockInjector, device):
"""Skip scroll should also use the injector."""
import random
random.seed(42)
mock_injector = MagicMock()
MockInjector.get_instance.return_value = mock_injector
from GramAddict.core.physics.humanized_input import humanized_scroll
humanized_scroll(device, is_skip=True)
mock_injector.inject_gesture.assert_called()
@@ -100,6 +106,7 @@ class TestHumanizedClick:
MockInjector.get_instance.return_value = mock_injector
from GramAddict.core.physics.humanized_input import humanized_click
humanized_click(device, 500, 1200)
assert mock_injector.inject_gesture.call_count == 1
@@ -111,6 +118,7 @@ class TestHumanizedClick:
MockInjector.get_instance.return_value = mock_injector
from GramAddict.core.physics.humanized_input import humanized_click
humanized_click(device, 500, 1200, double=True)
# Double-tap bypasses SendEventInjector and uses shell for timing precision
@@ -122,11 +130,13 @@ class TestHumanizedClick:
def test_tap_has_jitter(self, MockInjector, device):
"""Taps should have slight jitter (not exact coordinates)."""
import random
random.seed(1)
mock_injector = MagicMock()
MockInjector.get_instance.return_value = mock_injector
from GramAddict.core.physics.humanized_input import humanized_click
humanized_click(device, 500, 1200)
args = mock_injector.inject_gesture.call_args
@@ -147,6 +157,7 @@ class TestHumanizedHorizontalSwipe:
MockInjector.get_instance.return_value = mock_injector
from GramAddict.core.physics.humanized_input import humanized_horizontal_swipe
humanized_horizontal_swipe(device, 800, 200, 1200, 250)
mock_injector.inject_gesture.assert_called_once()
@@ -158,6 +169,7 @@ class TestHumanizedHorizontalSwipe:
MockInjector.get_instance.return_value = mock_injector
from GramAddict.core.physics.humanized_input import humanized_horizontal_swipe
humanized_horizontal_swipe(device, 800, 200, 1200, 250)
args = mock_injector.inject_gesture.call_args

View File

@@ -12,101 +12,124 @@ Covers:
- Error isolation (plugin crashes don't cascade)
- CarouselBrowsingPlugin activation and execution
"""
import pytest
from unittest.mock import MagicMock, patch
import pytest
from GramAddict.core.behaviors import (
BehaviorPlugin,
BehaviorContext,
BehaviorPlugin,
BehaviorResult,
PluginRegistry,
)
# ── Test Plugins ──
class AlwaysActivePlugin(BehaviorPlugin):
@property
def name(self): return "always_active"
def name(self):
return "always_active"
@property
def priority(self): return 50
def can_activate(self, ctx): return True
def priority(self):
return 50
def can_activate(self, ctx):
return True
def execute(self, ctx):
return BehaviorResult(executed=True, interactions=1)
class NeverActivePlugin(BehaviorPlugin):
@property
def name(self): return "never_active"
def name(self):
return "never_active"
@property
def priority(self): return 50
def can_activate(self, ctx): return False
def priority(self):
return 50
def can_activate(self, ctx):
return False
def execute(self, ctx):
return BehaviorResult(executed=True)
class HighPriorityPlugin(BehaviorPlugin):
@property
def name(self): return "high_priority"
def name(self):
return "high_priority"
@property
def priority(self): return 100
def can_activate(self, ctx): return True
def priority(self):
return 100
def can_activate(self, ctx):
return True
def execute(self, ctx):
return BehaviorResult(executed=True, metadata={"order": "first"})
class LowPriorityPlugin(BehaviorPlugin):
@property
def name(self): return "low_priority"
def name(self):
return "low_priority"
@property
def priority(self): return 10
def can_activate(self, ctx): return True
def priority(self):
return 10
def can_activate(self, ctx):
return True
def execute(self, ctx):
return BehaviorResult(executed=True, metadata={"order": "last"})
class ExclusiveGuardPlugin(BehaviorPlugin):
@property
def name(self): return "ad_guard"
def name(self):
return "ad_guard"
@property
def priority(self): return 100
def priority(self):
return 100
@property
def exclusive(self): return True
def can_activate(self, ctx): return "sponsored" in (ctx.context_xml or "")
def exclusive(self):
return True
def can_activate(self, ctx):
return "sponsored" in (ctx.context_xml or "")
def execute(self, ctx):
return BehaviorResult(executed=True, should_skip=True)
class CrashingPlugin(BehaviorPlugin):
@property
def name(self): return "crasher"
def name(self):
return "crasher"
@property
def priority(self): return 50
def can_activate(self, ctx): return True
def priority(self):
return 50
def can_activate(self, ctx):
return True
def execute(self, ctx):
raise RuntimeError("Plugin exploded!")
# ── Fixtures ──
@pytest.fixture
def registry():
PluginRegistry.reset()
@@ -121,14 +144,14 @@ def ctx():
device = MagicMock()
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.shell = MagicMock()
configs = MagicMock()
configs.args = MagicMock()
configs.args.carousel_percentage = "50"
configs.args.carousel_count = "2-4"
session_state = MagicMock()
return BehaviorContext(
device=device,
configs=configs,
@@ -141,6 +164,7 @@ def ctx():
# ── Registry Tests ──
class TestPluginRegistry:
"""Registry must manage plugins correctly."""
@@ -170,7 +194,7 @@ class TestPluginRegistry:
def test_plugins_sorted_by_priority(self, registry):
registry.register(LowPriorityPlugin())
registry.register(HighPriorityPlugin())
plugins = registry.plugins
assert plugins[0].name == "high_priority"
assert plugins[1].name == "low_priority"
@@ -178,7 +202,7 @@ class TestPluginRegistry:
def test_get_active_plugins_filters(self, registry, ctx):
registry.register(AlwaysActivePlugin())
registry.register(NeverActivePlugin())
active = registry.get_active_plugins(ctx)
assert len(active) == 1
assert active[0].name == "always_active"
@@ -201,6 +225,7 @@ class TestPluginRegistry:
# ── Execution Tests ──
class TestPluginExecution:
"""Plugin execution lifecycle."""
@@ -218,7 +243,7 @@ class TestPluginExecution:
def test_execute_respects_priority_order(self, registry, ctx):
registry.register(LowPriorityPlugin())
registry.register(HighPriorityPlugin())
results = registry.execute_all(ctx)
assert len(results) == 2
assert results[0].metadata["order"] == "first"
@@ -228,7 +253,7 @@ class TestPluginExecution:
ctx.context_xml = "sponsored content here"
registry.register(ExclusiveGuardPlugin())
registry.register(AlwaysActivePlugin()) # Lower priority
results = registry.execute_all(ctx)
# Only the guard should have run (it's exclusive)
assert len(results) == 1
@@ -237,7 +262,7 @@ class TestPluginExecution:
def test_crashing_plugin_doesnt_cascade(self, registry, ctx):
"""A crashing plugin must not break other plugins."""
registry.register(CrashingPlugin())
# Must NOT raise
results = registry.execute_all(ctx)
assert len(results) == 1
@@ -247,6 +272,7 @@ class TestPluginExecution:
# ── BehaviorContext Tests ──
class TestBehaviorContext:
"""BehaviorContext construction."""
@@ -265,6 +291,7 @@ class TestBehaviorContext:
# ── BehaviorResult Tests ──
class TestBehaviorResult:
"""BehaviorResult defaults."""
@@ -277,17 +304,14 @@ class TestBehaviorResult:
assert result.metadata == {}
def test_result_with_metadata(self):
result = BehaviorResult(
executed=True,
interactions=3,
metadata={"slides_viewed": 3}
)
result = BehaviorResult(executed=True, interactions=3, metadata={"slides_viewed": 3})
assert result.interactions == 3
assert result.metadata["slides_viewed"] == 3
# ── CarouselBrowsingPlugin Tests ──
class TestCarouselBrowsingPlugin:
"""Concrete plugin: carousel browsing."""
@@ -295,54 +319,57 @@ class TestCarouselBrowsingPlugin:
def carousel_ctx(self, ctx):
"""Context with carousel indicators."""
ctx.context_xml = (
'<hierarchy>'
"<hierarchy>"
'<node resource-id="com.instagram.android:id/carousel_page_indicator"/>'
'<node resource-id="com.instagram.android:id/row_feed_photo_profile_name"/>'
'</hierarchy>'
"</hierarchy>"
)
return ctx
def test_activates_on_carousel(self, carousel_ctx):
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
plugin = CarouselBrowsingPlugin()
assert plugin.can_activate(carousel_ctx) is True
def test_does_not_activate_without_carousel(self, ctx):
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
plugin = CarouselBrowsingPlugin()
assert plugin.can_activate(ctx) is False
def test_does_not_activate_with_zero_percentage(self, carousel_ctx):
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
carousel_ctx.configs.args.carousel_percentage = "0"
plugin = CarouselBrowsingPlugin()
assert plugin.can_activate(carousel_ctx) is False
def test_execute_sends_swipe_commands(self, carousel_ctx):
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
import random
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
random.seed(42)
carousel_ctx.configs.args.carousel_percentage = "100"
carousel_ctx.configs.args.carousel_count = "2-2"
plugin = CarouselBrowsingPlugin()
with patch("GramAddict.core.behaviors.carousel_browsing.sleep"):
result = plugin.execute(carousel_ctx)
assert result.executed is True
assert result.interactions == 2
assert result.metadata["slides_viewed"] == 2
# Should have sent 2 swipe commands (exclude SendEventInjector detection calls)
swipe_calls = [
c for c in carousel_ctx.device.shell.call_args_list
if "input swipe" in str(c)
]
swipe_calls = [c for c in carousel_ctx.device.shell.call_args_list if "input swipe" in str(c)]
assert len(swipe_calls) == 2
def test_plugin_name_and_priority(self):
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
plugin = CarouselBrowsingPlugin()
assert plugin.name == "carousel_browsing"
assert plugin.priority == 20
@@ -350,12 +377,13 @@ class TestCarouselBrowsingPlugin:
def test_execute_probabilistic_skip(self, carousel_ctx):
"""When random > carousel_pct, plugin should not execute."""
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
import random
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
carousel_ctx.configs.args.carousel_percentage = "1" # 1% chance
plugin = CarouselBrowsingPlugin()
# Force random to return high value
random.seed(0)
with patch("GramAddict.core.behaviors.carousel_browsing.sleep"):
@@ -365,25 +393,25 @@ class TestCarouselBrowsingPlugin:
result = plugin.execute(carousel_ctx)
if result.executed:
executed_count += 1
# With 1% chance, we expect very few executions
assert executed_count < 20
def test_full_registration_and_execution(self, carousel_ctx):
"""End-to-end: register plugin, execute via registry."""
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
PluginRegistry.reset()
registry = PluginRegistry()
registry.register(CarouselBrowsingPlugin())
carousel_ctx.configs.args.carousel_percentage = "100"
carousel_ctx.configs.args.carousel_count = "1-1"
with patch("GramAddict.core.behaviors.carousel_browsing.sleep"):
results = registry.execute_all(carousel_ctx)
assert len(results) == 1
assert results[0].executed is True
PluginRegistry.reset()

View File

@@ -1,22 +1,24 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.bot_flow import _wait_for_profile_loaded
def test_wait_for_profile_loaded_success():
device = MagicMock()
# First dump is empty, second dump has profile_header
device.dump_hierarchy.side_effect = [
'<hierarchy></hierarchy>',
'<hierarchy><node resource-id="com.instagram.android:id/profile_header" /></hierarchy>'
"<hierarchy></hierarchy>",
'<hierarchy><node resource-id="com.instagram.android:id/profile_header" /></hierarchy>',
]
assert _wait_for_profile_loaded(device, timeout=2) == True
assert _wait_for_profile_loaded(device, timeout=2)
assert device.dump_hierarchy.call_count == 2
def test_wait_for_profile_loaded_timeout():
device = MagicMock()
# Always reel
device.dump_hierarchy.return_value = '<hierarchy><node resource-id="clips_video_container" /></hierarchy>'
assert _wait_for_profile_loaded(device, timeout=1) == False
assert not _wait_for_profile_loaded(device, timeout=1)
assert device.dump_hierarchy.call_count >= 1

View File

@@ -1,12 +1,12 @@
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from GramAddict.core.goap import NavigationKnowledge, ScreenType
from GramAddict.core.qdrant_memory import QdrantBase
def test_qdrant_semantic_overlap_prevention():
"""
TDD Test: Ensures that get_tab_for_screen and get_requirements
TDD Test: Ensures that get_tab_for_screen and get_requirements
do not suffer from vector similarity overlap. They must use exact payload matching.
"""
# 1. Setup real NavigationKnowledge but with a mocked DB connection
@@ -17,24 +17,24 @@ def test_qdrant_semantic_overlap_prevention():
mock_client = MagicMock()
mock_db.client = mock_client
knowledge._db = mock_db
# 2. Simulate the bug: if the system used query_points (embedding search)
# The client shouldn't receive a query_points call.
# It SHOULD receive a scroll call with a FieldCondition.
# Mock scroll to return an empty result (not found)
mock_client.scroll.return_value = ([], None)
# Execute
result_action = knowledge.get_action_for_screen(ScreenType.OWN_PROFILE)
# Assert it returns None when there is no exact match
assert result_action is None
# Verify scroll was called with correct filter, NOT query_points
assert mock_client.scroll.called, "Must use client.scroll for exact matching, not query_points"
assert not mock_client.query_points.called, "query_points should not be used due to semantic overlap risks"
# Verify the scroll filter checks for "result_screen" == "OWN_PROFILE"
call_args = mock_client.scroll.call_args[1]
scroll_filter = call_args.get("scroll_filter")
@@ -42,6 +42,7 @@ def test_qdrant_semantic_overlap_prevention():
assert scroll_filter.must[0].key == "result_screen"
assert scroll_filter.must[0].match.value == "OWN_PROFILE"
def test_qdrant_semantic_overlap_prevention_requirements():
"""
TDD Test: Ensures that get_requirements uses exact matching.
@@ -53,29 +54,30 @@ def test_qdrant_semantic_overlap_prevention_requirements():
mock_client = MagicMock()
mock_db.client = mock_client
knowledge._db = mock_db
mock_client.scroll.return_value = ([], None)
requirements = knowledge.get_requirements("open profile")
assert requirements == []
assert mock_client.scroll.called
assert not mock_client.query_points.called
call_args = mock_client.scroll.call_args[1]
scroll_filter = call_args.get("scroll_filter")
assert scroll_filter.must[0].key == "goal"
assert scroll_filter.must[0].match.value == "open profile"
def test_qdrant_semantic_overlap_prevention_path_memory():
"""
TDD Test: Ensures that PathMemory.recall_path uses a strict query_filter
on the `start_screen` payload field so it doesn't recall a path that is
TDD Test: Ensures that PathMemory.recall_path uses a strict query_filter
on the `start_screen` payload field so it doesn't recall a path that is
semantically related but for the wrong screen.
"""
from GramAddict.core.goap import PathMemory
memory = PathMemory("testuser")
mock_db = MagicMock(spec=QdrantBase)
mock_db.is_connected = True
@@ -83,16 +85,16 @@ def test_qdrant_semantic_overlap_prevention_path_memory():
mock_client = MagicMock()
mock_db.client = mock_client
memory._db = mock_db
mock_client.query_points.return_value = MagicMock(points=[])
mock_db._get_embedding.return_value = [0.1] * 768
# Execute
path = memory.recall_path("like post", "EXPLORE_GRID")
assert path is None
assert mock_client.query_points.called
call_args = mock_client.query_points.call_args[1]
query_filter = call_args.get("query_filter")
assert query_filter is not None

View File

@@ -1,26 +1,28 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
from GramAddict.core.session_state import SessionState
import pytest
@pytest.fixture
def mock_device():
from GramAddict.core.physics.biomechanics import PhysicsBody
from GramAddict.core.physics.sendevent_injector import SendEventInjector
PhysicsBody.reset()
SendEventInjector.reset()
device = MagicMock()
device.deviceV2 = MagicMock()
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
device.app_id = "com.instagram.android"
device.shell = MagicMock(return_value="")
yield device
PhysicsBody.reset()
SendEventInjector.reset()
def test_reels_loop_repost_execution(mock_device):
"""
TDD Test: Verifies that the bot attempts to repost a Reel if resonance is high
@@ -40,22 +42,22 @@ def test_reels_loop_repost_execution(mock_device):
"radome": MagicMock(),
"crm": MagicMock(),
}
# Configure growth_brain to allow repost, disallow comment/double-tap
mock_cognitive_stack["growth_brain"].wants_to_double_tap.return_value = False
mock_cognitive_stack["growth_brain"].wants_to_repost.return_value = True
mock_cognitive_stack["growth_brain"].wants_to_comment.return_value = False
mock_cognitive_stack["growth_brain"].evaluate_hesitation.return_value = False
mock_cognitive_stack["radome"].sanitize_xml.side_effect = lambda x: x
# Simulate a single post interaction then exit
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
# High resonance to trigger repost
mock_cognitive_stack["resonance"].calculate_resonance.return_value = 0.95
configs = MagicMock()
configs.args.repost_percentage = 100
configs.args.interact_percentage = 100
@@ -63,104 +65,128 @@ def test_reels_loop_repost_execution(mock_device):
configs.args.comment_percentage = 0
configs.args.follow_percentage = 0
configs.args.visit_profiles = 0
session_state = MagicMock()
session_state.check_limit.return_value = (False, False, False, False)
# 2. Mock Reels UI
# Reels usually have 'clips_viewer' or similar in the hierarchy
reels_xml = '''<?xml version='1.0' ?>
reels_xml = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_profile_header" bounds="[0,200][1080,300]" />
<node resource-id="com.instagram.android:id/clips_author_username" text="test_user" />
<node resource-id="com.instagram.android:id/clips_media_component" content-desc="Check out this cool reel #repost #viral" />
<node resource-id="com.instagram.android:id/clips_video_container" />
<node resource-id="com.instagram.android:id/direct_share_button" content-desc="Share" />
</hierarchy>'''
</hierarchy>"""
mock_device.dump_hierarchy.return_value = reels_xml
mock_device.dump_hierarchy.return_value = reels_xml
# Repost Sheet XML
repost_sheet_xml = '''<?xml version='1.0' ?>
repost_sheet_xml = """<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/bottom_sheet_container">
<node text="Repost" content-desc="Repost interaction button with two arrows" />
</node>
</hierarchy>'''
</hierarchy>"""
# 3. Setup Telepathic Engine Mocks
mock_telepathic = mock_cognitive_stack["telepathic"]
# First call: find interaction buttons
mock_telepathic._extract_semantic_nodes.return_value = [{"x": 100, "y": 100, "semantic_string": "share button"}]
# Logic for finding nodes — return proper attributes for content extraction
def mock_find_best_node(xml, intent, **kwargs):
intent_lower = intent.lower() if isinstance(intent, str) else ""
if "repost" in intent_lower:
return {"x": 500, "y": 2000, "bounds": "[400,1950][600,2050]", "skip": False, "original_attribs": {"text": "Repost", "desc": ""}}
return {
"x": 500,
"y": 2000,
"bounds": "[400,1950][600,2050]",
"skip": False,
"original_attribs": {"text": "Repost", "desc": ""},
}
if "author" in intent_lower or "username" in intent_lower:
return {"x": 100, "y": 250, "text": "test_user", "content-desc": "", "bounds": "[0,200][1080,300]", "skip": False, "original_attribs": {"text": "test_user", "desc": ""}}
return {
"x": 100,
"y": 250,
"text": "test_user",
"content-desc": "",
"bounds": "[0,200][1080,300]",
"skip": False,
"original_attribs": {"text": "test_user", "desc": ""},
}
if "media" in intent_lower or "image" in intent_lower or "video" in intent_lower:
return {"x": 540, "y": 1200, "text": "", "content-desc": "Check out this cool reel #repost #viral", "bounds": "[0,300][1080,2100]", "skip": False, "original_attribs": {"text": "", "desc": "Check out this cool reel #repost #viral"}}
return {
"x": 540,
"y": 1200,
"text": "",
"content-desc": "Check out this cool reel #repost #viral",
"bounds": "[0,300][1080,2100]",
"skip": False,
"original_attribs": {"text": "", "desc": "Check out this cool reel #repost #viral"},
}
return {"x": 100, "y": 100, "bounds": "[90,90][110,110]", "skip": False}
mock_telepathic.find_best_node.side_effect = mock_find_best_node
# Simulate share button transition success
mock_cognitive_stack["nav_graph"]._execute_transition.return_value = True
# 4. Execute Feed Loop for Reels
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockEngine, \
patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance', return_value=mock_telepathic), \
patch('GramAddict.core.bot_flow._humanized_click') as mock_click, \
patch('GramAddict.core.bot_flow.sleep'), \
patch('GramAddict.core.physics.timing.sleep'), \
patch('GramAddict.core.bot_flow.dump_ui_state'):
with (
patch("GramAddict.core.bot_flow.TelepathicEngine") as MockEngine,
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic),
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
patch("GramAddict.core.bot_flow.sleep"),
patch("GramAddict.core.physics.timing.sleep"),
patch("GramAddict.core.bot_flow.dump_ui_state"),
):
MockEngine.get_instance.return_value = mock_telepathic
# Resilient state-based mock for dump_hierarchy
comment_sheet_xml = '''<?xml version='1.0' ?><hierarchy><node resource-id="com.instagram.android:id/layout_comment_thread" /><node resource-id="com.instagram.android:id/bottom_sheet_container" /></hierarchy>'''
state = {'current': reels_xml}
comment_sheet_xml = """<?xml version='1.0' ?><hierarchy><node resource-id="com.instagram.android:id/layout_comment_thread" /><node resource-id="com.instagram.android:id/bottom_sheet_container" /></hierarchy>"""
state = {"current": reels_xml}
def side_effect_func(*args, **kwargs):
return state['current']
return state["current"]
mock_device.dump_hierarchy.side_effect = side_effect_func
# We need to change the state when transition is called
original_execute = mock_cognitive_stack["nav_graph"]._execute_transition
mock_cognitive_stack["nav_graph"]._execute_transition
def mocked_execute(transition_name):
if transition_name == "tap_comment_button":
state['current'] = comment_sheet_xml
state["current"] = comment_sheet_xml
elif transition_name == "tap_share_button":
state['current'] = repost_sheet_xml
state["current"] = repost_sheet_xml
return True
mock_cognitive_stack["nav_graph"]._execute_transition.side_effect = mocked_execute
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,
"ReelsFeed",
mock_cognitive_stack,
is_reels=True
mock_device,
mock_cognitive_stack["zero_engine"],
mock_cognitive_stack["nav_graph"],
configs,
session_state,
"ReelsFeed",
mock_cognitive_stack,
is_reels=True,
)
# 5. Assertions
# Should click the share button (via transition) and the repost button (direct click)
assert mock_cognitive_stack["nav_graph"]._execute_transition.called_with("tap_share_button")
# Check if _humanized_click was called for the Repost button (x=500, y=2000)
click_args = [call.args for call in mock_click.call_args_list]
repost_clicked = any(args[1] == 500 and args[2] == 2000 for args in click_args)
assert repost_clicked, "Repost button was not clicked on Reels share sheet"
assert mock_telepathic.confirm_click.called_with("Repost interaction button with two arrows")

View File

@@ -1,49 +1,40 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
def test_run_zero_latency_feed_loop_name_error_regression():
"""
TDD RED PHASE: This test should FAIL with NameError: name '_detect_ad_structural' is not defined.
"""
mock_device = MagicMock()
mock_device.dump_hierarchy.return_value = "<?xml version='1.0' ?><hierarchy><node text='test'/></hierarchy>"
# Mock DopamineEngine
mock_dopamine = MagicMock()
mock_dopamine.is_app_session_over.side_effect = [False, True] # Run once then exit
mock_dopamine.is_app_session_over.side_effect = [False, True] # Run once then exit
mock_dopamine.wants_to_change_feed.return_value = False
mock_dopamine.wants_to_doomscroll.return_value = False
mock_stack = {
"dopamine": mock_dopamine,
"radome": MagicMock(),
"ai": MagicMock(),
"growth": MagicMock()
}
mock_stack = {"dopamine": mock_dopamine, "radome": MagicMock(), "ai": MagicMock(), "growth": MagicMock()}
mock_session_state = MagicMock()
mock_session_state.check_limit.return_value = (False,) # any() will be False
mock_session_state.check_limit.return_value = (False,) # any() will be False
# We want it to reach line 896 where _detect_ad_structural is called
mock_zero_engine = MagicMock()
mock_nav_graph = MagicMock()
mock_configs = MagicMock()
# Mocking globals
with patch("GramAddict.core.bot_flow._humanized_scroll"), \
patch("GramAddict.core.bot_flow.logger"), \
patch("GramAddict.core.bot_flow.random_sleep"), \
patch("GramAddict.core.bot_flow.ResonanceEngine"), \
patch("GramAddict.core.bot_flow.ZeroLatencyEngine"):
with (
patch("GramAddict.core.bot_flow._humanized_scroll"),
patch("GramAddict.core.bot_flow.logger"),
patch("GramAddict.core.bot_flow.random_sleep"),
patch("GramAddict.core.bot_flow.ResonanceEngine"),
patch("GramAddict.core.bot_flow.ZeroLatencyEngine"),
):
# Should now run without NameError
_run_zero_latency_feed_loop(
mock_device,
mock_zero_engine,
mock_nav_graph,
mock_configs,
mock_session_state,
"ExploreFeed",
mock_stack
mock_device, mock_zero_engine, mock_nav_graph, mock_configs, mock_session_state, "ExploreFeed", mock_stack
)

View File

@@ -1,7 +1,8 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.resonance_engine import ResonanceEngine
def test_resonance_engine_bootstraps_persona_from_config(monkeypatch):
"""
TDD Test: When the ResonanceEngine is instantiated with persona_interests,
@@ -11,42 +12,42 @@ def test_resonance_engine_bootstraps_persona_from_config(monkeypatch):
# Mock the Qdrant DBs
mock_content_db = MagicMock()
mock_persona_db = MagicMock()
# Simulate a vector embedding
def fake_get_embedding(text):
if not text:
return None
# Return a dummy vector
return [0.1] * 768
mock_content_db._get_embedding = MagicMock(side_effect=fake_get_embedding)
# We will simulate cosine similarity calculation.
# We will simulate cosine similarity calculation.
# Since both will be [0.1]*768, similarity would be 1.0.
def fake_calculate_similarity(vec1, vec2):
if not vec1 or not vec2:
return 0.5
return 0.95
monkeypatch.setattr("GramAddict.core.resonance_engine.ContentMemoryDB", lambda: mock_content_db)
monkeypatch.setattr("GramAddict.core.resonance_engine.PersonaMemoryDB", lambda: mock_persona_db)
monkeypatch.setattr("GramAddict.core.resonance_engine.cosine_similarity", fake_calculate_similarity, raising=False)
# 1. Create with NO persona interests
engine_blind = ResonanceEngine("test_user", persona_interests=[])
score_blind = engine_blind.calculate_resonance({"description": "Beautiful mountain sunset"})
assert score_blind == 0.5, "Blind engine should return exactly 0.5"
assert engine_blind._persona_vector is None
# 2. Create WITH persona interests
engine_smart = ResonanceEngine("test_user", persona_interests=["travel", "landscape"])
assert engine_smart._persona_vector is not None, "Persona vector must be bootstrapped!"
# Mocking semantic search behavior in ResonanceEngine:
# Actually, calculate_resonance uses self.content_memory._get_embedding(text)
# Let's mock the internal similarity function if it's there.
# We must ensure that target_audience is properly wired in bot_flow!
# This test just verifies the engine side, we will also add a test to verify config parsing.

View File

@@ -1,8 +1,8 @@
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
def test_sae_escalation_reset_on_situation_change():
"""
Test that the SAE resets its escalation counter if the situation type changes.
@@ -14,28 +14,15 @@ def test_sae_escalation_reset_on_situation_change():
# 1-3: System Permission Dialog
# 4-6: In-App Modal
# 7: Clear
dumps = [
# System Permission Dialog (attempts 1, 2, 3)
'<hierarchy><node package="com.google.android.permissioncontroller" /></hierarchy>',
'<hierarchy><node package="com.google.android.permissioncontroller" /></hierarchy>',
'<hierarchy><node package="com.google.android.permissioncontroller" /></hierarchy>',
# In-App Modal (attempts 1, 2, 3 on the NEW situation)
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/dialog_root_view" /></hierarchy>',
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/dialog_root_view" /></hierarchy>',
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/dialog_root_view" /></hierarchy>',
# Clear screen
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/tab_bar" /></hierarchy>'
]
# Needs 8 calls because the first attempt gets initial_xml if provided,
# Needs 8 calls because the first attempt gets initial_xml if provided,
# but subsequent attempts call dump_hierarchy(). In execute_escape we also call dump_hierarchy.
# Actually, let's just make dump_hierarchy yield from a generator, but also
# Actually, let's just make dump_hierarchy yield from a generator, but also
# the SAE perceive is what we care about.
# We will patch `perceive` to directly return our mock situations
sae = SituationalAwarenessEngine.get_instance(device_mock)
situations = [
# Attempt 0
SituationType.OBSTACLE_SYSTEM, # perceive
@@ -45,23 +32,24 @@ def test_sae_escalation_reset_on_situation_change():
SituationType.OBSTACLE_SYSTEM, # post-perceive -> success=False (counter=2)
# Attempt 2
SituationType.OBSTACLE_SYSTEM, # perceive
SituationType.OBSTACLE_MODAL, # post-perceive -> success=False (counter=3)
SituationType.OBSTACLE_MODAL, # post-perceive -> success=False (counter=3)
# Attempt 3 (new situation perceived!) -> situation_attempts resets to 0
SituationType.OBSTACLE_MODAL, # perceive
SituationType.OBSTACLE_MODAL, # post-perceive -> success=False (counter=1)
SituationType.OBSTACLE_MODAL, # perceive
SituationType.OBSTACLE_MODAL, # post-perceive -> success=False (counter=1)
# Attempt 4
SituationType.OBSTACLE_MODAL, # perceive
SituationType.OBSTACLE_MODAL, # post-perceive -> success=False (counter=2)
SituationType.OBSTACLE_MODAL, # perceive
SituationType.OBSTACLE_MODAL, # post-perceive -> success=False (counter=2)
# Attempt 5
SituationType.OBSTACLE_MODAL, # perceive
SituationType.NORMAL # post-perceive -> success=True!
SituationType.OBSTACLE_MODAL, # perceive
SituationType.NORMAL, # post-perceive -> success=True!
]
sae.perceive = MagicMock(side_effect=situations)
sae._plan_escape_via_llm = MagicMock()
sae._execute_escape = MagicMock()
from GramAddict.core.situational_awareness import EscapeAction
# Let the LLM "plan" something so it doesn't crash
# Each plan needs a unique reason to not be caught by failed_this_session perfectly if it's the same coordinate?
# Actually, the recall check does: failed_this_session.add(action_key)
@@ -75,21 +63,22 @@ def test_sae_escalation_reset_on_situation_change():
EscapeAction("tap_coordinates", 104, 100, "mock_5"),
EscapeAction("tap_coordinates", 105, 100, "mock_6"),
]
# Since execute_escape checks the device dump, we just mock device.dump_hierarchy to return garbage
# The actual situation check relies on perceive
device_mock.dump_hierarchy.return_value = "<mock/>"
success = sae.ensure_clear_screen(max_attempts=10)
assert success is True
assert sae.perceive.call_count == 12
# 6 LLM calls total
assert sae._plan_escape_via_llm.call_count == 6
# We should ensure that app_start (nuclear escalation) was NEVER called.
# We can check the actions executed
app_starts = [args[0][0].action_type for args in sae._execute_escape.call_args_list if args[0][0].action_type == "app_start"]
app_starts = [
args[0][0].action_type for args in sae._execute_escape.call_args_list if args[0][0].action_type == "app_start"
]
assert len(app_starts) == 0

View File

@@ -1,21 +1,20 @@
from GramAddict.core.goap import GoalPlanner
from GramAddict.core.goap import ScreenType
from GramAddict.core.goap import GoalPlanner, ScreenType
def test_semantic_heuristic_match_blank_start():
planner = GoalPlanner("testuser")
# Simulate an empty knowledge base
planner.knowledge._learned_screen_mappings = {}
# Simulate being on ExploreGrid
screen = {
'screen_type': ScreenType.EXPLORE_GRID,
'available_actions': ['press back', 'tap profile tab', 'tap reels tab', 'tap explore tab', 'tap home tab'],
'context': {}
"screen_type": ScreenType.EXPLORE_GRID,
"available_actions": ["press back", "tap profile tab", "tap reels tab", "tap explore tab", "tap home tab"],
"context": {},
}
action = planner.plan_next_step('open profile', screen)
assert action == 'tap profile tab', f"Expected 'tap profile tab', got {action}"
action2 = planner.plan_next_step('open home feed', screen)
assert action2 == 'tap home tab', f"Expected 'tap home tab', got {action2}"
action = planner.plan_next_step("open profile", screen)
assert action == "tap profile tab", f"Expected 'tap profile tab', got {action}"
action2 = planner.plan_next_step("open home feed", screen)
assert action2 == "tap home tab", f"Expected 'tap home tab', got {action2}"

View File

@@ -1,91 +1,93 @@
import pytest
from unittest.mock import MagicMock
from GramAddict.core.goap import GoalExecutor, ScreenType, GoalPlanner
from GramAddict.core.telepathic_engine import TelepathicEngine
from GramAddict.core.goap import GoalExecutor, ScreenType
def test_semantic_poison_guard_rejects_hallucinations(monkeypatch):
"""
TDD Test: Verifiziert, dass ein Klick auf 'tap messages tab', der
versehentlich im Reels-Feed landet (Halluzination), rigoros als Gift
versehentlich im Reels-Feed landet (Halluzination), rigoros als Gift
verworfen wird, anstatt die Konfidenz auf 1.0 zu setzen.
"""
device = MagicMock()
executor = GoalExecutor(device, "test_user")
# 1. Wir behaupten, das Device klickt erfolgreich
device.click = MagicMock()
# 2. Die UI ändert sich: Vor dem Klick waren wir auf Home, danach auf Reels
# (Obwohl 'tap messages tab' zu DM_INBOX führen sollte)
xml_home = "<hierarchy><node resource-id='home'/></hierarchy>"
xml_reels = "<hierarchy><node resource-id='reels'/></hierarchy>"
device.dump_hierarchy = MagicMock(side_effect=[xml_home, xml_reels])
# Mock perceive() passend zur echten Engine, so dass es REELS erkennt
def fake_perceive(xml=""):
if "reels" in xml:
return {'screen_type': ScreenType.REELS_FEED, 'available_actions': [], 'context': {}}
return {'screen_type': ScreenType.HOME_FEED, 'available_actions': [], 'context': {}}
return {"screen_type": ScreenType.REELS_FEED, "available_actions": [], "context": {}}
return {"screen_type": ScreenType.HOME_FEED, "available_actions": [], "context": {}}
executor.perceive = MagicMock(side_effect=fake_perceive)
engine_mock = MagicMock()
engine_mock.find_best_node.return_value = {"node": "fake_node"}
executor.planner.knowledge.TAB_ACTIONS = {'direct_tab': 'tap messages tab'}
executor.planner.knowledge.TAB_ACTIONS = {"direct_tab": "tap messages tab"}
# Speed up
monkeypatch.setattr("GramAddict.core.goap.time.sleep", lambda x: None)
monkeypatch.setattr("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", lambda: engine_mock)
# Führe Action aus
result = executor._execute_action("tap messages tab", goal="open messages")
# ASSERT: The Poison Guard SHOULD reject the navigation
# because it landed on REELS_FEED instead of DM_INBOX.
assert result is False, "Aktion 'tap messages tab' die nach REELS führt, MUSS False zurückgeben!"
# ASSERT: Die Engine MUSS angewiesen werden, den Klick zu verwerfen ("Poison Guard")
engine_mock.reject_click.assert_called_with("tap messages tab")
engine_mock.confirm_click.assert_not_called()
def test_goap_misplaced_blame_path_execution(monkeypatch):
"""
TDD Test: Verifiziert, dass ein korrekter erster Zwischenschritt eines Pfades
(z.B. 'tap profile tab' das zu 'own_profile' führt)
erfolgreich gewertet wird, auch wenn das finale Ziel ('open following list')
erfolgreich gewertet wird, auch wenn das finale Ziel ('open following list')
noch nicht direkt dadurch erreicht wurde.
"""
device = MagicMock()
executor = GoalExecutor(device, "test_user")
# Fake UI Transition: Klick auf Profile Tab öffnet das Profil
device.dump_hierarchy = MagicMock(side_effect=["<home/>", "<profile/>", "<profile/>"])
device.click = MagicMock()
def fake_perceive(xml=""):
if "profile" in xml:
return {'screen_type': ScreenType.OWN_PROFILE, 'available_actions': [], 'context': {}}
return {'screen_type': ScreenType.HOME_FEED, 'available_actions': [], 'context': {}}
return {"screen_type": ScreenType.OWN_PROFILE, "available_actions": [], "context": {}}
return {"screen_type": ScreenType.HOME_FEED, "available_actions": [], "context": {}}
executor.perceive = MagicMock(side_effect=fake_perceive)
engine_mock = MagicMock()
engine_mock.find_best_node.return_value = {"node": "fake_node"}
monkeypatch.setattr("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", lambda: engine_mock)
# Die Navigation führt zu ScreenType.OWN_PROFILE, Ziel ist "open following list".
monkeypatch.setattr("GramAddict.core.goap.time.sleep", lambda x: None)
# _execute_recalled_path ruft _execute_action mehrfach auf.
steps = [{'action': 'tap profile tab'}, {'action': 'tap following count'}]
# Für den Test prüfen wir direkt was _execute_action beim ERSTEN Schritt macht:
# Simuliere 'tap profile tab' während unser Langzeitziel 'open following list' ist!
result = executor._execute_action("tap profile tab", goal="open following list")
# ASSERT: Das MUß True sein, da die UI sich entscheidend zu einem gültigen Zustand bewegt hat!
assert result is True, "Misplaced Blame! legitimer Teilschritt wurde als Fehlschlag verworfen, weil das Endziel nicht direkt erreicht wurde."
assert (
result is True
), "Misplaced Blame! legitimer Teilschritt wurde als Fehlschlag verworfen, weil das Endziel nicht direkt erreicht wurde."
engine_mock.confirm_click.assert_called_with("tap profile tab")
engine_mock.reject_click.assert_not_called()