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:
317
test/npc/test_npc_manager.py
Normal file
317
test/npc/test_npc_manager.py
Normal 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: 605–1004)
|
||||
"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: 605–1004, y: 176–478)
|
||||
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: 605–1004]
|
||||
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
|
||||
348
test/town/test_town_manager.py
Normal file
348
test/town/test_town_manager.py
Normal 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
|
||||
Reference in New Issue
Block a user