fix(intent_resolver): add Nav Conflict Guard — Back button never valid for tab intents
PRODUCTION BUG 2026-04-30 11:50: VLM returned action_bar_button_back (desc='Back') for 'tap profile tab' intent on OTHER_PROFILE screen. This caused account_switcher to navigate AWAY from the profile instead of opening the account selector bottom sheet, halting the session. Root cause: _visual_discovery pre-filter did not exclude navigation buttons (Back, Close) when the intent was for a tab element. Fix: - Added filter_navigation_conflicts() method to IntentResolver - Guards tab intents by excluding nodes with 'back' in resource_id or content_desc == 'Back' - Does NOT apply for 'tap back button' or 'press back' intents TDD: - RED: test_intent_resolver_back_guard.py — 2 tests that call filter_navigation_conflicts on real OTHER_PROFILE XML - GREEN: filter_navigation_conflicts method + wired into _visual_discovery - E2E: test_workflow_account_switch.py, test_workflow_vlm_tab_confusion.py 31/31 tests passing.
This commit is contained in:
152
tests/unit/test_intent_resolver_back_guard.py
Normal file
152
tests/unit/test_intent_resolver_back_guard.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Unit Test: Intent Resolver Back-Button Guard
|
||||
==============================================
|
||||
RED phase: Tests that the intent resolver's candidate filtering
|
||||
MUST exclude Back/navigation buttons when searching for tab intents.
|
||||
|
||||
PRODUCTION BUG 2026-04-30:
|
||||
VLM returned Back button for "tap profile tab" → account switch failed.
|
||||
"""
|
||||
|
||||
from GramAddict.core.perception.spatial_parser import SpatialParser, SpatialNode
|
||||
|
||||
|
||||
def _flatten_tree(node: SpatialNode):
|
||||
"""Recursively flatten a SpatialNode tree into a list."""
|
||||
result = [node]
|
||||
for child in node.children:
|
||||
result.extend(_flatten_tree(child))
|
||||
return result
|
||||
|
||||
|
||||
def _parse_all_nodes(xml_str):
|
||||
"""Parse XML into flat list of SpatialNodes."""
|
||||
parser = SpatialParser()
|
||||
root = parser.parse(xml_str)
|
||||
if root is None:
|
||||
return []
|
||||
return _flatten_tree(root)
|
||||
|
||||
|
||||
OTHER_PROFILE_XML_MINIMAL = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy rotation="0">
|
||||
<node class="android.widget.FrameLayout" package="com.instagram.android"
|
||||
bounds="[0,0][1080,2400]">
|
||||
<node resource-id="com.instagram.android:id/action_bar_layout"
|
||||
class="android.widget.LinearLayout" bounds="[0,173][1080,320]">
|
||||
<node resource-id="com.instagram.android:id/action_bar_button_back"
|
||||
class="android.widget.ImageView"
|
||||
content-desc="Back" clickable="true"
|
||||
bounds="[0,173][127,320]" />
|
||||
<node resource-id="com.instagram.android:id/action_bar_title"
|
||||
class="android.widget.TextView" text="joannehollings"
|
||||
content-desc="joannehollings"
|
||||
bounds="[169,173][569,320]" />
|
||||
</node>
|
||||
<node resource-id="com.instagram.android:id/profile_header_full_name_above_vanity"
|
||||
class="android.widget.TextView" text="Joanne Hollings"
|
||||
bounds="[32,580][400,630]" />
|
||||
<node resource-id="com.instagram.android:id/tab_bar"
|
||||
class="android.widget.LinearLayout" bounds="[0,2300][1080,2400]">
|
||||
<node resource-id="com.instagram.android:id/feed_tab"
|
||||
content-desc="Home" clickable="true" selected="false"
|
||||
bounds="[0,2300][216,2400]" />
|
||||
<node resource-id="com.instagram.android:id/search_tab"
|
||||
content-desc="Search and explore" clickable="true" selected="false"
|
||||
bounds="[216,2300][432,2400]" />
|
||||
<node resource-id="com.instagram.android:id/profile_tab"
|
||||
content-desc="Profile" clickable="true" selected="true"
|
||||
bounds="[864,2300][1080,2400]" />
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
|
||||
def test_back_button_excluded_for_tab_intents():
|
||||
"""
|
||||
RED: When resolving "tap profile tab", the _visual_discovery pre-filter
|
||||
must exclude any node with resource-id containing "back" or
|
||||
content-desc == "Back".
|
||||
|
||||
This is the structural guard that would have prevented the bug.
|
||||
"""
|
||||
nodes = _parse_all_nodes(OTHER_PROFILE_XML_MINIMAL)
|
||||
|
||||
intent = "tap profile tab"
|
||||
intent_lower = intent.lower()
|
||||
|
||||
# Replicate intent_resolver.py:252-260 pre-filter
|
||||
candidates = [
|
||||
n for n in nodes
|
||||
if 200 < n.area < 400000
|
||||
and "com.android.systemui" not in (n.resource_id or "")
|
||||
]
|
||||
|
||||
# Verify the Back button IS in candidates (precondition)
|
||||
back_present = any(
|
||||
"back" in (n.resource_id or "").lower()
|
||||
or (n.content_desc or "").lower() == "back"
|
||||
for n in candidates
|
||||
)
|
||||
assert back_present, (
|
||||
"Precondition failed: Back button not in candidates. "
|
||||
"Cannot test the filtering guard."
|
||||
)
|
||||
|
||||
# NOW apply the guard that MUST exist in production code.
|
||||
# Import and test the actual production filter.
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
|
||||
resolver = IntentResolver()
|
||||
filtered = resolver.filter_navigation_conflicts(candidates, intent)
|
||||
|
||||
# Assert Back button is EXCLUDED after guard
|
||||
back_in_result = [
|
||||
n for n in filtered
|
||||
if "back" in (n.resource_id or "").lower()
|
||||
or (n.content_desc or "").lower() == "back"
|
||||
]
|
||||
assert len(back_in_result) == 0, (
|
||||
f"Back button survived filtering for tab intent! "
|
||||
f"Nodes: {[(n.resource_id, n.content_desc) for n in back_in_result]}"
|
||||
)
|
||||
|
||||
# Assert profile tab IS still in the filtered results
|
||||
profile_tabs = [
|
||||
n for n in filtered
|
||||
if "profile_tab" in (n.resource_id or "")
|
||||
or "profile" in (n.content_desc or "").lower()
|
||||
]
|
||||
assert len(profile_tabs) > 0, (
|
||||
"Profile tab was also filtered out! Over-filtering."
|
||||
)
|
||||
|
||||
|
||||
def test_back_button_kept_for_non_tab_intents():
|
||||
"""
|
||||
The Back button guard must ONLY apply for tab intents.
|
||||
For "press back" or "tap back button", Back must be kept.
|
||||
"""
|
||||
nodes = _parse_all_nodes(OTHER_PROFILE_XML_MINIMAL)
|
||||
candidates = [
|
||||
n for n in nodes
|
||||
if 200 < n.area < 400000
|
||||
and "com.android.systemui" not in (n.resource_id or "")
|
||||
]
|
||||
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
|
||||
resolver = IntentResolver()
|
||||
|
||||
# For "tap back button", the Back button MUST survive
|
||||
filtered = resolver.filter_navigation_conflicts(candidates, "tap back button")
|
||||
back_nodes = [
|
||||
n for n in filtered
|
||||
if "back" in (n.resource_id or "").lower()
|
||||
or (n.content_desc or "").lower() == "back"
|
||||
]
|
||||
assert len(back_nodes) > 0, (
|
||||
"Back button was filtered out for 'tap back button' intent! "
|
||||
"The guard is too aggressive."
|
||||
)
|
||||
Reference in New Issue
Block a user