Files
tft/backend/tests/test_groups.py
2026-09-07 07:44:09 +02:00

198 lines
7.7 KiB
Python

"""Gruppen: Kontrolle nach Level und Item-Menge, Schwellen, Schlüsselformat."""
from tft.model import groups
def board(place, level, *units):
"""units: (api_name, itemzahl) — Sterne sind hier immer 2."""
return {"place": place, "level": level,
"items": sum(n for _, n in units),
"entities": sorted((api, 2, n) for api, n in units)}
def test_baseline_splits_by_level_and_items():
boards = [board(1, 9, ("A", 3)), board(3, 9, ("B", 3)),
board(7, 5, ("A", 0)), board(8, 5, ("B", 0))]
base = groups.baseline(boards)
assert base[(9, 3)] == 2.0
assert base[(5, 0)] == 7.5
def test_edge_measures_against_comparable_boards():
"""Zwei Boards auf Level 9 mit gleichen Items: nur der Unterschied zählt."""
boards = ([board(1, 9, ("A", 3), ("B", 3)) for _ in range(40)]
+ [board(7, 9, ("C", 3), ("D", 3)) for _ in range(40)])
base = groups.baseline(boards)
table = groups.group_edge(boards, 2, base, min_boards=10)
assert table["A@2@3|B@2@3"]["edge"] > 0
assert table["C@2@3|D@2@3"]["edge"] < 0
assert table["A@2@3|B@2@3"]["boards"] == 40
def test_item_count_is_part_of_the_key():
"""Derselbe Champion mit und ohne Items ist nicht dieselbe Einheit.
Beide Boards tragen sechs Items — nur anders verteilt. Genau dafür ist
die Kontrolle da: die Frage ist, wer die Items tragen soll.
"""
boards = ([board(1, 9, ("A", 3), ("B", 3), ("C", 0)) for _ in range(40)]
+ [board(8, 9, ("A", 0), ("B", 0), ("C", 3), ("D", 3)) for _ in range(40)])
base = groups.baseline(boards)
table = groups.group_edge(boards, 2, base, min_boards=10)
assert "A@2@3|B@2@3" in table and "A@2@0|B@2@0" in table
assert table["A@2@3|B@2@3"]["edge"] > table["A@2@0|B@2@0"]["edge"]
def test_rare_groups_are_dropped():
boards = [board(1, 9, ("A", 3), ("B", 3)) for _ in range(9)]
base = groups.baseline(boards)
assert groups.group_edge(boards, 2, base, min_boards=10) == {}
def test_groups_smaller_than_size_are_skipped():
boards = [board(1, 9, ("A", 3)) for _ in range(40)]
base = groups.baseline(boards)
assert groups.group_edge(boards, 2, base, min_boards=10) == {}
def test_build_needs_enough_boards():
import json
class Conn:
"""Minimaler sqlite-Ersatz: execute(...).fetchall()."""
def __init__(self, n):
row = (1, 9, json.dumps([{"character_id": "A", "tier": 2,
"itemNames": []}]))
self.rows = [row] * n
def execute(self, *_):
return self
def fetchall(self):
return self.rows
static = {"A": {"cost": 1, "traits": []}}
assert groups.build(Conn(10), 18, static) == {}
out = groups.build(Conn(groups.MIN_BOARDS * 20), 18, static)
assert out["boards"] == groups.MIN_BOARDS * 20
def test_only_groups_with_exactly_one_carry():
"""Ein Champion trägt drei Items, der Rest stützt."""
assert groups.one_carry("A@2@3|B@2@0")
assert groups.one_carry("A@2@3|B@1@1|C@3@2")
assert not groups.one_carry("A@2@3|B@2@3") # zwei Carries
assert not groups.one_carry("A@2@1|B@2@0") # gar keiner
def test_build_keeps_only_one_carry_groups():
import json
class Conn:
def __init__(self, rows):
self.rows = rows
def execute(self, *_):
return self
def fetchall(self):
return self.rows
def raw(place, *pairs):
units = [{"character_id": api, "tier": 1, "itemNames": ["x"] * n}
for api, n in pairs]
return (place, 9, json.dumps(units))
static = {a: {"cost": 1, "traits": []} for a in "ABCD"}
rows = ([raw(1, ("A", 3), ("B", 0))] * (groups.MIN_BOARDS * 12)
+ [raw(2, ("C", 3), ("D", 3))] * (groups.MIN_BOARDS * 12))
out = groups.build(Conn(rows), 18, static)
assert "C@1@3|D@1@3" not in out["2"]
assert "A@1@3|B@1@0" in out["2"]
def carrier(place, *pairs):
"""pairs: (api, sterne, [items]) — ein Board mit Trägern."""
return {"place": place, "level": 9, "items": 3, "_edge": 4.5 - place,
"carried": [(api, st, min(len(items), 3), set(items))
for api, st, items in pairs]}
def test_only_craftable_items_are_ranked():
"""Embleme, Artefakte und Radiant-Varianten kann man nicht planen."""
boards = [carrier(1, ("T", 2, ["I_NORMAL", "I_EMBLEM"]))] * 200
table = groups.unit_item_edge(boards, {"I_NORMAL"}, min_boards=10)
assert set(table) == {"T|I_NORMAL"}
def test_item_value_is_measured_against_the_same_champion():
"""Ein gutes Board macht nicht jedes Item darauf gut.
Beide Items liegen auf derselben Unit, aber nur eines auf den guten
Boards. Das andere darf davon nicht profitieren.
"""
boards = ([carrier(1, ("T", 2, ["GOOD"])) for _ in range(60)]
+ [carrier(7, ("T", 2, ["BAD"])) for _ in range(60)])
t = groups.unit_item_edge(boards, {"GOOD", "BAD"}, min_boards=10)
assert t["T|GOOD"]["edge"] > 0 > t["T|BAD"]["edge"]
def test_stars_and_item_count_are_the_comparison_group():
"""Ein Item darf nicht gut aussehen, nur weil sein Träger der Carry ist.
Der Champion steht mit drei Items auf guten Boards, mit einem auf
schlechten. Beide Items liegen nur auf je einer der beiden Gruppen —
ohne die Kontrolle gewänne allein das Item der Drei-Item-Boards.
"""
boards = ([carrier(2, ("T", 2, ["A", "X", "Y"])) for _ in range(80)]
+ [carrier(7, ("T", 2, ["B"])) for _ in range(80)])
t = groups.unit_item_edge(boards, {"A", "B"}, min_boards=10)
assert abs(t["T|A"]["edge"]) < 0.05 and abs(t["T|B"]["edge"]) < 0.05
def test_items_are_kept_apart_per_champion():
"""Dieselbe Rolle, anderer Champion: die Werte dürfen nicht verschmelzen."""
boards = ([carrier(1, ("M", 2, ["AD_ITEM"])) for _ in range(60)]
+ [carrier(1, ("N", 2, ["AP_ITEM"])) for _ in range(60)])
t = groups.unit_item_edge(boards, {"AD_ITEM", "AP_ITEM"}, min_boards=10)
assert set(t) == {"M|AD_ITEM", "N|AP_ITEM"}
def test_role_of_maps_riot_names():
assert groups.role_of({"role": "MagicTank"}) == "Tank"
assert groups.role_of({"role": "AttackFighter"}) == "Bruiser"
assert groups.role_of({"role": "MagicCaster"}) == "Mage"
assert groups.role_of({"role": "AttackMarksman"}) == "Scharfschütze"
assert groups.role_of({"role": ""}) is None
def test_unit_edge_uses_one_star_level():
"""Ohne feste Sternstufe gewinnt die billige Unit, die nur als 3★ überlebt."""
boards = ([{"place": 1, "level": 9, "items": 0,
"entities": [("CHEAP", 3, 0)]} for _ in range(60)]
+ [{"place": 5, "level": 9, "items": 0,
"entities": [("CHEAP", 2, 0), ("RICH", 2, 0)]} for _ in range(60)])
base = groups.baseline(boards)
two = groups.unit_edge(boards, base, min_boards=10, stars=2)
three = groups.unit_edge(boards, base, min_boards=10, stars=3)
# Auf 2★ zählen nur die schlechten Boards, auf 3★ nur die guten.
assert set(two) == {"CHEAP", "RICH"}
assert set(three) == {"CHEAP"}
assert three["CHEAP"]["edge"] > two["CHEAP"]["edge"]
def test_thiefs_gloves_carriers_are_excluded():
"""Die Handschuhe würfeln ihren Inhalt — das hat niemand so gebaut.
Riot listet die gewürfelten Items daneben, als wären sie gelegt worden.
Hier stehen sie nur auf den guten Boards; ohne den Ausschluss würden
sie dadurch gut aussehen.
"""
boards = ([carrier(1, ("T", 2, ["DA_ThiefsGloves", "ZUFALL", "X"]))
for _ in range(200)]
+ [carrier(6, ("T", 2, ["ECHT", "X", "Y"])) for _ in range(200)])
t = groups.unit_item_edge(boards, {"ZUFALL", "ECHT", "X"}, min_boards=10)
assert "T|ZUFALL" not in t
assert "T|ECHT" in t