Files
my-botty/test/npc/test_npc_manager.py

320 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, "focus_d2r_window", lambda: True)
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, "focus_d2r_window", lambda: True)
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