feat(navigation): Implement 100% autonomous Blank Start architecture; purge heuristics

This commit is contained in:
2026-04-22 00:05:03 +02:00
parent fff9ec5b0a
commit 75009d91a2
41 changed files with 1875 additions and 231 deletions

View File

@@ -76,8 +76,9 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
with open(path, "r") as f:
return f.read()
# The current active state XML
device_mock._current_active_xml = load_xml(initial_xml)
# History stack to allow "back" navigation
device_mock._xml_history = [load_xml(initial_xml)]
device_mock._current_active_xml = device_mock._xml_history[-1]
import uuid
def _dump_hierarchy_hook():
@@ -92,6 +93,13 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
device_mock.dump_hierarchy.side_effect = _dump_hierarchy_hook
def _press_hook(key, *args, **kwargs):
if key == "back" and len(device_mock._xml_history) > 1:
device_mock._xml_history.pop()
device_mock._current_active_xml = device_mock._xml_history[-1]
clock.animation_target_time = clock.time + 1.5
device_mock.press.side_effect = _press_hook
class DummyEngine:
def find_best_node(self, *args, **kwargs):
return {"x": 500, "y": 500, "skip": False, "score": 1.0, "source": "e2e_mock"}
@@ -103,6 +111,8 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
pass
original_execute = QNavGraph._execute_transition
from GramAddict.core.goap import GoalExecutor
original_goap_execute = GoalExecutor._execute_action
def _mock_execute_transition(nav_self, action, zero_engine=None, max_retries=2):
if action == 'tap_post_username':
@@ -113,7 +123,9 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
def _click_hook(obj=None, *args, **kwargs):
original_click(obj, *args, **kwargs)
if action in state_map:
device_mock._current_active_xml = load_xml(state_map[action])
new_xml = load_xml(state_map[action])
device_mock._xml_history.append(new_xml)
device_mock._current_active_xml = new_xml
clock.animation_target_time = clock.time + 1.5
nav_self.device.click = _click_hook
@@ -123,8 +135,37 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
return success
finally:
nav_self.device.click = original_click
def _mock_execute_action(goap_self, action, goal=None):
action_key = action.replace(" ", "_")
if action_key == 'tap_post_username':
return True
original_click = goap_self.device.click
def _click_hook(obj=None, *args, **kwargs):
original_click(obj, *args, **kwargs)
if action_key in state_map:
new_xml = load_xml(state_map[action_key])
device_mock._xml_history.append(new_xml)
device_mock._current_active_xml = new_xml
clock.animation_target_time = clock.time + 1.5
elif action in state_map:
new_xml = load_xml(state_map[action])
device_mock._xml_history.append(new_xml)
device_mock._current_active_xml = new_xml
clock.animation_target_time = clock.time + 1.5
goap_self.device.click = _click_hook
try:
success = original_goap_execute(goap_self, action, goal=goal)
return success
finally:
goap_self.device.click = original_click
monkeypatch.setattr(QNavGraph, "_execute_transition", _mock_execute_transition)
monkeypatch.setattr(GoalExecutor, "_execute_action", _mock_execute_action)
return _inject

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -17,7 +18,7 @@ def test_full_e2e_carousel_handling(
Tests that the core feed loop successfully identifies native Carousel identifiers
in the XML and initiates organic swiping inputs.
"""
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
mock_create_device.return_value = device

View File

@@ -1,7 +1,10 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "test reply"})
@patch("GramAddict.core.stealth_typing.ghost_type")
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@patch("GramAddict.core.bot_flow.sleep")
@@ -10,12 +13,13 @@ from GramAddict.core.bot_flow import start_bot
@patch("GramAddict.core.bot_flow.SessionState")
@patch("GramAddict.core.bot_flow.DopamineEngine")
def test_full_e2e_dm_sequence(
mock_dopamine, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector
mock_dopamine, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, mock_ghost_type, mock_query_llm, dynamic_e2e_dump_injector
):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, True]
mock_d_inst.is_app_session_over.side_effect = [False, False, True, True, True, True]
mock_d_inst.wants_to_change_feed.return_value = True
mock_d_inst.boredom = 0.0
mock_sess.inside_working_hours.side_effect = [(True, 0), Exception("Clean Exit for DM")]
@@ -35,7 +39,7 @@ def test_full_e2e_dm_sequence(
configs.username = "testuser"
configs.args = ConfigArgs()
dynamic_e2e_dump_injector(device, {'tap_message_icon': 'dm_inbox_dump.xml'}, "home_feed_with_ad.xml")
dynamic_e2e_dump_injector(device, {'tap messages tab': 'dm_inbox_dump.xml'}, "home_feed_with_ad.xml")
# Let the core system hit its real execution loop with actual XMLs instead of circumventing it
try:

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -12,7 +13,7 @@ from GramAddict.core.bot_flow import start_bot
def test_dojo_lifecycle_integration(
mock_dojo, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector
):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_dojo_inst = mock_dojo.get_instance.return_value

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -12,7 +13,7 @@ from GramAddict.core.bot_flow import start_bot
def test_full_e2e_explore_feed_sequence(
mock_dopamine, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector
):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, True]

View File

@@ -14,6 +14,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from GramAddict.core.goap import (
ScreenIdentity, ScreenType, GoalPlanner, GoalExecutor, PathMemory
)
from GramAddict.core.device_facade import DeviceFacade
def mock_vlm_oracle(*args, **kwargs):
sys_prompt = kwargs.get('system', '')
@@ -71,7 +72,7 @@ POST_DETAIL_XML = load_fixture("post_detail_real.xml")
def make_mock_device():
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
device.app_id = "com.instagram.android"
device.deviceV2 = MagicMock()
return device
@@ -161,17 +162,25 @@ class TestGoalPlanner:
"""Tests that the planner correctly decomposes goals into next steps."""
def setup_method(self):
self.planner = GoalPlanner()
self.si = ScreenIdentity(bot_username="marisaundmarc")
# Use a hermetic test user so we don't accidentally pull real learned paths from Qdrant
self.planner = GoalPlanner(username="test_hermetic_goap_user")
self.si = ScreenIdentity(bot_username="test_hermetic_goap_user")
# Ensure clean state at setup (wipe all memory banks!)
if getattr(self.planner, 'path_memory', None):
self.planner.path_memory.wipe()
if getattr(self.planner, 'knowledge', None):
self.planner.knowledge.wipe()
# ── Navigation: "I need to get to the right screen" ──
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_plans_explore_from_home(self):
"""Goal: 'open explore' + On: HOME_FEED → Action: 'tap explore tab'"""
"""Goal: 'open explore' + On: HOME_FEED → returns goal for autonomous execution"""
screen = self.si.identify(HOME_FEED_XML)
action = self.planner.plan_next_step("open explore feed", screen)
assert action == 'tap explore tab'
goal = "open explore feed"
action = self.planner.plan_next_step(goal, screen)
assert action == goal
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_recognizes_explore_already_open(self):
@@ -189,51 +198,55 @@ class TestGoalPlanner:
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_plans_home_from_explore(self):
"""Goal: 'open home feed' + On: EXPLORE_GRID → 'tap home tab'"""
"""Goal: 'open home feed' + On: EXPLORE_GRID → returns goal"""
screen = self.si.identify(EXPLORE_GRID_XML)
action = self.planner.plan_next_step("open home feed", screen)
assert action == 'tap home tab'
goal = "open home feed"
action = self.planner.plan_next_step(goal, screen)
assert action == goal
# ── Goal Actions: "I'm on the right screen, execute the goal" ──
@pytest.mark.skipif(POST_DETAIL_XML is None, reason="Missing fixture")
def test_plans_like_on_post(self):
"""Goal: 'like this post' + On: POST/FEED → 'tap like button'"""
"""Goal: 'like this post' + On: POST/FEED → returns goal"""
screen = self.si.identify(POST_DETAIL_XML)
action = self.planner.plan_next_step("like this post", screen)
assert action == 'tap like button'
goal = "like this post"
action = self.planner.plan_next_step(goal, screen)
assert action == goal
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_plans_grid_tap_from_explore(self):
"""Goal: 'view a post from explore' + On: EXPLORE_GRID → 'tap first grid item'"""
"""Goal: 'view a post from explore' + On: EXPLORE_GRID → returns goal"""
screen = self.si.identify(EXPLORE_GRID_XML)
action = self.planner.plan_next_step("view a post from explore", screen)
assert action == 'tap first grid item'
goal = "view a post from explore"
action = self.planner.plan_next_step(goal, screen)
assert action == goal
@pytest.mark.skipif(OTHER_PROFILE_XML is None, reason="Missing fixture")
def test_plans_follow_on_profile(self):
"""Goal: 'follow this user' + On: OTHER_PROFILE → 'tap follow button'"""
"""Goal: 'follow this user' + On: OTHER_PROFILE → returns goal"""
screen = self.si.identify(OTHER_PROFILE_XML)
action = self.planner.plan_next_step("follow this user", screen)
# It should plan to follow (if follow button is detected as available)
assert action in ('tap follow button', 'scroll down', None)
goal = "follow this user"
action = self.planner.plan_next_step(goal, screen)
assert action == goal
# ── Multi-step planning: wrong screen for goal ──
@pytest.mark.skipif(HOME_FEED_XML is None, reason="Missing fixture")
def test_navigates_before_grid_tap(self):
"""Goal: 'tap first grid item' + On: HOME_FEED → 'tap explore tab' (must navigate first)"""
"""Goal: 'view a post from explore' + On: HOME_FEED → returns goal"""
screen = self.si.identify(HOME_FEED_XML)
action = self.planner.plan_next_step("tap first grid item", screen)
assert action == 'tap explore tab' # Navigate to explore first!
goal = "view a post from explore"
action = self.planner.plan_next_step(goal, screen)
assert action == goal
@pytest.mark.skipif(EXPLORE_GRID_XML is None, reason="Missing fixture")
def test_likes_require_post_or_feed(self):
"""Goal: 'like a post' + On: EXPLORE_GRID → needs to get to a post first"""
"""Goal: 'like a post' + On: EXPLORE_GRID → returns goal"""
screen = self.si.identify(EXPLORE_GRID_XML)
action = self.planner.plan_next_step("like a post", screen)
# Needs to navigate: explore grid doesn't have like buttons
assert action in ('tap home tab', 'tap first grid item')
goal = "like a post"
action = self.planner.plan_next_step(goal, screen)
assert action == goal
# ═══════════════════════════════════════════════════════

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch, call
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -16,7 +17,7 @@ def test_full_e2e_home_feed_sequence(
Test a full E2E sequence for Home Feed using actual real XML dumps.
Validates bot_flow session lifecycle — navigation is mocked via GOAP.
"""
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
# Setup mock dopamine & session

View File

@@ -101,6 +101,10 @@ class TestTelepathicEngineDmForbiddenZone:
mock_helper = MagicMock()
mock_helper._get_embedding.return_value = None
e.embedding_helper = mock_helper
# Mock ui_memory so Qdrant Fast Paths don't crash
e.ui_memory = MagicMock()
e.ui_memory.retrieve_memory.return_value = None
return e
def test_profile_intent_is_blocked_when_dm_thread_is_active(self):

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -12,7 +13,7 @@ from GramAddict.core.bot_flow import start_bot
def test_full_e2e_reels_feed_sequence(
mock_dopamine, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector
):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, False, True]

