fix(sae): purge all hardcoded fallback coords — autonomous structural dismiss
- Removed hardcoded (540,150) fallback in _plan_escape_via_llm - Added _find_structural_dismiss_target: scans raw XML for clickable dismiss/cancel/close buttons and extracts coords from bounds attrs - LLM repeat detection now falls back to structural scan (not magic numbers) - Temperature increases progressively when LLM is stubborn (0.1 → 0.7) - Failure history injected as CRITICAL block into LLM system prompt - Extended pre-commit test discovery to include tests/tdd/ - TDD: 7 new tests including meta-tests proving zero hardcoded coords - Full suite: 100/100 passed
This commit is contained in:
206
tests/tdd/test_situational_awareness.py
Normal file
206
tests/tdd/test_situational_awareness.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
TDD: Structural Dismiss Target Discovery
|
||||
|
||||
Proves that the SAE finds dismiss/close buttons by scanning
|
||||
the XML structurally — zero hardcoded coordinates.
|
||||
All coordinates come from bounds attributes in the XML itself.
|
||||
"""
|
||||
|
||||
from GramAddict.core.situational_awareness import (
|
||||
SituationalAwarenessEngine,
|
||||
)
|
||||
|
||||
|
||||
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 = []
|
||||
self.clicks = []
|
||||
|
||||
def press(self, key):
|
||||
self.pressed.append(key)
|
||||
|
||||
def click(self, x, y):
|
||||
self.clicks.append((x, y))
|
||||
|
||||
|
||||
# ── XML Fixtures ──
|
||||
|
||||
|
||||
BOTTOM_SHEET_WITH_CANCEL = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" package="com.instagram.android"
|
||||
resource-id="com.instagram.android:id/bottom_sheet_container"
|
||||
class="android.widget.FrameLayout" clickable="false"
|
||||
bounds="[0,800][1080,2424]">
|
||||
<node index="0" text="Mute" clickable="true" bounds="[100,900][980,1000]" />
|
||||
<node index="1" text="Restrict" clickable="true" bounds="[100,1010][980,1110]" />
|
||||
<node index="2" text="Block" clickable="true" bounds="[100,1120][980,1220]" />
|
||||
<node index="3" text="Cancel" clickable="true" bounds="[100,1400][980,1500]" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
BOTTOM_SHEET_WITH_CLOSE_DESC = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" package="com.instagram.android"
|
||||
resource-id="com.instagram.android:id/action_sheet_container"
|
||||
class="android.widget.FrameLayout" clickable="false"
|
||||
bounds="[0,600][1080,2424]">
|
||||
<node index="0" text="" content-desc="Close" clickable="true" bounds="[900,620][980,700]" />
|
||||
<node index="1" text="Share to..." clickable="true" bounds="[100,800][980,900]" />
|
||||
<node index="2" text="Copy Link" clickable="true" bounds="[100,910][980,1010]" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
BOTTOM_SHEET_NO_DISMISS = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" package="com.instagram.android"
|
||||
resource-id="com.instagram.android:id/bottom_sheet_container"
|
||||
class="android.widget.FrameLayout" clickable="false"
|
||||
bounds="[0,800][1080,2424]">
|
||||
<node index="0" text="Mute" clickable="true" bounds="[100,900][980,1000]" />
|
||||
<node index="1" text="Restrict" clickable="true" bounds="[100,1010][980,1110]" />
|
||||
<node index="2" text="Block" clickable="true" bounds="[100,1120][980,1220]" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
NOT_NOW_DIALOG = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" package="com.instagram.android"
|
||||
resource-id="com.instagram.android:id/survey_overlay_container"
|
||||
class="android.widget.FrameLayout" clickable="false"
|
||||
bounds="[0,0][1080,2424]">
|
||||
<node index="0" text="Turn on Notifications?" clickable="false" bounds="[100,800][980,900]" />
|
||||
<node index="1" text="Turn On" clickable="true" bounds="[100,950][980,1050]" />
|
||||
<node index="2" text="Not Now" clickable="true" bounds="[100,1060][980,1160]" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
GERMAN_DISMISS_DIALOG = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" package="com.instagram.android"
|
||||
resource-id="com.instagram.android:id/interstitial_container"
|
||||
class="android.widget.FrameLayout" clickable="false"
|
||||
bounds="[0,0][1080,2424]">
|
||||
<node index="0" text="Benachrichtigungen aktivieren?" clickable="false" bounds="[100,800][980,900]" />
|
||||
<node index="1" text="Aktivieren" clickable="true" bounds="[100,950][980,1050]" />
|
||||
<node index="2" text="Nicht jetzt" clickable="true" bounds="[100,1060][980,1160]" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
|
||||
# ── Tests ──
|
||||
|
||||
|
||||
class TestStructuralDismissTarget:
|
||||
"""Verify that _find_structural_dismiss_target discovers buttons from XML, not hardcoded values."""
|
||||
|
||||
def _get_sae(self):
|
||||
device = DummyDevice()
|
||||
return SituationalAwarenessEngine.get_instance(device)
|
||||
|
||||
def test_finds_cancel_button_from_xml(self):
|
||||
"""RED: structural scan must find 'Cancel' button with XML-derived coordinates."""
|
||||
sae = self._get_sae()
|
||||
result = sae._find_structural_dismiss_target(BOTTOM_SHEET_WITH_CANCEL)
|
||||
|
||||
assert result is not None, "Should have found the Cancel button"
|
||||
assert result.action_type == "click"
|
||||
# Coordinates must come from bounds="[100,1400][980,1500]" → center (540, 1450)
|
||||
assert result.x == 540
|
||||
assert result.y == 1450
|
||||
assert "cancel" in result.reason.lower()
|
||||
|
||||
def test_finds_close_via_content_desc(self):
|
||||
"""RED: structural scan must find 'Close' button via content-desc attribute."""
|
||||
sae = self._get_sae()
|
||||
result = sae._find_structural_dismiss_target(BOTTOM_SHEET_WITH_CLOSE_DESC)
|
||||
|
||||
assert result is not None, "Should have found the Close button via content-desc"
|
||||
assert result.action_type == "click"
|
||||
# bounds="[900,620][980,700]" → center (940, 660)
|
||||
assert result.x == 940
|
||||
assert result.y == 660
|
||||
assert "close" in result.reason.lower()
|
||||
|
||||
def test_returns_none_when_no_dismiss_exists(self):
|
||||
"""RED: if the XML has no dismiss-type button, return None (don't invent one)."""
|
||||
sae = self._get_sae()
|
||||
result = sae._find_structural_dismiss_target(BOTTOM_SHEET_NO_DISMISS)
|
||||
|
||||
assert result is None, "Must not invent dismiss targets when none exist in XML"
|
||||
|
||||
def test_finds_not_now_button(self):
|
||||
"""RED: structural scan must find 'Not Now' button."""
|
||||
sae = self._get_sae()
|
||||
result = sae._find_structural_dismiss_target(NOT_NOW_DIALOG)
|
||||
|
||||
assert result is not None, "Should have found 'Not Now' button"
|
||||
assert result.action_type == "click"
|
||||
# bounds="[100,1060][980,1160]" → center (540, 1110)
|
||||
assert result.x == 540
|
||||
assert result.y == 1110
|
||||
|
||||
def test_finds_german_nicht_jetzt(self):
|
||||
"""RED: structural scan must find German 'Nicht jetzt' button."""
|
||||
sae = self._get_sae()
|
||||
result = sae._find_structural_dismiss_target(GERMAN_DISMISS_DIALOG)
|
||||
|
||||
assert result is not None, "Should have found 'Nicht jetzt' button"
|
||||
assert result.action_type == "click"
|
||||
# bounds="[100,1060][980,1160]" → center (540, 1110)
|
||||
assert result.x == 540
|
||||
assert result.y == 1110
|
||||
|
||||
def test_no_hardcoded_coordinates_in_method(self):
|
||||
"""
|
||||
META-TEST: Prove that _find_structural_dismiss_target contains
|
||||
zero hardcoded pixel values. All coordinates must come from XML parsing.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(SituationalAwarenessEngine._find_structural_dismiss_target)
|
||||
|
||||
# Should NOT contain any hardcoded coordinate patterns like x=540, y=150, etc.
|
||||
# The only numbers allowed are in the regex patterns, not in EscapeAction constructors
|
||||
import re
|
||||
|
||||
# Find all EscapeAction instantiations with literal x= or y= values
|
||||
hardcoded_coords = re.findall(r"EscapeAction\([^)]*(?:x=\d+|y=\d+)", source)
|
||||
assert len(hardcoded_coords) == 0, (
|
||||
f"Found hardcoded coordinates in _find_structural_dismiss_target: {hardcoded_coords}. "
|
||||
"All coordinates must come from XML bounds parsing."
|
||||
)
|
||||
|
||||
|
||||
class TestLLMFallbackNoHardcoding:
|
||||
"""Verify that _plan_escape_via_llm does NOT use hardcoded fallback coordinates."""
|
||||
|
||||
def test_no_hardcoded_coordinates_in_llm_planner(self):
|
||||
"""
|
||||
META-TEST: Prove that _plan_escape_via_llm contains zero hardcoded
|
||||
pixel coordinates as fallback values. When the LLM repeats, it must
|
||||
delegate to _find_structural_dismiss_target (which reads from XML).
|
||||
"""
|
||||
import inspect
|
||||
import re
|
||||
|
||||
source = inspect.getsource(SituationalAwarenessEngine._plan_escape_via_llm)
|
||||
|
||||
# Find all EscapeAction("click", x=<number>, y=<number>) with hardcoded coords
|
||||
hardcoded = re.findall(r'EscapeAction\(\s*"click"\s*,\s*x=\d+\s*,\s*y=\d+', source)
|
||||
assert len(hardcoded) == 0, (
|
||||
f"Found hardcoded click coordinates in _plan_escape_via_llm: {hardcoded}. "
|
||||
"The bot must discover coordinates autonomously from the XML."
|
||||
)
|
||||
Reference in New Issue
Block a user