fix(sae): stabilize navigation engine, fix container filtering, and negative reinforcement logic

This commit is contained in:
2026-04-25 13:09:12 +02:00
parent ad1af4edfe
commit 77e8251aa7
43 changed files with 4107 additions and 4075 deletions

View 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

View 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

View 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

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

View File

@@ -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"

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

View File

@@ -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"

View 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()

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

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

View File

@@ -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"

View File

@@ -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']}"