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:
2026-04-30 12:10:44 +02:00
parent 556bd181fa
commit 392abff313
4 changed files with 474 additions and 0 deletions

View 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."
)