feat: add supplemental theme catalog tooling, additional theme selection, and custom theme selection

This commit is contained in:
matt 2025-10-03 10:43:24 -07:00
parent 3a1b011dbc
commit 9428e09cef
39 changed files with 3643 additions and 198 deletions

View 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

View 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

View 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"

View file

@ -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__":

View file

@ -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__":

View file

@ -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

View file

@ -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()

View 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)

View 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

View 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"]

View 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