320 lines
15 KiB
Python
320 lines
15 KiB
Python
"""
|
||
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, "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: 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, "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
|