This commit is contained in:
2026-04-17 00:44:41 +02:00
parent fa1d01527d
commit 89f14463c5
42 changed files with 2452 additions and 314 deletions

View File

@@ -0,0 +1,32 @@
import pytest
from xml.etree import ElementTree as ET
from GramAddict.core.bot_flow import _detect_ad_structural
def generate_xml_with_node(res_id, text="", desc=""):
return f'''<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<hierarchy>
<node class="android.widget.FrameLayout">
<node resource-id="{res_id}" text="{text}" content-desc="{desc}" />
</node>
</hierarchy>'''
def test_detects_real_ad_label():
xml = generate_xml_with_node("com.instagram.android:id/secondary_label", text="Sponsored", desc="Sponsored")
assert _detect_ad_structural(xml) is True
xml = generate_xml_with_node("com.instagram.android:id/secondary_label", text="gesponsert", desc="gesponsert")
assert _detect_ad_structural(xml) is True
def test_ignores_false_positive_short_text():
# If a location or normal text happens to be short, e.g., "ad" for an audio name like "Adele"
# But wait, exact match of "Ad" is usually an Ad.
pass
def test_ignores_non_ad_reels_cta():
# Normal reels can have a CTA for "Use template" or "Use Audio".
# If they use clips_browser_cta, it might be a false positive.
xml = generate_xml_with_node("com.instagram.android:id/clips_browser_cta", text="Use template", desc="Use template")
# If _detect_ad_structural says True, it's a FALSE POSITIVE because it's just a template CTA!
# A real ad CTA usually says "Install Now", "Learn More", etc.
# Currently _detect_ad_structural returns True for ALL clips_browser_cta.
assert _detect_ad_structural(xml) is False, "False positive: 'Use template' is not a sponsored ad"

View File

@@ -0,0 +1,47 @@
import pytest
from unittest.mock import patch, MagicMock
from GramAddict.core.q_nav_graph import QNavGraph
def test_autonomous_retry_on_ambiguity_failure():
"""
Verifies that _execute_transition now uses an internal retry loop.
If the first attempt fails semantic verification (Ambiguity Guard),
it should press BACK, blacklist the node, and retry automatically.
"""
mock_device = MagicMock()
mock_device.deviceV2.dump_hierarchy.side_effect = [
"initial_ui", # Before click 1
"changed_ui_wrong", # After click 1 (wrong menu opened)
"initial_ui", # After pressing BACK (UI restored)
"changed_ui_correct" # After click 2 (correct view opened)
]
mock_engine = MagicMock()
# Mock find_best_node to return Node A then Node B
mock_engine.find_best_node.side_effect = [
{"x": 10, "y": 10, "semantic_string": "Wrong Menu", "source": "vlm"},
{"x": 20, "y": 20, "semantic_string": "Correct Grid", "source": "vlm"}
]
# Mock verify_success to fail first time, succeed second time
mock_engine.verify_success.side_effect = [False, True]
nav_graph = QNavGraph(mock_device)
with patch("time.sleep"), patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_engine): # disable actual sleeping in the test
result = nav_graph._execute_transition("tap_grid_first_post", mock_engine)
# The transition should ultimately succeed because attempt 2 passes
assert result is True, "Autonomous retry loop failed to return True."
# Verify that the engine blacklisted the first attempt
mock_engine.reject_click.assert_called_once()
# Verify that the engine confirmed the second attempt
mock_engine.confirm_click.assert_called_once()
# Verify BACK was pressed exactly once to clear the wrong menu
mock_device.deviceV2.press.assert_called_once_with("back")
# Verify two clicks were made
assert mock_device.click.call_count == 2

View File

@@ -0,0 +1,35 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.dopamine_engine import DopamineEngine
import GramAddict.core.bot_flow as bot_flow
def test_feed_switch_resets_boredom():
"""
Test driven development to prove that changing the feed does not immediately
terminate the session due to a stale boredom value.
This replicates the exact crash reported where empty Inbox resulted in immediate Session Exit.
"""
# Initialize DopamineEngine and simulate the state right before a feed switch
dopamine = DopamineEngine()
# Simulate a full inbox clear causing maximum boredom
dopamine.boredom = 100.0
# Assert that ordinarily, the session WOULD be over
assert dopamine.is_app_session_over() is True
# SIMULATE bot_flow.py logic that occurs during BOREDOM_CHANGE_FEED
result = "BOREDOM_CHANGE_FEED"
assert result == "BOREDOM_CHANGE_FEED"
# Apply the fix from bot_flow.py lines 210-215
dopamine.boredom = max(0.0, dopamine.boredom * 0.2)
# Assert that the session is NO LONGER over, and the bot can continue to the new feed
assert dopamine.boredom == 20.0
assert dopamine.is_app_session_over() is False
def test_session_limit_terminates_session():
dopamine = DopamineEngine()
dopamine.session_limit_seconds = 0 # force time limit
assert dopamine.is_app_session_over() is True

