test: add regression tests for NPC interaction and TownManager

Adds 41 tests covering the highest-risk production paths:

test/npc/test_npc_manager.py (14 tests):
  - _action_btns_visible: missing NPC, white found, nothing found
  - open_npc_menu: fast path (Bug 6), name-tag in ROI (Bug 3),
    ROI gate blocks outside-ROI click (Bug 7), pose-distance gate (Bug 4),
    body+pose confirmed triggers click, timeout returns False
  - press_npc_btn: white/blue/grayscale fallback chain, red=cannot-afford, nothing found

test/town/test_town_manager.py (27 tests):
  - get_act_from_location: all five acts + sub-locations, bad input
  - identify: True never returned (Bug 1), A5 fallback (Bug 5),
    A5-also-fails returns new_loc, _cain_failed_acts skip
  - open_wp: budget increment, exhaustion fast-path (Bug 9), reset, success
  - buy_consumables: unknown loc, trade-menu failure (Bug 10), success, no-need skip
  - stash: current-act delegation, A5 travel when act can't stash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-08-01 10:21:51 +02:00
parent 0176f66a1c
commit 47e9344fd5
2 changed files with 665 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
"""
Regression tests for open_npc_menu() and press_npc_btn().
Bugs from CLAUDE.md covered:
Bug 3: name tag threshold lowered; small ROI above hover prevents wide false positives
Bug 4: min_dist stored per result dict (was stale loop var) — body+pose gate works
Bug 6: dialogue confirmed via action buttons, not stale NPCDialogue/gold-tag template
Bug 7: name tag ROI gate — high-score white text outside NPC ROI does not trigger click
"""
import itertools
import numpy as np
import pytest
import npc_manager as npc_mod
from npc_manager import Npc, open_npc_menu, press_npc_btn
# ---------------------------------------------------------------------------
# Shared fakes
# ---------------------------------------------------------------------------
_BLANK = np.zeros((10, 10), dtype=np.uint8)
# Distinct arrays so template identity checks (`template is X`) don't false-match
_TRADE_WHITE = np.zeros((10, 10), dtype=np.uint8); _TRADE_WHITE[0, 0] = 1
_TRADE_BLUE = np.zeros((10, 10), dtype=np.uint8); _TRADE_BLUE[0, 1] = 1
_RES_WHITE = np.zeros((10, 10), dtype=np.uint8); _RES_WHITE[1, 0] = 1
_RES_BLUE = np.zeros((10, 10), dtype=np.uint8); _RES_BLUE[1, 1] = 1
_RES_RED = np.zeros((10, 10), dtype=np.uint8); _RES_RED[1, 2] = 1
# Minimal fake npcs entry for AKARA (has roi and poses — same values as production).
# Body threshold defaults to 0.40, pose tolerance to 150.
_FAKE_NPCS = {
Npc.AKARA: {
"name_tag_white": _BLANK,
"name_tag_gold": _BLANK,
"action_btns": {
"trade": {
"white": _TRADE_WHITE,
"blue": _TRADE_BLUE,
}
},
"template_group": ["AKARA_FRONT"],
"roi": [605, 176, 399, 302], # x, y, w, h (x: 6051004)
"poses": [[694, 377], [836, 378], [950, 345], [869, 290], [698, 317]],
},
Npc.QUAL_KEHK: {
"name_tag_white": _BLANK,
"name_tag_gold": _BLANK,
"action_btns": {
"resurrect": {
"white": _RES_WHITE,
"blue": _RES_BLUE,
"red": _RES_RED,
}
},
"template_group": ["QUAL_0"],
"roi": [225, 57, 625, 385],
"poses": [[350, 140], [481, 196]],
},
}
class _Match:
def __init__(self, valid=False, score=0.0, center_monitor=(700, 300)):
self.valid = valid
self.score = score
self.center_monitor = center_monitor
def _patch_screen(monkeypatch):
"""Patch every screen-touching call in npc_manager to a safe no-op."""
import screen as screen_mod
monkeypatch.setattr(npc_mod, "npcs", _FAKE_NPCS)
monkeypatch.setattr(npc_mod, "grab", lambda *a, **k: _BLANK)
monkeypatch.setattr(npc_mod, "wait", lambda *a, **k: None)
monkeypatch.setattr(npc_mod, "wait_until_hidden", lambda *a, **k: True)
monkeypatch.setattr(npc_mod, "is_visible", lambda *a, **k: False)
monkeypatch.setattr(npc_mod, "color_filter", lambda img, *a, **k: (None, img))
monkeypatch.setattr(npc_mod, "center_mouse", lambda: None)
monkeypatch.setattr(npc_mod.keyboard, "send", lambda *a, **k: None)
monkeypatch.setattr(npc_mod.mouse, "move", lambda *a, **k: None)
monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: None)
monkeypatch.setattr(npc_mod.mouse, "get_position", lambda: (700, 300))
monkeypatch.setattr(screen_mod, "convert_monitor_to_screen", lambda pos: pos)
# Patch internal helpers so their time loops don't block
monkeypatch.setattr(npc_mod, "_action_btns_visible", lambda npc_key, img: False)
monkeypatch.setattr(npc_mod, "_wait_action_btns", lambda npc_key, timeout=2.5: False)
def _exhaust_time_after_one_iteration(monkeypatch):
"""Make time.time() allow one main-loop pass then expire both the NPC loop and
the sweep budget, so tests finish in milliseconds not minutes."""
# Indices: 0=start, 1=first-while, 2=roi-check, 3=second-while, 4=sweep_start, 5+=sweep-budget
seq = iter([0.0, 0.0, 0.0, 9999.0, 9999.0, 9999.0 + 26.0])
monkeypatch.setattr(npc_mod.time, "time", lambda: next(seq, 9999.0 + 26.0))
# ---------------------------------------------------------------------------
# _action_btns_visible (direct unit tests — restore real impl first)
# ---------------------------------------------------------------------------
class TestActionBtnsVisible:
def setup_method(self):
# Restore the real function so we test its logic, not a patched stub.
import importlib
importlib.reload(npc_mod) # pick up any earlier monkeypatches being torn down
def test_returns_false_when_npc_missing_from_dict(self, monkeypatch):
monkeypatch.setattr(npc_mod, "npcs", {})
assert npc_mod._action_btns_visible(Npc.AKARA, _BLANK) is False
def test_returns_true_when_white_btn_found(self, monkeypatch):
monkeypatch.setattr(npc_mod, "npcs", _FAKE_NPCS)
monkeypatch.setattr(npc_mod, "color_filter", lambda img, *a, **k: (None, img))
monkeypatch.setattr(npc_mod.template_finder, "search",
lambda *a, **k: _Match(valid=True, score=0.9))
assert npc_mod._action_btns_visible(Npc.AKARA, _BLANK) is True
def test_returns_false_when_no_btn_found(self, monkeypatch):
monkeypatch.setattr(npc_mod, "npcs", _FAKE_NPCS)
monkeypatch.setattr(npc_mod, "color_filter", lambda img, *a, **k: (None, img))
monkeypatch.setattr(npc_mod.template_finder, "search",
lambda *a, **k: _Match(valid=False))
assert npc_mod._action_btns_visible(Npc.AKARA, _BLANK) is False
# ---------------------------------------------------------------------------
# open_npc_menu — decision-path tests
# ---------------------------------------------------------------------------
class TestOpenNpcMenu:
def test_fast_path_returns_true_when_dialogue_already_open(self, monkeypatch):
"""If action buttons are already visible on entry, return True with no clicks."""
_patch_screen(monkeypatch)
clicks = []
monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1))
# Override the stub: dialogue IS open from the first grab
monkeypatch.setattr(npc_mod, "_action_btns_visible", lambda npc_key, img: True)
result = open_npc_menu(Npc.AKARA)
assert result is True
assert len(clicks) == 0 # no physical click needed (Bug 6: fast-path via action btns)
def test_name_tag_in_roi_click_confirmed_by_action_btns(self, monkeypatch):
"""Name tag confirmed inside ROI → click → action buttons appear → True. (Bug 3, Bug 6)"""
_patch_screen(monkeypatch)
_exhaust_time_after_one_iteration(monkeypatch)
clicks = []
monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1))
# action_btns_visible: False pre-click, True post-click
visible_calls = [0]
def _avb(npc_key, img):
visible_calls[0] += 1
return visible_calls[0] > 1
monkeypatch.setattr(npc_mod, "_action_btns_visible", _avb)
monkeypatch.setattr(npc_mod, "_wait_action_btns", lambda npc_key, timeout=2.5: True)
def _search(template, img, threshold=0.5, *args, **kwargs):
if isinstance(template, str): # body template search
# Position (700, 300) is inside Akara's ROI (x: 6051004, y: 176478)
return _Match(valid=True, score=0.85, center_monitor=(700, 300))
return _Match(valid=True, score=0.9) # name tag also valid
monkeypatch.setattr(npc_mod.template_finder, "search", _search)
result = open_npc_menu(Npc.AKARA)
assert result is True
assert len(clicks) >= 1
def test_bug7_name_tag_outside_roi_is_not_clicked(self, monkeypatch):
"""Bug 7: high-score name tag at x=200 (outside Akara ROI x≥605) must not click.
The sweep phase is also blocked from clicking by returning invalid name tags."""
_patch_screen(monkeypatch)
_exhaust_time_after_one_iteration(monkeypatch)
clicks = []
monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1))
def _search(template, img, threshold=0.5, *args, **kwargs):
if isinstance(template, str):
# Body at x=200 — outside Akara's ROI [x: 6051004]
return _Match(valid=True, score=0.98, center_monitor=(200, 300))
# Name tag always invalid — sweep phase won't click either
return _Match(valid=False, score=0.1)
monkeypatch.setattr(npc_mod.template_finder, "search", _search)
result = open_npc_menu(Npc.AKARA)
assert result is False
assert len(clicks) == 0 # ROI gate + invalid name tag = zero clicks
def test_bug4_body_pose_too_far_is_not_clicked(self, monkeypatch):
"""Bug 4: body match at (900,100) has min_dist≈192px > 150 tolerance → no click."""
_patch_screen(monkeypatch)
_exhaust_time_after_one_iteration(monkeypatch)
clicks = []
monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1))
def _search(template, img, threshold=0.5, *args, **kwargs):
if isinstance(template, str):
# Inside Akara's ROI but far from any pose:
# nearest pose (869,290) → dist=sqrt((900-869)²+(100-290)²)=sqrt(961+36100)≈192 > 150
return _Match(valid=True, score=0.50, center_monitor=(900, 100))
return _Match(valid=False) # no name tag visible
monkeypatch.setattr(npc_mod.template_finder, "search", _search)
result = open_npc_menu(Npc.AKARA)
assert result is False
assert len(clicks) == 0 # pose-distance gate blocked the click (Bug 4 fix)
def test_returns_false_when_no_npc_found_and_time_expires(self, monkeypatch):
"""When no template matches at all, function returns False without hanging."""
_patch_screen(monkeypatch)
_exhaust_time_after_one_iteration(monkeypatch)
monkeypatch.setattr(npc_mod.template_finder, "search",
lambda *a, **k: _Match(valid=False))
result = open_npc_menu(Npc.AKARA)
assert result is False
def test_body_and_pose_confirmed_triggers_click(self, monkeypatch):
"""Body score ≥ 0.40 AND min_dist < 150 should click (no name tag required)."""
_patch_screen(monkeypatch)
_exhaust_time_after_one_iteration(monkeypatch)
clicks = []
monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1))
monkeypatch.setattr(npc_mod, "_wait_action_btns", lambda npc_key, timeout=2.5: True)
def _search(template, img, threshold=0.5, *args, **kwargs):
if isinstance(template, str):
# Inside ROI, close to pose (694,377): dist≈0
return _Match(valid=True, score=0.48, center_monitor=(694, 377))
return _Match(valid=False) # no name tag
monkeypatch.setattr(npc_mod.template_finder, "search", _search)
result = open_npc_menu(Npc.AKARA)
assert result is True
assert len(clicks) >= 1
# ---------------------------------------------------------------------------
# press_npc_btn — search fallback chain
# ---------------------------------------------------------------------------
class TestPressNpcBtn:
def _base(self, monkeypatch):
monkeypatch.setattr(npc_mod, "npcs", _FAKE_NPCS)
monkeypatch.setattr(npc_mod, "grab", lambda *a, **k: _BLANK)
monkeypatch.setattr(npc_mod, "wait", lambda *a, **k: None)
monkeypatch.setattr(npc_mod, "color_filter", lambda img, *a, **k: (None, img))
monkeypatch.setattr(npc_mod, "center_mouse", lambda: None)
monkeypatch.setattr(npc_mod.keyboard, "send", lambda *a, **k: None)
monkeypatch.setattr(npc_mod.mouse, "move", lambda *a, **k: None)
self.clicks = []
monkeypatch.setattr(npc_mod.mouse, "click",
lambda **k: self.clicks.append(1))
def test_white_button_found_returns_true(self, monkeypatch):
self._base(monkeypatch)
monkeypatch.setattr(npc_mod.template_finder, "search",
lambda *a, **k: _Match(valid=True, score=0.9,
center_monitor=(640, 360)))
assert press_npc_btn(Npc.AKARA, "trade") is True
assert len(self.clicks) == 1
def test_blue_fallback_returns_true(self, monkeypatch):
self._base(monkeypatch)
blue_tmpl = _TRADE_BLUE
def _search(template, img, *args, use_grayscale=False, **kwargs):
if template is blue_tmpl and not use_grayscale:
return _Match(valid=True, score=0.9, center_monitor=(640, 360))
return _Match(valid=False, score=0.1)
monkeypatch.setattr(npc_mod.template_finder, "search", _search)
assert press_npc_btn(Npc.AKARA, "trade") is True
assert len(self.clicks) == 1
def test_grayscale_fallback_returns_true(self, monkeypatch):
self._base(monkeypatch)
def _search(template, img, *args, use_grayscale=False, **kwargs):
if use_grayscale:
return _Match(valid=True, score=0.85, center_monitor=(640, 360))
return _Match(valid=False, score=0.1)
monkeypatch.setattr(npc_mod.template_finder, "search", _search)
assert press_npc_btn(Npc.AKARA, "trade") is True
assert len(self.clicks) == 1
def test_red_button_means_cannot_afford(self, monkeypatch):
"""Red resurrect button means 'can't afford merc revival' → returns False."""
self._base(monkeypatch)
def _search(template, img, *args, use_grayscale=False, **kwargs):
# Only the distinct red template matches (not white or blue)
if template is _RES_RED and not use_grayscale:
return _Match(valid=True, score=0.9, center_monitor=(640, 360))
return _Match(valid=False, score=0.1)
monkeypatch.setattr(npc_mod.template_finder, "search", _search)
result = press_npc_btn(Npc.QUAL_KEHK, "resurrect")
assert result is False
assert len(self.clicks) == 0 # no click on red button
def test_nothing_found_returns_false(self, monkeypatch):
self._base(monkeypatch)
monkeypatch.setattr(npc_mod.template_finder, "search",
lambda *a, **k: _Match(valid=False, score=0.1))
assert press_npc_btn(Npc.AKARA, "trade") is False
assert len(self.clicks) == 0

