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

@@ -16,7 +16,7 @@ def mock_device():
device.deviceV2 = MagicMock()
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
# Link facade method to deviceV2 mock so old tests keep working
device.dump_hierarchy = device.deviceV2.dump_hierarchy
device.dump_hierarchy = device.dump_hierarchy
return device
@@ -55,7 +55,7 @@ def test_align_active_post(mock_device):
</hierarchy>'''
res = _align_active_post(mock_device)
# The header is at 850px. Target is 250px. Diff is 600px. It should swipe.
assert mock_device.deviceV2.swipe.called
assert mock_device.swipe.called
def test_feed_loop_boredom_change_feed(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].is_app_session_over.return_value = False
@@ -74,7 +74,7 @@ def test_feed_loop_context_lost(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
mock_device.deviceV2.dump_hierarchy.return_value = "<hierarchy></hierarchy>" # Blind
mock_device.dump_hierarchy.return_value = "<hierarchy></hierarchy>" # Blind
# Needs telepathic engine mock
with patch('GramAddict.core.bot_flow.TelepathicEngine') as MockTelepathic, patch('GramAddict.core.bot_flow.dump_ui_state'):
@@ -105,7 +105,7 @@ def test_feed_loop_zero_nodes(mock_device, mock_cognitive_stack):
_run_zero_latency_feed_loop(mock_device, mock_cognitive_stack["zero_engine"], mock_cognitive_stack["nav_graph"], configs, session_state, "HomeFeed", mock_cognitive_stack)
assert mock_device.deviceV2.press.called_with("back")
assert mock_device.press.called_with("back")
assert mock_scroll.called
def test_feed_loop_ad_skip(mock_device, mock_cognitive_stack):
@@ -113,7 +113,7 @@ def test_feed_loop_ad_skip(mock_device, mock_cognitive_stack):
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
mock_cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="ad_account" />
@@ -142,7 +142,7 @@ def test_stories_loop_success(mock_device, mock_cognitive_stack):
configs.args.stories = "1"
session_state = MagicMock()
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/story_viewer" />
</hierarchy>'''
@@ -162,7 +162,7 @@ def test_stories_loop_boredom(mock_device, mock_cognitive_stack):
with patch('GramAddict.core.bot_flow.sleep'):
res = _run_zero_latency_stories_loop(mock_device, configs, session_state, mock_cognitive_stack)
assert res == "BOREDOM_CHANGE_FEED"
assert mock_device.deviceV2.press.called_with("back")
assert mock_device.press.called_with("back")
def test_start_bot_interrupt():
from GramAddict.core.bot_flow import start_bot
@@ -215,7 +215,7 @@ def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
# Needs to report a structure that has NO ad, HAS content, and HAS feed markers.
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
@@ -271,7 +271,7 @@ def test_feed_loop_repost(mock_device, mock_cognitive_stack):
session_state = MagicMock()
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
@@ -311,7 +311,7 @@ def test_profile_learning_percentage_trigger(mock_device, mock_cognitive_stack):
session_state = MagicMock()
session_state.check_limit.side_effect = lambda limit_type: (False, False, False, False) if getattr(limit_type, "name", "") == "ALL" else False
mock_device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
mock_device.dump_hierarchy.return_value = '''<?xml version='1.0' ?>
<hierarchy>
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="legit_user" />
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" content-desc="test image" />

View File

@@ -79,7 +79,7 @@ def test_execute_proof_of_resonance_close_comments():
# Simulated UI Dump: No 'bottom_sheet_container', but neither 'row_feed' nor 'button_like'
# Which occurs when IG renames it to 'fragment_container_view' or similar wrapper
device.deviceV2.dump_hierarchy.return_value = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
device.dump_hierarchy.return_value = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
<!-- Random UI generic sheet classes the Bot doesn't track -->
@@ -95,4 +95,4 @@ def test_execute_proof_of_resonance_close_comments():
# Assert: Instead of checking string names for "bottom_sheet_container",
# it should verify the presence of 'row_feed' to confirm we are back in Home!
# If not in Home, it presses back twice.
assert device.deviceV2.press.call_count == 2
assert device.press.call_count == 2

View File

@@ -89,7 +89,7 @@ def test_ghost_typing_stealth_chunking():
_adb_inject_text(mock_device, "hello world")
# Assert space was correctly mapped to %s for native consumption
mock_device.deviceV2.shell.assert_called_with(["input", "text", "hello%sworld"])
mock_device.shell.assert_called_with(["input", "text", "hello%sworld"])
def test_ghost_typing_special_character_escaping():
"""
@@ -101,4 +101,4 @@ def test_ghost_typing_special_character_escaping():
_adb_inject_text(mock_device, "it's cool")
# assert single quote was escaped
mock_device.deviceV2.shell.assert_called_with(["input", "text", "it\\'s%scool"])
mock_device.shell.assert_called_with(["input", "text", "it\\'s%scool"])

View File

@@ -34,7 +34,7 @@ def test_recovery_from_dm_view(mock_device):
call_counts["dumps"] += 1
# If app_start hasn't been called, we are still locked in the DM screen
if not mock_device.deviceV2.app_start.called:
if not mock_device.app_start.called:
return dm_xml
else:
# After forced app_start, we land on Home.
@@ -69,6 +69,6 @@ def test_recovery_from_dm_view(mock_device):
assert success is True
assert nav.current_state == "ReelsFeed"
# Verify hard recovery was triggered
mock_device.deviceV2.app_start.assert_called_with("com.instagram.android", use_monkey=True)
mock_device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
# 15 perception dumps + 15 execute dumps + verified dumps + retry dumps
assert call_counts["dumps"] >= 16

View File

@@ -12,7 +12,7 @@ def test_qnavgraph_same_state_navigation_bug():
mock_device.deviceV2 = MagicMock()
# Mock search tab selected (ExploreFeed)
mock_device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/search_tab" selected="true" /></hierarchy>'
mock_device.deviceV2.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/search_tab" selected="true" /></hierarchy>'
mock_device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/search_tab" selected="true" /></hierarchy>'
with patch('GramAddict.core.goap.GoalExecutor._instance', None), \
patch('GramAddict.core.goap.ScreenIdentity._classify_screen', return_value=__import__('GramAddict.core.goap', fromlist=['ScreenType']).ScreenType.EXPLORE_GRID), \
@@ -26,7 +26,7 @@ def test_qnavgraph_same_state_navigation_bug():
graph = QNavGraph(mock_device)
graph.current_state = "ExploreFeed"
graph.navigate_to("ExploreFeed", zero_engine=None)
mock_device.deviceV2.app_start.assert_not_called()
mock_device.app_start.assert_not_called()
def test_qnavgraph_semantic_recovery_any_state():
"""
@@ -43,7 +43,7 @@ def test_qnavgraph_semantic_recovery_any_state():
'<hierarchy><node package="com.instagram.android" resource-id="com.instagram.android:id/clips_tab" selected="true" /></hierarchy>'
]
mock_device.dump_hierarchy.side_effect = mock_hierarchy + [mock_hierarchy[-1]] * 10
mock_device.deviceV2.dump_hierarchy.side_effect = mock_hierarchy + [mock_hierarchy[-1]] * 10
mock_device.dump_hierarchy.side_effect = mock_hierarchy + [mock_hierarchy[-1]] * 10
graph = QNavGraph(mock_device)
graph.current_state = "HomeFeed"
@@ -79,7 +79,7 @@ def test_qnavgraph_telepathic_tagging(caplog):
# 1. Test Keyword Fast Path (Score 1.0)
mock_hierarchy_1 = ['<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>', '<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>']
mock_device.dump_hierarchy.side_effect = mock_hierarchy_1 + [mock_hierarchy_1[-1]] * 10
mock_device.deviceV2.dump_hierarchy.side_effect = mock_hierarchy_1 + [mock_hierarchy_1[-1]] * 10
mock_device.dump_hierarchy.side_effect = mock_hierarchy_1 + [mock_hierarchy_1[-1]] * 10
mock_telepathic = MagicMock()
mock_telepathic.find_best_node.return_value = {
"x": 100, "y": 100, "score": 1.0, "semantic": "test match", "source": "keyword", "skip": False
@@ -93,7 +93,7 @@ def test_qnavgraph_telepathic_tagging(caplog):
caplog.clear()
mock_hierarchy_2 = ['<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>', '<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>']
mock_device.dump_hierarchy.side_effect = mock_hierarchy_2 + [mock_hierarchy_2[-1]] * 10
mock_device.deviceV2.dump_hierarchy.side_effect = mock_hierarchy_2 + [mock_hierarchy_2[-1]] * 10
mock_device.dump_hierarchy.side_effect = mock_hierarchy_2 + [mock_hierarchy_2[-1]] * 10
mock_telepathic.find_best_node.return_value = {
"x": 100, "y": 100, "score": 0.85, "semantic": "test LLM", "source": "agentic_fallback", "skip": False
}

View File

@@ -97,9 +97,9 @@ def test_full_mission_autopilot_sequence(fsd_fixtures):
print(f"DEBUG: State advanced to {state['index']}")
device.dump_hierarchy.side_effect = get_ui
device.deviceV2.dump_hierarchy.side_effect = get_ui
device.dump_hierarchy.side_effect = get_ui
device.click.side_effect = advance_state
device.click.side_effect = advance_state
device.deviceV2.click.side_effect = advance_state
device.app_id = "com.instagram.android"
device._get_current_app.return_value = "com.instagram.android"
device.app_is_running.return_value = True
@@ -262,8 +262,8 @@ def test_feed_loop_chaos_mode(fsd_fixtures):
def advance_state(*args, **kwargs):
state["index"] += 1
device.deviceV2.dump_hierarchy.side_effect = get_ui
device.deviceV2.click.side_effect = advance_state
device.dump_hierarchy.side_effect = get_ui
device.click.side_effect = advance_state
from GramAddict.core.sensors.honeypot_radome import HoneypotRadome

View File

@@ -154,7 +154,7 @@ class TestSafetyGuard:
mock_exists.return_value = False
engine = TelepathicEngine()
device = MagicMock()
device.deviceV2.screenshot = MagicMock()
device.screenshot = MagicMock()
mock_open.return_value.__enter__.return_value.read.return_value = b'fakeimage'
nodes = self.explore_nodes
@@ -197,7 +197,7 @@ class TestSafetyGuard:
mock_exists.return_value = False
engine = TelepathicEngine()
device = MagicMock()
device.deviceV2.screenshot = MagicMock()
device.screenshot = MagicMock()
mock_open.return_value.__enter__.return_value.read.return_value = b'fakeimage'
nodes = self.explore_nodes

View File

@@ -17,7 +17,7 @@ from GramAddict.core.telepathic_engine import TelepathicEngine
def mock_device(width=1080, height=2400):
device = MagicMock()
device.get_info.return_value = {"displayWidth": width, "displayHeight": height}
device.deviceV2.screenshot = MagicMock()
device.screenshot = MagicMock()
device.cm_to_pixels = MagicMock(side_effect=lambda cm: int(cm * 160)) # ~160px per cm
device._get_current_app.return_value = "com.instagram.android"
device.app_id = "com.instagram.android"
@@ -330,7 +330,7 @@ class TestTelepathicMemoryRecall:
assert result["score"] == 1.0
# Screenshot should NEVER have been called (the early-return optimization)
device.deviceV2.screenshot.assert_not_called()
device.screenshot.assert_not_called()
@patch('GramAddict.core.telepathic_engine.TelepathicEngine._extract_semantic_nodes')
@patch('builtins.open', new_callable=MagicMock)