View File

@@ -0,0 +1,222 @@
"""
TDD Test Suite: Explore Grid Navigation Hardening
==================================================
Reproduces the exact production failure from 2026-04-16 22:59 where the bot:
1. Blacklisted ALL image_buttons because of generic semantic strings
2. Could not match "first image in explore grid" via the keyword fast-path
3. VLM picked row 3 instead of row 1 because the prompt lacks spatial ranking
These tests MUST fail before the fix and pass after.
"""
import pytest
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)
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),
"raw_bounds": bounds,
"semantic_string": semantic,
"resource_id": res_id,
"class_name": "android.widget.FrameLayout",
"selected": False,
"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"),
# 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"),
# 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"),
# 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"),
# 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"),
# 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"),
]
class TestBlacklistPoisoning:
"""
Bug: Generic semantic strings like 'id context: image button' get blacklisted,
which kills ALL grid items because they share the same semantic string.
"""
def test_generic_semantic_should_not_be_blacklistable(self):
"""
A semantic string consisting ONLY of a generic id context (no text, no
description) is too ambiguous to blacklist. The engine must refuse to
blacklist it because it would poison all similar nodes.
"""
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
}
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, (
"CRITICAL: Generic semantic 'id context: image button' was blacklisted! "
"This poisons ALL image_buttons in ALL grids."
)
class TestExploreGridFastPath:
"""
Bug: There was no fast-path to match 'first image in explore grid' to a
grid_card_layout_container node. Now the Grid Fast-Path (Stage 1.25) handles
this deterministically via resource-ID + spatial sorting.
"""
def test_grid_fastpath_matches_container(self):
"""
The Grid Fast-Path must match 'first image in explore grid' to
a grid_card_layout_container node without calling VLM/embeddings.
"""
engine = TelepathicEngine()
# 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)
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 "image button" in result.get("semantic", "").lower(), (
f"Grid Fast-Path selected wrong node type: {result.get('semantic')}"
)
def test_grid_fastpath_prefers_topmost_row(self):
"""
When multiple grid items match, the Grid Fast-Path must prefer the
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)
if result is not None:
# Row 1 items have y ≈ 559, Row 3 items have y ≈ 1523
assert result["y"] < 800, (
f"Grid Fast-Path selected a grid item at y={result['y']} (row 3+) "
f"instead of row 1 (y≈559). The intent says 'first image'!"
)
class TestVerifySuccessExploreGrid:
"""
Bug: verify_success for explore grid tap checks for feed markers, but
the post_load_timeout dump proved the bot was STILL on the explore grid.
The verification correctly returned False, but the response was to blacklist
the grid_card_layout_container — which is the WRONG reaction. The tap
just didn't register; it doesn't mean the mapping is wrong.
"""
def test_verify_success_returns_false_when_still_on_grid(self):
"""
If we tapped a grid item but the screen still shows the explore grid
(no feed markers), verify_success must return False.
"""
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
}
# 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"
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 False, "verify_success should fail when still on explore grid"
def test_verify_success_returns_true_when_post_opened(self):
"""
If the grid tap succeeded and we're now viewing a post with feed markers,
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
}
# Post-click XML shows a feed post (has feed markers)
post_view_xml = """
<node class="android.widget.FrameLayout">
<node resource-id="com.instagram.android:id/row_feed_photo_imageview" />
<node resource-id="com.instagram.android:id/row_feed_button_like" />
<node resource-id="com.instagram.android:id/row_feed_button_comment" />
<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,59 @@
"""
TDD Test: Feed Loop Continuation After Stories
===============================================
Reproduces the exact production failure from 2026-04-16 23:12 where the bot
watched 3-5 stories (23 seconds), and then declared the entire session over
instead of continuing to the next feed (HomeFeed, ExploreFeed, ReelsFeed).
The root cause: _run_zero_latency_stories_loop returns "SESSION_OVER" when
stories are exhausted, and the main loop interprets this as "end the entire
bot session" via `else: break`.
"""
import pytest
class TestFeedLoopContinuation:
"""
Tests that completing a sub-feed (Stories, DMs, Search) does NOT terminate
the entire session. The bot must move to the next feed.
"""
def test_stories_complete_returns_feed_exhausted(self):
"""
When stories are watched to the limit, the loop MUST return
'FEED_EXHAUSTED' (not 'SESSION_OVER'). The main loop must then
switch to another feed, not end the session.
"""
# We can't easily mock the full stories loop, but we can verify
# the return value semantics are correct.
# If stories loop returns "SESSION_OVER", the main flow breaks.
# If it returns "FEED_EXHAUSTED", the main flow can switch feeds.
# This test checks the contract: after a sub-feed completes naturally,
# the session should NOT be over unless dopamine says so.
from GramAddict.core.bot_flow import _run_zero_latency_stories_loop
import inspect
source = inspect.getsource(_run_zero_latency_stories_loop)
# The function must return FEED_EXHAUSTED when stories are done naturally
assert "FEED_EXHAUSTED" in source, (
"StoriesFeed loop still returns 'SESSION_OVER' when stories are exhausted. "
"This kills the entire session after just 3-5 stories! "
"Must return 'FEED_EXHAUSTED' so the main loop switches to another feed."
)
def test_main_loop_handles_feed_exhausted(self):
"""
The main session loop must handle 'FEED_EXHAUSTED' by switching
to another available feed target, NOT by breaking.
"""
from GramAddict.core import bot_flow
import inspect
source = inspect.getsource(bot_flow.start_bot)
assert "FEED_EXHAUSTED" in source, (
"Main loop does not handle 'FEED_EXHAUSTED' result. "
"When a sub-feed is exhausted, the bot must switch to another feed."
)

View File

@@ -0,0 +1,36 @@
import pytest
from unittest.mock import patch, MagicMock
from GramAddict.core.llm_provider import query_llm
def test_query_llm_passes_timeout_to_requests():
"""
Verifies that the query_llm wrapper passes the configured
timeout down to the requests.post call, enabling long
generation tasks like comments to complete without crashing.
"""
mock_response = MagicMock()
mock_response.json.return_value = {"response": "test"}
mock_response.raise_for_status.return_value = None
with patch("GramAddict.core.llm_provider.requests.post", return_value=mock_response) as mock_post:
# Act with custom 180s timeout
# Using format_json=False because Ollama branch accesses resp_json["response"]
query_llm("http://localhost:11434", "test_model", "test_prompt", timeout=180, format_json=False)
# Assert
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert kwargs.get("timeout") == 180, "query_llm failed to pass the custom timeout to requests.post"
def test_query_llm_default_timeout_is_configurable():
"""
Verifies that if no timeout is passed, by default we still use a configured or sensible value (60s).
"""
mock_response = MagicMock()
mock_response.json.return_value = {"response": "test"}
with patch("GramAddict.core.llm_provider.requests.post", return_value=mock_response) as mock_post:
query_llm("http://localhost:11434", "test_model", "test_prompt", format_json=False)
_, kwargs = mock_post.call_args
assert kwargs.get("timeout") == 180, "query_llm default timeout should act as fallback"

View File

@@ -0,0 +1,66 @@
import pytest
from unittest.mock import patch, MagicMock, call
from GramAddict.core.bot_flow import _interact_with_profile
from GramAddict.core.session_state import SessionState
class FakeConfig:
def __init__(self):
class Args:
follow_percentage = 100
likes_percentage = 100
likes_count = "1-1"
self.args = Args()
def test_profile_grid_sync_delay_after_follow():
"""
Verifies that _interact_with_profile enforces a sleep delay
after a successful follow and BEFORE searching for the grid,
preventing UI automation from dumping mid-animation.
It now tracks the autonomous QNavGraph calls.
"""
mock_device = MagicMock()
mock_configs = FakeConfig()
mock_session_state = MagicMock(spec=SessionState)
mock_session_state.check_limit.return_value = False
manager = MagicMock()
with patch("GramAddict.core.q_nav_graph.QNavGraph") as MockQNavGraph, \
patch("GramAddict.core.bot_flow.sleep") as mock_sleep, \
patch("GramAddict.core.bot_flow.random.random", return_value=0.0): # Guarantee logic branches
mock_nav_instance = MagicMock()
mock_nav_instance._execute_transition.return_value = True # Always succeed transition
MockQNavGraph.return_value = mock_nav_instance
manager.attach_mock(mock_nav_instance._execute_transition, 'execute_transition')
manager.attach_mock(mock_sleep, 'sleep')
# Act
_interact_with_profile(mock_device, mock_configs, "test_user", mock_session_state, 1.0, MagicMock())
follow_idx = -1
grid_idx = -1
for i, mock_call in enumerate(manager.mock_calls):
# mock_call format: ('name', (args,), {kwargs})
if mock_call[0] == 'execute_transition':
args = mock_call[1]
if args and args[0] == "tap_follow_button":
follow_idx = i
elif args and args[0] == "tap_grid_first_post":
grid_idx = i
assert follow_idx != -1, "Follow transition was not executed"
assert grid_idx != -1, "Grid transition was not executed"
sleep_between = False
for i in range(follow_idx + 1, grid_idx):
if manager.mock_calls[i][0] == 'sleep':
sleep_between = True
assert sleep_between is True, (
"CRITICAL SYNC FAILURE: Found no sleep between Follow Confirmation and Grid search. "
"This causes VLM hallucinations mid-animation."
)

View File

@@ -0,0 +1,72 @@
import pytest
import GramAddict.core.telepathic_engine as telepathic_engine
from GramAddict.core.telepathic_engine import TelepathicEngine
def test_structural_guard_rejects_own_story_for_post_username():
"""
TDD Test: Reproduces the bug where Telepathic Engine might select the user's
OWN profile picture ("Your Story" in the Home Feed tray) when the intent
is to tap the post author's username.
"""
engine = TelepathicEngine()
screen_height = 2400
# Mock node representing the user's "Your Story" circle at the top
# It contains "story" or "your story", has low Y (top of screen)
your_story_node = {
"semantic_string": "description: 'Your Story', id context: 'row feed photo profile imageview'",
"y": 250, # Top story tray
"class_name": "android.widget.ImageView"
}
# Intent
intent = "tap post username"
# Expected behavior: Structural sanity check must REJECT this node to prevent
# clicking our own story/profile
is_valid = engine._structural_sanity_check(your_story_node, intent, screen_height)
assert is_valid is False, "Structural Guard failed to reject 'Your Story' when looking for 'post username'."
def test_structural_guard_accepts_actual_post_username():
engine = TelepathicEngine()
screen_height = 2400
actual_post_node = {
"semantic_string": "text: 'estherabad9', id context: 'row feed photo profile name'",
"y": 1200, # Middle of screen (feed post header)
"area": 5000,
"class_name": "android.widget.TextView"
}
intent = "tap post username"
is_valid = engine._structural_sanity_check(actual_post_node, intent, screen_height)
assert is_valid is True, "Structural Guard incorrectly rejected the actual post username."
def test_structural_guard_rejects_own_username_story():
"""
TDD Test: Reproduces 2026-04-16 23:18 bug where bot selected 'marisaundmarc's story'
instead of an unseen story from ANOTHER user.
"""
engine = TelepathicEngine()
screen_height = 2400
# Simulate current user is marisaundmarc
engine._get_current_username = lambda: "marisaundmarc"
# Mock node representing the user's OWN story, which contains their username
own_story_node = {
"semantic_string": "description: 'marisaundmarc\\'s story, 0 of 27, Unseen.', id context: 'avatar image view'",
"y": 250, # Top story tray
"class_name": "android.widget.ImageView"
}
intent = "profile picture avatar story ring"
# Should reject the user's own profile because clicking it means we edit/view our own story
# instead of doing interactions with prospects.
is_valid = engine._structural_sanity_check(own_story_node, intent, screen_height)
assert is_valid is False, "Structural Guard failed to reject the bot's OWN username story."

View File

@@ -0,0 +1,32 @@
import pytest
from GramAddict.core.telepathic_engine import TelepathicEngine
def test_media_intent_rejects_grid_containers():
"""
TDD Test: Reproduces the bug where intents containing "post" but
targeting specific grid items (like "first image post in profile grid")
were bypassing the MAX_CONTAINER_AREA guard, allowing massive
RecyclerView parent containers to be selected.
"""
engine = TelepathicEngine()
screen_height = 2400
# Mock node representing a massive RecyclerView containing the entire grid
# Area is 1080 * 2400 = 2592000 > MAX_CONTAINER_AREA (500000)
massive_grid_container = {
"semantic_string": "id context: 'swipeable nav view pager inner recycler view'",
"area": 2592000,
"y": 1200,
"class_name": "androidx.recyclerview.widget.RecyclerView",
"resource_id": "com.instagram.android:id/swipeable_nav_view_pager_inner_recycler_view"
}
# Intent
intent = "first image post in profile grid"
# Expected behavior: Structural sanity check must REJECT this node because
# although it's a "post" intent, it is specifically looking for an item within a grid/list,
# meaning we should NOT click massive screen-sized containers.
is_valid = engine._structural_sanity_check(massive_grid_container, intent, screen_height)
assert is_valid is False, "Structural Guard failed to reject massive Grid Container when specifically looking for a grid item."