chore: FSD stabilization, strict TDD enforcement, and unlearn mechanism
- implemented self-healing unlearn for Qdrant false positives - centralized testing logic in conftest - documented core rules, ai standards, and goap philosophy - purged old dev scratchpads
This commit is contained in:
217
tests/tdd/test_active_inference_deep.py
Normal file
217
tests/tdd/test_active_inference_deep.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
TDD: Deep Active Inference Integration Tests.
|
||||
|
||||
Tests the v2 Active Inference Engine behaviors:
|
||||
- Consecutive error → policy escalation
|
||||
- Interaction probability throttling
|
||||
- Session abort recommendation
|
||||
- Diagnostics reporting
|
||||
- Backward compatibility with existing callers
|
||||
"""
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai():
|
||||
"""Fresh Active Inference engine for each test."""
|
||||
from GramAddict.core.active_inference import ActiveInferenceEngine
|
||||
return ActiveInferenceEngine("test_user")
|
||||
|
||||
|
||||
class TestPolicyEscalation:
|
||||
"""Consecutive prediction errors must escalate the policy."""
|
||||
|
||||
def test_single_error_stays_stable(self, ai):
|
||||
"""One prediction error should not change policy from STABLE."""
|
||||
ai.predict_state(["feed_tab"])
|
||||
ai.evaluate_prediction("<hierarchy><node text='wrong'/></hierarchy>")
|
||||
# Free energy from single error: 0.0 * 0.7 + 1.0 * 0.3 = 0.3
|
||||
# 0.3 < 0.75, so still STABLE
|
||||
assert ai.policy == "STABLE"
|
||||
assert ai._consecutive_prediction_errors == 1
|
||||
|
||||
def test_three_errors_goes_cautious(self, ai):
|
||||
"""3 consecutive errors must trigger CAUTIOUS policy."""
|
||||
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
|
||||
|
||||
def test_five_errors_goes_dormant(self, ai):
|
||||
"""5 consecutive errors must trigger DORMANT policy."""
|
||||
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
|
||||
|
||||
def test_successful_prediction_resets_counter(self, ai):
|
||||
"""A successful prediction must reset the consecutive error counter."""
|
||||
# Build up 3 errors
|
||||
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):
|
||||
"""Error rate must be accurately tracked across the session."""
|
||||
# 3 errors, 2 successes = 3/5 = 0.6
|
||||
for _ in range(3):
|
||||
ai.predict_state(["missing"])
|
||||
ai.evaluate_prediction("<hierarchy/>")
|
||||
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)
|
||||
|
||||
|
||||
class TestInteractionProbability:
|
||||
"""Interaction probability must decrease under stress."""
|
||||
|
||||
def test_stable_has_full_probability(self, ai):
|
||||
"""STABLE policy → 100% interaction probability."""
|
||||
ai.policy = "STABLE"
|
||||
assert ai.get_interaction_probability() == 1.0
|
||||
|
||||
def test_cautious_halves_probability(self, ai):
|
||||
"""CAUTIOUS policy → 50% interaction probability."""
|
||||
ai.policy = "CAUTIOUS"
|
||||
assert ai.get_interaction_probability() == 0.5
|
||||
|
||||
def test_dormant_minimal_probability(self, ai):
|
||||
"""DORMANT policy → 10% interaction probability."""
|
||||
ai.policy = "DORMANT"
|
||||
assert ai.get_interaction_probability() == 0.1
|
||||
|
||||
def test_probability_bounds(self, ai):
|
||||
"""Interaction probability must always be in [0.0, 1.0]."""
|
||||
for policy in ["STABLE", "CAUTIOUS", "DORMANT"]:
|
||||
ai.policy = policy
|
||||
prob = ai.get_interaction_probability()
|
||||
assert 0.0 <= prob <= 1.0
|
||||
|
||||
|
||||
class TestSessionAbort:
|
||||
"""Session abort recommendation under extreme instability."""
|
||||
|
||||
def test_no_abort_on_stable(self, ai):
|
||||
"""STABLE engine should never recommend abort."""
|
||||
assert ai.should_abort_session() is False
|
||||
|
||||
def test_abort_after_five_consecutive_errors(self, ai):
|
||||
"""5 consecutive prediction errors must recommend abort."""
|
||||
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):
|
||||
"""Free energy > 2.0 must recommend abort."""
|
||||
ai.free_energy = 2.1
|
||||
assert ai.should_abort_session() is True
|
||||
|
||||
def test_no_abort_under_threshold(self, ai):
|
||||
"""Free energy < 2.0 with few errors should not abort."""
|
||||
ai.free_energy = 1.9
|
||||
ai._consecutive_prediction_errors = 4
|
||||
assert ai.should_abort_session() is False
|
||||
|
||||
|
||||
class TestDiagnostics:
|
||||
"""Diagnostics must provide accurate runtime snapshot."""
|
||||
|
||||
def test_diagnostics_has_required_fields(self, ai):
|
||||
"""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"
|
||||
]
|
||||
for field in required:
|
||||
assert field in diag, f"Missing diagnostic field: {field}"
|
||||
|
||||
def test_diagnostics_reflects_state(self, ai):
|
||||
"""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
|
||||
assert diag["total_errors"] == 1
|
||||
assert diag["error_rate"] == 1.0
|
||||
assert diag["should_abort"] is False
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Existing callers must work unchanged."""
|
||||
|
||||
def test_get_sleep_modifier_unchanged(self, ai):
|
||||
"""Sleep modifier values must match v1 behavior."""
|
||||
ai.policy = "STABLE"
|
||||
assert ai.get_sleep_modifier() == 1.0
|
||||
ai.policy = "CAUTIOUS"
|
||||
assert ai.get_sleep_modifier() == 2.0
|
||||
ai.policy = "DORMANT"
|
||||
assert ai.get_sleep_modifier() == 5.0
|
||||
|
||||
def test_predict_then_evaluate_success(self, ai):
|
||||
"""Basic predict → evaluate flow must work as before."""
|
||||
ai.predict_state(["row_feed", "button_like"])
|
||||
result = ai.evaluate_prediction(
|
||||
'<hierarchy><node resource-id="row_feed"/><node resource-id="button_like"/></hierarchy>'
|
||||
)
|
||||
assert result is True
|
||||
|
||||
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):
|
||||
"""Evaluating without a prior prediction must return True (no-op)."""
|
||||
result = ai.evaluate_prediction("<hierarchy/>")
|
||||
assert result is True
|
||||
assert ai._consecutive_prediction_errors == 0
|
||||
|
||||
|
||||
class TestFreeEnergyDecay:
|
||||
"""Free energy must decay over time (thermodynamic relaxation)."""
|
||||
|
||||
def test_free_energy_decays_over_time(self, ai):
|
||||
"""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
|
||||
|
||||
def test_free_energy_stabilizes_on_perfect_predictions(self, ai):
|
||||
"""Repeated perfect predictions should drive free energy toward zero."""
|
||||
ai.free_energy = 1.0
|
||||
for _ in range(20):
|
||||
ai.calculate_surprise(1.0, 1.0)
|
||||
|
||||
assert ai.free_energy < 0.05 # Near zero
|
||||
@@ -19,8 +19,8 @@ def test_wait_for_post_loaded_success():
|
||||
result = _wait_for_post_loaded(mock_device, timeout=1)
|
||||
assert result is True
|
||||
|
||||
@patch("GramAddict.core.bot_flow.sleep")
|
||||
@patch("GramAddict.core.bot_flow.dump_ui_state")
|
||||
@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):
|
||||
"""Test that being trapped in a story triggers a back press."""
|
||||
mock_device = MagicMock()
|
||||
@@ -36,8 +36,8 @@ def test_wait_for_post_loaded_adaptive_snap_story(mock_dump, mock_sleep):
|
||||
# Still returns False if feed markers are not found after recovery
|
||||
assert result is False
|
||||
|
||||
@patch("GramAddict.core.bot_flow.sleep")
|
||||
@patch("GramAddict.core.bot_flow.dump_ui_state")
|
||||
@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):
|
||||
"""Test that being trapped in a profile triggers a back press."""
|
||||
mock_device = MagicMock()
|
||||
@@ -49,8 +49,8 @@ def test_wait_for_post_loaded_adaptive_snap_profile(mock_dump, mock_sleep):
|
||||
mock_device.press.assert_called_with("back")
|
||||
assert result is False
|
||||
|
||||
@patch("GramAddict.core.bot_flow.sleep")
|
||||
@patch("GramAddict.core.bot_flow.dump_ui_state")
|
||||
@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):
|
||||
"""Test that being stuck between posts triggers a wobble if no nav_graph is provided."""
|
||||
mock_device = MagicMock()
|
||||
@@ -63,10 +63,15 @@ def test_wait_for_post_loaded_adaptive_snap_wobble(mock_dump, mock_sleep):
|
||||
|
||||
# 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
|
||||
for call in mock_device.swipe.call_args_list:
|
||||
args, kwargs = call
|
||||
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
|
||||
|
||||
@patch("GramAddict.core.bot_flow.sleep")
|
||||
@patch("GramAddict.core.bot_flow.dump_ui_state")
|
||||
@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):
|
||||
"""Test that being stuck between posts triggers nav_graph.do('align') if nav_graph is provided."""
|
||||
mock_device = MagicMock()
|
||||
@@ -78,5 +83,9 @@ def test_wait_for_post_loaded_adaptive_snap_align(mock_dump, mock_sleep):
|
||||
|
||||
# Now it should unconditionally micro-wobble (swipe twice)
|
||||
assert mock_device.swipe.call_count == 2
|
||||
for call in mock_device.swipe.call_args_list:
|
||||
args, kwargs = call
|
||||
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
|
||||
|
||||
|
||||
61
tests/tdd/test_aversive_learning.py
Normal file
61
tests/tdd/test_aversive_learning.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from GramAddict.core.goap import NavigationKnowledge, GoalPlanner
|
||||
from GramAddict.core.goap import NavigationKnowledge, GoalPlanner, GoalExecutor, ScreenType
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
with patch("GramAddict.core.goap.QdrantBase") as MockBase:
|
||||
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
|
||||
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
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# The guesser should select 'tap profile tab' because it linguistically matches 'profile'
|
||||
assert selected_action == "tap profile tab"
|
||||
338
tests/tdd/test_behavior_plugins.py
Normal file
338
tests/tdd/test_behavior_plugins.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
TDD: Behavior Plugins Tests.
|
||||
|
||||
Tests all concrete behavior plugins:
|
||||
- ProfileGuardPlugin (safety gates)
|
||||
- StoryViewPlugin (story watching)
|
||||
- FollowPlugin (follow interaction)
|
||||
- GridLikePlugin (grid liking)
|
||||
- Physics timing module (wait/align)
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorResult, PluginRegistry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device():
|
||||
dev = MagicMock()
|
||||
dev.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
dev.dump_hierarchy.return_value = '<hierarchy><node resource-id="row_feed_photo_profile_name"/></hierarchy>'
|
||||
dev.shell = MagicMock()
|
||||
dev.cm_to_pixels.return_value = 5
|
||||
return dev
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configs():
|
||||
c = MagicMock()
|
||||
c.args = MagicMock()
|
||||
c.args.carousel_percentage = "0"
|
||||
c.args.stories_percentage = "0"
|
||||
c.args.follow_percentage = "0"
|
||||
c.args.likes_percentage = "0"
|
||||
c.args.ignore_close_friends = False
|
||||
c.args.visual_vibe_check_percentage = "0"
|
||||
c.args.scrape_profiles = False
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_state():
|
||||
ss = MagicMock()
|
||||
ss.my_username = "testbot"
|
||||
ss.totalFollowed = {}
|
||||
ss.totalLikes = 0
|
||||
ss.check_limit.return_value = False
|
||||
return ss
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(device, configs, session_state):
|
||||
mock_nav = MagicMock()
|
||||
mock_nav.current_state = "ProfileView"
|
||||
return BehaviorContext(
|
||||
device=device,
|
||||
configs=configs,
|
||||
session_state=session_state,
|
||||
cognitive_stack={"nav_graph": mock_nav},
|
||||
context_xml='<hierarchy><node resource-id="com.instagram.android:id/profile_header" /><node resource-id="row_feed_photo_profile_name"/></hierarchy>',
|
||||
sleep_mod=1.0,
|
||||
username="target_user",
|
||||
)
|
||||
|
||||
|
||||
# ── Profile Guard Tests ──
|
||||
|
||||
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)
|
||||
assert result.executed is True
|
||||
assert result.should_skip is True
|
||||
assert result.metadata["reason"] == "self_profile"
|
||||
|
||||
def test_blocks_private_account(self, ctx):
|
||||
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
|
||||
ctx.context_xml = '<hierarchy>This account is private</hierarchy>'
|
||||
plugin = ProfileGuardPlugin()
|
||||
result = plugin.execute(ctx)
|
||||
assert result.executed is True
|
||||
assert result.should_skip is True
|
||||
assert result.metadata["reason"] == "private"
|
||||
|
||||
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>'
|
||||
plugin = ProfileGuardPlugin()
|
||||
result = plugin.execute(ctx)
|
||||
assert result.executed is True
|
||||
assert result.metadata["reason"] == "private"
|
||||
|
||||
def test_blocks_empty_account(self, ctx):
|
||||
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
|
||||
ctx.context_xml = '<hierarchy>No Posts Yet</hierarchy>'
|
||||
plugin = ProfileGuardPlugin()
|
||||
result = plugin.execute(ctx)
|
||||
assert result.should_skip is True
|
||||
assert result.metadata["reason"] == "empty"
|
||||
|
||||
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>'
|
||||
plugin = ProfileGuardPlugin()
|
||||
result = plugin.execute(ctx)
|
||||
assert result.should_skip is True
|
||||
assert result.metadata["reason"] == "close_friend"
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── Story View Tests ──
|
||||
|
||||
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>'
|
||||
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
|
||||
assert StoryViewPlugin().priority < FollowPlugin().priority # 40 < 60 — but stories run first
|
||||
|
||||
|
||||
# ── Follow Tests ──
|
||||
|
||||
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()
|
||||
assert plugin.can_activate(ctx) is False
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
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()
|
||||
assert plugin.can_activate(ctx) is False
|
||||
|
||||
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
|
||||
assert GridLikePlugin().priority < FollowPlugin().priority # 50 < 60
|
||||
|
||||
|
||||
# ── Physics Timing Tests ──
|
||||
|
||||
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>'
|
||||
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>'
|
||||
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>'
|
||||
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)
|
||||
assert result is False
|
||||
|
||||
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
|
||||
|
||||
PluginRegistry.reset()
|
||||
registry = PluginRegistry()
|
||||
registry.register(ProfileGuardPlugin())
|
||||
registry.register(FollowPlugin())
|
||||
registry.register(GridLikePlugin())
|
||||
|
||||
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.follow import FollowPlugin
|
||||
from GramAddict.core.behaviors.grid_like import GridLikePlugin
|
||||
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
|
||||
|
||||
plugins = [
|
||||
ProfileGuardPlugin(),
|
||||
StoryViewPlugin(),
|
||||
FollowPlugin(),
|
||||
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
|
||||
]
|
||||
231
tests/tdd/test_bezier_gesture.py
Normal file
231
tests/tdd/test_bezier_gesture.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
TDD Tests for BezierGesture — Curve Mathematics.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def body_right():
|
||||
"""Right-handed PhysicsBody with standard display."""
|
||||
PhysicsBody.reset()
|
||||
return PhysicsBody(handedness="right", device_info={"displayWidth": 1080, "displayHeight": 2400})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def body_left():
|
||||
"""Left-handed PhysicsBody."""
|
||||
PhysicsBody.reset()
|
||||
return PhysicsBody(handedness="left", device_info={"displayWidth": 1080, "displayHeight": 2400})
|
||||
|
||||
|
||||
class TestScrollCurve:
|
||||
"""Tests for BezierGesture.scroll_curve()."""
|
||||
|
||||
def test_returns_correct_number_of_points(self, body_right):
|
||||
points = BezierGesture.scroll_curve((500, 1800), (500, 800), body_right, n_points=15)
|
||||
# n_points + 1 (inclusive of start and end)
|
||||
assert len(points) == 16
|
||||
|
||||
def test_start_and_end_are_near_requested_positions(self, body_right):
|
||||
"""Start/end points should be close to the requested coordinates (with micro-noise)."""
|
||||
start = (540, 1800)
|
||||
end = (540, 600)
|
||||
points = BezierGesture.scroll_curve(start, end, body_right, n_points=12)
|
||||
|
||||
# First point near start (within noise tolerance)
|
||||
assert abs(points[0][0] - start[0]) < 20
|
||||
assert abs(points[0][1] - start[1]) < 20
|
||||
|
||||
# Last point near end
|
||||
assert abs(points[-1][0] - end[0]) < 20
|
||||
assert abs(points[-1][1] - end[1]) < 20
|
||||
|
||||
def test_path_is_non_linear(self, body_right):
|
||||
"""The Bézier path should NOT be a straight line (thumb arc)."""
|
||||
start = (540, 1800)
|
||||
end = (540, 600)
|
||||
points = BezierGesture.scroll_curve(start, end, body_right, n_points=20)
|
||||
|
||||
# Extract X coordinates of middle points
|
||||
mid_xs = [p[0] for p in points[5:15]]
|
||||
|
||||
# A straight line would have all X ≈ 540. Bézier arc should deviate.
|
||||
max_deviation = max(abs(x - start[0]) for x in mid_xs)
|
||||
assert max_deviation > 3, (
|
||||
f"Expected non-linear path (arc deviation > 3px), got max deviation {max_deviation}px. "
|
||||
f"The curve is too straight — Bézier control points aren't applying."
|
||||
)
|
||||
|
||||
def test_right_hander_arcs_right(self, body_right):
|
||||
"""Right-handers should produce a rightward arc (positive X deviation)."""
|
||||
start = (540, 1800)
|
||||
end = (540, 600)
|
||||
|
||||
# Run multiple times to get statistical average
|
||||
total_deviation = 0
|
||||
n_runs = 20
|
||||
for _ in range(n_runs):
|
||||
points = BezierGesture.scroll_curve(start, end, body_right, n_points=15)
|
||||
mid_xs = [p[0] for p in points[4:12]]
|
||||
avg_x = sum(mid_xs) / len(mid_xs)
|
||||
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"
|
||||
)
|
||||
|
||||
def test_left_hander_arcs_left(self, body_left):
|
||||
"""Left-handers should produce a leftward arc (negative X deviation)."""
|
||||
start = (540, 1800)
|
||||
end = (540, 600)
|
||||
|
||||
total_deviation = 0
|
||||
n_runs = 20
|
||||
for _ in range(n_runs):
|
||||
points = BezierGesture.scroll_curve(start, end, body_left, n_points=15)
|
||||
mid_xs = [p[0] for p in points[4:12]]
|
||||
avg_x = sum(mid_xs) / len(mid_xs)
|
||||
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"
|
||||
)
|
||||
|
||||
def test_pressure_has_gaussian_peak(self, body_right):
|
||||
"""Pressure should peak in the middle of the gesture (Gaussian profile)."""
|
||||
points = BezierGesture.scroll_curve((540, 1800), (540, 600), body_right, n_points=20)
|
||||
pressures = [p[2] for p in points]
|
||||
|
||||
# Find the peak pressure index
|
||||
peak_idx = pressures.index(max(pressures))
|
||||
n = len(pressures)
|
||||
|
||||
# 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}"
|
||||
)
|
||||
|
||||
def test_pressure_within_valid_range(self, body_right):
|
||||
"""All pressure values should be in [0.08, 0.92]."""
|
||||
points = BezierGesture.scroll_curve((540, 1800), (540, 600), body_right, n_points=20)
|
||||
for x, y, p in points:
|
||||
assert 0.08 <= p <= 0.92, f"Pressure {p} out of range at ({x}, {y})"
|
||||
|
||||
def test_all_points_have_three_components(self, body_right):
|
||||
"""Each point must be (x, y, pressure)."""
|
||||
points = BezierGesture.scroll_curve((540, 1800), (540, 600), body_right)
|
||||
for point in points:
|
||||
assert len(point) == 3, f"Expected (x, y, pressure), got {point}"
|
||||
|
||||
|
||||
class TestTapCurve:
|
||||
"""Tests for BezierGesture.tap_curve()."""
|
||||
|
||||
def test_returns_three_points(self, body_right):
|
||||
"""Tap curve should have exactly 3 points (down, contact, up)."""
|
||||
points = BezierGesture.tap_curve(500, 1000, body_right)
|
||||
assert len(points) == 3
|
||||
|
||||
def test_micro_drift_is_small(self, body_right):
|
||||
"""Tap drift should be tiny (< 15px from target)."""
|
||||
target_x, target_y = 500, 1000
|
||||
points = BezierGesture.tap_curve(target_x, target_y, body_right)
|
||||
|
||||
for x, y, p in points:
|
||||
assert abs(x - target_x) < 20, f"X drift too large: {abs(x - target_x)}px"
|
||||
assert abs(y - target_y) < 20, f"Y drift too large: {abs(y - target_y)}px"
|
||||
|
||||
def test_pressure_sequence_is_down_peak_up(self, body_right):
|
||||
"""Pressure should follow: light → firm → light."""
|
||||
points = BezierGesture.tap_curve(500, 1000, body_right)
|
||||
p_down, p_full, p_up = [p[2] for p in points]
|
||||
|
||||
assert p_full > p_down, "Full contact pressure should exceed touch-down"
|
||||
assert p_full > p_up, "Full contact pressure should exceed touch-up"
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
assert points[-1][0] < points[0][0], "Horizontal swipe left should decrease X"
|
||||
|
||||
def test_vertical_arc_exists(self, body_right):
|
||||
"""Horizontal swipe should have a vertical arc (thumb drops during swipe)."""
|
||||
start_y = 1200
|
||||
total_y_deviation = 0
|
||||
n_runs = 15
|
||||
|
||||
for _ in range(n_runs):
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class TestSigmoidTiming:
|
||||
"""Tests for BezierGesture.compute_sigmoid_timing()."""
|
||||
|
||||
def test_total_duration_matches(self):
|
||||
"""Sum of intervals should approximately equal total duration."""
|
||||
total_ms = 300
|
||||
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"
|
||||
)
|
||||
|
||||
def test_edges_are_slower_than_middle(self):
|
||||
"""Start and end intervals should be longer than middle intervals."""
|
||||
intervals = BezierGesture.compute_sigmoid_timing(20, 400)
|
||||
|
||||
# Average of first 3 and last 3
|
||||
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
|
||||
|
||||
# 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}"
|
||||
)
|
||||
|
||||
def test_single_point_returns_single_interval(self):
|
||||
intervals = BezierGesture.compute_sigmoid_timing(1, 100)
|
||||
assert len(intervals) == 1
|
||||
assert abs(intervals[0] - 0.1) < 0.02
|
||||
|
||||
def test_no_negative_intervals(self):
|
||||
"""All intervals must be positive."""
|
||||
intervals = BezierGesture.compute_sigmoid_timing(25, 200)
|
||||
for i, interval in enumerate(intervals):
|
||||
assert interval > 0, f"Interval {i} is non-positive: {interval}"
|
||||
@@ -46,8 +46,9 @@ def mock_nav_db(monkeypatch):
|
||||
def test_avoids_refresh_loop_during_discovery(mock_nav_db):
|
||||
"""
|
||||
TDD Test: When the bot is discovering a path and evaluates the available tabs,
|
||||
it must NOT click a tab if it ALREADY KNOWS that tab leads to the CURRENT screen
|
||||
or a screen that is not our goal.
|
||||
the planner uses heuristic semantic matching to pick the right tab INSTANTLY.
|
||||
Goal 'open profile' + available 'tap profile tab' → deterministic match.
|
||||
After a failed attempt that learns a mapping, it should still pick the correct tab.
|
||||
"""
|
||||
planner = GoalPlanner("test_user")
|
||||
planner.knowledge.wipe()
|
||||
@@ -56,32 +57,29 @@ def test_avoids_refresh_loop_during_discovery(mock_nav_db):
|
||||
screen_type = ScreenType.HOME_FEED
|
||||
available_actions = ["tap home tab", "tap explore tab", "tap profile tab"]
|
||||
|
||||
# First attempt: It might try 'tap home tab' because it's first in TAB_ACTIONS
|
||||
# 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
|
||||
})
|
||||
|
||||
# Let's say it picked 'open profile'. We execute it, and it lands on HOME_FEED.
|
||||
# The bot LEARNS this mapping:
|
||||
action_used = goal # corresponding to the intent
|
||||
planner.knowledge.learn_screen_mapping(action_used, ScreenType.HOME_FEED)
|
||||
assert first_action == "tap profile tab", "Planner should heuristically match 'open profile' → 'tap profile tab'"
|
||||
|
||||
# Next attempt: The bot MUST NOT blindly pick the same failing intent if it knows it leads back to HOME_FEED,
|
||||
# but wait! Actually, if it's trapped, the executor handles trap prevention. The planner itself will still return the goal,
|
||||
# and the executor will try alternative nodes via explored_actions.
|
||||
# For planner unit test: the planner returns the goal for discovery.
|
||||
# Simulate: the action was tried but led back to HOME_FEED (wrong mapping learned)
|
||||
planner.knowledge.learn_screen_mapping(goal, ScreenType.HOME_FEED)
|
||||
|
||||
# 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
|
||||
})
|
||||
|
||||
assert second_action == goal, "Planner delegates to telepathic engine for discovery."
|
||||
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',
|
||||
and there is an available action 'tap messages tab', it should prioritize it!
|
||||
and there is an available action 'tap messages tab', the planner's heuristic
|
||||
word-boundary matching should pick it INSTANTLY — zero LLM calls.
|
||||
"""
|
||||
planner = GoalPlanner("test_user")
|
||||
planner.knowledge.wipe()
|
||||
@@ -94,4 +92,4 @@ def test_heuristic_semantic_tab_matching(mock_nav_db):
|
||||
"available_actions": available_actions
|
||||
})
|
||||
|
||||
assert action == goal, "Planner should return pure intent to let Telepathic Engine find the semantic match autonomously!"
|
||||
assert action == "tap messages tab", "Planner should heuristically match 'open messages' → 'tap messages tab' instantly!"
|
||||
|
||||
272
tests/tdd/test_evolution_engine.py
Normal file
272
tests/tdd/test_evolution_engine.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
TDD: Evolution Engine Tests.
|
||||
|
||||
Tests the genetic algorithm for behavioral parameter optimization:
|
||||
- Fitness computation from session outcomes
|
||||
- Genome mutation within safety bounds
|
||||
- Exploitation (lock winning params) vs. exploration (mutate losing params)
|
||||
- Hard safety bounds enforcement
|
||||
- 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
|
||||
)
|
||||
|
||||
|
||||
@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)):
|
||||
e = EvolutionEngine("test_user")
|
||||
e._qdrant_connected = False # Force offline mode
|
||||
yield e
|
||||
EvolutionEngine.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def genome():
|
||||
"""Default genome for isolated tests."""
|
||||
return Genome()
|
||||
|
||||
|
||||
class TestGenome:
|
||||
"""Genome serialization and construction."""
|
||||
|
||||
def test_default_genome_has_valid_params(self, genome):
|
||||
"""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}]"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
def test_genome_from_dict_ignores_unknown_keys(self):
|
||||
"""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")
|
||||
|
||||
|
||||
class TestFitnessComputation:
|
||||
"""Fitness function correctness."""
|
||||
|
||||
def test_perfect_session_high_fitness(self, engine):
|
||||
"""A session with max follows, zero blocks → high fitness."""
|
||||
result = SessionResult(
|
||||
follows_gained=20,
|
||||
likes_given=50,
|
||||
stories_viewed=20,
|
||||
blocks_received=0,
|
||||
duration_minutes=60,
|
||||
prediction_error_rate=0.0,
|
||||
)
|
||||
fitness = engine.compute_fitness(result)
|
||||
assert fitness >= 0.9
|
||||
|
||||
def test_blocked_session_near_zero_fitness(self, engine):
|
||||
"""A session with a block → severe fitness penalty."""
|
||||
result = SessionResult(
|
||||
follows_gained=10,
|
||||
likes_given=30,
|
||||
blocks_received=1,
|
||||
duration_minutes=30,
|
||||
)
|
||||
fitness = engine.compute_fitness(result)
|
||||
# Block penalty: 0.5^1 = 0.5 multiplier
|
||||
assert fitness <= 0.5
|
||||
|
||||
def test_double_block_catastrophic(self, engine):
|
||||
"""Two blocks in a session → fitness near zero."""
|
||||
result = SessionResult(blocks_received=2)
|
||||
fitness = engine.compute_fitness(result)
|
||||
# Block penalty: 0.5^2 = 0.25 multiplier on already low base
|
||||
assert fitness < 0.1
|
||||
|
||||
def test_empty_session_zero_fitness(self, engine):
|
||||
"""A session with zero interactions → zero fitness."""
|
||||
result = SessionResult()
|
||||
fitness = engine.compute_fitness(result)
|
||||
# No follows, likes, stories, short duration → near zero
|
||||
# But accuracy bonus is 1.0 (no errors), so 0.25 * 1.0 = 0.25
|
||||
assert 0.0 <= fitness <= 0.3
|
||||
|
||||
def test_fitness_always_bounded(self, engine):
|
||||
"""Fitness must always be in [0.0, 1.0]."""
|
||||
for _ in range(50):
|
||||
result = SessionResult(
|
||||
follows_gained=random.randint(0, 50),
|
||||
likes_given=random.randint(0, 100),
|
||||
blocks_received=random.randint(0, 5),
|
||||
duration_minutes=random.uniform(0, 180),
|
||||
prediction_error_rate=random.uniform(0, 1),
|
||||
)
|
||||
fitness = engine.compute_fitness(result)
|
||||
assert 0.0 <= fitness <= 1.0
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
fitness_good = engine.compute_fitness(good)
|
||||
fitness_bad = engine.compute_fitness(bad)
|
||||
|
||||
assert fitness_good > fitness_bad
|
||||
|
||||
|
||||
class TestEvolution:
|
||||
"""Exploitation vs. exploration behavior."""
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
def test_regressed_fitness_triggers_mutation(self, engine):
|
||||
"""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
|
||||
)
|
||||
|
||||
# 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
|
||||
assert engine.genome.generation == 1
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestMutation:
|
||||
"""Mutation respects safety bounds."""
|
||||
|
||||
def test_mutation_stays_within_bounds(self, engine):
|
||||
"""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}]"
|
||||
)
|
||||
|
||||
def test_mutation_changes_at_least_one_param(self, engine):
|
||||
"""With mutation_rate=1.0, at least one param must change."""
|
||||
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
|
||||
)
|
||||
assert changed, "100% mutation rate should change at least one parameter"
|
||||
|
||||
def test_zero_mutation_rate_changes_nothing(self, engine):
|
||||
"""With mutation_rate=0.0, no params should change."""
|
||||
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]
|
||||
|
||||
def test_integer_params_stay_integer(self, engine):
|
||||
"""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)
|
||||
|
||||
|
||||
class TestParameterAccess:
|
||||
"""External parameter access API."""
|
||||
|
||||
def test_get_param_returns_current_value(self, engine):
|
||||
"""get_param must return the current genome value."""
|
||||
assert engine.get_param("scroll_correction_probability") == 0.15
|
||||
assert engine.get_param("resonance_threshold") == 0.7
|
||||
|
||||
def test_get_param_default_for_unknown(self, engine):
|
||||
"""Unknown params must return the default value."""
|
||||
assert engine.get_param("nonexistent_param", 42) == 42
|
||||
assert engine.get_param("nonexistent_param") is None
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
"""Singleton lifecycle management."""
|
||||
|
||||
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)):
|
||||
e1 = EvolutionEngine.get_instance("test")
|
||||
e2 = EvolutionEngine.get_instance("test")
|
||||
assert e1 is e2
|
||||
EvolutionEngine.reset()
|
||||
|
||||
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)):
|
||||
e1 = EvolutionEngine.get_instance("test")
|
||||
EvolutionEngine.reset()
|
||||
e2 = EvolutionEngine.get_instance("test")
|
||||
assert e1 is not e2
|
||||
EvolutionEngine.reset()
|
||||
222
tests/tdd/test_following_list_navigation.py
Normal file
222
tests/tdd/test_following_list_navigation.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
TDD Tests: Following List Navigation Loop Prevention
|
||||
|
||||
These tests reproduce the infinite loop bug where the GOAP planner
|
||||
repeatedly sends "open following list" as a synthetic intent,
|
||||
the TelepathicEngine's VLM selects the wrong element (Profile Tab),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures")
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> str:
|
||||
path = os.path.join(FIXTURES_DIR, name)
|
||||
with open(path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Bug 1: _plan_navigation fall-through
|
||||
# ─────────────────────────────────────────────────────
|
||||
|
||||
class TestPlanNavigationFallThrough:
|
||||
"""The planner must NOT return the same failed synthetic intent forever."""
|
||||
|
||||
def test_plan_navigation_stops_after_explored_failure(self):
|
||||
"""
|
||||
When a synthetic intent has been explored (added to explored_nav_actions)
|
||||
and failed, _plan_navigation must return None — NOT the same goal again.
|
||||
This prevents the infinite retry loop.
|
||||
"""
|
||||
planner = GoalPlanner("test_user")
|
||||
# Ensure no learned requirements exist (Blank Start)
|
||||
planner.knowledge.get_requirements = MagicMock(return_value=[])
|
||||
|
||||
goal = "open following list"
|
||||
screen = {
|
||||
"screen_type": ScreenType.OWN_PROFILE,
|
||||
"available_actions": ["tap home tab", "press back", "tap profile tab"],
|
||||
"context": {},
|
||||
}
|
||||
|
||||
# First call: no explored actions → should return the goal for discovery
|
||||
action1 = planner.plan_next_step(goal, screen, explored_nav_actions=set())
|
||||
assert action1 == goal, "First attempt should return the goal for autonomous discovery"
|
||||
|
||||
# Second call: goal was explored and failed → must NOT return the same goal
|
||||
explored = {goal} # The goal itself was tried as an action and failed
|
||||
action2 = planner.plan_next_step(goal, screen, explored_nav_actions=explored)
|
||||
|
||||
assert action2 != goal, (
|
||||
f"Planner returned the SAME failed intent '{goal}' again! "
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Bug 2: VLM StructuralGuard nav_keywords mismatch
|
||||
# ─────────────────────────────────────────────────────
|
||||
|
||||
class TestStructuralGuardNavKeywords:
|
||||
"""The VLM post-guard must recognize 'following list' as a nav intent."""
|
||||
|
||||
def test_structural_guard_allows_following_list_intent(self):
|
||||
"""
|
||||
When the VLM selects a bottom-nav-zone element for intent
|
||||
'open following list', the StructuralGuard at line 1594-1612
|
||||
must NOT reject it as a 'non-nav intent'.
|
||||
|
||||
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"
|
||||
low_intent = intent.lower()
|
||||
|
||||
# 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",
|
||||
# These MUST be present to fix the bug:
|
||||
"following", "follower", "followers",
|
||||
]
|
||||
|
||||
is_nav_intent = any(k in low_intent for k in nav_keywords_vlm)
|
||||
|
||||
assert is_nav_intent, (
|
||||
f"Intent '{intent}' was classified as non-nav by the VLM guard! "
|
||||
f"The nav_keywords list is missing 'following'/'follower' keywords. "
|
||||
f"This causes the StructuralGuard to reject valid following-list clicks."
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Bug 3: Synthetic intent masking
|
||||
# ─────────────────────────────────────────────────────
|
||||
|
||||
class TestSyntheticIntentTracking:
|
||||
"""GoalExecutor must stop retrying synthetic intents that fail."""
|
||||
|
||||
def test_goap_stops_retrying_synthetic_intents(self, monkeypatch):
|
||||
"""
|
||||
When a synthetic intent (not in available_actions) fails execution,
|
||||
the GoalExecutor must track it in explored_nav_actions AND prevent
|
||||
the planner from returning it again.
|
||||
|
||||
This ensures the bot doesn't burn 15 steps on the same failing action.
|
||||
"""
|
||||
device = MagicMock()
|
||||
executor = GoalExecutor(device, "test_user")
|
||||
executor.max_steps = 8
|
||||
|
||||
# Mock PathMemory to avoid DB
|
||||
executor.path_memory.recall_path = MagicMock(return_value=None)
|
||||
|
||||
call_count = {"execute": 0, "plan": 0}
|
||||
action_history = []
|
||||
|
||||
# Track which actions the planner returns
|
||||
def fake_perceive(*args, **kwargs):
|
||||
return {
|
||||
"screen_type": ScreenType.OWN_PROFILE,
|
||||
"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
|
||||
def fake_execute(action, **kwargs):
|
||||
call_count["execute"] += 1
|
||||
action_history.append(action)
|
||||
if action == "open following list":
|
||||
return False
|
||||
if action == "press back":
|
||||
return True
|
||||
return False
|
||||
monkeypatch.setattr(executor, "_execute_action", fake_execute)
|
||||
|
||||
# Speed up sleeps
|
||||
monkeypatch.setattr("GramAddict.core.goap.random_sleep", lambda x, y: None)
|
||||
|
||||
# Run
|
||||
executor.achieve("open following list", max_steps=8)
|
||||
|
||||
# Count how many times the synthetic intent was tried
|
||||
synthetic_attempts = action_history.count("open following list")
|
||||
|
||||
assert synthetic_attempts <= 2, (
|
||||
f"GoalExecutor tried the synthetic intent 'open following list' "
|
||||
f"{synthetic_attempts} times! Maximum should be 2 (initial + 1 retry). "
|
||||
f"Full action history: {action_history}"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────
|
||||
# Bug 4: _extract_available_actions for own profile
|
||||
# ─────────────────────────────────────────────────────
|
||||
|
||||
class TestAvailableActionsOwnProfile:
|
||||
"""available_actions must include 'tap following list' on own profile."""
|
||||
|
||||
def test_available_actions_includes_following_via_resource_id(self):
|
||||
"""
|
||||
On the own profile page with German locale ('Abonniert' instead of
|
||||
'following'), _extract_available_actions must detect the following
|
||||
counter via resource-id `profile_header_following_stacked_familiar`.
|
||||
"""
|
||||
xml = _load_fixture("own_profile_with_stats.xml")
|
||||
identity = ScreenIdentity("marisaundmarc")
|
||||
result = identity.identify(xml)
|
||||
|
||||
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, (
|
||||
f"'tap following list' not in available_actions! "
|
||||
f"The bot can't even perceive that the following counter is clickable. "
|
||||
f"Available: {available}"
|
||||
)
|
||||
|
||||
def test_available_actions_includes_following_via_content_desc(self):
|
||||
"""
|
||||
When content-desc contains 'following' (English locale),
|
||||
'tap following list' must also be detected.
|
||||
"""
|
||||
# Use user_profile_dump.xml which has content-desc="991following"
|
||||
xml = _load_fixture("user_profile_dump.xml")
|
||||
identity = ScreenIdentity("testuser")
|
||||
|
||||
# The user_profile_dump.xml has no selected nav tab, so _classify_screen
|
||||
# would fall back to LLM. Mock it to return OTHER_PROFILE.
|
||||
with patch("GramAddict.core.llm_provider.query_llm", return_value="OTHER_PROFILE"):
|
||||
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}"
|
||||
)
|
||||
|
||||
available = result["available_actions"]
|
||||
assert "tap following list" in available, (
|
||||
f"'tap following list' not in available_actions on English profile! "
|
||||
f"Available: {available}"
|
||||
)
|
||||
@@ -35,22 +35,10 @@ def test_learnable_fast_paths_use_qdrant(monkeypatch):
|
||||
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 should fall back to hardcoded defaults
|
||||
# (to seed the database on the very first run), and THEN it should STORE them.
|
||||
# 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 not None, "Should fall back to default seed"
|
||||
assert result2["x"] == 30, "Should select feed_tab node"
|
||||
assert result2["source"] == "core_nav", "Source should be marked as legacy fallback"
|
||||
|
||||
# Verify it attempted to learn/store this default seed into Qdrant for the future!
|
||||
mock_memory.store_memory.assert_any_call(
|
||||
"tap home tab",
|
||||
"",
|
||||
{
|
||||
"resource_id": "feed_tab",
|
||||
"action": "tap"
|
||||
}
|
||||
)
|
||||
assert result2 is None, "Should NOT fall back to default seed; must enforce Blank Start!"
|
||||
|
||||
45
tests/tdd/test_null_action_penalty.py
Normal file
45
tests/tdd/test_null_action_penalty.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from GramAddict.core.goap import GoalPlanner, NavigationKnowledge, 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
|
||||
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"]
|
||||
})
|
||||
|
||||
# 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"
|
||||
)
|
||||
109
tests/tdd/test_perception_module.py
Normal file
109
tests/tdd/test_perception_module.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
TDD: Perception Module Tests.
|
||||
|
||||
Tests the extracted feed analysis functions in isolation.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestFeedMarkers:
|
||||
"""FEED_MARKERS must correctly identify feed presence."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestCarouselDetection:
|
||||
"""Carousel detection must use Instagram-specific resource IDs."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestExtractPostContent:
|
||||
"""Post content extraction must return valid dict structure."""
|
||||
|
||||
def test_returns_dict_with_required_keys(self):
|
||||
"""Must always return dict with username, description, caption."""
|
||||
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
|
||||
assert "caption" in result
|
||||
|
||||
def test_handles_garbage_xml_gracefully(self):
|
||||
"""Must not crash on corrupted XML."""
|
||||
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"] == ""
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""bot_flow.py re-exports must work unchanged."""
|
||||
|
||||
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)
|
||||
265
tests/tdd/test_physics_body.py
Normal file
265
tests/tdd/test_physics_body.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
TDD Tests for PhysicsBody — Session-Persistent Thumb Kinematics.
|
||||
|
||||
Validates that the PhysicsBody correctly models:
|
||||
- Handedness-dependent anchor positioning
|
||||
- Session drift (posture changes over time)
|
||||
- Fatigue accumulation and recovery
|
||||
- Gaussian jitter on start positions
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singleton():
|
||||
"""Reset the session singleton before each test."""
|
||||
PhysicsBody.reset()
|
||||
yield
|
||||
PhysicsBody.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def body_right():
|
||||
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}
|
||||
)
|
||||
|
||||
|
||||
class TestHandedness:
|
||||
"""Tests for handedness-dependent behavior."""
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
class TestThumbArcBias:
|
||||
"""Tests for thumb arc direction."""
|
||||
|
||||
def test_right_hander_arcs_right(self, body_right):
|
||||
"""Right-hander arc bias should be positive (rightward)."""
|
||||
biases = [body_right.get_thumb_arc_bias() for _ in range(30)]
|
||||
avg_bias = sum(biases) / len(biases)
|
||||
assert avg_bias > 0, f"Right-hander arc should be positive, got {avg_bias:.2f}"
|
||||
|
||||
def test_left_hander_arcs_left(self, body_left):
|
||||
"""Left-hander arc bias should be negative (leftward)."""
|
||||
biases = [body_left.get_thumb_arc_bias() for _ in range(30)]
|
||||
avg_bias = sum(biases) / len(biases)
|
||||
assert avg_bias < 0, f"Left-hander arc should be negative, got {avg_bias:.2f}"
|
||||
|
||||
|
||||
class TestSessionDrift:
|
||||
"""Tests for posture-drift simulation."""
|
||||
|
||||
def test_drift_is_zero_initially(self, body_right):
|
||||
"""Drift should start at zero."""
|
||||
assert body_right.drift_x == 0.0
|
||||
assert body_right.drift_y == 0.0
|
||||
|
||||
def test_drift_accumulates_over_many_gestures(self, body_right):
|
||||
"""After many gestures, drift should accumulate."""
|
||||
for _ in range(500):
|
||||
body_right.get_scroll_start()
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
class TestStartPositions:
|
||||
"""Tests for scroll start position generation."""
|
||||
|
||||
def test_positions_stay_within_screen_bounds(self, body_right):
|
||||
"""All generated positions must be within safe screen margins."""
|
||||
for _ in range(200):
|
||||
x, y = body_right.get_scroll_start()
|
||||
assert 50 <= x <= body_right.w - 50, f"X out of bounds: {x}"
|
||||
assert 200 <= y <= body_right.h - 200, f"Y out of bounds: {y}"
|
||||
|
||||
def test_positions_are_not_identical(self, body_right):
|
||||
"""Consecutive positions should have variation (jitter)."""
|
||||
positions = [body_right.get_scroll_start() for _ in range(20)]
|
||||
xs = set(p[0] for p in positions)
|
||||
ys = set(p[1] for p in positions)
|
||||
|
||||
assert len(xs) > 5, f"Expected position variation in X, got {len(xs)} unique values"
|
||||
assert len(ys) > 5, f"Expected position variation in Y, got {len(ys)} unique values"
|
||||
|
||||
def test_gesture_count_increments(self, body_right):
|
||||
"""Each scroll start should increment the gesture counter."""
|
||||
assert body_right.gesture_count == 0
|
||||
body_right.get_scroll_start()
|
||||
assert body_right.gesture_count == 1
|
||||
body_right.get_scroll_start()
|
||||
assert body_right.gesture_count == 2
|
||||
|
||||
|
||||
class TestFatigue:
|
||||
"""Tests for the fatigue model."""
|
||||
|
||||
def test_fatigue_starts_at_zero(self, body_right):
|
||||
assert body_right.fatigue == 0.0
|
||||
|
||||
def test_rapid_gestures_increase_fatigue(self, body_right):
|
||||
"""Rapid sequential gestures should increase fatigue."""
|
||||
body_right.last_gesture_time = time.time() # Just now
|
||||
body_right._update_fatigue()
|
||||
body_right.last_gesture_time = time.time() - 0.1 # 100ms ago
|
||||
body_right._update_fatigue()
|
||||
|
||||
assert body_right.fatigue > 0, "Rapid gestures should increase fatigue"
|
||||
|
||||
def test_idle_period_reduces_fatigue(self, body_right):
|
||||
"""Long idle periods should reduce fatigue."""
|
||||
body_right.fatigue = 0.5
|
||||
body_right.last_gesture_time = time.time() - 10.0 # 10 seconds ago
|
||||
body_right._update_fatigue()
|
||||
|
||||
assert body_right.fatigue < 0.5, "Idle period should reduce fatigue"
|
||||
|
||||
def test_fatigue_is_clamped_0_to_1(self, body_right):
|
||||
"""Fatigue should never exceed [0, 1]."""
|
||||
# Force rapid updates
|
||||
for _ in range(200):
|
||||
body_right.last_gesture_time = time.time() - 0.05
|
||||
body_right._update_fatigue()
|
||||
|
||||
assert body_right.fatigue <= 1.0, f"Fatigue exceeded 1.0: {body_right.fatigue}"
|
||||
|
||||
# Force recovery
|
||||
for _ in range(100):
|
||||
body_right.last_gesture_time = time.time() - 20.0
|
||||
body_right._update_fatigue()
|
||||
|
||||
assert body_right.fatigue >= 0.0, f"Fatigue below 0.0: {body_right.fatigue}"
|
||||
|
||||
|
||||
class TestTapPosition:
|
||||
"""Tests for tap position generation."""
|
||||
|
||||
def test_tap_position_near_target(self, body_right):
|
||||
"""Tap position should be near the target coordinates."""
|
||||
target_x, target_y = 540, 1200
|
||||
for _ in range(50):
|
||||
x, y = body_right.get_tap_position(target_x, target_y)
|
||||
assert abs(x - target_x) < 25, f"Tap X too far: {abs(x - target_x)}px"
|
||||
assert abs(y - target_y) < 25, f"Tap Y too far: {abs(y - target_y)}px"
|
||||
|
||||
def test_tap_stays_on_screen(self, body_right):
|
||||
"""Tap positions must stay within screen bounds."""
|
||||
for _ in range(100):
|
||||
x, y = body_right.get_tap_position(10, 10)
|
||||
assert 5 <= x <= body_right.w - 5
|
||||
assert 5 <= y <= body_right.h - 5
|
||||
|
||||
|
||||
class TestPressureAndTouchMajor:
|
||||
"""Tests for pressure and touch contact area."""
|
||||
|
||||
def test_pressure_baseline_in_range(self, body_right):
|
||||
for _ in range(50):
|
||||
p = body_right.get_pressure_baseline()
|
||||
assert 0.1 <= p <= 0.85, f"Pressure baseline out of range: {p}"
|
||||
|
||||
def test_fatigue_increases_pressure(self, body_right):
|
||||
"""Fatigued thumbs should press harder."""
|
||||
body_right.fatigue = 0.0
|
||||
pressures_fresh = [body_right.get_pressure_baseline() for _ in range(30)]
|
||||
|
||||
body_right.fatigue = 0.8
|
||||
pressures_tired = [body_right.get_pressure_baseline() for _ in range(30)]
|
||||
|
||||
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})"
|
||||
)
|
||||
|
||||
def test_touch_major_in_range(self, body_right):
|
||||
for _ in range(50):
|
||||
tm = body_right.get_touch_major()
|
||||
assert 2 <= tm <= 20, f"Touch major out of range: {tm}"
|
||||
|
||||
def test_fatigue_increases_touch_major(self, body_right):
|
||||
"""Fatigued thumbs should have larger contact area."""
|
||||
body_right.fatigue = 0.0
|
||||
tm_fresh = [body_right.get_touch_major() for _ in range(30)]
|
||||
|
||||
body_right.fatigue = 0.9
|
||||
tm_tired = [body_right.get_touch_major() for _ in range(30)]
|
||||
|
||||
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})"
|
||||
)
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
"""Tests for the session singleton pattern."""
|
||||
|
||||
def test_singleton_returns_same_instance(self):
|
||||
body1 = PhysicsBody.get_session_instance(device=None, handedness="right")
|
||||
body2 = PhysicsBody.get_session_instance()
|
||||
assert body1 is body2
|
||||
|
||||
def test_reset_clears_singleton(self):
|
||||
body1 = PhysicsBody.get_session_instance(device=None, handedness="right")
|
||||
PhysicsBody.reset()
|
||||
body2 = PhysicsBody.get_session_instance(device=None, handedness="left")
|
||||
assert body1 is not body2
|
||||
assert body2.handedness == "left"
|
||||
168
tests/tdd/test_physics_module.py
Normal file
168
tests/tdd/test_physics_module.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
TDD: Physics Module Tests.
|
||||
|
||||
Tests the extracted humanized input functions in isolation,
|
||||
verifying they produce valid device interactions via the
|
||||
biomechanical gesture pipeline (BezierGesture → SendEventInjector).
|
||||
|
||||
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
|
||||
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singletons():
|
||||
"""Reset singletons between tests for isolation."""
|
||||
PhysicsBody.reset()
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
SendEventInjector.reset()
|
||||
yield
|
||||
PhysicsBody.reset()
|
||||
SendEventInjector.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device():
|
||||
"""Mock Android device."""
|
||||
dev = MagicMock()
|
||||
dev.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
dev.cm_to_pixels.return_value = 5
|
||||
dev.shell = MagicMock(return_value="")
|
||||
return dev
|
||||
|
||||
|
||||
class TestHumanizedScroll:
|
||||
"""Scroll must produce valid gesture injection calls."""
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
def test_scroll_calls_injector(self, MockInjector, device):
|
||||
"""Scroll must call the injector's inject_gesture method."""
|
||||
mock_injector = MagicMock()
|
||||
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]
|
||||
|
||||
# Validate gesture data structure
|
||||
assert len(points) >= 5, f"Expected at least 5 points, got {len(points)}"
|
||||
for p in points:
|
||||
assert len(p) == 3, f"Each point must be (x, y, pressure), got {p}"
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
def test_scroll_coordinates_within_screen(self, MockInjector, device):
|
||||
"""All scroll coordinates must be within screen bounds."""
|
||||
mock_injector = MagicMock()
|
||||
MockInjector.get_instance.return_value = mock_injector
|
||||
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
for _ in range(20):
|
||||
mock_injector.inject_gesture.reset_mock()
|
||||
humanized_scroll(device)
|
||||
|
||||
args = mock_injector.inject_gesture.call_args
|
||||
points = args[0][0]
|
||||
for x, y, p in points:
|
||||
assert 0 <= x <= 1200, f"x={x} out of bounds"
|
||||
assert 0 <= y <= 2500, f"y={y} out of bounds"
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
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()
|
||||
|
||||
|
||||
class TestHumanizedClick:
|
||||
"""Click must produce valid gesture injection calls."""
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
def test_single_tap_calls_injector_once(self, MockInjector, device):
|
||||
"""Single tap should call injector exactly once."""
|
||||
mock_injector = MagicMock()
|
||||
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
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
def test_double_tap_calls_injector_twice(self, MockInjector, device):
|
||||
"""Double tap uses device.shell for timing-critical sub-300ms double-tap."""
|
||||
mock_injector = MagicMock()
|
||||
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
|
||||
device.shell.assert_called_once()
|
||||
shell_cmd = device.shell.call_args[0][0]
|
||||
assert "input tap" in shell_cmd, f"Expected 'input tap' in shell command, got: {shell_cmd}"
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
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
|
||||
points = args[0][0]
|
||||
# First point should be near but not exactly (500, 1200)
|
||||
x, y, p = points[0]
|
||||
assert 475 <= x <= 525, f"Tap X {x} too far from target 500"
|
||||
assert 1175 <= y <= 1225, f"Tap Y {y} too far from target 1200"
|
||||
|
||||
|
||||
class TestHumanizedHorizontalSwipe:
|
||||
"""Horizontal swipe must produce valid gesture injection calls."""
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
def test_horizontal_swipe_calls_injector(self, MockInjector, device):
|
||||
"""Horizontal swipe should call injector once."""
|
||||
mock_injector = MagicMock()
|
||||
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()
|
||||
|
||||
@patch("GramAddict.core.physics.humanized_input.SendEventInjector")
|
||||
def test_horizontal_swipe_has_arc(self, MockInjector, device):
|
||||
"""Horizontal swipe points should have Y-axis variation (thumb arc)."""
|
||||
mock_injector = MagicMock()
|
||||
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
|
||||
points = args[0][0]
|
||||
ys = [p[1] for p in points]
|
||||
# Y values should NOT all be identical (thumb arc produces variation)
|
||||
unique_ys = set(ys)
|
||||
assert len(unique_ys) > 1, "Expected Y-axis variation from thumb arc"
|
||||
389
tests/tdd/test_plugin_architecture.py
Normal file
389
tests/tdd/test_plugin_architecture.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
TDD: Plugin Architecture Tests.
|
||||
|
||||
Tests the BehaviorPlugin base class, PluginRegistry, and the
|
||||
first concrete plugin (CarouselBrowsingPlugin).
|
||||
|
||||
Covers:
|
||||
- Plugin lifecycle (register, unregister, activate, execute)
|
||||
- Priority ordering
|
||||
- Exclusive plugin chain-breaking
|
||||
- Duplicate registration prevention
|
||||
- Error isolation (plugin crashes don't cascade)
|
||||
- CarouselBrowsingPlugin activation and execution
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from GramAddict.core.behaviors import (
|
||||
BehaviorPlugin,
|
||||
BehaviorContext,
|
||||
BehaviorResult,
|
||||
PluginRegistry,
|
||||
)
|
||||
|
||||
|
||||
# ── Test Plugins ──
|
||||
|
||||
class AlwaysActivePlugin(BehaviorPlugin):
|
||||
@property
|
||||
def name(self): return "always_active"
|
||||
|
||||
@property
|
||||
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"
|
||||
|
||||
@property
|
||||
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"
|
||||
|
||||
@property
|
||||
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"
|
||||
|
||||
@property
|
||||
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"
|
||||
|
||||
@property
|
||||
def priority(self): return 100
|
||||
|
||||
@property
|
||||
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"
|
||||
|
||||
@property
|
||||
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()
|
||||
reg = PluginRegistry()
|
||||
yield reg
|
||||
PluginRegistry.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx():
|
||||
"""Minimal BehaviorContext for testing."""
|
||||
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,
|
||||
session_state=session_state,
|
||||
cognitive_stack={},
|
||||
context_xml='<hierarchy><node resource-id="com.instagram.android:id/row_feed_photo_profile_name"/></hierarchy>',
|
||||
sleep_mod=1.0,
|
||||
)
|
||||
|
||||
|
||||
# ── Registry Tests ──
|
||||
|
||||
class TestPluginRegistry:
|
||||
"""Registry must manage plugins correctly."""
|
||||
|
||||
def test_register_adds_plugin(self, registry):
|
||||
registry.register(AlwaysActivePlugin())
|
||||
assert len(registry) == 1
|
||||
|
||||
def test_duplicate_registration_ignored(self, registry):
|
||||
registry.register(AlwaysActivePlugin())
|
||||
registry.register(AlwaysActivePlugin())
|
||||
assert len(registry) == 1
|
||||
|
||||
def test_unregister_removes_plugin(self, registry):
|
||||
registry.register(AlwaysActivePlugin())
|
||||
registry.unregister("always_active")
|
||||
assert len(registry) == 0
|
||||
|
||||
def test_unregister_nonexistent_is_noop(self, registry):
|
||||
registry.unregister("nonexistent")
|
||||
assert len(registry) == 0
|
||||
|
||||
def test_contains_check(self, registry):
|
||||
registry.register(AlwaysActivePlugin())
|
||||
assert "always_active" in registry
|
||||
assert "nonexistent" not in registry
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
def test_singleton_pattern(self):
|
||||
PluginRegistry.reset()
|
||||
r1 = PluginRegistry.get_instance()
|
||||
r2 = PluginRegistry.get_instance()
|
||||
assert r1 is r2
|
||||
PluginRegistry.reset()
|
||||
|
||||
def test_reset_clears_singleton(self):
|
||||
PluginRegistry.reset()
|
||||
r1 = PluginRegistry.get_instance()
|
||||
PluginRegistry.reset()
|
||||
r2 = PluginRegistry.get_instance()
|
||||
assert r1 is not r2
|
||||
PluginRegistry.reset()
|
||||
|
||||
|
||||
# ── Execution Tests ──
|
||||
|
||||
class TestPluginExecution:
|
||||
"""Plugin execution lifecycle."""
|
||||
|
||||
def test_execute_all_runs_active_plugins(self, registry, ctx):
|
||||
registry.register(AlwaysActivePlugin())
|
||||
results = registry.execute_all(ctx)
|
||||
assert len(results) == 1
|
||||
assert results[0].executed is True
|
||||
|
||||
def test_execute_all_skips_inactive_plugins(self, registry, ctx):
|
||||
registry.register(NeverActivePlugin())
|
||||
results = registry.execute_all(ctx)
|
||||
assert len(results) == 0
|
||||
|
||||
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"
|
||||
assert results[1].metadata["order"] == "last"
|
||||
|
||||
def test_exclusive_plugin_stops_chain(self, registry, ctx):
|
||||
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
|
||||
assert results[0].should_skip is True
|
||||
|
||||
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
|
||||
assert results[0].executed is False
|
||||
assert "error" in results[0].metadata
|
||||
|
||||
|
||||
# ── BehaviorContext Tests ──
|
||||
|
||||
class TestBehaviorContext:
|
||||
"""BehaviorContext construction."""
|
||||
|
||||
def test_default_values(self):
|
||||
ctx = BehaviorContext(
|
||||
device=MagicMock(),
|
||||
configs=MagicMock(),
|
||||
session_state=MagicMock(),
|
||||
cognitive_stack={},
|
||||
)
|
||||
assert ctx.context_xml == ""
|
||||
assert ctx.sleep_mod == 1.0
|
||||
assert ctx.post_data is None
|
||||
assert ctx.username == ""
|
||||
|
||||
|
||||
# ── BehaviorResult Tests ──
|
||||
|
||||
class TestBehaviorResult:
|
||||
"""BehaviorResult defaults."""
|
||||
|
||||
def test_default_result(self):
|
||||
result = BehaviorResult()
|
||||
assert result.executed is False
|
||||
assert result.should_continue is True
|
||||
assert result.should_skip is False
|
||||
assert result.interactions == 0
|
||||
assert result.metadata == {}
|
||||
|
||||
def test_result_with_metadata(self):
|
||||
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."""
|
||||
|
||||
@pytest.fixture
|
||||
def carousel_ctx(self, ctx):
|
||||
"""Context with carousel indicators."""
|
||||
ctx.context_xml = (
|
||||
'<hierarchy>'
|
||||
'<node resource-id="com.instagram.android:id/carousel_page_indicator"/>'
|
||||
'<node resource-id="com.instagram.android:id/row_feed_photo_profile_name"/>'
|
||||
'</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
|
||||
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)
|
||||
]
|
||||
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
|
||||
assert plugin.exclusive is False
|
||||
|
||||
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
|
||||
|
||||
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"):
|
||||
# Run many times — most should skip
|
||||
executed_count = 0
|
||||
for _ in range(100):
|
||||
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()
|
||||
22
tests/tdd/test_profile_transition.py
Normal file
22
tests/tdd/test_profile_transition.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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>'
|
||||
]
|
||||
|
||||
assert _wait_for_profile_loaded(device, timeout=2) == True
|
||||
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 device.dump_hierarchy.call_count >= 1
|
||||
@@ -5,11 +5,21 @@ from GramAddict.core.session_state import SessionState
|
||||
|
||||
@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"
|
||||
return device
|
||||
device.shell = MagicMock(return_value="")
|
||||
|
||||
yield device
|
||||
|
||||
PhysicsBody.reset()
|
||||
SendEventInjector.reset()
|
||||
|
||||
def test_reels_loop_repost_execution(mock_device):
|
||||
"""
|
||||
@@ -84,10 +94,15 @@ def test_reels_loop_repost_execution(mock_device):
|
||||
# First call: find interaction buttons
|
||||
mock_telepathic._extract_semantic_nodes.return_value = [{"x": 100, "y": 100, "semantic_string": "share button"}]
|
||||
|
||||
# Logic for finding the Repost button inside the share sheet
|
||||
# Logic for finding nodes — return proper attributes for content extraction
|
||||
def mock_find_best_node(xml, intent, **kwargs):
|
||||
if "Repost" in intent:
|
||||
return {"x": 500, "y": 2000, "bounds": "[400,1950][600,2050]", "skip": False}
|
||||
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": ""}}
|
||||
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": ""}}
|
||||
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": 100, "y": 100, "bounds": "[90,90][110,110]", "skip": False}
|
||||
|
||||
mock_telepathic.find_best_node.side_effect = mock_find_best_node
|
||||
@@ -97,8 +112,11 @@ def test_reels_loop_repost_execution(mock_device):
|
||||
|
||||
# 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.bot_flow.sleep'), \
|
||||
patch('GramAddict.core.physics.timing.sleep'), \
|
||||
patch('GramAddict.core.bot_flow.dump_ui_state'):
|
||||
|
||||
MockEngine.get_instance.return_value = mock_telepathic
|
||||
|
||||
@@ -111,7 +129,6 @@ def test_reels_loop_repost_execution(mock_device):
|
||||
return state['current']
|
||||
|
||||
mock_device.dump_hierarchy.side_effect = side_effect_func
|
||||
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
|
||||
@@ -146,3 +163,4 @@ def test_reels_loop_repost_execution(mock_device):
|
||||
|
||||
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")
|
||||
|
||||
|
||||
95
tests/tdd/test_sae_escalation.py
Normal file
95
tests/tdd/test_sae_escalation.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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.
|
||||
This prevents the 'Nuclear Escalation' trap when transitioning from
|
||||
a system permission dialog to an in-app modal.
|
||||
"""
|
||||
device_mock = MagicMock()
|
||||
# Mocking a sequence of dumps:
|
||||
# 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,
|
||||
# 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
|
||||
# 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
|
||||
SituationType.OBSTACLE_SYSTEM, # post-perceive -> success=False (counter=1)
|
||||
# Attempt 1
|
||||
SituationType.OBSTACLE_SYSTEM, # perceive
|
||||
SituationType.OBSTACLE_SYSTEM, # post-perceive -> success=False (counter=2)
|
||||
# Attempt 2
|
||||
SituationType.OBSTACLE_SYSTEM, # perceive
|
||||
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)
|
||||
# Attempt 4
|
||||
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!
|
||||
]
|
||||
|
||||
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)
|
||||
# If the LLM keeps returning the same coordinates, it might be an issue.
|
||||
# We can just return different EscapeActions on side_effect
|
||||
sae._plan_escape_via_llm.side_effect = [
|
||||
EscapeAction("tap_coordinates", 100, 100, "mock_1"),
|
||||
EscapeAction("tap_coordinates", 101, 100, "mock_2"),
|
||||
EscapeAction("tap_coordinates", 102, 100, "mock_3"),
|
||||
EscapeAction("tap_coordinates", 103, 100, "mock_4"),
|
||||
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"]
|
||||
assert len(app_starts) == 0
|
||||
|
||||
21
tests/tdd/test_semantic_heuristic_match.py
Normal file
21
tests/tdd/test_semantic_heuristic_match.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from GramAddict.core.goap import GoalPlanner
|
||||
from GramAddict.core.goap import 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': {}
|
||||
}
|
||||
|
||||
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}"
|
||||
|
||||
@@ -42,13 +42,13 @@ def test_semantic_poison_guard_rejects_hallucinations(monkeypatch):
|
||||
# Führe Action aus
|
||||
result = executor._execute_action("tap messages tab", goal="open messages")
|
||||
|
||||
# ASSERT: Since we removed the Poison Guard, it should accept the navigation
|
||||
# and empirically map 'tap messages tab' to REELS_FEED.
|
||||
assert result is True, "Aktion 'tap messages tab' die nach REELS führt, MUSS True zurückgeben (Empirisches Lernen)!"
|
||||
# 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.confirm_click.assert_called_with("tap messages tab")
|
||||
engine_mock.reject_click.assert_not_called()
|
||||
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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user