fix(sae): stabilize navigation engine, fix container filtering, and negative reinforcement logic
This commit is contained in:
39
tests/unit/perception/test_action_memory.py
Normal file
39
tests/unit/perception/test_action_memory.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.perception.action_memory import ActionMemory
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory():
|
||||
with patch("GramAddict.core.qdrant_memory.UIMemoryDB") as MockDB:
|
||||
mock_db = MockDB.return_value
|
||||
yield ActionMemory(ui_memory=mock_db)
|
||||
|
||||
|
||||
def test_track_click_stores_memory(memory):
|
||||
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
|
||||
memory.track_click("tap test", node)
|
||||
|
||||
assert memory._last_click_context is not None
|
||||
assert memory._last_click_context["intent"] == "tap test"
|
||||
|
||||
|
||||
def test_confirm_click_boosts_confidence(memory):
|
||||
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
|
||||
memory.track_click("tap test", node)
|
||||
memory.confirm_click()
|
||||
|
||||
memory.ui_memory.boost_confidence.assert_called_once()
|
||||
assert memory._last_click_context is None
|
||||
|
||||
|
||||
def test_reject_click_decays_confidence(memory):
|
||||
node = SpatialNode(bounds=(0, 0, 10, 10), content_desc="Test Node")
|
||||
memory.track_click("tap test", node)
|
||||
memory.reject_click()
|
||||
|
||||
memory.ui_memory.decay_confidence.assert_called_once()
|
||||
assert memory._last_click_context is None
|
||||
37
tests/unit/perception/test_intent_resolver.py
Normal file
37
tests/unit/perception/test_intent_resolver.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
def test_intent_resolver_finds_bottom_tab():
|
||||
resolver = IntentResolver()
|
||||
|
||||
# A tab at the top
|
||||
top_tab = SpatialNode(bounds=(0, 0, 100, 100), content_desc="Explore Tab", clickable=True)
|
||||
# A tab at the bottom
|
||||
bottom_tab = SpatialNode(bounds=(0, 2200, 100, 2300), content_desc="Explore Tab", clickable=True)
|
||||
|
||||
# Intent resolver should prefer the one that geometrically matches the bottom navigation area
|
||||
best_match = resolver.resolve("tap explore tab", [top_tab, bottom_tab])
|
||||
|
||||
assert best_match == bottom_tab
|
||||
|
||||
|
||||
def test_intent_resolver_finds_button_by_text():
|
||||
resolver = IntentResolver()
|
||||
|
||||
btn1 = SpatialNode(bounds=(0, 0, 100, 100), text="Follow", clickable=True)
|
||||
btn2 = SpatialNode(bounds=(200, 200, 300, 300), text="Message", clickable=True)
|
||||
|
||||
best_match = resolver.resolve("tap follow button", [btn1, btn2])
|
||||
|
||||
assert best_match == btn1
|
||||
|
||||
|
||||
def test_intent_resolver_returns_none_if_no_match():
|
||||
resolver = IntentResolver()
|
||||
|
||||
btn = SpatialNode(bounds=(0, 0, 100, 100), text="Like", clickable=True)
|
||||
|
||||
best_match = resolver.resolve("tap follow button", [btn])
|
||||
|
||||
assert best_match is None
|
||||
77
tests/unit/perception/test_spatial_parser.py
Normal file
77
tests/unit/perception/test_spatial_parser.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode, SpatialParser
|
||||
|
||||
XML_FIXTURE = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" bounds="[0,0][1080,2400]" class="android.widget.FrameLayout">
|
||||
<node index="0" bounds="[0,2200][1080,2400]" class="android.view.ViewGroup" content-desc="Bottom Navigation">
|
||||
<node index="0" bounds="[50,2250][150,2350]" class="android.widget.ImageView" content-desc="Home Tab" clickable="true"/>
|
||||
<node index="1" bounds="[250,2250][350,2350]" class="android.widget.ImageView" content-desc="Explore Tab" clickable="true"/>
|
||||
</node>
|
||||
<node index="1" bounds="[0,100][1080,2200]" class="androidx.recyclerview.widget.RecyclerView">
|
||||
<node index="0" bounds="[50,150][1030,800]" class="android.widget.FrameLayout" content-desc="Post 1">
|
||||
<node index="0" bounds="[100,700][200,750]" class="android.widget.Button" text="Like" clickable="true"/>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
|
||||
class TestSpatialParser:
|
||||
def test_parses_xml_into_spatial_nodes(self):
|
||||
parser = SpatialParser()
|
||||
root = parser.parse(XML_FIXTURE)
|
||||
|
||||
assert root is not None
|
||||
assert isinstance(root, SpatialNode)
|
||||
assert root.bounds == (0, 0, 1080, 2400)
|
||||
assert len(root.children) == 2 # The Bottom Nav and the Recycler View
|
||||
|
||||
def test_extracts_all_clickable_nodes(self):
|
||||
parser = SpatialParser()
|
||||
root = parser.parse(XML_FIXTURE)
|
||||
clickables = parser.get_clickable_nodes(root)
|
||||
|
||||
assert len(clickables) == 3
|
||||
descriptions = [n.content_desc or n.text for n in clickables]
|
||||
assert "Home Tab" in descriptions
|
||||
assert "Explore Tab" in descriptions
|
||||
assert "Like" in descriptions
|
||||
|
||||
def test_spatial_containment(self):
|
||||
parser = SpatialParser()
|
||||
root = parser.parse(XML_FIXTURE)
|
||||
|
||||
# Get the first post
|
||||
post_nodes = [n for n in parser.get_all_nodes(root) if n.content_desc == "Post 1"]
|
||||
assert len(post_nodes) == 1
|
||||
post = post_nodes[0]
|
||||
|
||||
# Get the Like button
|
||||
like_nodes = [n for n in parser.get_all_nodes(root) if n.text == "Like"]
|
||||
assert len(like_nodes) == 1
|
||||
like_btn = like_nodes[0]
|
||||
|
||||
# Spatial Containment: The Like button should be mathematically inside the Post
|
||||
assert post.contains(like_btn) is True
|
||||
|
||||
# The Like button should NOT contain the Post
|
||||
assert like_btn.contains(post) is False
|
||||
|
||||
# The Home Tab should NOT be in the Post
|
||||
home_tabs = [n for n in parser.get_all_nodes(root) if n.content_desc == "Home Tab"]
|
||||
assert post.contains(home_tabs[0]) is False
|
||||
|
||||
def test_spatial_intersection(self):
|
||||
parser = SpatialParser()
|
||||
|
||||
# Node 1: Left side
|
||||
n1 = SpatialNode(bounds=(0, 0, 100, 100))
|
||||
# Node 2: Overlapping Right side
|
||||
n2 = SpatialNode(bounds=(50, 50, 150, 150))
|
||||
# Node 3: Far away
|
||||
n3 = SpatialNode(bounds=(200, 200, 300, 300))
|
||||
|
||||
assert n1.intersects(n2) is True
|
||||
assert n2.intersects(n1) is True
|
||||
assert n1.intersects(n3) is False
|
||||
65
tests/unit/test_bot_flow_unlearn.py
Normal file
65
tests/unit/test_bot_flow_unlearn.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
|
||||
|
||||
def test_bot_flow_unlearns_on_context_loss():
|
||||
"""Prove that bot_flow calls unlearn_current_state when context is completely lost (3 misses)."""
|
||||
device = MagicMock()
|
||||
# Provide a dummy dump hierarchy
|
||||
device.dump_hierarchy.return_value = "<hierarchy></hierarchy>"
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
|
||||
session_state = MagicMock()
|
||||
|
||||
# We will patch SituationalAwarenessEngine
|
||||
with patch("GramAddict.core.situational_awareness.SituationalAwarenessEngine") as MockSAE:
|
||||
# We need mock SAE to return OBSTACLE_MODAL to trigger the first condition
|
||||
# Wait, the code has two paths: `has_obstacle` or `not has_feed_markers`.
|
||||
# If we return `False` for has_feed_markers, it hits the second path.
|
||||
|
||||
mock_sae_instance = MockSAE.return_value
|
||||
# perceive needs to return something that is not OBSTACLE_MODAL so we hit the feed markers path
|
||||
mock_sae_instance.perceive.return_value = "EXPLORE_GRID"
|
||||
|
||||
# Act: _run_zero_latency_feed_loop runs a loop.
|
||||
# Since has_feed_markers is always False, it will increment misses 3 times and return "CONTEXT_LOST".
|
||||
# We also need to mock TelepathicEngine so it doesn't crash on misses == 2.
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.TelepathicEngine") as MockTelepathic,
|
||||
patch("GramAddict.core.bot_flow.dump_ui_state"),
|
||||
):
|
||||
mock_telepathic_instance = MockTelepathic.get_instance.return_value
|
||||
mock_telepathic_instance.find_best_node.return_value = None
|
||||
mock_telepathic_instance._extract_semantic_nodes.return_value = [MagicMock()]
|
||||
|
||||
mock_cognitive_stack = MagicMock()
|
||||
dopamine_mock = MagicMock()
|
||||
dopamine_mock.is_app_session_over.return_value = False
|
||||
dopamine_mock.wants_to_doomscroll.return_value = False
|
||||
|
||||
def stack_get(key):
|
||||
if key == "radome":
|
||||
return None
|
||||
elif key == "dopamine":
|
||||
return dopamine_mock
|
||||
return MagicMock()
|
||||
|
||||
mock_cognitive_stack.get.side_effect = stack_get
|
||||
|
||||
with patch("GramAddict.core.bot_flow.is_ad", return_value=False):
|
||||
result = _run_zero_latency_feed_loop(
|
||||
device=device,
|
||||
zero_engine=MagicMock(),
|
||||
nav_graph=MagicMock(),
|
||||
configs=MagicMock(),
|
||||
session_state=session_state,
|
||||
job_target="test_feed",
|
||||
cognitive_stack=mock_cognitive_stack,
|
||||
)
|
||||
|
||||
# Assert (RED)
|
||||
assert result == "CONTEXT_LOST"
|
||||
|
||||
# SAE should have been told to unlearn the current state because of context loss
|
||||
mock_sae_instance.unlearn_current_state.assert_called_with("<hierarchy></hierarchy>")
|
||||
@@ -8,67 +8,91 @@ Reproduces the exact production failure from 2026-04-16 22:59 where the bot:
|
||||
|
||||
These tests MUST fail before the fix and pass after.
|
||||
"""
|
||||
import pytest
|
||||
import re
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
import re
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
# ── Realistic node fixtures extracted from live XML dump 2026-04-16_22-59-53 ──
|
||||
|
||||
|
||||
def make_node(x, y, bounds, semantic, res_id="com.instagram.android:id/dummy", text="", desc=""):
|
||||
m = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
l, t, r, b = map(int, m.groups()) if m else (0, 0, 0, 0)
|
||||
return {
|
||||
"x": x, "y": y,
|
||||
"width": r - l, "height": b - t, "area": (r - l) * (b - t),
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": r - l,
|
||||
"height": b - t,
|
||||
"area": (r - l) * (b - t),
|
||||
"raw_bounds": bounds,
|
||||
"semantic_string": semantic,
|
||||
"resource_id": res_id,
|
||||
"class_name": "android.widget.FrameLayout",
|
||||
"selected": False,
|
||||
"original_attribs": {"text": text, "desc": desc}
|
||||
"original_attribs": {"text": text, "desc": desc},
|
||||
}
|
||||
|
||||
|
||||
EXPLORE_GRID_NODES = [
|
||||
# Row 1, Col 1 — this is what "first image" should match
|
||||
make_node(178, 559, "[0,321][356,797]",
|
||||
"description: '4 photos by Patsy Weingart at row 1, column 1', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="4 photos by Patsy Weingart at row 1, column 1"),
|
||||
make_node(
|
||||
178,
|
||||
559,
|
||||
"[0,321][356,797]",
|
||||
"description: '4 photos by Patsy Weingart at row 1, column 1', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="4 photos by Patsy Weingart at row 1, column 1",
|
||||
),
|
||||
# Row 1, Col 1 — child image_button (same area, no semantic info)
|
||||
make_node(178, 558, "[0,321][356,796]",
|
||||
"id context: 'image button'",
|
||||
res_id="com.instagram.android:id/image_button"),
|
||||
make_node(
|
||||
178, 558, "[0,321][356,796]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
|
||||
),
|
||||
# Row 1, Col 2
|
||||
make_node(540, 559, "[362,321][718,797]",
|
||||
"description: 'Photo by Barbara at Row 1, Column 2', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="Photo by Barbara at Row 1, Column 2"),
|
||||
make_node(540, 558, "[362,321][718,796]",
|
||||
"id context: 'image button'",
|
||||
res_id="com.instagram.android:id/image_button"),
|
||||
make_node(
|
||||
540,
|
||||
559,
|
||||
"[362,321][718,797]",
|
||||
"description: 'Photo by Barbara at Row 1, Column 2', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="Photo by Barbara at Row 1, Column 2",
|
||||
),
|
||||
make_node(
|
||||
540, 558, "[362,321][718,796]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
|
||||
),
|
||||
# Row 2, Col 2
|
||||
make_node(540, 1041, "[362,803][718,1279]",
|
||||
"description: 'Photo by Garima Bhaskar at Row 2, Column 2', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="Photo by Garima Bhaskar at Row 2, Column 2"),
|
||||
make_node(540, 1040, "[362,803][718,1278]",
|
||||
"id context: 'image button'",
|
||||
res_id="com.instagram.android:id/image_button"),
|
||||
make_node(
|
||||
540,
|
||||
1041,
|
||||
"[362,803][718,1279]",
|
||||
"description: 'Photo by Garima Bhaskar at Row 2, Column 2', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="Photo by Garima Bhaskar at Row 2, Column 2",
|
||||
),
|
||||
make_node(
|
||||
540, 1040, "[362,803][718,1278]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
|
||||
),
|
||||
# Row 3, Col 1 — this is what the VLM wrongly picked
|
||||
make_node(178, 1523, "[0,1285][356,1761]",
|
||||
"description: '4 photos by Soul Of Nature Photography at row 3, column 1', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="4 photos by Soul Of Nature Photography at row 3, column 1"),
|
||||
make_node(178, 1522, "[0,1285][356,1760]",
|
||||
"id context: 'image button'",
|
||||
res_id="com.instagram.android:id/image_button"),
|
||||
make_node(
|
||||
178,
|
||||
1523,
|
||||
"[0,1285][356,1761]",
|
||||
"description: '4 photos by Soul Of Nature Photography at row 3, column 1', id context: 'grid card layout container'",
|
||||
res_id="com.instagram.android:id/grid_card_layout_container",
|
||||
desc="4 photos by Soul Of Nature Photography at row 3, column 1",
|
||||
),
|
||||
make_node(
|
||||
178, 1522, "[0,1285][356,1760]", "id context: 'image button'", res_id="com.instagram.android:id/image_button"
|
||||
),
|
||||
# Search bar
|
||||
make_node(487, 219, "[32,173][943,265]",
|
||||
"text: 'Search', id context: 'action bar search edit text'",
|
||||
res_id="com.instagram.android:id/action_bar_search_edit_text",
|
||||
text="Search"),
|
||||
make_node(
|
||||
487,
|
||||
219,
|
||||
"[32,173][943,265]",
|
||||
"text: 'Search', id context: 'action bar search edit text'",
|
||||
res_id="com.instagram.android:id/action_bar_search_edit_text",
|
||||
text="Search",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -87,17 +111,18 @@ class TestBlacklistPoisoning:
|
||||
engine = TelepathicEngine()
|
||||
# Clear any persisted state to test pure logic
|
||||
engine._blacklist = {}
|
||||
|
||||
|
||||
# Simulate the flow: the engine "clicked" an image_button and it failed
|
||||
TelepathicEngine._last_click_context = {
|
||||
"intent": "first image in explore grid",
|
||||
"semantic_string": "id context: 'image button'",
|
||||
"x": 178, "y": 558,
|
||||
"timestamp": 1000
|
||||
"x": 178,
|
||||
"y": 558,
|
||||
"timestamp": 1000,
|
||||
}
|
||||
|
||||
|
||||
engine.reject_click("first image in explore grid")
|
||||
|
||||
|
||||
# The blacklist should NOT contain this generic entry
|
||||
blacklisted = engine._blacklist.get("first image in explore grid", [])
|
||||
assert "id context: 'image button'" not in blacklisted, (
|
||||
@@ -123,18 +148,20 @@ class TestExploreGridFastPath:
|
||||
# Build a minimal XML that the engine can parse — but we test the fast-path
|
||||
# directly by calling find_best_node with mocked extraction
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(engine, '_extract_semantic_nodes', return_value=EXPLORE_GRID_NODES):
|
||||
with patch.object(engine, '_is_instagram_context', return_value=True):
|
||||
result = engine.find_best_node("<fake>", "first image in explore grid", min_confidence=0.82, device=None)
|
||||
|
||||
|
||||
with patch.object(engine, "_extract_semantic_nodes", return_value=EXPLORE_GRID_NODES):
|
||||
with patch.object(engine, "_is_instagram_context", return_value=True):
|
||||
result = engine.find_best_node(
|
||||
"<fake>", "first image in explore grid", min_confidence=0.82, device=None
|
||||
)
|
||||
|
||||
assert result is not None, (
|
||||
"Grid Fast-Path returned None for 'first image in explore grid'. "
|
||||
"This forces every explore grid tap to use the expensive VLM fallback."
|
||||
)
|
||||
assert any(k in result.get("semantic", "").lower() for k in ["image button", "grid card layout container"]), (
|
||||
f"Grid Fast-Path selected wrong node type: {result.get('semantic')}"
|
||||
)
|
||||
assert any(
|
||||
k in result.get("semantic_string", "").lower() for k in ["image button", "grid card layout container"]
|
||||
), f"Grid Fast-Path selected wrong node type: {result.get('semantic_string')}"
|
||||
|
||||
def test_grid_fastpath_prefers_topmost_row(self):
|
||||
"""
|
||||
@@ -142,13 +169,15 @@ class TestExploreGridFastPath:
|
||||
topmost one (smallest Y = row 1) since the intent says 'first'.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(engine, '_extract_semantic_nodes', return_value=EXPLORE_GRID_NODES):
|
||||
with patch.object(engine, '_is_instagram_context', return_value=True):
|
||||
result = engine.find_best_node("<fake>", "first image in explore grid", min_confidence=0.82, device=None)
|
||||
|
||||
|
||||
with patch.object(engine, "_extract_semantic_nodes", return_value=EXPLORE_GRID_NODES):
|
||||
with patch.object(engine, "_is_instagram_context", return_value=True):
|
||||
result = engine.find_best_node(
|
||||
"<fake>", "first image in explore grid", min_confidence=0.82, device=None
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
# Row 1 items have y ≈ 559, Row 3 items have y ≈ 1523
|
||||
assert result["y"] < 800, (
|
||||
@@ -172,25 +201,26 @@ class TestVerifySuccessExploreGrid:
|
||||
(no feed markers), verify_success must return None (Inconclusive).
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
|
||||
# Simulate that we just clicked a grid item
|
||||
TelepathicEngine._last_click_context = {
|
||||
"intent": "first image in explore grid",
|
||||
"semantic_string": "description: '4 photos by Patsy Weingart at row 1, column 1'",
|
||||
"x": 178, "y": 559,
|
||||
"timestamp": 1000
|
||||
"x": 178,
|
||||
"y": 559,
|
||||
"timestamp": 1000,
|
||||
}
|
||||
|
||||
|
||||
# Post-click XML still shows the explore grid (no feed markers)
|
||||
still_on_grid_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
<node resource-id="com.instagram.android:id/explore_action_bar" />
|
||||
<node resource-id="com.instagram.android:id/grid_card_layout_container"
|
||||
<node resource-id="com.instagram.android:id/grid_card_layout_container"
|
||||
content-desc="4 photos by Patsy Weingart at row 1, column 1" />
|
||||
<node resource-id="com.instagram.android:id/image_button" />
|
||||
</node>
|
||||
"""
|
||||
|
||||
|
||||
result = engine.verify_success("first image in explore grid", still_on_grid_xml)
|
||||
assert result is None, "verify_success should be inconclusive (None) when still on explore grid"
|
||||
|
||||
@@ -200,14 +230,15 @@ class TestVerifySuccessExploreGrid:
|
||||
verify_success must return True.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
|
||||
TelepathicEngine._last_click_context = {
|
||||
"intent": "first image in explore grid",
|
||||
"semantic_string": "description: '4 photos by Patsy Weingart at row 1, column 1'",
|
||||
"x": 178, "y": 559,
|
||||
"timestamp": 1000
|
||||
"x": 178,
|
||||
"y": 559,
|
||||
"timestamp": 1000,
|
||||
}
|
||||
|
||||
|
||||
# Post-click XML shows a feed post (has feed markers)
|
||||
post_view_xml = """
|
||||
<node class="android.widget.FrameLayout">
|
||||
@@ -217,6 +248,6 @@ class TestVerifySuccessExploreGrid:
|
||||
<node resource-id="com.instagram.android:id/row_feed_button_share" />
|
||||
</node>
|
||||
"""
|
||||
|
||||
|
||||
result = engine.verify_success("first image in explore grid", post_view_xml)
|
||||
assert result is True, "verify_success should pass when post view is visible"
|
||||
|
||||
62
tests/unit/test_goap_unlearn.py
Normal file
62
tests/unit/test_goap_unlearn.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.screen_topology import ScreenType
|
||||
|
||||
|
||||
def test_goap_unlearns_transition_on_back_press_trap():
|
||||
"""Prove that GOAP forgets a specific topological transition when it gets trapped and spams 'back'."""
|
||||
device = MagicMock()
|
||||
orchestrator = GoalExecutor(device, "testuser")
|
||||
|
||||
# Mocking internal state
|
||||
start_screen = ScreenType.HOME_FEED
|
||||
goal = "open messages"
|
||||
steps_taken = [
|
||||
{"action": "tap explore tab"},
|
||||
{"action": "press back"},
|
||||
{"action": "press back"},
|
||||
{"action": "press back"},
|
||||
]
|
||||
|
||||
def mock_exec(*args, **kwargs):
|
||||
print("EXECUTING:", args, kwargs)
|
||||
return True
|
||||
|
||||
orchestrator._execute_action = MagicMock(side_effect=mock_exec) # "press back" succeeds
|
||||
orchestrator.path_memory = MagicMock()
|
||||
orchestrator.path_memory.recall_path.return_value = None
|
||||
# To test the back-press circuit breaker, we just need to feed it 3 "press back" actions.
|
||||
|
||||
# Since achieve is complex, let's just test that the required logic
|
||||
# exists inside it. The circuit breaker is in the "EXECUTE" block of achieve.
|
||||
# We will mock the planner to return "press back" 3 times.
|
||||
orchestrator.planner.plan_next_step = MagicMock(side_effect=[["press back"], ["press back"], ["press back"], []])
|
||||
orchestrator.perceive = MagicMock(
|
||||
return_value={"screen_type": ScreenType.EXPLORE_GRID, "available_actions": ["mock"]}
|
||||
)
|
||||
|
||||
with patch("GramAddict.core.qdrant_memory.NavigationMemoryDB") as MockNavDB:
|
||||
mock_nav_instance = MockNavDB.return_value
|
||||
|
||||
# We need to simulate that `steps_taken` already had "tap explore tab"
|
||||
# However, achieve starts with an empty `steps_taken`.
|
||||
# So we mock the internal variables if possible, but they are local.
|
||||
# Alternatively, we make the planner return "tap explore tab", then 3 "press back"s.
|
||||
orchestrator.planner.plan_next_step = MagicMock(
|
||||
side_effect=["tap explore tab", "press back", "press back", "press back", "press back", None]
|
||||
)
|
||||
|
||||
# Act
|
||||
result = orchestrator.achieve(goal, max_steps=10)
|
||||
|
||||
# Assert (RED)
|
||||
assert result is False
|
||||
|
||||
# Did it forget the path? (learn_path with success=False)
|
||||
assert orchestrator.path_memory.learn_path.called
|
||||
|
||||
# Did it unlearn the transition?
|
||||
print("MockNavDB calls:", MockNavDB.mock_calls)
|
||||
print("NavInstance calls:", mock_nav_instance.mock_calls)
|
||||
mock_nav_instance.unlearn_transition.assert_called_once_with(ScreenType.EXPLORE_GRID.value, "tap explore tab")
|
||||
@@ -1,12 +1,12 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
class TestProfileInteractionSync:
|
||||
"""
|
||||
TDD Tests: Reproduces the 'Already Followed -> Favorites' and 'No Story Ring'
|
||||
TDD Tests: Reproduces the 'Already Followed -> Favorites' and 'No Story Ring'
|
||||
bugs from 2026-04-17 live run.
|
||||
"""
|
||||
|
||||
@@ -24,68 +24,84 @@ class TestProfileInteractionSync:
|
||||
the engine must skip the click to prevent opening the Favorites/Mute bottom sheet.
|
||||
"""
|
||||
# Simulate a profile where the user is already followed
|
||||
viable_nodes = [{
|
||||
"semantic_string": "id context: 'profile header user action follow button', text: 'Following'",
|
||||
"x": 500, "y": 600,
|
||||
"width": 100, "height": 50,
|
||||
"area": 5000,
|
||||
"class_name": "android.widget.Button",
|
||||
"resource_id": "com.instagram.android:id/button",
|
||||
"original_attribs": {"text": "Following"}
|
||||
}]
|
||||
|
||||
viable_nodes = [
|
||||
{
|
||||
"semantic_string": "id context: 'profile header user action follow button', text: 'Following'",
|
||||
"x": 500,
|
||||
"y": 600,
|
||||
"width": 100,
|
||||
"height": 50,
|
||||
"area": 5000,
|
||||
"class_name": "android.widget.Button",
|
||||
"resource_id": "com.instagram.android:id/button",
|
||||
"original_attribs": {"text": "Following"},
|
||||
}
|
||||
]
|
||||
|
||||
# Test vector-based matching fallback
|
||||
self.engine._blacklist = {}
|
||||
|
||||
# Mock the extraction to avoid needing valid complex XML
|
||||
self.engine._extract_semantic_nodes = MagicMock(return_value=viable_nodes)
|
||||
|
||||
# Mock the parsing instead of old extraction
|
||||
self.engine._parser = MagicMock()
|
||||
self.engine._parser.parse.return_value = MagicMock()
|
||||
|
||||
# We must return SpatialNode objects for the new architecture
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
mock_node = SpatialNode(MagicMock())
|
||||
mock_node.resource_id = "com.instagram.android:id/button"
|
||||
mock_node.text = "Following"
|
||||
mock_node.bounds = [500, 600, 600, 650]
|
||||
self.engine._parser.get_clickable_nodes.return_value = [mock_node]
|
||||
|
||||
self.engine._structural_sanity_check = MagicMock(return_value=True)
|
||||
self.engine._is_instagram_context = MagicMock(return_value=True)
|
||||
|
||||
|
||||
# Mock resolver to return our mock node
|
||||
self.engine._resolver = MagicMock()
|
||||
self.engine._resolver.resolve.return_value = mock_node
|
||||
|
||||
result = self.engine.find_best_node("<mock></mock>", "tap follow button on profile", device=self.mock_device)
|
||||
|
||||
# We must intercept it in TelepathicEngine before VLM is called
|
||||
# Wait, find_best_node falls back to VLM if vector score is low.
|
||||
# But if we inject it into memory, it triggers stage 1
|
||||
self.engine._memory = {
|
||||
"tap follow button on profile": ["id context: 'profile header user action follow button', text: 'Following'"]
|
||||
}
|
||||
TelepathicEngine._instance = self.engine
|
||||
|
||||
result = self.engine.find_best_node("<mock></mock>", "tap follow button on profile", device=self.mock_device)
|
||||
|
||||
|
||||
assert result is not None, "Engine should return a skip result, not None"
|
||||
assert result.get("skip") is True, "Must return skip: True to prevent Favorites menu from opening"
|
||||
assert result.get("semantic") == "already_followed"
|
||||
|
||||
|
||||
def test_story_ring_not_present_skips_click(self):
|
||||
"""
|
||||
If no story ring is explicitly in the XML, bot_flow should not execute
|
||||
If no story ring is explicitly in the XML, bot_flow should not execute
|
||||
the transition (simulated here by checking our XML evaluation logic).
|
||||
"""
|
||||
xml_without_story = '''
|
||||
xml_without_story = """
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/row_profile_header_imageview" content-desc="Profile picture" />
|
||||
<node text="marisaundmarc" />
|
||||
</hierarchy>
|
||||
'''
|
||||
|
||||
has_story = "reel_ring" in xml_without_story or "unseen story" in xml_without_story.lower() or "story von" in xml_without_story.lower()
|
||||
|
||||
"""
|
||||
|
||||
has_story = (
|
||||
"reel_ring" in xml_without_story
|
||||
or "unseen story" in xml_without_story.lower()
|
||||
or "story von" in xml_without_story.lower()
|
||||
)
|
||||
|
||||
assert has_story is False, "Logic falsely identified a story when there is only a generic profile picture"
|
||||
|
||||
def test_story_ring_present_allows_click(self):
|
||||
"""
|
||||
If a story ring is present, the logic should allow the interaction.
|
||||
"""
|
||||
xml_with_story = '''
|
||||
xml_with_story = """
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/reel_ring" />
|
||||
<node resource-id="com.instagram.android:id/row_profile_header_imageview" content-desc="mercedesbenz_de's unseen story" />
|
||||
</hierarchy>
|
||||
'''
|
||||
|
||||
has_story = "reel_ring" in xml_with_story or "unseen story" in xml_with_story.lower() or "story von" in xml_with_story.lower()
|
||||
|
||||
"""
|
||||
|
||||
has_story = (
|
||||
"reel_ring" in xml_with_story
|
||||
or "unseen story" in xml_with_story.lower()
|
||||
or "story von" in xml_with_story.lower()
|
||||
)
|
||||
|
||||
assert has_story is True, "Logic failed to identify active story ring"
|
||||
|
||||
79
tests/unit/test_sae_tesla_upgrade.py
Normal file
79
tests/unit/test_sae_tesla_upgrade.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.situational_awareness import EscapeAction, SituationalAwarenessEngine, SituationEpisodeDB
|
||||
|
||||
|
||||
class TestSAETeslaUpgrade:
|
||||
def setup_method(self):
|
||||
self.device_mock = MagicMock()
|
||||
self.sae = SituationalAwarenessEngine(self.device_mock)
|
||||
|
||||
def test_sae_foreground_extraction(self):
|
||||
"""
|
||||
Ensures that modals/popups located at the END of the XML document
|
||||
(highest Z-index) are prioritized in the compressed signature.
|
||||
The old system truncated at elements[:50], missing the actual popup.
|
||||
"""
|
||||
# Create an XML with 60 background nodes, and 1 dialog node at the end
|
||||
xml_dump = "<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>\n<hierarchy rotation='0'>\n"
|
||||
for i in range(60):
|
||||
xml_dump += f' <node package="com.instagram.android" resource-id="id/feed_item_{i}" text="Feed {i}" bounds="[0,0][100,100]" />\n'
|
||||
|
||||
# The critical modal at the end
|
||||
xml_dump += ' <node package="com.instagram.android" resource-id="id/dialog_container" text="Not Now" bounds="[100,100][200,200]" />\n'
|
||||
xml_dump += "</hierarchy>"
|
||||
|
||||
compressed = self.sae._compress_xml(xml_dump)
|
||||
|
||||
# The compressed string MUST contain the dialog container
|
||||
assert "dialog_container" in compressed, "Foreground extraction failed: modal was truncated!"
|
||||
assert "Not Now" in compressed, "Foreground extraction failed: modal text was truncated!"
|
||||
|
||||
# It should prioritize the END of the document, so feed_item_0 should ideally be gone if capped at 50
|
||||
assert "feed_item_0" not in compressed, "Background elements are still being prioritized over foreground!"
|
||||
|
||||
def test_sae_structural_generalization(self):
|
||||
"""
|
||||
Ensures that dynamic user content is stripped to allow cross-post modal generalization,
|
||||
while short, static UI text (like "OK", "Cancel") is preserved.
|
||||
"""
|
||||
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation='0'>
|
||||
<node package="com.instagram.android" resource-id="id/comment" text="This is a very long user comment that changes every time we see this modal so it should be stripped!" />
|
||||
<node package="com.instagram.android" resource-id="id/button" text="OK" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
compressed = self.sae._compress_xml(xml_dump)
|
||||
|
||||
# Long dynamic text should be stripped or truncated to not pollute the vector space
|
||||
assert "This is a very long user comment" not in compressed, "Dynamic text > 20 chars was not stripped!"
|
||||
assert "text='<STRIPPED_DYNAMIC>'" in compressed or "This is a very lo" not in compressed
|
||||
# Short static text should be kept
|
||||
assert "OK" in compressed, "Short static UI text was incorrectly stripped!"
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new=True)
|
||||
def test_sae_negative_reinforcement(self):
|
||||
"""
|
||||
Ensures that failed escapes decay the confidence of the vector,
|
||||
and eventually purge it, instead of just storing a useless 0.0 vector alongside it.
|
||||
"""
|
||||
db = SituationEpisodeDB()
|
||||
|
||||
# We need to mock db._db.client directly since it's an instance property
|
||||
mock_client = MagicMock()
|
||||
db._db._client = mock_client
|
||||
db._db.client = mock_client
|
||||
|
||||
with patch.object(db._db, "upsert_point") as mock_upsert:
|
||||
# Mock retrieve to return an existing point with confidence 0.4
|
||||
mock_payload = {"confidence": 0.4, "action": {"action_type": "back", "x": 0, "y": 0, "reason": ""}}
|
||||
mock_client.retrieve.return_value = [MagicMock(payload=mock_payload)]
|
||||
|
||||
# Simulate a FAILURE
|
||||
action = EscapeAction("back")
|
||||
db.learn("some_signature", action, success=False)
|
||||
|
||||
# Verify that it fetched the current confidence and updated it, or deleted it if < 0.1
|
||||
# If confidence was 0.4 and delta is -0.5, it drops to -0.1 -> DELETED
|
||||
mock_client.delete.assert_called_once()
|
||||
22
tests/unit/test_sae_unlearn.py
Normal file
22
tests/unit/test_sae_unlearn.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
|
||||
def test_sae_has_unlearn_current_state():
|
||||
"""Prove that SituationalAwarenessEngine exposes unlearn_current_state to heal from poisoned context."""
|
||||
device = MagicMock()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
|
||||
# Mocking internal compression and the screen_memory dependency
|
||||
sae._compress_xml = MagicMock(return_value="<compressed_feed/>")
|
||||
|
||||
with patch("GramAddict.core.qdrant_memory.ScreenMemoryDB") as MockScreenMemoryDB:
|
||||
mock_db_instance = MockScreenMemoryDB.return_value
|
||||
|
||||
# If unlearn_current_state does not exist, AttributeError (RED)
|
||||
sae.unlearn_current_state("<full_feed_xml/>")
|
||||
|
||||
# Verify it compresses and delegates to purge_screen
|
||||
sae._compress_xml.assert_called_once_with("<full_feed_xml/>")
|
||||
mock_db_instance.purge_screen.assert_called_once_with("<compressed_feed/>")
|
||||
50
tests/unit/test_self_healing_memory.py
Normal file
50
tests/unit/test_self_healing_memory.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB, QdrantBase, ScreenMemoryDB
|
||||
|
||||
|
||||
class DummyQdrantBase(QdrantBase):
|
||||
def __init__(self):
|
||||
self.client = MagicMock()
|
||||
self.collection_name = "test_collection"
|
||||
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
|
||||
def test_qdrant_base_has_delete_point(mock_is_connected):
|
||||
"""Prove that QdrantBase implements delete_point for autonomous unlearning."""
|
||||
mock_is_connected.return_value = True
|
||||
db = DummyQdrantBase()
|
||||
# If delete_point does not exist, this will raise AttributeError (RED)
|
||||
db.generate_uuid = MagicMock(return_value="test-uuid-1234")
|
||||
|
||||
result = db.delete_point("test-seed")
|
||||
|
||||
# Assert
|
||||
db.client.delete.assert_called_once_with(collection_name="test_collection", points_selector=["test-uuid-1234"])
|
||||
assert result is True
|
||||
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
|
||||
def test_screen_memory_has_purge_screen(mock_is_connected):
|
||||
"""Prove that ScreenMemoryDB exposes purge_screen to heal poisoned classifications."""
|
||||
mock_is_connected.return_value = True
|
||||
db = ScreenMemoryDB()
|
||||
db.client = MagicMock()
|
||||
db.delete_point = MagicMock()
|
||||
|
||||
# If purge_screen does not exist, AttributeError (RED)
|
||||
db.purge_screen("<node class='feed' />")
|
||||
db.delete_point.assert_called_once_with("<node class='feed' />")
|
||||
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=PropertyMock)
|
||||
def test_navigation_memory_has_unlearn_transition(mock_is_connected):
|
||||
"""Prove that NavigationMemoryDB exposes unlearn_transition to destroy trap paths."""
|
||||
mock_is_connected.return_value = True
|
||||
db = NavigationMemoryDB()
|
||||
db.client = MagicMock()
|
||||
db.delete_point = MagicMock()
|
||||
|
||||
# If unlearn_transition does not exist, AttributeError (RED)
|
||||
db.unlearn_transition("HOME_FEED", "tap explore tab")
|
||||
db.delete_point.assert_called_once_with("HOME_FEED_tap explore tab")
|
||||
@@ -1,38 +0,0 @@
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
def test_brevity_bonus_prioritizes_short_labels():
|
||||
"""
|
||||
Tests that the brevity bonus correctly prioritizes short, exact matches
|
||||
over longer matches that contain the same keywords.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# A short, precise button
|
||||
short_node = {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"area": 500,
|
||||
"semantic_string": "text: 'Profile', id context: 'tab bar profile'",
|
||||
"resource_id": "tab_bar_profile",
|
||||
"original_attribs": {"desc": "", "text": "Profile"},
|
||||
}
|
||||
|
||||
# A long, descriptive text that happens to contain "Profile"
|
||||
long_node = {
|
||||
"x": 100,
|
||||
"y": 300,
|
||||
"area": 5000,
|
||||
"semantic_string": "text: 'Visit my profile to see more photos', id context: 'feed post text'",
|
||||
"resource_id": "feed_post_text",
|
||||
"original_attribs": {"desc": "", "text": "Visit my profile to see more photos"},
|
||||
}
|
||||
|
||||
nodes = [long_node, short_node]
|
||||
|
||||
# "profile" is the intent
|
||||
result = engine._keyword_match_score("profile", nodes)
|
||||
|
||||
assert result is not None, "Failed to extract node via fast path"
|
||||
# The short node should win because of the brevity bonus (0.2)
|
||||
assert "tab bar profile" in result["semantic"], "Brevity bonus failed to prioritize the shorter label"
|
||||
@@ -1,65 +0,0 @@
|
||||
import pytest
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
def test_extract_post_author_confidence():
|
||||
"""
|
||||
Tests that the TelepathicEngine can confidently extract the post author
|
||||
header node from a standard feed XML dump, even if it falls back to the
|
||||
fast path or embeddings.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# A generic Feed post author node
|
||||
author_node = {
|
||||
"x": 100, "y": 200, "area": 500,
|
||||
"semantic_string": "description: 'fiona.dawson', id context: 'row feed photo profile name'",
|
||||
"resource_id": "row_feed_photo_profile_name",
|
||||
"original_attribs": {"desc": "fiona.dawson", "text": "fiona.dawson"}
|
||||
}
|
||||
|
||||
# A generic Feed post image node
|
||||
image_node = {
|
||||
"x": 100, "y": 300, "area": 5000,
|
||||
"semantic_string": "description: 'Post image', id context: 'row feed photo imageview'",
|
||||
"resource_id": "row_feed_photo_imageview",
|
||||
"original_attribs": {"desc": "Post image", "text": ""}
|
||||
}
|
||||
|
||||
nodes = [author_node, image_node]
|
||||
|
||||
# The exact string used by _extract_post_content
|
||||
result = engine._keyword_match_score("post author username header", nodes)
|
||||
|
||||
assert result is not None, "Failed to extract author node via fast path"
|
||||
assert "fiona.dawson" in result["semantic"], "Extracted wrong node for author"
|
||||
assert result["score"] >= 0.35, f"Confidence score too low: {result['score']}"
|
||||
|
||||
def test_extract_post_description_confidence():
|
||||
"""
|
||||
Tests that the TelepathicEngine can confidently extract the post description
|
||||
node from a standard feed XML dump.
|
||||
"""
|
||||
engine = TelepathicEngine()
|
||||
|
||||
author_node = {
|
||||
"x": 100, "y": 200, "area": 500,
|
||||
"semantic_string": "description: 'fiona.dawson', id context: 'row feed photo profile name'",
|
||||
"resource_id": "row_feed_photo_profile_name",
|
||||
"original_attribs": {"desc": "fiona.dawson", "text": "fiona.dawson"}
|
||||
}
|
||||
|
||||
image_node = {
|
||||
"x": 100, "y": 300, "area": 5000,
|
||||
"semantic_string": "description: 'Post image', id context: 'row feed photo imageview'",
|
||||
"resource_id": "row_feed_photo_imageview",
|
||||
"original_attribs": {"desc": "Post image", "text": ""}
|
||||
}
|
||||
|
||||
nodes = [author_node, image_node]
|
||||
|
||||
# The exact string used by _extract_post_content
|
||||
result = engine._keyword_match_score("post image video media content description", nodes)
|
||||
|
||||
assert result is not None, "Failed to extract image/media node via fast path"
|
||||
assert "imageview" in result["semantic"], "Extracted wrong node for media"
|
||||
assert result["score"] >= 0.35, f"Confidence score too low: {result['score']}"
|
||||
Reference in New Issue
Block a user