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

@@ -19,7 +19,7 @@ sys.modules["qdrant_client"].QdrantClient = MagicMock(return_value=mock_qdrant)
@pytest.fixture
def e2e_device_dump_injector():
"""
Provides a factory to mock device.deviceV2.dump_hierarchy using real XML files.
Provides a factory to mock device.dump_hierarchy using real XML files.
Will gracefully fail with a comprehensive assertion if the file is missing
(per 'ECHTE DUMPS fehlen' reporting requirement).
"""
@@ -33,7 +33,7 @@ def e2e_device_dump_injector():
with open(xml_path, "r") as f:
real_xml = f.read()
device_mock.deviceV2.dump_hierarchy.return_value = real_xml
device_mock.dump_hierarchy.return_value = real_xml
return real_xml
return _inject_dump
@@ -73,14 +73,17 @@ def dynamic_e2e_dump_injector(monkeypatch):
# The current active state XML
device_mock._current_active_xml = load_xml(initial_xml)
import uuid
def _dump_hierarchy_hook():
if clock.time < clock.animation_target_time:
pytest.fail(f"UI SYNCHRONIZATION FAILURE: dump_hierarchy() called mid-animation! "
f"Virtual Clock is at {clock.time:.1f}s but UI needs until {clock.animation_target_time:.1f}s to settle. "
f"Add a time.sleep() guard before interacting with the UI after a click.", pytrace=False)
return device_mock._current_active_xml
xml = device_mock._current_active_xml
if xml and "</hierarchy>" in xml:
xml = xml.replace("</hierarchy>", f"<node sid=\"{uuid.uuid4()}\" /></hierarchy>")
return xml
device_mock.deviceV2.dump_hierarchy.side_effect = _dump_hierarchy_hook
device_mock.dump_hierarchy.side_effect = _dump_hierarchy_hook
class DummyEngine:

View File

@@ -78,9 +78,9 @@ def make_mock_device(app_id="com.instagram.android"):
device.app_id = app_id
device.deviceV2 = MagicMock()
device.dump_hierarchy = MagicMock()
device.deviceV2.click = MagicMock()
device.deviceV2.press = MagicMock()
device.deviceV2.app_start = MagicMock()
device.click = MagicMock()
device.press = MagicMock()
device.app_start = MagicMock()
# Mock trace counter to prevent file writes
device._trace_counter = 0
device._trace_dir = "/tmp/test_traces"
@@ -257,7 +257,7 @@ class TestSAEAutonomousRecovery:
patch.object(sae.episodes, 'learn'):
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.deviceV2.app_start.assert_called_with("com.instagram.android", use_monkey=True)
device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
def test_recovers_from_survey_back_first_then_click(self):
"""Instagram survey → SAE tries BACK first → if BACK fails → clicks 'Not Now'."""
@@ -275,10 +275,10 @@ class TestSAEAutonomousRecovery:
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# First action was BACK, second was click
device.deviceV2.press.assert_called_with("back")
device.deviceV2.click.assert_called_once()
device.press.assert_called_with("back")
device.click.assert_called_once()
# Verify it clicked the "Not Now" button coordinates
click_args = device.deviceV2.click.call_args
click_args = device.click.call_args
assert click_args[0] == (320, 1850)
def test_recovers_from_survey_via_back(self):
@@ -294,8 +294,8 @@ class TestSAEAutonomousRecovery:
patch.object(sae.episodes, 'learn'):
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.deviceV2.press.assert_called_with("back")
device.deviceV2.click.assert_not_called() # Never needed to click!
device.press.assert_called_with("back")
device.click.assert_not_called() # Never needed to click!
def test_recovers_from_unknown_modal_german(self):
"""German modal → BACK first → fails → finds 'Später' by TEXT → clicks."""
@@ -312,7 +312,7 @@ class TestSAEAutonomousRecovery:
patch.object(sae.episodes, 'learn'):
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
device.deviceV2.click.assert_called_once()
device.click.assert_called_once()
def test_never_clicks_close_friends_on_follow_sheet(self):
"""CRITICAL REAL-WORLD BUG: Follow sheet has 'close_friends' row.
@@ -339,8 +339,8 @@ class TestSAEAutonomousRecovery:
result = sae.ensure_clear_screen(max_attempts=5)
assert result is True
# CRITICAL: Must use BACK, never click any follow sheet button
device.deviceV2.press.assert_called_with("back")
device.deviceV2.click.assert_not_called()
device.press.assert_called_with("back")
device.click.assert_not_called()
def test_escalates_to_app_start_after_failures(self):
"""If BACK fails repeatedly, SAE must escalate to app_start."""
@@ -365,7 +365,7 @@ class TestSAEAutonomousRecovery:
with patch.object(sae, '_plan_escape_via_llm', return_value=EscapeAction("back", reason="LLM says back")):
result = sae.ensure_clear_screen(max_attempts=7)
assert result is True
device.deviceV2.app_start.assert_called()
device.app_start.assert_called()
def test_normal_screen_returns_immediately(self):
"""No obstacle → returns True instantly, no actions taken."""
@@ -375,9 +375,9 @@ class TestSAEAutonomousRecovery:
sae = SituationalAwarenessEngine(device)
result = sae.ensure_clear_screen()
assert result is True
device.deviceV2.press.assert_not_called()
device.deviceV2.click.assert_not_called()
device.deviceV2.app_start.assert_not_called()
device.press.assert_not_called()
device.click.assert_not_called()
device.app_start.assert_not_called()
def test_action_blocked_raises_exception(self):
"""If Instagram blocks us, SAE must HALT — never try to dismiss."""