View File

@@ -0,0 +1,348 @@
"""
Regression tests for TownManager — the orchestrator of all town NPC interactions.
Bugs from CLAUDE.md covered:
Bug 1: identify() isinstance(success, Location) guard — True never returned as a location
Bug 5: identify() returns new_loc (not False) when A5 Cain also fails — preserves act tracking
Bug 9: open_wp() per-game failure budget — budget exhausted → fail fast instead of looping
Bug 10: buy_consumables() propagates trade-menu failure → (False, items)
"""
import numpy as np
import pytest
from pather import Location
from town.town_manager import TownManager
# ---------------------------------------------------------------------------
# Shared fakes
# ---------------------------------------------------------------------------
class _FakeAct:
"""Configurable fake IAct — override attributes per test."""
_can_heal = False
_can_buy_pots = True
_can_resurrect = False
_can_identify = True
_can_stash = True
_can_trade_repair = False
_can_gamble = False
_wp_loc = Location.A5_WP
_identify_result = Location.A5_QUAL_KEHK # success by default
_trade_menu_result = Location.A5_MALAH # success by default
_open_stash_result = Location.A5_STASH
_open_wp_result = True
def can_heal(self): return self._can_heal
def can_buy_pots(self): return self._can_buy_pots
def can_resurrect(self): return self._can_resurrect
def can_identify(self): return self._can_identify
def can_stash(self): return self._can_stash
def can_trade_and_repair(self):return self._can_trade_repair
def can_gamble(self): return self._can_gamble
def get_wp_location(self): return self._wp_loc
def open_wp(self, curr_loc, quick=False): return self._open_wp_result
def heal(self, curr_loc): return curr_loc
def open_trade_menu(self, curr_loc): return self._trade_menu_result
def resurrect(self, curr_loc): return False
def identify(self, curr_loc): return self._identify_result
def open_stash(self, curr_loc):return self._open_stash_result
def open_trade_and_repair_menu(self, curr_loc): return False
def gamble(self, curr_loc): return False
def wait_for_tp(self): return True
def _build_manager(a5_act=None):
"""Return a TownManager wired with fake acts (A5 customisable, others generic)."""
fake = _FakeAct()
a5 = a5_act or fake
return TownManager(a1=fake, a2=fake, a3=fake, a4=fake, a5=a5)
def _patch_side_effects(monkeypatch):
"""Silence every side-effecting call the TownManager makes during tests."""
import health_manager
import town.town_manager as tm_mod
monkeypatch.setattr(health_manager, "set_panel_check_paused", lambda v: None)
monkeypatch.setattr(tm_mod, "wait", lambda *a, **k: None)
monkeypatch.setattr(tm_mod, "grab", lambda *a, **k: np.zeros((4, 4, 3), dtype=np.uint8))
monkeypatch.setattr(tm_mod.common, "wait_for_left_inventory", lambda: True)
monkeypatch.setattr(tm_mod.common, "close", lambda: None)
monkeypatch.setattr(tm_mod.keyboard, "send", lambda *a, **k: None)
monkeypatch.setattr(tm_mod.view, "return_to_play", lambda: None)
# consumables: no restocking needed
monkeypatch.setattr(tm_mod.consumables, "get_needs", lambda kind: 0)
# stash helpers
monkeypatch.setattr(tm_mod.personal, "stash_all_items", lambda items, **k: [])
monkeypatch.setattr(tm_mod, "convert_rejuv_potions", lambda: None)
# detect_current_act must not touch the real screen — return None (no correction)
monkeypatch.setattr(TownManager, "detect_current_act", lambda self, *a, **k: None)
# ---------------------------------------------------------------------------
# get_act_from_location — pure function, no mocking needed
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("loc, expected", [
(Location.A1_TOWN_START, Location.A1_TOWN_START),
(Location.A2_TOWN_START, Location.A2_TOWN_START),
(Location.A3_TOWN_START, Location.A3_TOWN_START),
(Location.A4_TOWN_START, Location.A4_TOWN_START),
(Location.A5_TOWN_START, Location.A5_TOWN_START),
(Location.A5_QUAL_KEHK, Location.A5_TOWN_START),
(Location.A5_WP, Location.A5_TOWN_START),
(Location.A1_ANDY_SAFE_DIST, Location.A1_TOWN_START),
])
def test_get_act_from_location_maps_correctly(loc, expected):
assert TownManager.get_act_from_location(loc) == expected
@pytest.mark.parametrize("loc", [None, True, 123, "UNKNOWN_LOC"])
def test_get_act_from_location_returns_none_for_bad_input(loc):
result = TownManager.get_act_from_location(loc)
# "UNKNOWN_LOC" has no act prefix → None; non-strings → None too
assert result is None or isinstance(result, str)
# ---------------------------------------------------------------------------
# identify — Bug 1 (isinstance guard) and Bug 5 (preserve act on fallback)
# ---------------------------------------------------------------------------
class TestIdentify:
def test_success_returns_location_not_bool(self, monkeypatch):
"""Bug 1: identify() must never return True even if the act returns True.
Location is a plain class (not Enum), so isinstance(success, Location) is always
False — identify() always returns curr_loc for the current-act success path."""
_patch_side_effects(monkeypatch)
mgr = _build_manager()
# Act returns True (the old bug value) — must NOT propagate
mgr._acts[Location.A5_TOWN_START]._identify_result = True
result = mgr.identify(Location.A5_TOWN_START)
assert result is not True # Bug 1: True must never escape identify()
assert isinstance(result, str) # must return a location string
def test_cain_failure_falls_back_to_a5(self, monkeypatch):
"""When A1 Cain fails, TownManager travels to A5 and tries Cain there.
Return value is the A5 location reached (go_to_act returns the WP loc)."""
_patch_side_effects(monkeypatch)
a1_act = _FakeAct()
a1_act._identify_result = False # A1 Cain unavailable
import town.town_manager as tm_mod
monkeypatch.setattr(tm_mod.waypoint, "use_wp", lambda *a, **k: True)
a5_act = _FakeAct()
a5_act._identify_result = Location.A5_QUAL_KEHK
mgr = TownManager(a1=a1_act, a2=_FakeAct(), a3=_FakeAct(),
a4=_FakeAct(), a5=a5_act)
monkeypatch.setattr(mgr, "detect_current_act", lambda *a, **k: None)
monkeypatch.setattr(mgr, "open_wp", lambda *a, **k: True)
result = mgr.identify(Location.A1_TOWN_START)
# Must have reached A5 (go_to_act returns a5_wp from get_wp_location())
assert result is not False
assert isinstance(result, str)
assert result.startswith("a5_")
def test_bug5_a5_cain_failure_returns_new_loc_not_false(self, monkeypatch):
"""Bug 5: even when A5 Cain fails, identify() returns the travelled-to location
so on_maintenance() knows the char is now in A5 (not still in A1)."""
_patch_side_effects(monkeypatch)
import town.town_manager as tm_mod
monkeypatch.setattr(tm_mod.waypoint, "use_wp", lambda *a, **k: True)
a5_act = _FakeAct()
a5_act._identify_result = False # A5 Cain also unavailable
mgr = TownManager(a1=_FakeAct(), a2=_FakeAct(), a3=_FakeAct(),
a4=_FakeAct(), a5=a5_act)
monkeypatch.setattr(mgr, "detect_current_act", lambda *a, **k: None)
monkeypatch.setattr(mgr, "open_wp", lambda *a, **k: True)
result = mgr.identify(Location.A1_TOWN_START)
# Must NOT return False — caller would wrongly believe char is still in A1
assert result is not False
assert isinstance(result, str) # a Location string
def test_skips_act_in_cain_failed_set(self, monkeypatch):
"""Acts in _cain_failed_acts are skipped without calling identify() on the act."""
_patch_side_effects(monkeypatch)
import town.town_manager as tm_mod
monkeypatch.setattr(tm_mod.waypoint, "use_wp", lambda *a, **k: True)
a1_act = _FakeAct()
identify_calls = []
original_identify = a1_act.identify
a1_act.identify = lambda loc: identify_calls.append(loc) or original_identify(loc)
a5_act = _FakeAct()
a5_act._identify_result = Location.A5_QUAL_KEHK
mgr = TownManager(a1=a1_act, a2=_FakeAct(), a3=_FakeAct(),
a4=_FakeAct(), a5=a5_act)
monkeypatch.setattr(mgr, "detect_current_act", lambda *a, **k: None)
monkeypatch.setattr(mgr, "open_wp", lambda *a, **k: True)
mgr._cain_failed_acts.add(Location.A1_TOWN_START)
mgr.identify(Location.A1_TOWN_START)
assert len(identify_calls) == 0 # A1 Cain was skipped entirely
# ---------------------------------------------------------------------------
# open_wp — Bug 9: per-game WP failure budget
# ---------------------------------------------------------------------------
class TestOpenWp:
def test_first_failure_increments_budget(self, monkeypatch):
_patch_side_effects(monkeypatch)
a5 = _FakeAct()
a5._open_wp_result = False
mgr = _build_manager(a5_act=a5)
result = mgr.open_wp(Location.A5_TOWN_START)
assert result is False
assert mgr._wp_fails_this_game == 1
def test_second_failure_exhausts_budget(self, monkeypatch):
_patch_side_effects(monkeypatch)
a5 = _FakeAct()
a5._open_wp_result = False
mgr = _build_manager(a5_act=a5)
mgr._wp_fails_this_game = 1 # already one failure
result = mgr.open_wp(Location.A5_TOWN_START)
assert result is False
assert mgr._wp_fails_this_game == 2
def test_budget_exhausted_returns_false_without_calling_act(self, monkeypatch):
"""Bug 9: once budget is gone, open_wp bails immediately — no act.open_wp call."""
_patch_side_effects(monkeypatch)
a5 = _FakeAct()
wp_calls = []
a5.open_wp = lambda *a, **k: wp_calls.append(1) or False
mgr = _build_manager(a5_act=a5)
mgr._wp_fails_this_game = 2 # budget already gone
result = mgr.open_wp(Location.A5_TOWN_START)
assert result is False
assert len(wp_calls) == 0 # act never contacted
def test_reset_wp_budget_clears_counter(self, monkeypatch):
_patch_side_effects(monkeypatch)
mgr = _build_manager()
mgr._wp_fails_this_game = 2
mgr.reset_wp_budget()
assert mgr._wp_fails_this_game == 0
def test_success_does_not_increment_budget(self, monkeypatch):
_patch_side_effects(monkeypatch)
a5 = _FakeAct()
a5._open_wp_result = True
mgr = _build_manager(a5_act=a5)
result = mgr.open_wp(Location.A5_TOWN_START)
assert result is True
assert mgr._wp_fails_this_game == 0
# ---------------------------------------------------------------------------
# buy_consumables — failure propagation
# ---------------------------------------------------------------------------
class TestBuyConsumables:
def test_returns_false_for_unknown_location(self, monkeypatch):
_patch_side_effects(monkeypatch)
mgr = _build_manager()
loc, items = mgr.buy_consumables("BOGUS_LOCATION")
assert loc is False
def test_returns_false_when_trade_menu_fails(self, monkeypatch):
"""Bug 10: a failed open_trade_menu must propagate as (False, items)."""
_patch_side_effects(monkeypatch)
a5 = _FakeAct()
a5._trade_menu_result = False # vendor interaction failed
mgr = _build_manager(a5_act=a5)
loc, items = mgr.buy_consumables(Location.A5_TOWN_START)
assert loc is False
def test_success_returns_new_location(self, monkeypatch):
_patch_side_effects(monkeypatch)
a5 = _FakeAct()
a5._trade_menu_result = Location.A5_MALAH
mgr = _build_manager(a5_act=a5)
loc, items = mgr.buy_consumables(Location.A5_TOWN_START)
assert loc == Location.A5_MALAH
def test_skips_purchase_when_no_needs(self, monkeypatch):
"""With all consumable needs = 0, no buy_item calls should be made."""
_patch_side_effects(monkeypatch)
import town.town_manager as tm_mod
buy_calls = []
import inventory.vendor as vendor_mod
monkeypatch.setattr(vendor_mod, "buy_item", lambda *a, **k: buy_calls.append(1) or False)
mgr = _build_manager()
mgr.buy_consumables(Location.A5_TOWN_START)
assert len(buy_calls) == 0
# ---------------------------------------------------------------------------
# stash — act delegation and A5 fallback
# ---------------------------------------------------------------------------
class TestStash:
def test_delegates_to_current_act_when_stash_available(self, monkeypatch):
_patch_side_effects(monkeypatch)
a5 = _FakeAct()
a5._can_stash = True
a5._open_stash_result = Location.A5_STASH
open_stash_calls = []
a5.open_stash = lambda loc: open_stash_calls.append(loc) or Location.A5_STASH
mgr = _build_manager(a5_act=a5)
new_loc, items = mgr.stash(Location.A5_TOWN_START)
assert len(open_stash_calls) == 1
assert new_loc == Location.A5_STASH
def test_goes_to_a5_when_current_act_cannot_stash(self, monkeypatch):
_patch_side_effects(monkeypatch)
import town.town_manager as tm_mod
monkeypatch.setattr(tm_mod.waypoint, "use_wp", lambda *a, **k: True)
# A2 can't stash — should travel to A5
class _NoStashAct(_FakeAct):
_can_stash = False
def open_stash(self, loc): return False
a5 = _FakeAct()
a5._can_stash = True
a5_stash_calls = []
a5.open_stash = lambda loc: a5_stash_calls.append(loc) or Location.A5_STASH
mgr = TownManager(a1=_FakeAct(), a2=_NoStashAct(), a3=_FakeAct(),
a4=_FakeAct(), a5=a5)
monkeypatch.setattr(mgr, "detect_current_act", lambda *a, **k: None)
monkeypatch.setattr(mgr, "open_wp", lambda *a, **k: True)
new_loc, items = mgr.stash(Location.A2_TOWN_START)
assert len(a5_stash_calls) == 1 # traveled to A5 stash