fix(feed): resolve home feed back-button scroll-to-top trap

- Separated obstacle detection from feed marker validation
- Prevented blind BACK button presses when markers are missing mid-scroll
- Added TDD verification for feed navigation stability
- Cleaned up debug artifacts and temporary test output
This commit is contained in:
2026-04-21 02:00:01 +02:00
parent 2c6404f387
commit 0a02e901b6
37 changed files with 311 additions and 274 deletions

View File

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

View File

@@ -16,7 +16,7 @@ def test_modal_guard_blocks_nav_intent_on_failed_xml():
with open(FAILED_XML_PATH, "r") as f:
xml_content = f.read()
engine = TelepathicEngine.get_instance()
engine = TelepathicEngine()
# Intent that SHOULD be blocked because Home Tab is obscured by the comment sheet
intent = "tap home tab"
@@ -36,7 +36,7 @@ def test_zone_enforcement_blocks_mid_screen_tab_hallucination():
Tests that even if VLM is triggered (e.g. no modal detected but low confidence),
any result for a 'tab' intent that is in the middle of the screen is rejected.
"""
engine = TelepathicEngine.get_instance()
engine = TelepathicEngine()
# Minimal XML
xml = "<?xml version='1.0' ?><hierarchy><node index='0' text='Add comment' bounds='[42,2224][1038,2350]' visible-to-user='true' /></hierarchy>"

View File

@@ -68,7 +68,7 @@ def test_reels_loop_repost_execution(mock_device):
<node resource-id="com.instagram.android:id/direct_share_button" content-desc="Share" />
</hierarchy>'''
mock_device.deviceV2.dump_hierarchy.return_value = reels_xml
mock_device.dump_hierarchy.return_value = reels_xml
mock_device.dump_hierarchy.return_value = reels_xml
# Repost Sheet XML
@@ -111,7 +111,7 @@ def test_reels_loop_repost_execution(mock_device):
return state['current']
mock_device.dump_hierarchy.side_effect = side_effect_func
mock_device.deviceV2.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