mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: add supplemental theme catalog tooling, additional theme selection, and custom theme selection
This commit is contained in:
parent
3a1b011dbc
commit
9428e09cef
39 changed files with 3643 additions and 198 deletions
69
code/tests/test_additional_theme_config.py
Normal file
69
code/tests/test_additional_theme_config.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from headless_runner import _resolve_additional_theme_inputs, _parse_theme_list
|
||||
|
||||
|
||||
def _write_catalog(path: Path) -> None:
|
||||
path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# theme_catalog version=test_version",
|
||||
"theme,commander_count,card_count",
|
||||
"Lifegain,5,20",
|
||||
"Token Swarm,3,15",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_theme_list_handles_semicolons() -> None:
|
||||
assert _parse_theme_list("Lifegain;Token Swarm ; lifegain") == ["Lifegain", "Token Swarm"]
|
||||
|
||||
|
||||
def test_resolve_additional_themes_permissive(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(catalog_path)
|
||||
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
|
||||
|
||||
resolution = _resolve_additional_theme_inputs(
|
||||
["Lifegain", "Unknown"],
|
||||
mode="permissive",
|
||||
commander_tags=["Lifegain"],
|
||||
)
|
||||
|
||||
assert resolution.mode == "permissive"
|
||||
assert resolution.catalog_version == "test_version"
|
||||
# Lifegain deduped against commander tag
|
||||
assert resolution.resolved == []
|
||||
assert resolution.matches[0]["matched"] == "Lifegain"
|
||||
assert len(resolution.unresolved) == 1
|
||||
assert resolution.unresolved[0]["input"] == "Unknown"
|
||||
assert resolution.unresolved[0]["reason"] in {"no_match", "suggestions", "no_candidates"}
|
||||
|
||||
|
||||
def test_resolve_additional_themes_strict_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(catalog_path)
|
||||
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_resolve_additional_theme_inputs(["Mystery"], mode="strict")
|
||||
assert "Mystery" in str(exc.value)
|
||||
|
||||
|
||||
def test_resolve_additional_themes_fuzzy_correction(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(catalog_path)
|
||||
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
|
||||
|
||||
resolution = _resolve_additional_theme_inputs(["lifgain"], mode="permissive")
|
||||
|
||||
assert resolution.resolved == ["Lifegain"]
|
||||
assert resolution.fuzzy_corrections == {"lifgain": "Lifegain"}
|
||||
assert not resolution.unresolved
|
||||
102
code/tests/test_custom_theme_htmx.py
Normal file
102
code/tests/test_custom_theme_htmx.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from deck_builder.theme_resolution import ThemeResolutionInfo
|
||||
from web.app import app
|
||||
from web.services import custom_theme_manager as ctm
|
||||
|
||||
|
||||
def _make_info(
|
||||
requested: Sequence[str],
|
||||
*,
|
||||
resolved: Sequence[str] | None = None,
|
||||
matches: Sequence[dict[str, object]] | None = None,
|
||||
unresolved: Sequence[dict[str, object]] | None = None,
|
||||
mode: str = "permissive",
|
||||
catalog_version: str = "test-cat",
|
||||
) -> ThemeResolutionInfo:
|
||||
return ThemeResolutionInfo(
|
||||
requested=list(requested),
|
||||
mode=mode,
|
||||
catalog_version=catalog_version,
|
||||
resolved=list(resolved or []),
|
||||
matches=list(matches or []),
|
||||
unresolved=list(unresolved or []),
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
def fake_resolve(
|
||||
requested: Sequence[str],
|
||||
mode: str,
|
||||
*,
|
||||
commander_tags: Iterable[str] = (),
|
||||
) -> ThemeResolutionInfo:
|
||||
inputs = list(requested)
|
||||
if not inputs:
|
||||
return _make_info([], resolved=[], matches=[], unresolved=[])
|
||||
if inputs == ["lifgian"]:
|
||||
return _make_info(
|
||||
inputs,
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=[
|
||||
{
|
||||
"input": "lifgian",
|
||||
"reason": "suggestions",
|
||||
"score": 72.0,
|
||||
"suggestions": [{"theme": "Lifegain", "score": 91.2}],
|
||||
}
|
||||
],
|
||||
)
|
||||
if inputs == ["Lifegain"]:
|
||||
return _make_info(
|
||||
inputs,
|
||||
resolved=["Lifegain"],
|
||||
matches=[
|
||||
{
|
||||
"input": "Lifegain",
|
||||
"matched": "Lifegain",
|
||||
"score": 91.2,
|
||||
"reason": "suggestion",
|
||||
"suggestions": [],
|
||||
}
|
||||
],
|
||||
unresolved=[],
|
||||
)
|
||||
raise AssertionError(f"Unexpected inputs: {inputs}")
|
||||
|
||||
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_remove_theme_updates_htmx_section(client: TestClient) -> None:
|
||||
add_resp = client.post("/build/themes/add", data={"theme": "lifgian"})
|
||||
assert add_resp.status_code == 200
|
||||
add_html = add_resp.text
|
||||
assert "lifgian" in add_html
|
||||
assert "Needs attention" in add_html
|
||||
|
||||
choose_resp = client.post(
|
||||
"/build/themes/choose",
|
||||
data={"original": "lifgian", "choice": "Lifegain"},
|
||||
)
|
||||
assert choose_resp.status_code == 200
|
||||
choose_html = choose_resp.text
|
||||
assert "Lifegain" in choose_html
|
||||
assert "Updated 'lifgian' to 'Lifegain'." in choose_html
|
||||
|
||||
remove_resp = client.post("/build/themes/remove", data={"theme": "Lifegain"})
|
||||
assert remove_resp.status_code == 200
|
||||
remove_html = remove_resp.text
|
||||
assert "Theme removed." in remove_html
|
||||
assert "No supplemental themes yet." in remove_html
|
||||
assert "All themes resolved." in remove_html
|
||||
assert "Use Lifegain" not in remove_html
|
||||
assert "theme-chip" not in remove_html
|
||||
145
code/tests/test_custom_theme_manager_smoke.py
Normal file
145
code/tests/test_custom_theme_manager_smoke.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from deck_builder.theme_resolution import ThemeResolutionInfo
|
||||
from web.services import custom_theme_manager as ctm
|
||||
|
||||
|
||||
def _make_info(
|
||||
requested: Sequence[str],
|
||||
*,
|
||||
resolved: Sequence[str] | None = None,
|
||||
matches: Sequence[Dict[str, object]] | None = None,
|
||||
unresolved: Sequence[Dict[str, object]] | None = None,
|
||||
mode: str = "permissive",
|
||||
) -> ThemeResolutionInfo:
|
||||
return ThemeResolutionInfo(
|
||||
requested=list(requested),
|
||||
mode=mode,
|
||||
catalog_version="test-cat",
|
||||
resolved=list(resolved or []),
|
||||
matches=list(matches or []),
|
||||
unresolved=list(unresolved or []),
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
|
||||
|
||||
def test_add_theme_exact_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session: Dict[str, object] = {}
|
||||
|
||||
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
|
||||
assert list(requested) == ["Lifegain"]
|
||||
assert mode == "permissive"
|
||||
return _make_info(
|
||||
requested,
|
||||
resolved=["Lifegain"],
|
||||
matches=[{"input": "Lifegain", "matched": "Lifegain", "score": 100.0, "reason": "exact", "suggestions": []}],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
|
||||
|
||||
info, message, level = ctm.add_theme(
|
||||
session,
|
||||
"Lifegain",
|
||||
commander_tags=(),
|
||||
mode="permissive",
|
||||
limit=ctm.DEFAULT_THEME_LIMIT,
|
||||
)
|
||||
|
||||
assert info is not None
|
||||
assert info.resolved == ["Lifegain"]
|
||||
assert session["custom_theme_inputs"] == ["Lifegain"]
|
||||
assert session["additional_themes"] == ["Lifegain"]
|
||||
assert message == "Added theme 'Lifegain'."
|
||||
assert level == "success"
|
||||
|
||||
|
||||
def test_add_theme_choose_suggestion_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session: Dict[str, object] = {}
|
||||
|
||||
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
|
||||
inputs = list(requested)
|
||||
if inputs == ["lifgian"]:
|
||||
return _make_info(
|
||||
inputs,
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=[
|
||||
{
|
||||
"input": "lifgian",
|
||||
"reason": "suggestions",
|
||||
"score": 72.0,
|
||||
"suggestions": [{"theme": "Lifegain", "score": 91.2}],
|
||||
}
|
||||
],
|
||||
)
|
||||
if inputs == ["Lifegain"]:
|
||||
return _make_info(
|
||||
inputs,
|
||||
resolved=["Lifegain"],
|
||||
matches=[
|
||||
{
|
||||
"input": "lifgian",
|
||||
"matched": "Lifegain",
|
||||
"score": 91.2,
|
||||
"reason": "suggestion",
|
||||
"suggestions": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
pytest.fail(f"Unexpected inputs {inputs}")
|
||||
|
||||
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
|
||||
|
||||
info, message, level = ctm.add_theme(
|
||||
session,
|
||||
"lifgian",
|
||||
commander_tags=(),
|
||||
mode="permissive",
|
||||
limit=ctm.DEFAULT_THEME_LIMIT,
|
||||
)
|
||||
assert info is not None
|
||||
assert not info.resolved
|
||||
assert session["custom_theme_inputs"] == ["lifgian"]
|
||||
assert message == "Added theme 'lifgian'."
|
||||
assert level == "success"
|
||||
|
||||
info, message, level = ctm.choose_suggestion(
|
||||
session,
|
||||
"lifgian",
|
||||
"Lifegain",
|
||||
commander_tags=(),
|
||||
mode="permissive",
|
||||
)
|
||||
assert info is not None
|
||||
assert info.resolved == ["Lifegain"]
|
||||
assert session["custom_theme_inputs"] == ["Lifegain"]
|
||||
assert session["additional_themes"] == ["Lifegain"]
|
||||
assert message == "Updated 'lifgian' to 'Lifegain'."
|
||||
assert level == "success"
|
||||
|
||||
|
||||
def test_remove_theme_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session: Dict[str, object] = {"custom_theme_inputs": ["Lifegain"], "additional_themes": ["Lifegain"]}
|
||||
|
||||
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
|
||||
assert requested == []
|
||||
return _make_info(requested, resolved=[], matches=[], unresolved=[])
|
||||
|
||||
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
|
||||
|
||||
info, message, level = ctm.remove_theme(
|
||||
session,
|
||||
"Lifegain",
|
||||
commander_tags=(),
|
||||
mode="permissive",
|
||||
)
|
||||
|
||||
assert info is not None
|
||||
assert session["custom_theme_inputs"] == []
|
||||
assert session["additional_themes"] == []
|
||||
assert message == "Theme removed."
|
||||
assert level == "success"
|
||||
|
|
@ -6,6 +6,7 @@ back with full fidelity, supporting the persistence layer of the include/exclude
|
|||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
|
@ -88,6 +89,11 @@ class TestJSONRoundTrip:
|
|||
assert re_exported_config["enforcement_mode"] == "strict"
|
||||
assert re_exported_config["allow_illegal"] is True
|
||||
assert re_exported_config["fuzzy_matching"] is False
|
||||
assert re_exported_config["additional_themes"] == []
|
||||
assert re_exported_config["theme_match_mode"] == "permissive"
|
||||
assert re_exported_config["theme_catalog_version"] is None
|
||||
assert re_exported_config["userThemes"] == []
|
||||
assert re_exported_config["themeCatalogVersion"] is None
|
||||
|
||||
def test_empty_lists_round_trip(self):
|
||||
"""Test that empty include/exclude lists are handled correctly."""
|
||||
|
|
@ -113,6 +119,8 @@ class TestJSONRoundTrip:
|
|||
assert exported_config["enforcement_mode"] == "warn"
|
||||
assert exported_config["allow_illegal"] is False
|
||||
assert exported_config["fuzzy_matching"] is True
|
||||
assert exported_config["userThemes"] == []
|
||||
assert exported_config["themeCatalogVersion"] is None
|
||||
|
||||
def test_default_values_export(self):
|
||||
"""Test that default values are exported correctly."""
|
||||
|
|
@ -134,6 +142,9 @@ class TestJSONRoundTrip:
|
|||
assert exported_config["enforcement_mode"] == "warn"
|
||||
assert exported_config["allow_illegal"] is False
|
||||
assert exported_config["fuzzy_matching"] is True
|
||||
assert exported_config["additional_themes"] == []
|
||||
assert exported_config["theme_match_mode"] == "permissive"
|
||||
assert exported_config["theme_catalog_version"] is None
|
||||
|
||||
def test_backward_compatibility_no_include_exclude_fields(self):
|
||||
"""Test that configs without include/exclude fields still work."""
|
||||
|
|
@ -167,6 +178,63 @@ class TestJSONRoundTrip:
|
|||
assert "enforcement_mode" not in loaded_config
|
||||
assert "allow_illegal" not in loaded_config
|
||||
assert "fuzzy_matching" not in loaded_config
|
||||
assert "additional_themes" not in loaded_config
|
||||
assert "theme_match_mode" not in loaded_config
|
||||
assert "theme_catalog_version" not in loaded_config
|
||||
assert "userThemes" not in loaded_config
|
||||
assert "themeCatalogVersion" not in loaded_config
|
||||
|
||||
def test_export_backward_compatibility_hash(self):
|
||||
"""Ensure exports without user themes remain hash-compatible with legacy payload."""
|
||||
builder = DeckBuilder()
|
||||
builder.commander_name = "Test Commander"
|
||||
builder.include_cards = ["Sol Ring"]
|
||||
builder.exclude_cards = []
|
||||
builder.enforcement_mode = "warn"
|
||||
builder.allow_illegal = False
|
||||
builder.fuzzy_matching = True
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exported_path = builder.export_run_config_json(directory=temp_dir, suppress_output=True)
|
||||
|
||||
with open(exported_path, 'r', encoding='utf-8') as f:
|
||||
exported_config = json.load(f)
|
||||
|
||||
legacy_expected = {
|
||||
"commander": "Test Commander",
|
||||
"primary_tag": None,
|
||||
"secondary_tag": None,
|
||||
"tertiary_tag": None,
|
||||
"bracket_level": None,
|
||||
"tag_mode": "AND",
|
||||
"use_multi_theme": True,
|
||||
"add_lands": True,
|
||||
"add_creatures": True,
|
||||
"add_non_creature_spells": True,
|
||||
"prefer_combos": False,
|
||||
"combo_target_count": None,
|
||||
"combo_balance": None,
|
||||
"include_cards": ["Sol Ring"],
|
||||
"exclude_cards": [],
|
||||
"enforcement_mode": "warn",
|
||||
"allow_illegal": False,
|
||||
"fuzzy_matching": True,
|
||||
"additional_themes": [],
|
||||
"theme_match_mode": "permissive",
|
||||
"theme_catalog_version": None,
|
||||
"fetch_count": None,
|
||||
"ideal_counts": {},
|
||||
}
|
||||
|
||||
sanitized_payload = {k: exported_config.get(k) for k in legacy_expected.keys()}
|
||||
|
||||
assert sanitized_payload == legacy_expected
|
||||
assert exported_config["userThemes"] == []
|
||||
assert exported_config["themeCatalogVersion"] is None
|
||||
|
||||
legacy_hash = hashlib.sha256(json.dumps(legacy_expected, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
sanitized_hash = hashlib.sha256(json.dumps(sanitized_payload, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
assert sanitized_hash == legacy_hash
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -264,6 +264,8 @@ class TestJSONRoundTrip:
|
|||
assert exported_data['enforcement_mode'] == "strict"
|
||||
assert exported_data['allow_illegal'] is True
|
||||
assert exported_data['fuzzy_matching'] is False
|
||||
assert exported_data['userThemes'] == []
|
||||
assert exported_data['themeCatalogVersion'] is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ def test_enforce_and_reexport_includes_json_reexport():
|
|||
assert 'include_cards' in json_data, "JSON should contain include_cards field"
|
||||
assert 'exclude_cards' in json_data, "JSON should contain exclude_cards field"
|
||||
assert 'enforcement_mode' in json_data, "JSON should contain enforcement_mode field"
|
||||
assert 'userThemes' in json_data, "JSON should surface userThemes alias"
|
||||
assert 'themeCatalogVersion' in json_data, "JSON should surface themeCatalogVersion alias"
|
||||
|
||||
except Exception:
|
||||
# If enforce_and_reexport fails completely, that's also fine for this test
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import csv
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from code.scripts import generate_theme_catalog as new_catalog
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / 'code' / 'scripts' / 'build_theme_catalog.py'
|
||||
|
||||
|
|
@ -60,3 +66,129 @@ def test_catalog_schema_contains_descriptions(tmp_path):
|
|||
data = json.loads(out_path.read_text(encoding='utf-8'))
|
||||
assert all('description' in t for t in data['themes'])
|
||||
assert all(t['description'] for t in data['themes'])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fixed_now() -> datetime:
|
||||
return datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _write_csv(path: Path, rows: list[dict[str, object]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not rows:
|
||||
path.write_text('', encoding='utf-8')
|
||||
return
|
||||
fieldnames = sorted({field for row in rows for field in row.keys()})
|
||||
with path.open('w', encoding='utf-8', newline='') as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
|
||||
def _read_catalog_rows(path: Path) -> list[dict[str, str]]:
|
||||
with path.open('r', encoding='utf-8') as handle:
|
||||
header_comment = handle.readline()
|
||||
assert header_comment.startswith(new_catalog.HEADER_COMMENT_PREFIX)
|
||||
reader = csv.DictReader(handle)
|
||||
return list(reader)
|
||||
|
||||
|
||||
def test_generate_theme_catalog_basic(tmp_path: Path, fixed_now: datetime) -> None:
|
||||
csv_dir = tmp_path / 'csv_files'
|
||||
cards = csv_dir / 'cards.csv'
|
||||
commander = csv_dir / 'commander_cards.csv'
|
||||
|
||||
_write_csv(
|
||||
cards,
|
||||
[
|
||||
{
|
||||
'name': 'Card A',
|
||||
'themeTags': '["Lifegain", "Token Swarm"]',
|
||||
},
|
||||
{
|
||||
'name': 'Card B',
|
||||
'themeTags': '[" lifegain ", "Control"]',
|
||||
},
|
||||
{
|
||||
'name': 'Card C',
|
||||
'themeTags': '[]',
|
||||
},
|
||||
],
|
||||
)
|
||||
_write_csv(
|
||||
commander,
|
||||
[
|
||||
{
|
||||
'name': 'Commander 1',
|
||||
'themeTags': '["Lifegain", " Voltron "]',
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
output_path = tmp_path / 'theme_catalog.csv'
|
||||
result = new_catalog.build_theme_catalog(
|
||||
csv_directory=csv_dir,
|
||||
output_path=output_path,
|
||||
generated_at=fixed_now,
|
||||
)
|
||||
|
||||
assert result.output_path == output_path
|
||||
assert result.generated_at == '2025-01-01T12:00:00Z'
|
||||
|
||||
rows = _read_catalog_rows(output_path)
|
||||
assert [row['theme'] for row in rows] == ['Control', 'Lifegain', 'Token Swarm', 'Voltron']
|
||||
lifegain = next(row for row in rows if row['theme'] == 'Lifegain')
|
||||
assert lifegain['card_count'] == '2'
|
||||
assert lifegain['commander_count'] == '1'
|
||||
assert lifegain['source_count'] == '3'
|
||||
|
||||
assert all(row['last_generated_at'] == result.generated_at for row in rows)
|
||||
assert all(row['version'] == result.version for row in rows)
|
||||
|
||||
expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) # type: ignore[attr-defined]
|
||||
assert result.version == expected_hash
|
||||
|
||||
|
||||
def test_generate_theme_catalog_deduplicates_variants(tmp_path: Path, fixed_now: datetime) -> None:
|
||||
csv_dir = tmp_path / 'csv_files'
|
||||
cards = csv_dir / 'cards.csv'
|
||||
commander = csv_dir / 'commander_cards.csv'
|
||||
|
||||
_write_csv(
|
||||
cards,
|
||||
[
|
||||
{
|
||||
'name': 'Card A',
|
||||
'themeTags': '[" Token Swarm ", "Combo"]',
|
||||
},
|
||||
{
|
||||
'name': 'Card B',
|
||||
'themeTags': '["token swarm"]',
|
||||
},
|
||||
],
|
||||
)
|
||||
_write_csv(
|
||||
commander,
|
||||
[
|
||||
{
|
||||
'name': 'Commander 1',
|
||||
'themeTags': '["TOKEN SWARM"]',
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
output_path = tmp_path / 'theme_catalog.csv'
|
||||
result = new_catalog.build_theme_catalog(
|
||||
csv_directory=csv_dir,
|
||||
output_path=output_path,
|
||||
generated_at=fixed_now,
|
||||
)
|
||||
|
||||
rows = _read_catalog_rows(output_path)
|
||||
assert [row['theme'] for row in rows] == ['Combo', 'Token Swarm']
|
||||
token_row = next(row for row in rows if row['theme'] == 'Token Swarm')
|
||||
assert token_row['card_count'] == '2'
|
||||
assert token_row['commander_count'] == '1'
|
||||
assert token_row['source_count'] == '3'
|
||||
assert result.output_path.exists()
|
||||
|
|
|
|||
61
code/tests/test_theme_catalog_loader.py
Normal file
61
code/tests/test_theme_catalog_loader.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry, load_theme_catalog
|
||||
|
||||
|
||||
def _write_catalog(path: Path, lines: list[str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_load_theme_catalog_basic(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(
|
||||
catalog_path,
|
||||
[
|
||||
"# theme_catalog version=abc123 generated_at=2025-01-02T00:00:00Z",
|
||||
"theme,source_count,commander_count,card_count,last_generated_at,version",
|
||||
"Lifegain,3,1,2,2025-01-02T00:00:00Z,abc123",
|
||||
"Token Swarm,5,2,3,2025-01-02T00:00:00Z,abc123",
|
||||
],
|
||||
)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
entries, version = load_theme_catalog(catalog_path)
|
||||
|
||||
assert version == "abc123"
|
||||
assert entries == [
|
||||
ThemeCatalogEntry(theme="Lifegain", commander_count=1, card_count=2),
|
||||
ThemeCatalogEntry(theme="Token Swarm", commander_count=2, card_count=3),
|
||||
]
|
||||
log_messages = {record.message for record in caplog.records}
|
||||
assert any("theme_catalog_loaded" in message for message in log_messages)
|
||||
|
||||
|
||||
def test_load_theme_catalog_empty_file(tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(catalog_path, ["# theme_catalog version=empty"])
|
||||
|
||||
entries, version = load_theme_catalog(catalog_path)
|
||||
|
||||
assert entries == []
|
||||
assert version == "empty"
|
||||
|
||||
|
||||
def test_load_theme_catalog_missing_columns(tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(
|
||||
catalog_path,
|
||||
[
|
||||
"# theme_catalog version=missing",
|
||||
"theme,card_count,last_generated_at,version",
|
||||
"Lifegain,2,2025-01-02T00:00:00Z,missing",
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
load_theme_catalog(catalog_path)
|
||||
92
code/tests/test_theme_matcher.py
Normal file
92
code/tests/test_theme_matcher.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry
|
||||
from code.deck_builder.theme_matcher import (
|
||||
ACCEPT_MATCH_THRESHOLD,
|
||||
SUGGEST_MATCH_THRESHOLD,
|
||||
ThemeMatcher,
|
||||
normalize_theme,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_entries() -> list[ThemeCatalogEntry]:
|
||||
themes = [
|
||||
"Aristocrats",
|
||||
"Sacrifice Matters",
|
||||
"Life Gain",
|
||||
"Token Swarm",
|
||||
"Control",
|
||||
"Superfriends",
|
||||
"Spellslinger",
|
||||
"Artifact Tokens",
|
||||
"Treasure Storm",
|
||||
"Graveyard Loops",
|
||||
]
|
||||
return [ThemeCatalogEntry(theme=theme, commander_count=0, card_count=0) for theme in themes]
|
||||
|
||||
|
||||
def test_normalize_theme_collapses_spaces() -> None:
|
||||
assert normalize_theme(" Life Gain \t") == "life gain"
|
||||
|
||||
|
||||
def test_exact_match_case_insensitive(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("aristocrats")
|
||||
assert result.matched_theme == "Aristocrats"
|
||||
assert result.score == pytest.approx(100.0)
|
||||
assert result.reason == "high_confidence"
|
||||
|
||||
|
||||
def test_minor_typo_accepts_with_high_score(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("aristrocrats")
|
||||
assert result.matched_theme == "Aristocrats"
|
||||
assert result.score >= ACCEPT_MATCH_THRESHOLD
|
||||
assert result.reason in {"high_confidence", "accepted_confidence"}
|
||||
|
||||
|
||||
def test_multi_typo_only_suggests(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("arzstrcrats")
|
||||
assert result.matched_theme is None
|
||||
assert result.score >= SUGGEST_MATCH_THRESHOLD
|
||||
assert result.reason == "suggestions"
|
||||
assert any(s.theme == "Aristocrats" for s in result.suggestions)
|
||||
|
||||
|
||||
def test_no_match_returns_empty(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("planeship")
|
||||
assert result.matched_theme is None
|
||||
assert result.suggestions == []
|
||||
assert result.reason in {"no_candidates", "no_match"}
|
||||
|
||||
|
||||
def test_short_input_requires_exact(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("ar")
|
||||
assert result.matched_theme is None
|
||||
assert result.reason == "input_too_short"
|
||||
|
||||
result_exact = matcher.resolve("lo")
|
||||
assert result_exact.matched_theme is None
|
||||
|
||||
|
||||
def test_resolution_speed(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
many_entries = [
|
||||
ThemeCatalogEntry(theme=f"Theme {i}", commander_count=0, card_count=0) for i in range(400)
|
||||
]
|
||||
matcher = ThemeMatcher(many_entries)
|
||||
matcher.resolve("theme 42")
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(20):
|
||||
matcher.resolve("theme 123")
|
||||
duration = time.perf_counter() - start
|
||||
# Observed ~0.03s per resolution (<=0.65s for 20 resolves) on dev machine (2025-10-02).
|
||||
assert duration < 0.7
|
||||
115
code/tests/test_theme_spell_weighting.py
Normal file
115
code/tests/test_theme_spell_weighting.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deck_builder.theme_context import ThemeContext, ThemeTarget
|
||||
from deck_builder.phases.phase4_spells import SpellAdditionMixin
|
||||
from deck_builder import builder_utils as bu
|
||||
|
||||
|
||||
class DummyRNG:
|
||||
def uniform(self, _a: float, _b: float) -> float:
|
||||
return 1.0
|
||||
|
||||
def random(self) -> float:
|
||||
return 0.0
|
||||
|
||||
def choice(self, seq):
|
||||
return seq[0]
|
||||
|
||||
|
||||
class DummySpellBuilder(SpellAdditionMixin):
|
||||
def __init__(self, df: pd.DataFrame, context: ThemeContext):
|
||||
self._combined_cards_df = df
|
||||
# Pre-populate 99 cards so we target a single filler slot
|
||||
self.card_library: Dict[str, Dict[str, Any]] = {
|
||||
f"Existing{i}": {"Count": 1} for i in range(99)
|
||||
}
|
||||
self.primary_tag = context.ordered_targets[0].display if context.ordered_targets else None
|
||||
self.secondary_tag = None
|
||||
self.tertiary_tag = None
|
||||
self.tag_mode = context.combine_mode
|
||||
self.prefer_owned = False
|
||||
self.owned_card_names: set[str] = set()
|
||||
self.bracket_limits: Dict[str, Any] = {}
|
||||
self.output_log: List[str] = []
|
||||
self.output_func = self.output_log.append
|
||||
self._rng = DummyRNG()
|
||||
self._theme_context = context
|
||||
self.added_cards: List[str] = []
|
||||
|
||||
def _get_rng(self) -> DummyRNG:
|
||||
return self._rng
|
||||
|
||||
@property
|
||||
def rng(self) -> DummyRNG:
|
||||
return self._rng
|
||||
|
||||
def get_theme_context(self) -> ThemeContext: # type: ignore[override]
|
||||
return self._theme_context
|
||||
|
||||
def add_card(self, name: str, **kwargs: Any) -> None: # type: ignore[override]
|
||||
self.card_library[name] = {"Count": kwargs.get("count", 1)}
|
||||
self.added_cards.append(name)
|
||||
|
||||
|
||||
def make_context(user_theme_weight: float) -> ThemeContext:
|
||||
user = ThemeTarget(
|
||||
role="user_1",
|
||||
display="Angels",
|
||||
slug="angels",
|
||||
source="user",
|
||||
weight=1.0,
|
||||
)
|
||||
return ThemeContext(
|
||||
ordered_targets=[user],
|
||||
combine_mode="AND",
|
||||
weights={"user_1": 1.0},
|
||||
commander_slugs=[],
|
||||
user_slugs=["angels"],
|
||||
resolution=None,
|
||||
user_theme_weight=user_theme_weight,
|
||||
)
|
||||
|
||||
|
||||
def build_dataframe() -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"name": "Angel Song",
|
||||
"type": "Instant",
|
||||
"themeTags": ["Angels"],
|
||||
"manaValue": 2,
|
||||
"edhrecRank": 1400,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_user_theme_bonus_increases_weight(monkeypatch) -> None:
|
||||
captured: List[List[tuple[str, float]]] = []
|
||||
|
||||
def fake_weighted(pool: List[tuple[str, float]], k: int, rng=None) -> List[str]:
|
||||
captured.append(list(pool))
|
||||
ranked = sorted(pool, key=lambda item: item[1], reverse=True)
|
||||
return [name for name, _ in ranked[:k]]
|
||||
|
||||
monkeypatch.setattr(bu, "weighted_sample_without_replacement", fake_weighted)
|
||||
|
||||
def run(user_weight: float) -> Dict[str, float]:
|
||||
start = len(captured)
|
||||
context = make_context(user_weight)
|
||||
builder = DummySpellBuilder(build_dataframe(), context)
|
||||
builder.fill_remaining_theme_spells()
|
||||
assert start < len(captured) # ensure we captured weights
|
||||
pool = captured[start]
|
||||
return dict(pool)
|
||||
|
||||
weights_no_bonus = run(1.0)
|
||||
weights_bonus = run(1.5)
|
||||
|
||||
assert "Angel Song" in weights_no_bonus
|
||||
assert "Angel Song" in weights_bonus
|
||||
assert weights_bonus["Angel Song"] > weights_no_bonus["Angel Song"]
|
||||
61
code/tests/test_theme_summary_telemetry.py
Normal file
61
code/tests/test_theme_summary_telemetry.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from deck_builder.summary_telemetry import (
|
||||
_reset_metrics_for_test,
|
||||
get_theme_metrics,
|
||||
record_theme_summary,
|
||||
)
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
_reset_metrics_for_test()
|
||||
|
||||
|
||||
def teardown_function() -> None:
|
||||
_reset_metrics_for_test()
|
||||
|
||||
|
||||
def test_record_theme_summary_tracks_user_themes() -> None:
|
||||
payload = {
|
||||
"commanderThemes": ["Lifegain"],
|
||||
"userThemes": ["Angels", "Life Gain"],
|
||||
"requested": ["Angels"],
|
||||
"resolved": ["angels"],
|
||||
"unresolved": [],
|
||||
"mode": "AND",
|
||||
"weight": 1.3,
|
||||
"themeCatalogVersion": "test-cat",
|
||||
}
|
||||
record_theme_summary(payload)
|
||||
metrics = get_theme_metrics()
|
||||
assert metrics["total_builds"] == 1
|
||||
assert metrics["with_user_themes"] == 1
|
||||
summary = metrics["last_summary"]
|
||||
assert summary is not None
|
||||
assert summary["commanderThemes"] == ["Lifegain"]
|
||||
assert summary["userThemes"] == ["Angels", "Life Gain"]
|
||||
assert summary["mergedThemes"] == ["Lifegain", "Angels", "Life Gain"]
|
||||
assert summary["unresolvedCount"] == 0
|
||||
assert metrics["top_user_themes"][0]["theme"] in {"Angels", "Life Gain"}
|
||||
|
||||
|
||||
def test_record_theme_summary_without_user_themes() -> None:
|
||||
payload = {
|
||||
"commanderThemes": ["Artifacts"],
|
||||
"userThemes": [],
|
||||
"requested": [],
|
||||
"resolved": [],
|
||||
"unresolved": [],
|
||||
"mode": "AND",
|
||||
"weight": 1.0,
|
||||
}
|
||||
record_theme_summary(payload)
|
||||
metrics = get_theme_metrics()
|
||||
assert metrics["total_builds"] == 1
|
||||
assert metrics["with_user_themes"] == 0
|
||||
summary = metrics["last_summary"]
|
||||
assert summary is not None
|
||||
assert summary["commanderThemes"] == ["Artifacts"]
|
||||
assert summary["userThemes"] == []
|
||||
assert summary["mergedThemes"] == ["Artifacts"]
|
||||
assert summary["unresolvedCount"] == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue