test: expand LLM perception suite to 80 tests — 3 new contracts
CONTRACT 6: extract_json() — LLM Response Sanitizer (9 tests)
Previously COMPLETELY untested. Every single LLM response in the entire
system passes through this function. Tests:
- Clean JSON, markdown fences (json+plain), thinking block purge
- Text prefix before JSON, truncated JSON fuzzy recovery
- Garbage/empty/None resilience
CONTRACT 7: _visual_discovery Pre-Filters (8 tests)
The guards INSIDE _visual_discovery() that run BEFORE the VLM ever
sees the candidates. If they fail, the VLM gets garbage and hallucinates.
Tests:
- Area filter: tiny nodes (<200px²) and huge containers (>400000px²)
- SystemUI filter: Android system elements, notifications, battery
- Strict Button Guard: long text captions excluded for button intents
- Grid Item Guard: non-grid elements excluded, fallthrough on no match
- Author/Username Guard: nav tabs excluded for author intents
CONTRACT 8: verify_success Structural Delta (8 tests)
The post-click verification logic that decides if a click actually worked.
Tests:
- Toggle massive XML shift = navigation error (False)
- Toggle small diff = success (True)
- Toggle zero diff = inconclusive (None)
- Follow success with 'Requested' marker (private accounts)
- Follow success with German locale ('Abonniert')
- View post with clips_viewer marker (Reels)
- View post still on grid = inconclusive (None)
- Semantic gate blocks wrong toggle element (caption != like button)
This commit is contained in:
@@ -515,3 +515,368 @@ class TestActionMemoryLifecycle:
|
||||
"view a post", pre_click_xml="<node/>", post_click_xml=post_xml_success
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONTRACT 6: extract_json() — LLM Response Sanitizer
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# EVERY LLM response passes through this function. If it fails,
|
||||
# the entire Brain/GOAP/Perception pipeline gets garbage data.
|
||||
# Previously untested.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestExtractJson:
|
||||
"""Validates extract_json handles every LLM output format."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_response,expected_keys",
|
||||
[
|
||||
# Clean JSON
|
||||
('{"action": "scroll_down"}', ["action"]),
|
||||
# Markdown code fences (VLM wraps JSON in ```)
|
||||
('```json\n{"box": 3}\n```', ["box"]),
|
||||
('```\n{"box": 3}\n```', ["box"]),
|
||||
# Thinking blocks (reasoning models like qwen3.5)
|
||||
(
|
||||
'<think>I need to find the button...</think>{"box": 5}',
|
||||
["box"],
|
||||
),
|
||||
# Text prefix before JSON
|
||||
(
|
||||
'Based on my analysis, the answer is: {"should_like": true}',
|
||||
["should_like"],
|
||||
),
|
||||
# Truncated JSON — fuzzy recovery (only completed key-value pairs are salvaged)
|
||||
('{"action": "scroll_down", "target": "feed', ["action"]),
|
||||
# Pure garbage — must return None
|
||||
("I don't know what to do", None),
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
ids=[
|
||||
"clean_json",
|
||||
"markdown_json_fence",
|
||||
"markdown_fence",
|
||||
"thinking_block",
|
||||
"text_prefix",
|
||||
"truncated_recovery",
|
||||
"garbage",
|
||||
"empty",
|
||||
"none",
|
||||
],
|
||||
)
|
||||
def test_extract_json(self, raw_response, expected_keys):
|
||||
from GramAddict.core.llm_provider import extract_json
|
||||
|
||||
result = extract_json(raw_response)
|
||||
|
||||
if expected_keys is None:
|
||||
assert result is None, f"Expected None but got: {result}"
|
||||
else:
|
||||
assert result is not None, f"Expected JSON with keys {expected_keys} but got None"
|
||||
import json as json_mod
|
||||
|
||||
parsed = json_mod.loads(result)
|
||||
for key in expected_keys:
|
||||
assert key in parsed, f"Expected key '{key}' in {parsed}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONTRACT 7: _visual_discovery Pre-Filters
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# These are the guards INSIDE _visual_discovery() that run BEFORE the
|
||||
# VLM ever sees the candidates. If they fail, the VLM gets garbage
|
||||
# candidates and hallucinates. Previously completely untested because
|
||||
# they require a device object.
|
||||
#
|
||||
# We test them in ISOLATION by extracting the filter logic.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestVisualDiscoveryPreFilters:
|
||||
"""Tests the pre-filtering logic that runs before the VLM sees candidates."""
|
||||
|
||||
# ── Area Filter ──
|
||||
|
||||
def test_area_filter_excludes_tiny_nodes(self):
|
||||
"""Nodes with area <= 200 must be filtered (invisible dots)."""
|
||||
tiny = _make_node(text="dot", bounds=(0, 0, 10, 10)) # area = 100
|
||||
normal = _make_node(text="button", bounds=(0, 0, 50, 50)) # area = 2500
|
||||
candidates = [tiny, normal]
|
||||
|
||||
filtered = [n for n in candidates if 200 < n.area < 400000]
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].text == "button"
|
||||
|
||||
def test_area_filter_excludes_huge_containers(self):
|
||||
"""Nodes with area >= 400000 must be filtered (full-screen containers)."""
|
||||
huge = _make_node(text="container", bounds=(0, 0, 1080, 2400)) # area = 2,592,000
|
||||
normal = _make_node(text="button", bounds=(0, 0, 50, 50)) # area = 2500
|
||||
candidates = [huge, normal]
|
||||
|
||||
filtered = [n for n in candidates if 200 < n.area < 400000]
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].text == "button"
|
||||
|
||||
# ── SystemUI Filter ──
|
||||
|
||||
def test_system_ui_excluded(self):
|
||||
"""Android system UI elements must never reach the VLM."""
|
||||
sys_node = _make_node(
|
||||
resource_id="com.android.systemui:id/notification_icon",
|
||||
content_desc="Battery 85 per cent",
|
||||
)
|
||||
app_node = _make_node(
|
||||
resource_id="com.instagram.android:id/like_button",
|
||||
content_desc="Like",
|
||||
)
|
||||
candidates = [sys_node, app_node]
|
||||
|
||||
filtered = [
|
||||
n
|
||||
for n in candidates
|
||||
if "com.android.systemui" not in (n.resource_id or "")
|
||||
and "notification:" not in (n.content_desc or "").lower()
|
||||
and "per cent" not in (n.content_desc or "").lower()
|
||||
]
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].content_desc == "Like"
|
||||
|
||||
# ── Strict Button Guard ──
|
||||
|
||||
def test_button_guard_filters_long_text_captions(self):
|
||||
"""
|
||||
When looking for a 'button', nodes with long text (captions,
|
||||
comments) must be excluded to prevent VLM confusion.
|
||||
"""
|
||||
caption = _make_node(
|
||||
text="This is a really long caption about my amazing vacation in Bali #travel #photography #blessed",
|
||||
resource_id="caption_text",
|
||||
)
|
||||
button = _make_node(
|
||||
text="Like",
|
||||
resource_id="like_button",
|
||||
)
|
||||
candidates = [caption, button]
|
||||
intent_lower = "like button"
|
||||
|
||||
# Replicate the exact guard from _visual_discovery
|
||||
if "button" in intent_lower:
|
||||
candidates = [n for n in candidates if len(n.text or "") < 40]
|
||||
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].text == "Like"
|
||||
|
||||
def test_button_guard_preserves_short_text_buttons(self):
|
||||
"""Short-text elements (actual buttons) must pass through."""
|
||||
candidates = [
|
||||
_make_node(text="Follow", resource_id="follow_btn"),
|
||||
_make_node(text="Following", resource_id="following_btn"),
|
||||
_make_node(text="Message", resource_id="msg_btn"),
|
||||
]
|
||||
intent_lower = "follow button"
|
||||
|
||||
if "button" in intent_lower:
|
||||
candidates = [n for n in candidates if len(n.text or "") < 40]
|
||||
|
||||
assert len(candidates) == 3
|
||||
|
||||
# ── Grid Item Guard ──
|
||||
|
||||
def test_grid_guard_filters_non_grid_elements(self):
|
||||
"""
|
||||
When looking for 'first post', the guard must filter to actual
|
||||
grid items (with 'row 1', 'photos by', 'reel by' in desc).
|
||||
"""
|
||||
search_tab = _make_node(content_desc="Search and explore", resource_id="search_tab")
|
||||
grid_item = _make_node(
|
||||
content_desc="6 photos by photographer_jane. Row 1, Column 1.",
|
||||
resource_id="grid_card",
|
||||
)
|
||||
reel_item = _make_node(
|
||||
content_desc="Reel by dancer_x. 1.2M views",
|
||||
resource_id="reel_card",
|
||||
)
|
||||
candidates = [search_tab, grid_item, reel_item]
|
||||
intent_lower = "first post"
|
||||
|
||||
if "first post" in intent_lower:
|
||||
grid_candidates = []
|
||||
for node in candidates:
|
||||
desc = (node.content_desc or "").lower()
|
||||
if "row 1" in desc or "column" in desc or "photos by" in desc or "reel by" in desc:
|
||||
grid_candidates.append(node)
|
||||
if grid_candidates:
|
||||
candidates = grid_candidates
|
||||
|
||||
assert len(candidates) == 2
|
||||
descs = [n.content_desc for n in candidates]
|
||||
assert "Search and explore" not in descs
|
||||
|
||||
def test_grid_guard_fallthrough_when_no_grid_items(self):
|
||||
"""If no grid items match, all candidates pass through (no crash)."""
|
||||
candidates = [
|
||||
_make_node(content_desc="Search"),
|
||||
_make_node(content_desc="Home"),
|
||||
]
|
||||
intent_lower = "first post"
|
||||
|
||||
if "first post" in intent_lower:
|
||||
grid_candidates = []
|
||||
for node in candidates:
|
||||
desc = (node.content_desc or "").lower()
|
||||
if "row 1" in desc or "column" in desc or "photos by" in desc or "reel by" in desc:
|
||||
grid_candidates.append(node)
|
||||
if grid_candidates:
|
||||
candidates = grid_candidates
|
||||
|
||||
# No grid items matched, so all candidates are kept
|
||||
assert len(candidates) == 2
|
||||
|
||||
# ── Author/Username Guard ──
|
||||
|
||||
def test_author_guard_filters_nav_tabs(self):
|
||||
"""
|
||||
When looking for 'post author username', navigation tabs
|
||||
(Profile, Home, Search, Reels) must be excluded.
|
||||
"""
|
||||
profile_tab = _make_node(
|
||||
resource_id="com.instagram.android:id/profile_tab",
|
||||
content_desc="Profile",
|
||||
)
|
||||
username = _make_node(
|
||||
resource_id="com.instagram.android:id/row_feed_photo_profile_name",
|
||||
text="photographer_jane",
|
||||
content_desc="",
|
||||
)
|
||||
candidates = [profile_tab, username]
|
||||
intent_lower = "post author username"
|
||||
|
||||
if "author" in intent_lower or "username" in intent_lower:
|
||||
filtered = []
|
||||
for node in candidates:
|
||||
res_id = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
if (
|
||||
"tab" in res_id
|
||||
or "navigation" in res_id
|
||||
or "tabbar" in res_id
|
||||
or desc in ["home", "search", "reels", "profile"]
|
||||
):
|
||||
continue
|
||||
filtered.append(node)
|
||||
candidates = filtered
|
||||
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].text == "photographer_jane"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONTRACT 8: verify_success Structural Delta Logic
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# After every click, verify_success() decides if the click worked.
|
||||
# If the delta logic is wrong, the bot either:
|
||||
# - Thinks failed clicks succeeded (ghost interactions)
|
||||
# - Thinks successful clicks failed (infinite retry loops)
|
||||
# Previously only tested for follow/view-post markers.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestVerifySuccessStructuralDelta:
|
||||
"""Tests the structural XML diff logic in verify_success."""
|
||||
|
||||
def _make_memory(self):
|
||||
fake_db = FakeUIMemoryDB()
|
||||
return ActionMemory(ui_memory=fake_db), fake_db
|
||||
|
||||
def test_toggle_massive_shift_is_navigation_error(self):
|
||||
"""
|
||||
If a 'like' click causes >1000 char XML diff, the bot
|
||||
accidentally navigated away. Must return False.
|
||||
"""
|
||||
memory, _ = self._make_memory()
|
||||
pre_xml = "<node>" + "x" * 500 + "</node>"
|
||||
post_xml = "<completely_different>" + "y" * 2000 + "</completely_different>"
|
||||
|
||||
result = memory.verify_success("like", pre_click_xml=pre_xml, post_click_xml=post_xml)
|
||||
assert result is False
|
||||
|
||||
def test_toggle_small_diff_is_success(self):
|
||||
"""A small structural change for a toggle intent = success."""
|
||||
memory, _ = self._make_memory()
|
||||
# Track a click so the semantic gate has context
|
||||
node = _make_node(content_desc="Like", resource_id="like_button")
|
||||
memory.track_click("like", node)
|
||||
|
||||
pre_xml = '<node text="Like" />'
|
||||
post_xml = '<node text="Liked" />' # Small change
|
||||
|
||||
result = memory.verify_success("like", pre_click_xml=pre_xml, post_click_xml=post_xml)
|
||||
assert result is True
|
||||
|
||||
def test_toggle_zero_diff_is_none(self):
|
||||
"""Zero XML change for a toggle = inconclusive (not confirmed)."""
|
||||
memory, _ = self._make_memory()
|
||||
same_xml = '<node text="Like" />'
|
||||
|
||||
result = memory.verify_success("like", pre_click_xml=same_xml, post_click_xml=same_xml)
|
||||
# Zero diff, no markers → None (inconclusive) not True
|
||||
assert result is None or result is False
|
||||
|
||||
def test_follow_success_with_requested_marker(self):
|
||||
"""
|
||||
Private accounts show 'Requested' instead of 'Following'.
|
||||
Must still count as success.
|
||||
"""
|
||||
memory, _ = self._make_memory()
|
||||
post_xml = '<node text="Requested" />'
|
||||
result = memory.verify_success("follow", pre_click_xml="<node/>", post_click_xml=post_xml)
|
||||
assert result is True
|
||||
|
||||
def test_follow_success_german_locale(self):
|
||||
"""German locale uses 'Abonniert' or 'Angefragt'."""
|
||||
memory, _ = self._make_memory()
|
||||
post_xml = '<node text="Abonniert" />'
|
||||
result = memory.verify_success("follow", pre_click_xml="<node/>", post_click_xml=post_xml)
|
||||
assert result is True
|
||||
|
||||
def test_view_post_clips_viewer_marker(self):
|
||||
"""Opening a Reel shows clips_viewer_view_pager — must be detected."""
|
||||
memory, _ = self._make_memory()
|
||||
post_xml = '<node resource-id="com.instagram.android:id/clips_viewer_view_pager" />'
|
||||
result = memory.verify_success("view a post", pre_click_xml="<node/>", post_click_xml=post_xml)
|
||||
assert result is True
|
||||
|
||||
def test_view_post_still_on_grid_is_inconclusive(self):
|
||||
"""
|
||||
If after 'view a post' the XML still shows explore_action_bar
|
||||
WITHOUT post detail markers, navigation failed.
|
||||
"""
|
||||
memory, _ = self._make_memory()
|
||||
post_xml = '<node resource-id="com.instagram.android:id/explore_action_bar" />'
|
||||
result = memory.verify_success("grid item", pre_click_xml="<node/>", post_click_xml=post_xml)
|
||||
assert result is None # Inconclusive
|
||||
|
||||
def test_semantic_gate_blocks_wrong_toggle_element(self):
|
||||
"""
|
||||
If a 'like' click was tracked on a caption (not a like button),
|
||||
verify_success must reject it via the semantic gate.
|
||||
"""
|
||||
memory, _ = self._make_memory()
|
||||
node = _make_node(
|
||||
text="Beautiful sunset photo!",
|
||||
resource_id="caption_text",
|
||||
content_desc="",
|
||||
)
|
||||
memory.track_click("like", node)
|
||||
|
||||
pre_xml = '<node text="Like" />'
|
||||
post_xml = '<node text="Liked" />'
|
||||
|
||||
result = memory.verify_success("like", pre_click_xml=pre_xml, post_click_xml=post_xml)
|
||||
assert result is False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user