test(e2e): enforce 100% production-parity in dm_engine and follow verification tests
This commit is contained in:
@@ -15,7 +15,6 @@ Each test MUST fail before any production code is touched (TDD RED).
|
||||
# Helpers — Minimal realistic mocks (no lying)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
import os
|
||||
import types
|
||||
|
||||
FIXTURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
|
||||
|
||||
@@ -37,28 +36,6 @@ def _get_dm_thread_pair():
|
||||
return _load_fixture_img("dm_thread_dump.jpg"), _load_fixture_xml("dm_thread_dump.xml")
|
||||
|
||||
|
||||
def _make_configs(dm_reply_enabled=False):
|
||||
"""Create a realistic Config mock using the real Config class."""
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
configs = Config(first_run=True)
|
||||
configs.args = types.SimpleNamespace(
|
||||
disable_ai_messaging=False,
|
||||
ai_condenser_model="qwen3.5:latest",
|
||||
ai_condenser_url="http://localhost:11434/api/generate",
|
||||
)
|
||||
configs.config = {"plugins": {"dm_reply": {"enabled": dm_reply_enabled}}}
|
||||
return configs
|
||||
|
||||
|
||||
def _make_session_state(configs):
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
session = SessionState(configs)
|
||||
session.set_limits_session()
|
||||
return session
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Test 1: DM Engine MUST respect dm_reply.enabled config
|
||||
# ═══════════════════════════════════════════════════════
|
||||
@@ -67,7 +44,9 @@ def _make_session_state(configs):
|
||||
class TestDMConfigGating:
|
||||
"""Verifies that dm_reply.enabled=false prevents ALL DM interactions."""
|
||||
|
||||
def test_dm_engine_blocks_when_dm_reply_disabled(self, make_real_device_with_image):
|
||||
def test_dm_engine_blocks_when_dm_reply_disabled(
|
||||
self, make_real_device_with_image, e2e_configs, e2e_cognitive_stack_factory
|
||||
):
|
||||
"""BUG: dm_engine.py:96 checks 'disable_ai_messaging' (doesn't exist)
|
||||
instead of dm_reply.enabled from config. This means DMs fire even when
|
||||
config says enabled: false.
|
||||
@@ -76,29 +55,26 @@ class TestDMConfigGating:
|
||||
is disabled in the config.
|
||||
"""
|
||||
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
inbox_img, inbox_xml = _get_dm_inbox_pair()
|
||||
device = make_real_device_with_image(inbox_img, inbox_xml)
|
||||
|
||||
# Real Config
|
||||
configs = _make_configs(dm_reply_enabled=False)
|
||||
if "dm_reply" not in e2e_configs.config["plugins"]:
|
||||
e2e_configs.config["plugins"]["dm_reply"] = {}
|
||||
e2e_configs.config["plugins"]["dm_reply"]["enabled"] = False
|
||||
|
||||
session_state = _make_session_state(configs)
|
||||
session_state = SessionState(e2e_configs)
|
||||
session_state.set_limits_session()
|
||||
|
||||
dopamine = DopamineEngine()
|
||||
dopamine.boredom = 0.0
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
|
||||
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": None}
|
||||
cognitive_stack = e2e_cognitive_stack_factory(device)
|
||||
|
||||
# No patches, 100% real engine
|
||||
_run_zero_latency_dm_loop(
|
||||
device,
|
||||
make_real_device_with_image(inbox_img, inbox_xml),
|
||||
None,
|
||||
configs,
|
||||
e2e_configs,
|
||||
session_state,
|
||||
"MessageInbox",
|
||||
cognitive_stack,
|
||||
@@ -118,53 +94,45 @@ class TestDMConfigGating:
|
||||
class TestDMSendVerification:
|
||||
"""Verifies that 'Successfully sent' is only logged when the message was actually sent."""
|
||||
|
||||
def test_dm_engine_successfully_identifies_and_sends_reply(self, make_real_device_with_image):
|
||||
def test_dm_engine_successfully_identifies_and_sends_reply(
|
||||
self, make_real_device_with_image, e2e_configs, e2e_cognitive_stack_factory
|
||||
):
|
||||
"""
|
||||
EXPECTED: DM engine must use structural verification and successfully find the
|
||||
message input field, type the message, find the 'Send' button, and send the message
|
||||
using the real UI hierarchy and real LLM calls.
|
||||
"""
|
||||
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
# Use the real dm_thread_dump.xml and image. We want to test that if the VLM selects the wrong thing
|
||||
# it is rejected. Since the real VLM will pick the real send button in dm_thread_dump, this test
|
||||
# as originally written (testing failure) needs to be modified or it might pass as a success now.
|
||||
# Actually, let's just test that the DM engine successfully identifies the send button using the real thread!
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
inbox_img, inbox_xml = _get_dm_inbox_pair()
|
||||
thread_img, thread_xml = _get_dm_thread_pair()
|
||||
|
||||
# Sequence for 1 reply:
|
||||
# Loop 1: Inbox (start) -> Thread (read msg) -> Thread (find Send button) -> Thread (check after 1st back press)
|
||||
# Loop 2: Inbox (start) -> Thread (read msg) -> Exits due to MAX_REPLIES_PER_INBOX_VISIT == 1
|
||||
# Note: We don't provide a loop 2 here since we test success. Dopamine might hit boredom and exit cleanly.
|
||||
# But wait, DM engine loops until dopamine triggers or MAX_REPLIES (3).
|
||||
# We will set boredom artificially high so it exits after 1 reply.
|
||||
device = make_real_device_with_image(
|
||||
[inbox_img, thread_img, thread_img, thread_img, inbox_img, thread_img],
|
||||
[inbox_xml, thread_xml, thread_xml, thread_xml, inbox_xml, thread_xml],
|
||||
)
|
||||
|
||||
# Real Config
|
||||
configs = _make_configs(dm_reply_enabled=True)
|
||||
if "dm_reply" not in e2e_configs.config["plugins"]:
|
||||
e2e_configs.config["plugins"]["dm_reply"] = {}
|
||||
e2e_configs.config["plugins"]["dm_reply"]["enabled"] = True
|
||||
|
||||
session_state = _make_session_state(configs)
|
||||
session_state = SessionState(e2e_configs)
|
||||
session_state.set_limits_session()
|
||||
|
||||
dopamine = DopamineEngine()
|
||||
dopamine.boredom = 0.0
|
||||
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
|
||||
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": None}
|
||||
|
||||
import GramAddict.core.dm_engine as dme
|
||||
|
||||
dme.MAX_REPLIES_PER_INBOX_VISIT = 1
|
||||
cognitive_stack = e2e_cognitive_stack_factory(device)
|
||||
cognitive_stack["dopamine"].boredom = 99.0 # Will trigger BOREDOM_CHANGE_FEED on the next loop
|
||||
|
||||
_run_zero_latency_dm_loop(
|
||||
device,
|
||||
make_real_device_with_image(inbox_img, inbox_xml),
|
||||
None,
|
||||
configs,
|
||||
e2e_configs,
|
||||
session_state,
|
||||
"MessageInbox",
|
||||
cognitive_stack,
|
||||
@@ -182,20 +150,19 @@ class TestDMSendVerification:
|
||||
class TestDMContextRequirement:
|
||||
"""Verifies that the DM engine refuses to generate replies without context."""
|
||||
|
||||
def test_dm_engine_skips_thread_with_no_extractable_message(self, make_real_device_with_image):
|
||||
def test_dm_engine_skips_thread_with_no_extractable_message(
|
||||
self, make_real_device_with_image, e2e_configs, e2e_cognitive_stack_factory
|
||||
):
|
||||
"""BUG: dm_engine.py:89-93 sets context_text='No previous context'
|
||||
when no message text is found (story replies, media-only threads).
|
||||
Then proceeds to call the LLM with that string, producing garbage
|
||||
like 'the to the'.
|
||||
|
||||
Evidence from logs:
|
||||
7 out of 8 threads had 'Last received message context: No previous context'
|
||||
All 7 were blindly replied to anyway.
|
||||
|
||||
EXPECTED: When context_text is 'No previous context' or empty,
|
||||
the DM engine must SKIP the thread entirely (press back, continue).
|
||||
"""
|
||||
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
inbox_img, inbox_xml = _get_dm_inbox_pair()
|
||||
|
||||
@@ -211,25 +178,20 @@ class TestDMContextRequirement:
|
||||
[inbox_img, thread_img, inbox_img, ""], [inbox_xml, thread_xml, inbox_xml, ""]
|
||||
)
|
||||
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
if "dm_reply" not in e2e_configs.config["plugins"]:
|
||||
e2e_configs.config["plugins"]["dm_reply"] = {}
|
||||
e2e_configs.config["plugins"]["dm_reply"]["enabled"] = True
|
||||
|
||||
configs = _make_configs(dm_reply_enabled=True)
|
||||
session_state = SessionState(e2e_configs)
|
||||
session_state.set_limits_session()
|
||||
|
||||
session_state = _make_session_state(configs)
|
||||
|
||||
dopamine = DopamineEngine()
|
||||
dopamine.boredom = 0.0
|
||||
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
|
||||
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": None}
|
||||
cognitive_stack = e2e_cognitive_stack_factory(device)
|
||||
|
||||
_run_zero_latency_dm_loop(
|
||||
device,
|
||||
None,
|
||||
None,
|
||||
configs,
|
||||
e2e_configs,
|
||||
session_state,
|
||||
"MessageInbox",
|
||||
cognitive_stack,
|
||||
@@ -247,24 +209,28 @@ class TestDMContextRequirement:
|
||||
class TestDMIterationLimit:
|
||||
"""Verifies the DM engine doesn't spam infinite replies."""
|
||||
|
||||
def test_dm_engine_caps_replies_per_session(self, make_real_device_with_image):
|
||||
def test_dm_engine_caps_replies_per_session(
|
||||
self, make_real_device_with_image, e2e_configs, e2e_cognitive_stack_factory
|
||||
):
|
||||
"""BUG: dm_engine.py:34 while loop only exits on session timeout or
|
||||
boredom. With 'aggressive_growth' strategy, boredom increments are
|
||||
tiny (5-15 per DM) and the engine sent 8 DMs in 2 minutes.
|
||||
|
||||
EXPECTED: DM engine must have an explicit max_replies_per_inbox
|
||||
cap (e.g., 3) to prevent spam behavior. After reaching the cap,
|
||||
cap (3 by default) to prevent spam behavior. After reaching the cap,
|
||||
it should return 'BOREDOM_CHANGE_FEED'.
|
||||
"""
|
||||
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
inbox_img, inbox_xml = _get_dm_inbox_pair()
|
||||
thread_img, thread_xml = _get_dm_thread_pair()
|
||||
|
||||
# Sequence for 2 replies (to test cap = 2):
|
||||
# Sequence for 3 replies (production cap is 3):
|
||||
# Loop 1: Inbox -> Thread -> Thread -> Thread
|
||||
# Loop 2: Inbox -> Thread -> Thread -> Thread
|
||||
# Loop 3: Inbox -> Thread -> Exits because cap=2
|
||||
# Loop 3: Inbox -> Thread -> Thread -> Thread
|
||||
# Loop 4: Inbox -> Thread -> Exits because cap=3
|
||||
device = make_real_device_with_image(
|
||||
[
|
||||
inbox_img,
|
||||
@@ -276,7 +242,11 @@ class TestDMIterationLimit:
|
||||
thread_img,
|
||||
thread_img, # Loop 2
|
||||
inbox_img,
|
||||
thread_img, # Loop 3 (hits limit)
|
||||
thread_img,
|
||||
thread_img,
|
||||
thread_img, # Loop 3
|
||||
inbox_img,
|
||||
thread_img, # Loop 4 (hits limit)
|
||||
],
|
||||
[
|
||||
inbox_xml,
|
||||
@@ -289,37 +259,32 @@ class TestDMIterationLimit:
|
||||
thread_xml,
|
||||
inbox_xml,
|
||||
thread_xml,
|
||||
thread_xml,
|
||||
thread_xml,
|
||||
inbox_xml,
|
||||
thread_xml,
|
||||
],
|
||||
)
|
||||
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
if "dm_reply" not in e2e_configs.config["plugins"]:
|
||||
e2e_configs.config["plugins"]["dm_reply"] = {}
|
||||
e2e_configs.config["plugins"]["dm_reply"]["enabled"] = True
|
||||
|
||||
configs = _make_configs(dm_reply_enabled=True)
|
||||
session_state = SessionState(e2e_configs)
|
||||
session_state.set_limits_session()
|
||||
|
||||
session_state = _make_session_state(configs)
|
||||
|
||||
dopamine = DopamineEngine()
|
||||
dopamine.boredom = 0.0
|
||||
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": None}
|
||||
|
||||
# Override session_state methods that are used in loop directly instead of MagicMock
|
||||
configs.args.current_success_limit = 8
|
||||
configs.args.current_pm_limit = 8
|
||||
cognitive_stack = e2e_cognitive_stack_factory(device)
|
||||
|
||||
# Force the session limits higher so it strictly tests the inner loop cap
|
||||
e2e_configs.args.current_success_limit = 8
|
||||
e2e_configs.args.current_pm_limit = 8
|
||||
session_state.totalMessages = 0
|
||||
import GramAddict.core.dm_engine as dme
|
||||
|
||||
dme.MAX_REPLIES_PER_INBOX_VISIT = 2
|
||||
|
||||
# Force the session to never hit limits (simulating the real scenario)
|
||||
_run_zero_latency_dm_loop(
|
||||
device,
|
||||
make_real_device_with_image(inbox_img, inbox_xml),
|
||||
None,
|
||||
configs,
|
||||
e2e_configs,
|
||||
session_state,
|
||||
"MessageInbox",
|
||||
cognitive_stack,
|
||||
@@ -341,14 +306,17 @@ class TestDMConfigGatingProduction:
|
||||
"""Verifies the REAL dm_engine._run_zero_latency_dm_loop config check,
|
||||
not a local re-implementation of bot_flow.py logic."""
|
||||
|
||||
def test_dm_engine_config_gating_reads_real_plugin_config(self):
|
||||
def test_dm_engine_config_gating_reads_real_plugin_config(self, e2e_configs):
|
||||
"""The dm_engine kill-switch at line 46-50 reads configs.get_plugin_config('dm_reply').
|
||||
We verify this path with the real Config class — NOT a local dict simulation."""
|
||||
configs_disabled = _make_configs(dm_reply_enabled=False)
|
||||
configs_enabled = _make_configs(dm_reply_enabled=True)
|
||||
|
||||
dm_config_off = configs_disabled.get_plugin_config("dm_reply")
|
||||
dm_config_on = configs_enabled.get_plugin_config("dm_reply")
|
||||
if "dm_reply" not in e2e_configs.config["plugins"]:
|
||||
e2e_configs.config["plugins"]["dm_reply"] = {}
|
||||
|
||||
e2e_configs.config["plugins"]["dm_reply"]["enabled"] = False
|
||||
dm_config_off = e2e_configs.get_plugin_config("dm_reply")
|
||||
assert dm_config_off.get("enabled", False) is False, "Config should report dm_reply as disabled"
|
||||
|
||||
e2e_configs.config["plugins"]["dm_reply"]["enabled"] = True
|
||||
dm_config_on = e2e_configs.get_plugin_config("dm_reply")
|
||||
assert dm_config_on.get("enabled", False) is True, "Config should report dm_reply as enabled"
|
||||
|
||||
@@ -15,10 +15,8 @@ Root cause chain:
|
||||
Each test MUST fail (RED) before any production code is fixed.
|
||||
"""
|
||||
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.perception.action_memory import ActionMemory
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# TEST 1: verify_success MUST reject wrong-element clicks for follow
|
||||
@@ -131,7 +129,7 @@ class TestQNavGraphDoBlocksFollowWithoutButton:
|
||||
when the current screen has no Follow button.
|
||||
"""
|
||||
|
||||
def test_do_rejects_follow_when_not_in_available_actions(self, make_real_device_with_image):
|
||||
def test_do_rejects_follow_when_not_in_available_actions(self, make_real_device_with_image, e2e_configs):
|
||||
"""
|
||||
If the current screen's available_actions does not contain 'tap follow button',
|
||||
QNavGraph.do("tap 'Follow' button") MUST return False immediately.
|
||||
@@ -140,20 +138,11 @@ class TestQNavGraphDoBlocksFollowWithoutButton:
|
||||
so it NEVER gets checked. The bot blindly passes through to GOAP.
|
||||
"""
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
device = make_real_device_with_image(None, "<hierarchy/>")
|
||||
|
||||
import types
|
||||
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
configs = Config(first_run=True)
|
||||
configs.args = types.SimpleNamespace()
|
||||
configs.args.disable_ai_messaging = False
|
||||
configs.args.ai_condenser_model = "qwen3.5:latest"
|
||||
configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
|
||||
SessionState(configs)
|
||||
SessionState(e2e_configs)
|
||||
|
||||
nav = QNavGraph(device)
|
||||
|
||||
@@ -239,7 +228,9 @@ class TestGOAPInteractionCrossCheck:
|
||||
and the intent BEFORE trusting the VLM verification.
|
||||
"""
|
||||
|
||||
def test_execute_action_rejects_when_clicked_node_doesnt_match_intent(self, make_real_device_with_image):
|
||||
def test_execute_action_rejects_when_clicked_node_doesnt_match_intent(
|
||||
self, make_real_device_with_image, e2e_configs
|
||||
):
|
||||
"""
|
||||
If find_best_node returns a node with desc='3 photos by ...'
|
||||
for intent='tap Follow button', _execute_action MUST reject it
|
||||
@@ -248,17 +239,8 @@ class TestGOAPInteractionCrossCheck:
|
||||
Currently: _execute_action clicks first, then asks VLM to verify.
|
||||
The VLM verification is the fox guarding the henhouse.
|
||||
"""
|
||||
import types
|
||||
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
|
||||
configs = Config(first_run=True)
|
||||
configs.args = types.SimpleNamespace()
|
||||
configs.args.disable_ai_messaging = False
|
||||
configs.args.ai_condenser_model = "qwen3.5:latest"
|
||||
configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
|
||||
|
||||
xml_dump = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/image_button"
|
||||
@@ -307,7 +289,9 @@ class TestFollowPluginEndToEnd:
|
||||
session state is corrupted.
|
||||
"""
|
||||
|
||||
def test_follow_plugin_does_not_count_follow_when_wrong_element_clicked(self, make_real_device_with_image):
|
||||
def test_follow_plugin_does_not_count_follow_when_wrong_element_clicked(
|
||||
self, make_real_device_with_image, e2e_configs
|
||||
):
|
||||
"""
|
||||
By removing lying mocks, we test the REAL E2E behavior:
|
||||
If we give the plugin a screen with NO follow button, QNavGraph.do()
|
||||
@@ -319,18 +303,16 @@ class TestFollowPluginEndToEnd:
|
||||
|
||||
plugin = FollowPlugin()
|
||||
|
||||
import types
|
||||
e2e_configs.args.follow_percentage = 100
|
||||
e2e_configs.args.current_likes_limit = 300
|
||||
|
||||
configs = Config(first_run=True)
|
||||
configs.args = types.SimpleNamespace()
|
||||
configs.args.follow_percentage = 100
|
||||
configs.args.current_likes_limit = 300
|
||||
configs.args.disable_ai_messaging = False
|
||||
configs.args.ai_condenser_model = "qwen3.5:latest"
|
||||
configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
|
||||
configs.config = {"plugins": {"follow": {"percentage": 100}}}
|
||||
if "follow" not in e2e_configs.config["plugins"]:
|
||||
e2e_configs.config["plugins"]["follow"] = {}
|
||||
e2e_configs.config["plugins"]["follow"]["percentage"] = 100
|
||||
|
||||
session_state = SessionState(configs)
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
session_state = SessionState(e2e_configs)
|
||||
session_state.added_interactions = []
|
||||
|
||||
original_add_interaction = session_state.add_interaction
|
||||
@@ -359,7 +341,7 @@ class TestFollowPluginEndToEnd:
|
||||
ctx = BehaviorContext(
|
||||
device=device,
|
||||
session_state=session_state,
|
||||
configs=configs,
|
||||
configs=e2e_configs,
|
||||
username="missiongreenenergy",
|
||||
cognitive_stack={"nav_graph": nav_graph},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user