View File

@@ -10,12 +10,26 @@ from unittest.mock import MagicMock, patch
from GramAddict.core.situational_awareness import (
SituationalAwarenessEngine, SituationType, EscapeAction, SituationEpisodeDB
)
from GramAddict.core.device_facade import DeviceFacade
# ─────────────────────────────────────────────────────
# Test Fixtures: Real-world XML scenarios
# ─────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def mock_telepathic_classifier():
with patch("GramAddict.core.llm_provider.query_telepathic_llm") as mock_llm:
def side_effect(model, url, system_prompt, user_prompt, use_local_edge):
if "keyguard_status_view" in user_prompt or "lock_icon" in user_prompt:
return '{"situation": "OBSTACLE_LOCKED_SCREEN"}'
elif "permissioncontroller" in user_prompt:
return '{"situation": "OBSTACLE_SYSTEM"}'
else:
return '{"situation": "OBSTACLE_FOREIGN_APP"}'
mock_llm.side_effect = side_effect
yield mock_llm
GOOGLE_SEARCH_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.google.android.googlequicksearchbox" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
@@ -68,13 +82,23 @@ PERMISSION_DIALOG_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes'
</node>
</hierarchy>'''
LOCK_SCREEN_XML = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node index="0" text="" resource-id="com.android.systemui:id/keyguard_status_view" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" clickable="false" bounds="[0,0][1080,2400]">
<node index="0" text="19:15" resource-id="com.android.systemui:id/clock_view" class="android.widget.TextView" package="com.android.systemui" content-desc="" clickable="false" bounds="[0,0][1080,2400]" />
</node>
<node index="1" text="" resource-id="com.android.systemui:id/lock_icon" class="android.widget.ImageView" package="com.android.systemui" content-desc="Lock icon" clickable="true" bounds="[490,2100][590,2200]" />
</node>
</hierarchy>'''
# ─────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────
def make_mock_device(app_id="com.instagram.android"):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
device.app_id = app_id
device.deviceV2 = MagicMock()
device.dump_hierarchy = MagicMock()
@@ -106,6 +130,19 @@ class TestSAEPerception:
result = sae.perceive(GOOGLE_SEARCH_XML)
assert result == SituationType.OBSTACLE_FOREIGN_APP
def test_perceive_notification_shade(self):
import os
dump_path = os.path.join(os.path.dirname(__file__), "..", "fixtures", "notification_shade.xml")
try:
with open(dump_path, "r") as f:
shade_xml = f.read()
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
result = sae.perceive(shade_xml)
assert result == SituationType.OBSTACLE_FOREIGN_APP
except FileNotFoundError:
pass # allow test format to compile if fixture accidentally not available
def test_perceive_system_permission_dialog(self):
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
@@ -259,6 +296,22 @@ class TestSAEAutonomousRecovery:
assert result is True
device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
def test_recovers_from_locked_screen(self):
"""Lock screen detected → SAE triggers unlock() → Instagram returns."""
device = make_mock_device()
device.dump_hierarchy.side_effect = [
LOCK_SCREEN_XML, # perceive: locked
INSTAGRAM_HOME_XML, # verify after unlock
]
sae = SituationalAwarenessEngine(device)
with patch.object(sae.episodes, 'recall', return_value=None), \
patch.object(sae.episodes, 'learn'):
result = sae.ensure_clear_screen(max_attempts=3)
assert result is True
device.unlock.assert_called_once()
device.app_start.assert_called_with("com.instagram.android", use_monkey=True)
def test_recovers_from_survey_back_first_then_click(self):
"""Instagram survey → SAE tries BACK first → if BACK fails → clicks 'Not Now'."""
device = make_mock_device()

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -14,7 +15,7 @@ from GramAddict.core.bot_flow import start_bot
def test_full_e2e_scraping_sequence(
mock_interact, mock_resonance, mock_dopamine, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector, e2e_configs
):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
mock_create_device.return_value = device

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -12,7 +13,7 @@ from GramAddict.core.bot_flow import start_bot
def test_full_e2e_search_sequence(
mock_dopamine, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector
):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -18,7 +19,7 @@ def test_full_start_bot_e2e_working_hours_limits(
Verifies that the bot correctly sleeps when outside working hours
and exits the loop when session limits are reached.
"""
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
mock_create_device.return_value = device

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -12,7 +13,7 @@ from GramAddict.core.bot_flow import start_bot
def test_full_e2e_stories_feed_sequence(
mock_dopamine, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector
):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, False, True]
@@ -37,7 +38,9 @@ def test_full_e2e_stories_feed_sequence(
configs.username = "testuser"
configs.args = ConfigArgs()
dynamic_e2e_dump_injector(device, {'tap_home_tab': 'stories_feed_dump.xml'}, "home_feed_with_ad.xml")
# The agent taps 'tap story ring avatar' to open stories.
# The injector tracks clicks, so it needs to transition to the story dump when the avatar is clicked.
dynamic_e2e_dump_injector(device, {'tap story ring avatar': 'stories_feed_dump.xml'}, "home_feed_with_ad.xml")
try:
with patch("secrets.choice", return_value="StoriesFeed"):

View File

@@ -1,6 +1,7 @@
import pytest
from unittest.mock import MagicMock, patch
from GramAddict.core.bot_flow import start_bot
from GramAddict.core.device_facade import DeviceFacade
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
@patch("GramAddict.core.bot_flow.close_instagram")
@@ -12,7 +13,7 @@ from GramAddict.core.bot_flow import start_bot
def test_full_e2e_unfollow_sequence(
mock_dopamine, mock_sess, mock_create_device, mock_rsleep, mock_sleep, mock_close, mock_open, dynamic_e2e_dump_injector
):
device = MagicMock()
device = MagicMock(spec=DeviceFacade)
mock_create_device.return_value = device
mock_d_inst = mock_dopamine.return_value
mock_d_inst.is_app_session_over.side_effect = [False, True]