feat: structural hardening, grid fast-paths, and state-aware adaptive snap recovery

This commit is contained in:
2026-05-04 17:43:25 +02:00
parent 22216cbd2d
commit 3800254fe3
22 changed files with 262 additions and 761 deletions

View File

@@ -14,6 +14,10 @@ import sys
import pytest
def pytest_addoption(parser):
parser.addoption("--live", action="store_true", default=False, help="Run live tests")
@pytest.fixture(autouse=True)
def _isolate_config_from_argparse(monkeypatch):
"""Prevent Config() from reading sys.argv during tests.

View File

@@ -24,7 +24,8 @@ from GramAddict.core.session_state import SessionState
def pytest_addoption(parser):
parser.addoption("--live", action="store_true", default=False, help="Run live tests")
# CLI options are now registered in the root tests/conftest.py
pass
# ═══════════════════════════════════════════════════════
@@ -525,6 +526,7 @@ def e2e_configs():
max_success=10,
max_total=10,
max_crashes=10,
live=False,
)
from GramAddict.core.config import Config

View File

@@ -46,3 +46,6 @@ def test_ad_guard_detects_sponsored_post(make_real_device_with_image, e2e_config
# Executed should be True, meaning it triggered and took action (scrolled past the ad)
assert result.executed is True, "AdGuardPlugin failed to detect the sponsored post!"
assert result.should_skip is True, "AdGuardPlugin executed but did not set should_skip"
assert (
getattr(result, "skip_type", "normal") == "fast"
), "AdGuardPlugin did not delegate a fast skip to the orchestrator"

View File

@@ -35,14 +35,15 @@ def test_story_view_clicks_story_ring(make_real_device_with_image, e2e_configs,
with open("tests/fixtures/story_view_full.xml", "r", encoding="utf-8") as f:
xml_after = f.read()
# The sequence of dumps for plugin.execute() calling nav_graph.do:
# 1. goap.perceive() (xml_before)
# 2. goap._execute_action find_node (xml_before)
# 3. goap verification post-click (xml_after)
# 4. Fallbacks/extras (xml_after, xml_after)
device = make_real_device_with_image(
"tests/fixtures/home_feed_with_ad.jpg", [xml_before, xml_after, xml_after, xml_after]
)
device = make_real_device_with_image("tests/fixtures/home_feed_with_ad.jpg", xml_before)
# Return xml_after only after a click has been performed
def patched_dump(compressed=False):
if len(device.deviceV2.interaction_log) > 0:
return xml_after
return xml_before
device.dump_hierarchy = patched_dump
from GramAddict.core.session_state import SessionState

View File

@@ -193,7 +193,13 @@ class TestAvailableActions:
assert "tap first post" in actions
def test_profile_has_following_list(self):
xml = _xml_with_ids("profile_header_container", "profile_tab", selected_tab="profile_tab", descs=["following"])
xml = _xml_with_ids(
"profile_header_container",
"profile_header_following",
"profile_tab",
selected_tab="profile_tab",
descs=["following"],
)
result = self.si.identify(xml)
actions = result["available_actions"]
assert "tap following list" in actions
@@ -521,10 +527,10 @@ class TestStepValidation:
expected = ScreenTopology.expected_screen_for_action("press back", ScreenType.FOLLOW_LIST)
assert expected == ScreenType.OWN_PROFILE
def test_other_profile_to_home_feed_routing_uses_back_press(self):
# We removed 'tap home tab' from OTHER_PROFILE because it doesn't exist.
def test_other_profile_to_home_feed_routing_uses_home_tab(self):
# tap home tab is deterministic, whereas press back could lead to EXPLORE_GRID
route = ScreenTopology.find_route(ScreenType.OTHER_PROFILE, ScreenType.HOME_FEED)
assert route is not None
assert len(route) == 1
assert route[0][0] == "press back"
assert route[0][0] == "tap home tab"
assert route[0][1] == ScreenType.HOME_FEED

View File

@@ -47,23 +47,27 @@ def _extract_tab_bar_nodes(xml_str):
# Tab bar nodes have specific resource-ids and are at the bottom
if "tab_bar" in res_id or "tab_icon" in res_id:
tab_nodes.append({
"resource-id": res_id,
"content-desc": content_desc,
"text": elem.attrib.get("text", ""),
"bounds": bounds,
"clickable": elem.attrib.get("clickable", "false"),
})
tab_nodes.append(
{
"resource-id": res_id,
"content-desc": content_desc,
"text": elem.attrib.get("text", ""),
"bounds": bounds,
"clickable": elem.attrib.get("clickable", "false"),
}
)
# Profile tab specifically
if "profile" in content_desc.lower() and "tab" in content_desc.lower():
tab_nodes.append({
"resource-id": res_id,
"content-desc": content_desc,
"text": elem.attrib.get("text", ""),
"bounds": bounds,
"clickable": elem.attrib.get("clickable", "false"),
})
tab_nodes.append(
{
"resource-id": res_id,
"content-desc": content_desc,
"text": elem.attrib.get("text", ""),
"bounds": bounds,
"clickable": elem.attrib.get("clickable", "false"),
}
)
return tab_nodes
@@ -79,12 +83,8 @@ def test_other_profile_has_back_button_and_profile_tab():
has_back = "action_bar_button_back" in xml or 'content-desc="Back"' in xml
has_profile_tab = "profile_tab" in xml or 'content-desc="Profile"' in xml
assert has_back, (
"other_profile_real.xml is missing the Back button — "
"cannot reproduce the VLM confusion bug"
)
# Note: if profile tab is missing from fixture, the bug is even worse
# because there's nothing correct for the VLM to find
assert has_back, "other_profile_real.xml is missing the Back button — " "cannot reproduce the VLM confusion bug"
assert has_profile_tab, "other_profile_real.xml is missing the profile tab"
def test_account_switcher_navigates_to_own_profile_first():
@@ -94,15 +94,16 @@ def test_account_switcher_navigates_to_own_profile_first():
the nav must happen first.
"""
import inspect
from GramAddict.core.account_switcher import verify_and_switch_account
source = inspect.getsource(verify_and_switch_account)
# The function must call navigate_to("OwnProfile") BEFORE
# calling find_best_node for profile tab
nav_pos = source.find('navigate_to("OwnProfile"')
# calling find_best_node for profile
nav_pos = source.find('achieve("open profile")')
assert nav_pos >= 0, (
"account_switcher does not navigate to OwnProfile — "
"account_switcher does not use GOAP to navigate to OwnProfile — "
"it will try to switch from arbitrary screens!"
)
@@ -136,9 +137,7 @@ def test_back_button_is_not_profile_tab():
if "profile" in desc.lower() and elem.attrib.get("selected", "false") == "true":
profile_tabs.append({"id": res_id, "desc": desc, "bounds": bounds})
assert len(back_buttons) > 0, (
"No Back button found in OTHER_PROFILE XML — fixture is incomplete"
)
assert len(back_buttons) > 0, "No Back button found in OTHER_PROFILE XML — fixture is incomplete"
# The back button bounds must be in the TOP of the screen (action bar)
# while profile tab must be at the BOTTOM (tab bar)

