fix: systemic GOAP self-sabotage — SSOT consolidation + step-aware validation
ROOT CAUSE: The bot was systematically sabotaging its own navigation. The HD Map planned correct 2-step routes (Home→Profile→FollowList), but _execute_action() validated each INTERMEDIATE step against the FINAL goal. Step 1 (tap profile tab → OWN_PROFILE) got rejected because the goal said 'following', triggering aversive learning that permanently burned the only valid route. SIX COMPOUNDING FIXES: 1. SSOT Consolidation (_is_goal_achieved): Replaced 12-line hardcoded if-chain with ScreenTopology.goal_to_target_screen(). Single source of truth for all goal→screen mappings. 2. Step-Aware Navigation Validation (_execute_action): Replaced goal_screen_map keyword-scan with ScreenTopology.expected_screen_for_action(action, pre_action_screen). Now validates: 'did this step land where IT should?' not 'did I reach my goal yet?' 3. Topology Guard (aversive learning): ScreenTopology.is_structural_action() prevents burning HD Map actions. VLM may fail to find the element, but the route itself is structurally valid. 4. Fast-Path Threshold Fix (telepathic_engine): 100% match threshold now applies only to NAV_TAB_KEYWORDS (actual tabs), not NAV_ZONE_BYPASS_KEYWORDS. 'tap following list' was blocked because 'following' triggered the tab threshold on a non-tab action. 5. navigate_to_screen SSOT: Replaced 8-entry goal_map dict with ScreenTopology.screen_name_to_goal(). 6. QNavGraph Name Map Dedup: Replaced 2 inline screen_name_map/name_to_screen dicts with ScreenTopology.SCREEN_NAME_MAP reverse lookups. Before: 4 competing goal→screen mappings. Self-sabotage loop. After: 1 SSOT. Step-aware validation. Topology-protected routing. 141 unit tests pass. Zero regressions.
This commit is contained in:
@@ -725,29 +725,22 @@ class GoalPlanner:
|
||||
return "force start instagram"
|
||||
|
||||
def _is_goal_achieved(self, goal: str, screen_type: ScreenType, context: dict) -> bool:
|
||||
"""Check if the goal is already satisfied."""
|
||||
"""Check if the goal is already satisfied. Delegates to ScreenTopology SSOT."""
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
# Interaction goals (context-specific, not navigation)
|
||||
if "like" in goal and context.get("is_liked") is True:
|
||||
return True
|
||||
if "open explore" in goal and screen_type == ScreenType.EXPLORE_GRID:
|
||||
return True
|
||||
if "open home" in goal and screen_type == ScreenType.HOME_FEED:
|
||||
return True
|
||||
if "open reels" in goal and screen_type == ScreenType.REELS_FEED:
|
||||
return True
|
||||
if "open profile" in goal and screen_type == ScreenType.OWN_PROFILE:
|
||||
return True
|
||||
if "learn own profile" in goal and screen_type == ScreenType.OWN_PROFILE:
|
||||
return True
|
||||
if "open messages" in goal and screen_type == ScreenType.DM_INBOX:
|
||||
return True
|
||||
if ("following list" in goal or "followers list" in goal) and screen_type == ScreenType.FOLLOW_LIST:
|
||||
return True
|
||||
if "open explore" in goal and screen_type == ScreenType.EXPLORE_GRID:
|
||||
return True
|
||||
if "view profile" in goal and (
|
||||
screen_type == ScreenType.OWN_PROFILE or screen_type == ScreenType.OTHER_PROFILE
|
||||
if "view profile" in goal and screen_type in (
|
||||
ScreenType.OWN_PROFILE, ScreenType.OTHER_PROFILE
|
||||
):
|
||||
return True
|
||||
|
||||
# Navigation goals — delegate to SSOT
|
||||
target = ScreenTopology.goal_to_target_screen(goal)
|
||||
if target and screen_type == target:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _plan_navigation(
|
||||
@@ -1086,10 +1079,19 @@ class GoalExecutor:
|
||||
explored_nav_actions.add(action)
|
||||
|
||||
if self.action_failures[action] >= MAX_RETRIES:
|
||||
self.planner.knowledge.learn_trap(screen_type, action, "repeated_failure_or_null_action")
|
||||
logger.error(
|
||||
f"💀 [GOAP Execute] Action '{action}' failed {MAX_RETRIES} times. Marked as permanent trap."
|
||||
)
|
||||
# ── Topology Guard: Never poison structural HD Map actions ──
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
if ScreenTopology.is_structural_action(screen_type, action):
|
||||
logger.warning(
|
||||
f"🛡️ [Topology Guard] NOT burning structural action '{action}' — "
|
||||
f"it's in the HD Map. VLM may have failed, but the route is valid."
|
||||
)
|
||||
else:
|
||||
self.planner.knowledge.learn_trap(screen_type, action, "repeated_failure_or_null_action")
|
||||
logger.error(
|
||||
f"💀 [GOAP Execute] Action '{action}' failed {MAX_RETRIES} times. Marked as permanent trap."
|
||||
)
|
||||
else:
|
||||
logger.warning(f"⚠️ [GOAP Execute] Action '{action}' failed. Continuing with replanning...")
|
||||
|
||||
@@ -1153,49 +1155,44 @@ class GoalExecutor:
|
||||
|
||||
# Verify success via Goal Context + Screen Feedback
|
||||
post_xml = self.device.dump_hierarchy()
|
||||
pre_action_screen = self.perceive(xml_dump) # Screen state BEFORE the click
|
||||
post_screen = self.perceive(post_xml)
|
||||
post_screen_type = post_screen["screen_type"]
|
||||
pre_action_screen_type = pre_action_screen["screen_type"]
|
||||
|
||||
# Determine if this was a navigation or an interaction
|
||||
is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate"])
|
||||
is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate", "following list"])
|
||||
action_success = False
|
||||
goal_met = False
|
||||
ui_changed = post_xml != xml_dump
|
||||
|
||||
if is_navigation:
|
||||
if ui_changed:
|
||||
# ── Goal-Aware Navigation Validation ──
|
||||
# If we have a goal, verify we landed on the correct screen.
|
||||
# E.g., "tap messages tab" + goal="open messages" must land on DM_INBOX, not REELS_FEED.
|
||||
if goal:
|
||||
goal_screen_map = {
|
||||
"messages": ScreenType.DM_INBOX,
|
||||
"explore": ScreenType.EXPLORE_GRID,
|
||||
"home": ScreenType.HOME_FEED,
|
||||
"profile": ScreenType.OWN_PROFILE,
|
||||
"reels": ScreenType.REELS_FEED,
|
||||
"following": ScreenType.FOLLOW_LIST,
|
||||
"followers": ScreenType.FOLLOW_LIST,
|
||||
}
|
||||
expected_screen = None
|
||||
for keyword, screen in goal_screen_map.items():
|
||||
if keyword in goal.lower():
|
||||
expected_screen = screen
|
||||
break
|
||||
# ── Step-Aware Navigation Validation (SSOT) ──
|
||||
# Validates against the EXPECTED screen for THIS ACTION, not the final goal.
|
||||
# This enables multi-step routes: "tap profile tab" should land on
|
||||
# OWN_PROFILE (intermediate), even if the goal is "open following list".
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
if expected_screen and post_screen_type != expected_screen:
|
||||
expected_step_screen = ScreenTopology.expected_screen_for_action(action, pre_action_screen_type)
|
||||
|
||||
if expected_step_screen:
|
||||
if post_screen_type == expected_step_screen:
|
||||
action_success = True
|
||||
logger.info(
|
||||
f"✅ [GOAP Step] '{action}' → {post_screen_type.name} (matches HD Map)"
|
||||
)
|
||||
self.planner.knowledge.learn_screen_mapping(action, post_screen_type)
|
||||
else:
|
||||
logger.warning(
|
||||
f"❌ [GOAP Navigation] Wanted {expected_screen.name} but landed on "
|
||||
f"{post_screen_type.name}. Rejecting navigation '{action}'."
|
||||
f"❌ [GOAP Step] '{action}' expected {expected_step_screen.name}, "
|
||||
f"got {post_screen_type.name}. Rejecting."
|
||||
)
|
||||
action_success = False
|
||||
else:
|
||||
action_success = True
|
||||
logger.info(f"✅ [GOAP Step] Navigation '{action}' successful -> {post_screen_type.name}.")
|
||||
self.planner.knowledge.learn_screen_mapping(action, post_screen_type)
|
||||
else:
|
||||
# Unknown action (not in HD Map) — accept any screen change as progress
|
||||
action_success = True
|
||||
logger.info(f"✅ [GOAP Step] Navigation '{action}' successful -> {post_screen_type.name}.")
|
||||
logger.info(f"✅ [GOAP Step] Navigation '{action}' → {post_screen_type.name} (discovery).")
|
||||
self.planner.knowledge.learn_screen_mapping(action, post_screen_type)
|
||||
else:
|
||||
# Check if we're already on the target screen (no-op is OK)
|
||||
@@ -1230,22 +1227,13 @@ class GoalExecutor:
|
||||
logger.warning(f"❌ [GOAP Verify] No UI change detected after interaction '{action}'.")
|
||||
action_success = None # Inconclusive if UI didn't change at all
|
||||
|
||||
# Optional: Log if the overarching goal was miraculously met early
|
||||
# Optional: Log if the overarching goal was met during this step
|
||||
if goal and action_success:
|
||||
required_screens = self.planner.knowledge.get_requirements(goal)
|
||||
if not required_screens:
|
||||
if "messages" in goal and post_screen_type == ScreenType.DM_INBOX:
|
||||
goal_met = True
|
||||
if "explore" in goal and post_screen_type == ScreenType.EXPLORE_GRID:
|
||||
goal_met = True
|
||||
if "home" in goal and post_screen_type == ScreenType.HOME_FEED:
|
||||
goal_met = True
|
||||
if "profile" in goal and post_screen_type == ScreenType.OWN_PROFILE:
|
||||
goal_met = True
|
||||
if "reels" in goal and post_screen_type == ScreenType.REELS_FEED:
|
||||
goal_met = True
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
if goal_met or (required_screens and post_screen_type in required_screens):
|
||||
goal_target = ScreenTopology.goal_to_target_screen(goal.lower() if goal else "")
|
||||
if goal_target and post_screen_type == goal_target:
|
||||
goal_met = True
|
||||
logger.info(f"🎉 [GOAP Verify] OVERARCHING Goal '{goal}' achieved during step '{action}'.")
|
||||
|
||||
if action_success is True:
|
||||
@@ -1287,18 +1275,10 @@ class GoalExecutor:
|
||||
# ── Convenience methods (backward compatibility with navigate_to) ──
|
||||
|
||||
def navigate_to_screen(self, target: str) -> bool:
|
||||
"""Navigate to a screen by name. Wrapper for achieve()."""
|
||||
goal_map = {
|
||||
"HomeFeed": "open home feed",
|
||||
"ExploreFeed": "open explore feed",
|
||||
"ReelsFeed": "open reels",
|
||||
"OwnProfile": "open profile",
|
||||
"MessageInbox": "open messages",
|
||||
"StoriesFeed": "open home feed", # Stories are on home feed
|
||||
"FollowingList": "open following list",
|
||||
"SearchFeed": "open explore feed",
|
||||
}
|
||||
goal = goal_map.get(target, f"navigate to {target}")
|
||||
"""Navigate to a screen by name. Wrapper for achieve(). Delegates to ScreenTopology SSOT."""
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
goal = ScreenTopology.screen_name_to_goal(target)
|
||||
return self.achieve(goal)
|
||||
|
||||
def get_current_screen_type(self) -> ScreenType:
|
||||
|
||||
@@ -45,17 +45,9 @@ class QNavGraph:
|
||||
# Generate core_nodes from ScreenTopology (single source of truth)
|
||||
core_nodes = {}
|
||||
for screen_type, transitions in ScreenTopology.TRANSITIONS.items():
|
||||
# Map ScreenType enum names to QNavGraph string names
|
||||
screen_name_map = {
|
||||
ScreenType.HOME_FEED: "HomeFeed",
|
||||
ScreenType.EXPLORE_GRID: "ExploreFeed",
|
||||
ScreenType.REELS_FEED: "ReelsFeed",
|
||||
ScreenType.OWN_PROFILE: "OwnProfile",
|
||||
ScreenType.DM_INBOX: "MessageInbox",
|
||||
ScreenType.FOLLOW_LIST: "FollowingList",
|
||||
ScreenType.OTHER_PROFILE: "OtherProfile",
|
||||
ScreenType.UNKNOWN: "UNKNOWN",
|
||||
}
|
||||
# Reverse lookup: ScreenType → QNavGraph string name from SSOT
|
||||
screen_name_map = {v: k for k, v in ScreenTopology.SCREEN_NAME_MAP.items()
|
||||
if v not in (ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID) or k not in ("StoriesFeed", "SearchFeed")}
|
||||
node_name = screen_name_map.get(screen_type)
|
||||
if not node_name:
|
||||
continue
|
||||
@@ -153,19 +145,8 @@ class QNavGraph:
|
||||
|
||||
def _find_path(self, start: str, end: str):
|
||||
"""Delegates to ScreenTopology for BFS pathfinding (SSOT)."""
|
||||
# Map string names to ScreenType for ScreenTopology
|
||||
name_to_screen = {
|
||||
"HomeFeed": ScreenType.HOME_FEED,
|
||||
"ExploreFeed": ScreenType.EXPLORE_GRID,
|
||||
"ReelsFeed": ScreenType.REELS_FEED,
|
||||
"OwnProfile": ScreenType.OWN_PROFILE,
|
||||
"MessageInbox": ScreenType.DM_INBOX,
|
||||
"FollowingList": ScreenType.FOLLOW_LIST,
|
||||
"OtherProfile": ScreenType.OTHER_PROFILE,
|
||||
"UNKNOWN": ScreenType.UNKNOWN,
|
||||
}
|
||||
from_screen = name_to_screen.get(start)
|
||||
to_screen = name_to_screen.get(end)
|
||||
from_screen = ScreenTopology.SCREEN_NAME_MAP.get(start)
|
||||
to_screen = ScreenTopology.SCREEN_NAME_MAP.get(end)
|
||||
if not from_screen or not to_screen:
|
||||
return None
|
||||
|
||||
|
||||
@@ -133,3 +133,62 @@ class ScreenTopology:
|
||||
return screen
|
||||
|
||||
return None
|
||||
|
||||
# ── QNavGraph screen name ↔ ScreenType mapping (SSOT) ──
|
||||
SCREEN_NAME_MAP: Dict[str, ScreenType] = {
|
||||
"HomeFeed": ScreenType.HOME_FEED,
|
||||
"ExploreFeed": ScreenType.EXPLORE_GRID,
|
||||
"ReelsFeed": ScreenType.REELS_FEED,
|
||||
"OwnProfile": ScreenType.OWN_PROFILE,
|
||||
"MessageInbox": ScreenType.DM_INBOX,
|
||||
"FollowingList": ScreenType.FOLLOW_LIST,
|
||||
"OtherProfile": ScreenType.OTHER_PROFILE,
|
||||
"StoriesFeed": ScreenType.HOME_FEED, # Stories are on home feed
|
||||
"SearchFeed": ScreenType.EXPLORE_GRID, # Search uses explore
|
||||
"UNKNOWN": ScreenType.UNKNOWN,
|
||||
}
|
||||
|
||||
# ── Reverse map: ScreenType → canonical goal string ──
|
||||
_SCREEN_TO_GOAL: Dict[ScreenType, str] = {
|
||||
ScreenType.HOME_FEED: "open home feed",
|
||||
ScreenType.EXPLORE_GRID: "open explore feed",
|
||||
ScreenType.REELS_FEED: "open reels",
|
||||
ScreenType.OWN_PROFILE: "open profile",
|
||||
ScreenType.DM_INBOX: "open messages",
|
||||
ScreenType.FOLLOW_LIST: "open following list",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def screen_name_to_goal(cls, screen_name: str) -> str:
|
||||
"""Convert QNavGraph screen name to GOAP goal string.
|
||||
|
||||
Returns a canonical goal string for known screens,
|
||||
or 'navigate to <name>' for unknown ones.
|
||||
"""
|
||||
screen_type = cls.SCREEN_NAME_MAP.get(screen_name)
|
||||
if screen_type and screen_type in cls._SCREEN_TO_GOAL:
|
||||
return cls._SCREEN_TO_GOAL[screen_type]
|
||||
return f"navigate to {screen_name}"
|
||||
|
||||
@classmethod
|
||||
def expected_screen_for_action(
|
||||
cls, action: str, from_screen: ScreenType
|
||||
) -> Optional[ScreenType]:
|
||||
"""What screen should we land on after this action from this screen?
|
||||
|
||||
Used by _execute_action to validate INTERMEDIATE navigation steps.
|
||||
Returns None if the action isn't a known transition from this screen.
|
||||
"""
|
||||
transitions = cls.TRANSITIONS.get(from_screen, {})
|
||||
return transitions.get(action)
|
||||
|
||||
@classmethod
|
||||
def is_structural_action(cls, screen: ScreenType, action: str) -> bool:
|
||||
"""Check if an action is a structural transition in the HD Map.
|
||||
|
||||
Structural actions must NEVER be aversively learned as traps —
|
||||
they are architectural facts about Instagram's UI.
|
||||
VLM may fail to find the element, but the route itself is valid.
|
||||
"""
|
||||
transitions = cls.TRANSITIONS.get(screen, {})
|
||||
return action in transitions
|
||||
|
||||
@@ -697,11 +697,12 @@ class TelepathicEngine:
|
||||
score *= 0.3 # Heavy penalty
|
||||
|
||||
# Thresholding:
|
||||
# - Navigation intents: Require 100% exact match to avoid feed-cross-talk
|
||||
# - Tab intents (actual bottom tabs): Require 100% exact match to avoid feed cross-talk
|
||||
# - Non-tab navigation (following list, DM, etc.): Standard thresholds
|
||||
# - Short intents (1-2 words): Require at least 50% hit (0.45)
|
||||
# - Longer intents: Require 75% to avoid false matches on noisy screens.
|
||||
is_nav_intent = any(k in intent_lower for k in NAV_ZONE_BYPASS_KEYWORDS)
|
||||
if is_nav_intent:
|
||||
is_tab_intent = any(k in intent_lower for k in NAV_TAB_KEYWORDS)
|
||||
if is_tab_intent:
|
||||
threshold = 1.0
|
||||
else:
|
||||
threshold = 0.45 if len(intent_words) <= 2 else 0.75
|
||||
|
||||
116
tests/unit/test_goap_step_validation.py
Normal file
116
tests/unit/test_goap_step_validation.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
🔴 RED TDD: Step-Aware Navigation Validation + Topology Guard
|
||||
|
||||
Tests that validate the GOAP planner correctly handles multi-step navigation:
|
||||
- Intermediate steps (tap profile tab → OWN_PROFILE) must be ACCEPTED
|
||||
- Wrong screens (tap profile tab → REELS_FEED) must be REJECTED
|
||||
- Structural HD Map actions must NEVER be aversively learned as traps
|
||||
"""
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor, GoalPlanner, ScreenType, NavigationKnowledge
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
|
||||
class TestStepAwareValidation:
|
||||
"""_execute_action must validate intermediate steps, not just final goals."""
|
||||
|
||||
@pytest.fixture
|
||||
def executor(self):
|
||||
device = MagicMock()
|
||||
device.dump_hierarchy.return_value = "<xml>mock</xml>"
|
||||
device.click = MagicMock()
|
||||
executor = GoalExecutor(device, bot_username="testbot")
|
||||
executor.action_failures = {}
|
||||
return executor
|
||||
|
||||
def test_intermediate_step_accepted(self, executor):
|
||||
"""
|
||||
Action: 'tap profile tab' from HOME_FEED
|
||||
Goal: 'open following list'
|
||||
Landing: OWN_PROFILE (intermediate step)
|
||||
|
||||
The validator must ACCEPT this — OWN_PROFILE is the correct
|
||||
intermediate screen for 'tap profile tab' from HOME_FEED.
|
||||
It must NOT reject it because the goal says 'following'.
|
||||
"""
|
||||
# The critical question: does expected_screen_for_action return OWN_PROFILE?
|
||||
expected = ScreenTopology.expected_screen_for_action("tap profile tab", ScreenType.HOME_FEED)
|
||||
assert expected == ScreenType.OWN_PROFILE, "HD Map must know 'tap profile tab' → OWN_PROFILE"
|
||||
|
||||
# And the goal target is FOLLOW_LIST, which is DIFFERENT from where we landed
|
||||
goal_target = ScreenTopology.goal_to_target_screen("open following list")
|
||||
assert goal_target == ScreenType.FOLLOW_LIST
|
||||
|
||||
# The old code would fail here because it checks goal_target against landing screen.
|
||||
# The new code checks expected (from action) against landing screen.
|
||||
landing = ScreenType.OWN_PROFILE
|
||||
assert landing == expected, "Step validation: landing matches expected → ACCEPT"
|
||||
assert landing != goal_target, "Goal not yet achieved — but step is valid"
|
||||
|
||||
def test_wrong_screen_rejected(self, executor):
|
||||
"""
|
||||
Action: 'tap profile tab' from HOME_FEED
|
||||
Landing: REELS_FEED (wrong!)
|
||||
|
||||
The validator must REJECT this — expected was OWN_PROFILE.
|
||||
"""
|
||||
expected = ScreenTopology.expected_screen_for_action("tap profile tab", ScreenType.HOME_FEED)
|
||||
landing = ScreenType.REELS_FEED
|
||||
assert landing != expected, "Wrong screen: REELS_FEED ≠ OWN_PROFILE → REJECT"
|
||||
|
||||
def test_final_step_accepted(self, executor):
|
||||
"""
|
||||
Action: 'tap following list' from OWN_PROFILE
|
||||
Goal: 'open following list'
|
||||
Landing: FOLLOW_LIST (final step!)
|
||||
|
||||
The validator must ACCEPT this.
|
||||
"""
|
||||
expected = ScreenTopology.expected_screen_for_action("tap following list", ScreenType.OWN_PROFILE)
|
||||
assert expected == ScreenType.FOLLOW_LIST
|
||||
landing = ScreenType.FOLLOW_LIST
|
||||
assert landing == expected
|
||||
|
||||
|
||||
class TestTopologyGuard:
|
||||
"""Structural HD Map actions must NEVER be aversively learned as traps."""
|
||||
|
||||
def test_structural_action_not_burned(self):
|
||||
"""'tap profile tab' on HOME_FEED is structural — must not be burned."""
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap profile tab") is True
|
||||
|
||||
def test_non_structural_action_can_be_burned(self):
|
||||
"""'open following list' on HOME_FEED is NOT structural — can be burned."""
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "open following list") is False
|
||||
|
||||
|
||||
class TestSSOTConsolidation:
|
||||
"""All goal→screen mappings must use ScreenTopology as SSOT."""
|
||||
|
||||
def test_is_goal_achieved_uses_topology_for_following(self):
|
||||
"""_is_goal_achieved must recognize FOLLOW_LIST for 'open following list'."""
|
||||
planner = GoalPlanner("testbot")
|
||||
planner.knowledge = MagicMock()
|
||||
planner.knowledge.is_trap.return_value = False
|
||||
planner.knowledge.get_requirements.return_value = None
|
||||
|
||||
# On FOLLOW_LIST with goal "open following list" → achieved
|
||||
result = planner._is_goal_achieved("open following list", ScreenType.FOLLOW_LIST, {})
|
||||
assert result is True, "Goal must be achieved when on target screen"
|
||||
|
||||
def test_is_goal_achieved_not_achieved_on_wrong_screen(self):
|
||||
planner = GoalPlanner("testbot")
|
||||
result = planner._is_goal_achieved("open following list", ScreenType.HOME_FEED, {})
|
||||
assert result is False
|
||||
|
||||
def test_navigate_to_screen_uses_topology(self):
|
||||
"""navigate_to_screen must use ScreenTopology.screen_name_to_goal()."""
|
||||
assert ScreenTopology.screen_name_to_goal("FollowingList") == "open following list"
|
||||
assert ScreenTopology.screen_name_to_goal("ExploreFeed") == "open explore feed"
|
||||
assert ScreenTopology.screen_name_to_goal("OwnProfile") == "open profile"
|
||||
@@ -96,3 +96,83 @@ class TestGetTransitions:
|
||||
def test_unknown_screen_returns_empty(self):
|
||||
transitions = ScreenTopology.get_transitions(ScreenType.FOREIGN_APP)
|
||||
assert transitions == {}
|
||||
|
||||
|
||||
class TestScreenNameMap:
|
||||
"""Bidirectional QNavGraph string ↔ ScreenType mapping."""
|
||||
|
||||
def test_following_list_maps(self):
|
||||
assert ScreenTopology.SCREEN_NAME_MAP["FollowingList"] == ScreenType.FOLLOW_LIST
|
||||
|
||||
def test_home_feed_maps(self):
|
||||
assert ScreenTopology.SCREEN_NAME_MAP["HomeFeed"] == ScreenType.HOME_FEED
|
||||
|
||||
def test_stories_feed_maps_to_home(self):
|
||||
"""StoriesFeed is a virtual target — it maps to HOME_FEED."""
|
||||
assert ScreenTopology.SCREEN_NAME_MAP["StoriesFeed"] == ScreenType.HOME_FEED
|
||||
|
||||
def test_search_feed_maps_to_explore(self):
|
||||
"""SearchFeed is a virtual target — it maps to EXPLORE_GRID."""
|
||||
assert ScreenTopology.SCREEN_NAME_MAP["SearchFeed"] == ScreenType.EXPLORE_GRID
|
||||
|
||||
|
||||
class TestScreenNameToGoal:
|
||||
"""Convert QNavGraph screen name to GOAP goal string."""
|
||||
|
||||
def test_following_list(self):
|
||||
assert ScreenTopology.screen_name_to_goal("FollowingList") == "open following list"
|
||||
|
||||
def test_home_feed(self):
|
||||
assert ScreenTopology.screen_name_to_goal("HomeFeed") == "open home feed"
|
||||
|
||||
def test_explore_feed(self):
|
||||
assert ScreenTopology.screen_name_to_goal("ExploreFeed") == "open explore feed"
|
||||
|
||||
def test_stories_feed(self):
|
||||
"""StoriesFeed → open home feed (stories are on home)."""
|
||||
assert ScreenTopology.screen_name_to_goal("StoriesFeed") == "open home feed"
|
||||
|
||||
def test_unknown_target(self):
|
||||
assert ScreenTopology.screen_name_to_goal("BogusScreen") == "navigate to BogusScreen"
|
||||
|
||||
|
||||
class TestExpectedScreenForAction:
|
||||
"""Step-aware validation: what screen should an action land on?"""
|
||||
|
||||
def test_tap_profile_tab_from_home(self):
|
||||
result = ScreenTopology.expected_screen_for_action("tap profile tab", ScreenType.HOME_FEED)
|
||||
assert result == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_tap_following_list_from_profile(self):
|
||||
result = ScreenTopology.expected_screen_for_action("tap following list", ScreenType.OWN_PROFILE)
|
||||
assert result == ScreenType.FOLLOW_LIST
|
||||
|
||||
def test_press_back_from_follow_list(self):
|
||||
result = ScreenTopology.expected_screen_for_action("press back", ScreenType.FOLLOW_LIST)
|
||||
assert result == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_unknown_action_returns_none(self):
|
||||
result = ScreenTopology.expected_screen_for_action("do something random", ScreenType.HOME_FEED)
|
||||
assert result is None
|
||||
|
||||
def test_action_not_available_on_screen(self):
|
||||
"""'tap following list' from HOME_FEED should return None (not a valid transition)."""
|
||||
result = ScreenTopology.expected_screen_for_action("tap following list", ScreenType.HOME_FEED)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestIsStructuralAction:
|
||||
"""Topology guard: protect HD Map actions from aversive learning."""
|
||||
|
||||
def test_tap_profile_tab_is_structural(self):
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap profile tab") is True
|
||||
|
||||
def test_tap_following_list_is_structural(self):
|
||||
assert ScreenTopology.is_structural_action(ScreenType.OWN_PROFILE, "tap following list") is True
|
||||
|
||||
def test_random_action_is_not_structural(self):
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "open following list") is False
|
||||
|
||||
def test_action_on_wrong_screen_is_not_structural(self):
|
||||
"""'tap following list' on HOME_FEED is NOT structural (not in that screen's transitions)."""
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap following list") is False
|
||||
|
||||
Reference in New Issue
Block a user