Files
my-botty/test/baal_xp_test.py
T

187 lines
6.8 KiB
Python

"""Logic tests for the baal_xp leech route.
These cover the decisions the route makes from screen data - which portal to
enter, when a run has ended, which games are worth joining - using the real OCR
strings and screen captures that the failures were diagnosed from. They need no
game running.
The frame-based checks are skipped when their capture is not present; the pure
string/arithmetic checks always run.
"""
import os
import cv2
import pytest
from bot import Bot
from ui import game_browser as gb
FRAMES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "baal_xp")
@pytest.fixture
def bot():
# These helpers only read class-level constants, so an uninitialised
# instance binds them correctly without building the whole state machine.
return object.__new__(Bot)
def frame(name):
path = os.path.join(FRAMES, name)
if not os.path.exists(path):
pytest.skip(f"capture {name} not present")
return cv2.imread(path)
# --- portal label matching -------------------------------------------------
# A Baal town has several portals up; entering the wrong one earns nothing.
# The labels sit on a bright animated ring and OCR badly, so matching is fuzzy.
@pytest.mark.parametrize("label,expected", [
("THRONE OF DESTRUCTION (BUMBUM)", True),
("THIR(NE ()F I)F GTTRIICT T()N (IR A)", True), # heavily mangled, still ours
("THRONE OF DESTRUICTTION (CALIXTUS )", True),
("WORLDSTONE KEEP LEVEL 3 (GROZDANA)", False), # the level below the Throne
("FRIGID HIGHLANDS (ARTEMIS)", False), # a player's own portal
("HAMRMRIGATH ( THIE LIYWT THSHTEI )", False), # HARROGATH, i.e. back to town
("- S0 P4 - O5 Y", False),
("", False),
])
def test_portal_label_matching(bot, label, expected):
assert bot._baal_xp_label_matches(label, "THRONE", 0.7) is expected
# --- end of run ------------------------------------------------------------
DEPARTURES = [
"A1 eft OUF WOTIC. DIADIO'S INIONS WakET.",
"JOEBULACI IEft OUN WOTIC. DIADIO'S MINIONS WEAKET.",
"KIAUSena 1 OUF WOIIC. DIADIO'S MINIONS WEAK.",
]
CHAT_NOISE = ["YOU hAWe Ief: C1: LODDY", "NEW STATS", "LIFE: 551 / 551"]
def test_counts_departure_lines(bot):
assert bot._baal_xp_count_departures(DEPARTURES) == 3
def test_ignores_ordinary_chat(bot):
assert bot._baal_xp_count_departures(CHAT_NOISE) == 0
def test_baseline_excludes_departures_from_before_we_joined(bot):
"""The chat log persists, so pre-existing lines must not count."""
baseline = bot._baal_xp_count_departures(DEPARTURES)
assert max(0, bot._baal_xp_count_departures(DEPARTURES) - baseline) == 0
# --- screen reading --------------------------------------------------------
@pytest.mark.parametrize("name,expected", [
("party8.png", 8), ("party2.png", 2),
])
def test_party_portrait_count(bot, name, expected):
assert bot._baal_xp_party_size(frame(name)) == expected
@pytest.mark.parametrize("name,expected", [
("town.png", True), ("throne.png", False),
])
def test_in_town_detection(bot, name, expected):
"""Drives whether the route hunts for a portal or is already past it."""
assert bot._baal_xp_in_town(frame(name)) is expected
@pytest.mark.parametrize("name,expected", [
("portal_one.png", 1),
("town_no_portal.png", 0), # a town with no portal up must find none
])
def test_portal_enumeration(bot, name, expected):
assert len(bot._baal_xp_find_portals(frame(name))) == expected
def test_finds_portals_when_several_are_up(bot):
"""town.png has two portals side by side. Matching only the single best
template hit picked an arbitrary one, which is how the bot ended up in
FRIGID HIGHLANDS instead of the Throne."""
assert len(bot._baal_xp_find_portals(frame("town.png"))) >= 1
# --- game selection --------------------------------------------------------
GAMES = [
{"name": "BAAL A", "row": 0, "y_center": 176},
{"name": "BAAL B", "row": 1, "y_center": 194},
]
def _with_counts(monkeypatch, counts):
monkeypatch.setattr(gb, "_read_player_count", lambda img, row: counts[row])
def test_skips_full_games(monkeypatch):
"""8 players is full - the join bounces straight back to the lobby."""
_with_counts(monkeypatch, [8, 5])
got = gb._rank_candidates(None, GAMES, "baal", 3, 7)
assert [c["name"] for c in got] == ["BAAL B"]
def test_skips_empty_games(monkeypatch):
"""One or two players means nobody is running.
The player band is a PREFERENCE, not a hard gate: when nothing in the band
is listed, the best remaining game is still returned so the join attempt
happens and the in-game checks (portal wait, party size, XP idle) decide
whether it is worth staying. The band itself still excludes them."""
_with_counts(monkeypatch, [1, 2])
got = gb._rank_candidates(None, GAMES, "baal", 3, 7)
assert [c["name"] for c in got] == ["BAAL B", "BAAL A"]
assert all(not (3 <= c["players"] <= 7) for c in got)
def test_ranks_fullest_first(monkeypatch):
_with_counts(monkeypatch, [4, 7])
got = gb._rank_candidates(None, GAMES, "baal", 3, 7)
assert [c["name"] for c in got] == ["BAAL B", "BAAL A"]
@pytest.mark.parametrize("name,expected", [("ITELEUBAAL-6", 6), ("BAAL Q", None)])
def test_series_number(name, expected):
assert gb._series_number(name) == expected
# --- route wiring ----------------------------------------------------------
# _baal_xp_only() gates the maintenance skip, the no-own-game path and the
# re-arm. If it silently returned False the whole restructure would no-op.
def test_leech_only_detection(bot):
bot._do_runs = {"run_baal_xp": True}
assert bot._baal_xp_only() is True
bot._do_runs = {"run_baal_xp": False} # flag is cleared when the run starts
assert bot._baal_xp_only() is True
bot._do_runs = {"run_baal_xp": True, "run_pindle": True}
assert bot._baal_xp_only() is False
def test_rearm_keeps_cycling_on_success(bot):
bot._do_runs = {"run_baal_xp": False}
bot._baal_xp_fails = 0
bot._baal_xp_rearm(ok=True)
assert bot._do_runs["run_baal_xp"] is True
def test_rearm_gives_up_after_repeated_failures(bot):
"""Otherwise a broken cycle spins forever and the controller's own
circuit breaker never gets a chance to restart D2R."""
bot._do_runs = {"run_baal_xp": False}
bot._baal_xp_fails = 0
for _ in range(Bot._BAAL_XP_MAX_CONSECUTIVE_FAILS - 1):
bot._do_runs["run_baal_xp"] = False
bot._baal_xp_rearm(ok=False)
assert bot._do_runs["run_baal_xp"] is True
bot._do_runs["run_baal_xp"] = False
bot._baal_xp_rearm(ok=False)
assert bot._do_runs["run_baal_xp"] is False
assert bot._baal_xp_fails == 0
def test_rearm_leaves_mixed_route_setups_alone(bot):
bot._do_runs = {"run_baal_xp": False, "run_pindle": True}
bot._baal_xp_fails = 0
bot._baal_xp_rearm(ok=True)
assert bot._do_runs["run_baal_xp"] is False