View File

@@ -0,0 +1,45 @@
"""
E2E: Ad Skip Workflow
=====================
Tests the feed loop logic to ensure it rapidly skips ads without triggering deep evaluation
or full interaction cycles, and delegates scrolling properly to the orchestrator via skip_type='fast'.
"""
import pytest
@pytest.mark.live_llm
def test_workflow_rapidly_skips_ads(make_real_device_with_image, e2e_configs, e2e_workflow_ctx):
"""
TDD Test: The full plugin registry must process an ad post and return a 'fast' skip type.
This guarantees that the orchestrator (bot_flow) will perform a low-latency skip.
"""
xml_path = "tests/fixtures/home_feed_with_ad.xml"
jpg_path = "tests/fixtures/home_feed_with_ad.jpg"
with open(xml_path, "r", encoding="utf-8") as f:
xml = f.read()
device = make_real_device_with_image(jpg_path, xml)
# e2e_workflow_ctx builds the real BehaviorContext + runs execute_all()
results, ctx = e2e_workflow_ctx(device)
# Find the result that triggered the skip
skip_triggered = False
skip_type = "normal"
for result in results:
if result.executed and result.should_skip:
skip_triggered = True
skip_type = getattr(result, "skip_type", "normal")
break
assert skip_triggered is True, "Pipeline failed to trigger a skip for an ad!"
assert skip_type in ["fast", "double_fast"], f"Expected 'fast' skip for an ad, got '{skip_type}'."
# We also assert that the orchestrator loop would catch this.
# The ad_guard itself must NOT have scrolled, it just signals.
# Our mocked device records swipes and clicks.
# AdGuard should NOT have generated a swipe in the plugin!
assert len(device.swipes) == 0, "AdGuard plugin scrolled the device! It should delegate to the orchestrator."

View File

@@ -42,13 +42,8 @@ def test_has_comments_zero_reel(darwin):
def test_has_comments_regex_cases(darwin):
# Specific edge cases string tests
assert darwin._has_comments('<node text="View all 12 comments" />') is True
assert darwin._has_comments('<node text="Alle 4 Kommentare ansehen" />') is True
assert darwin._has_comments('<node text="View 1 comment" />') is True
assert darwin._has_comments('<node text="1 Kommentar ansehen" />') is True
assert darwin._has_comments('<node content-desc="Photo by Alice, 0 comments" />') is False
assert darwin._has_comments('<node content-desc="Photo by Alice, 0 Kommentare" />') is False
assert darwin._has_comments('<node content-desc="Liked by john and others, 1,234 comments" />') is True
assert darwin._has_comments('<node content-desc="Liked by john and others, 12.345 Kommentare" />') is True
# Just the comment button shouldn't trigger as having comments
assert darwin._has_comments('<node content-desc="Comment" />') is False
assert darwin._has_comments('<node content-desc="Kommentieren" />') is False