feat(navigation): harden obstacle guard and intent resolver against keyboard hallucinations
This commit is contained in:
@@ -32,26 +32,31 @@ class TestContextGateCategoricalBans:
|
||||
"""P2-2: Every screen/action combination that is structurally impossible
|
||||
must be categorically banned — not reliant on marker presence."""
|
||||
|
||||
def test_like_blocked_on_story_view(self, gate):
|
||||
"""Cannot like on STORY_VIEW — there's no feed-style like button."""
|
||||
screen = _make_screen(ScreenType.STORY_VIEW)
|
||||
assert gate.is_allowed("like", screen) is False
|
||||
|
||||
def test_comment_blocked_on_story_view(self, gate):
|
||||
"""Cannot comment on STORY_VIEW — reply is a different action."""
|
||||
screen = _make_screen(ScreenType.STORY_VIEW)
|
||||
assert gate.is_allowed("comment", screen) is False
|
||||
|
||||
def test_follow_blocked_on_story_view(self, gate):
|
||||
"""Cannot follow from STORY_VIEW — no follow button exists."""
|
||||
screen = _make_screen(ScreenType.STORY_VIEW)
|
||||
assert gate.is_allowed("follow", screen) is False
|
||||
|
||||
def test_save_blocked_on_story_view(self, gate):
|
||||
"""Cannot save on STORY_VIEW."""
|
||||
"""Cannot save on STORY_VIEW — no bookmark button exists."""
|
||||
screen = _make_screen(ScreenType.STORY_VIEW)
|
||||
assert gate.is_allowed("save", screen) is False
|
||||
|
||||
def test_repost_blocked_on_story_view(self, gate):
|
||||
"""Cannot repost on STORY_VIEW — only share button, no repost."""
|
||||
screen = _make_screen(ScreenType.STORY_VIEW)
|
||||
assert gate.is_allowed("repost", screen) is False
|
||||
|
||||
def test_like_allowed_on_story_view(self, gate):
|
||||
"""Stories have toolbar_like_button — like IS valid."""
|
||||
screen = _make_screen(ScreenType.STORY_VIEW)
|
||||
assert gate.is_allowed("like", screen) is True
|
||||
|
||||
def test_comment_allowed_on_story_view(self, gate):
|
||||
"""Stories have reel_viewer_comments_button — comment/reply IS valid."""
|
||||
screen = _make_screen(ScreenType.STORY_VIEW)
|
||||
assert gate.is_allowed("comment", screen) is True
|
||||
|
||||
def test_follow_allowed_on_story_view(self, gate):
|
||||
"""Stories have reel_header_unconnected_follow_button_stub."""
|
||||
screen = _make_screen(ScreenType.STORY_VIEW)
|
||||
assert gate.is_allowed("follow", screen) is True
|
||||
|
||||
def test_like_blocked_on_follow_list(self, gate):
|
||||
"""Cannot like on FOLLOW_LIST — it's a list of users, not posts."""
|
||||
screen = _make_screen(ScreenType.FOLLOW_LIST)
|
||||
|
||||
49
tests/tdd/test_intent_resolver_guards.py
Normal file
49
tests/tdd/test_intent_resolver_guards.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
def test_reply_guard_filters_edittext(monkeypatch):
|
||||
"""
|
||||
TDD: Prove that the Reply Guard filters out EditText classes
|
||||
when the intent does not imply typing/messaging.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
|
||||
# Create mock candidates
|
||||
node_normal = SpatialNode(
|
||||
bounds=(0, 0, 100, 100),
|
||||
resource_id="com.instagram.android:id/button_next",
|
||||
class_name="android.widget.Button",
|
||||
text="Next",
|
||||
)
|
||||
|
||||
node_edittext = SpatialNode(
|
||||
bounds=(0, 100, 100, 200),
|
||||
resource_id="com.instagram.android:id/comment_composer",
|
||||
class_name="android.widget.EditText",
|
||||
text="Add a comment...",
|
||||
)
|
||||
|
||||
candidates = [node_normal, node_edittext]
|
||||
|
||||
# We will capture the candidates passed to _annotate_screenshot_with_candidates
|
||||
captured_candidates = []
|
||||
|
||||
def fake_annotate(device, cands):
|
||||
captured_candidates.extend(cands)
|
||||
# Return a dummy annotation output
|
||||
return (None, {0: node_normal})
|
||||
|
||||
monkeypatch.setattr(resolver, "_annotate_screenshot_with_candidates", fake_annotate)
|
||||
|
||||
class DummyDevice:
|
||||
pass
|
||||
|
||||
dummy_device = DummyDevice()
|
||||
|
||||
# Attempt to resolve an intent that shouldn't match EditText
|
||||
resolver._visual_discovery("tap the next button", candidates, dummy_device, screen_height=1000)
|
||||
|
||||
assert len(captured_candidates) == 1
|
||||
assert captured_candidates[0].class_name == "android.widget.Button"
|
||||
assert node_edittext not in captured_candidates
|
||||
@@ -35,14 +35,19 @@ class TestMemoryHygiene:
|
||||
|
||||
def test_low_confidence_entries_are_pruned(self):
|
||||
"""Low-confidence (stale/poisoned) entries must be pruned during hygiene."""
|
||||
import uuid
|
||||
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
db = ScreenMemoryDB()
|
||||
if not db.is_connected:
|
||||
pytest.skip("Qdrant not available")
|
||||
|
||||
# Use a unique signature to prevent Qdrant state pollution from other tests
|
||||
unique_sig = f"sig_low_conf_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Store a low-confidence entry (representing a bad VLM guess)
|
||||
db.store_screen("sig_low_conf", "MODAL", confidence=0.2)
|
||||
db.store_screen(unique_sig, "MODAL", confidence=0.2)
|
||||
|
||||
# Run hygiene — this SHOULD delete the low-confidence entry
|
||||
from GramAddict.core.qdrant_memory import memory_hygiene
|
||||
@@ -50,7 +55,7 @@ class TestMemoryHygiene:
|
||||
memory_hygiene()
|
||||
|
||||
# Verify it was pruned
|
||||
result = db.get_screen_type("sig_low_conf", similarity_threshold=0.90)
|
||||
result = db.get_screen_type(unique_sig, similarity_threshold=0.99)
|
||||
assert result is None, "Low-confidence poisoned entry survived hygiene!"
|
||||
|
||||
def test_memory_hygiene_function_exists(self):
|
||||
|
||||
70
tests/tdd/test_obstacle_keyboard.py
Normal file
70
tests/tdd/test_obstacle_keyboard.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from GramAddict.core.behaviors import BehaviorContext
|
||||
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
|
||||
|
||||
class DummyDeviceV2:
|
||||
def __init__(self):
|
||||
self.info = {"screenOn": True}
|
||||
|
||||
|
||||
class DummyDevice:
|
||||
def __init__(self):
|
||||
self.deviceV2 = DummyDeviceV2()
|
||||
self.app_id = "com.instagram.android"
|
||||
self.pressed = []
|
||||
|
||||
def press(self, key):
|
||||
self.pressed.append(key)
|
||||
|
||||
|
||||
def test_sae_detects_obstacle_keyboard():
|
||||
"""
|
||||
TDD: Prove that the SAE detects the on-screen keyboard as an obstacle.
|
||||
"""
|
||||
real_device = DummyDevice()
|
||||
sae = SituationalAwarenessEngine.get_instance(real_device)
|
||||
|
||||
xml_with_keyboard = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" package="com.instagram.android" resource-id="" text="" content-desc="" />
|
||||
<node index="1" package="com.google.android.inputmethod.latin" resource-id="com.google.android.inputmethod.latin:id/keyboard_view" text="" content-desc="Keyboard" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
situation = sae.perceive(xml_with_keyboard)
|
||||
assert situation == SituationType.OBSTACLE_KEYBOARD
|
||||
|
||||
|
||||
def test_obstacle_guard_dismisses_keyboard():
|
||||
"""
|
||||
TDD: Prove that ObstacleGuardPlugin handles OBSTACLE_KEYBOARD by pressing BACK.
|
||||
"""
|
||||
real_device = DummyDevice()
|
||||
|
||||
xml_with_keyboard = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" package="com.instagram.android" resource-id="" text="" content-desc="" />
|
||||
<node index="1" package="com.google.android.inputmethod.latin" resource-id="com.google.android.inputmethod.latin:id/keyboard_view" text="" content-desc="Keyboard" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
class DummySessionState:
|
||||
job_target = "test"
|
||||
|
||||
ctx = BehaviorContext(
|
||||
device=real_device,
|
||||
session_state=DummySessionState(),
|
||||
shared_state={},
|
||||
context_xml=xml_with_keyboard,
|
||||
configs=None,
|
||||
cognitive_stack=None,
|
||||
)
|
||||
|
||||
plugin = ObstacleGuardPlugin()
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
# Assert it recognized and handled the keyboard
|
||||
assert "back" in real_device.pressed
|
||||
assert result.executed is True
|
||||
assert result.should_skip is True
|
||||
Reference in New Issue
Block a user