feat: Added Partners, Backgrounds, and related variation selections to commander building.

This commit is contained in:
matt 2025-10-06 09:17:59 -07:00
parent 641b305955
commit d416c9b238
65 changed files with 11835 additions and 691 deletions

View file

@ -0,0 +1,62 @@
from __future__ import annotations
from pathlib import Path
import pytest
from code.deck_builder.background_loader import (
BackgroundCatalog,
BackgroundCard,
clear_background_cards_cache,
load_background_cards,
)
@pytest.fixture(autouse=True)
def clear_cache() -> None:
clear_background_cards_cache()
def _write_csv(tmp_path: Path, rows: str) -> Path:
path = tmp_path / "background_cards.csv"
path.write_text(rows, encoding="utf-8")
return path
def test_load_background_cards_filters_non_backgrounds(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level("INFO")
csv_text = """# version=123 count=2\nname,faceName,type,text,themeTags,colorIdentity,colors,manaCost,manaValue,keywords,edhrecRank,layout,side\nAcolyte of Bahamut,,Legendary Enchantment — Background,Commander creatures you own have menace.,['Backgrounds Matter'],G,G,{1}{G},2.0,,7570,normal,\nNot a Background,,Legendary Creature — Elf,Partner with Foo,,G,G,{3}{G},4.0,,5000,normal,\n"""
path = _write_csv(tmp_path, csv_text)
catalog = load_background_cards(path)
assert isinstance(catalog, BackgroundCatalog)
assert [card.display_name for card in catalog.entries] == ["Acolyte of Bahamut"]
assert catalog.version == "123"
assert "background_cards_loaded" in caplog.text
def test_load_background_cards_empty_file(tmp_path: Path) -> None:
csv_text = """# version=empty count=0\nname,faceName,type,text,themeTags,colorIdentity,colors,manaCost,manaValue,keywords,edhrecRank,layout,side\n"""
path = _write_csv(tmp_path, csv_text)
catalog = load_background_cards(path)
assert catalog.version == "empty"
assert catalog.entries == tuple()
def test_load_background_cards_deduplicates_by_name(tmp_path: Path) -> None:
csv_text = (
"# version=dedupe count=2\n"
"name,faceName,type,text,themeTags,colorIdentity,colors,manaCost,manaValue,keywords,edhrecRank,layout,side\n"
"Guild Artisan,,Legendary Enchantment — Background,Commander creatures you own have treasure.,['Backgrounds Matter'],R,R,{1}{R},2.0,,3366,normal,\n"
"Guild Artisan,,Legendary Enchantment — Background,Commander creatures you own have treasure tokens.,['Backgrounds Matter'],R,R,{1}{R},2.0,,3366,normal,\n"
)
path = _write_csv(tmp_path, csv_text)
catalog = load_background_cards(path)
assert len(catalog.entries) == 1
card = catalog.entries[0]
assert isinstance(card, BackgroundCard)
assert card.display_name == "Guild Artisan"
assert "treasure" in card.oracle_text.lower()
assert catalog.get("guild artisan") is card

View file

@ -0,0 +1,147 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import importlib
import pytest
hr = importlib.import_module("code.headless_runner")
def _parse_cli(args: list[str]) -> object:
parser = hr._build_arg_parser()
return parser.parse_args(args)
def test_cli_partner_options_in_dry_run(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DECK_SECONDARY_COMMANDER", raising=False)
monkeypatch.delenv("ENABLE_PARTNER_MECHANICS", raising=False)
args = _parse_cli(
[
"--commander",
"Halana, Kessig Ranger",
"--secondary-commander",
"Alena, Kessig Trapper",
"--enable-partner-mechanics",
"true",
"--dry-run",
]
)
json_cfg: dict[str, object] = {}
secondary = hr._resolve_string_option(args.secondary_commander, "DECK_SECONDARY_COMMANDER", json_cfg, "secondary_commander")
background = hr._resolve_string_option(args.background, "DECK_BACKGROUND", json_cfg, "background")
partner_flag = hr._resolve_bool_option(args.enable_partner_mechanics, "ENABLE_PARTNER_MECHANICS", json_cfg, "enable_partner_mechanics")
assert secondary == "Alena, Kessig Trapper"
assert background is None
assert partner_flag is True
def test_cli_background_option_in_dry_run(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DECK_BACKGROUND", raising=False)
monkeypatch.delenv("ENABLE_PARTNER_MECHANICS", raising=False)
args = _parse_cli(
[
"--commander",
"Lae'zel, Vlaakith's Champion",
"--background",
"Scion of Halaster",
"--enable-partner-mechanics",
"true",
"--dry-run",
]
)
json_cfg: dict[str, object] = {}
background = hr._resolve_string_option(args.background, "DECK_BACKGROUND", json_cfg, "background")
partner_flag = hr._resolve_bool_option(args.enable_partner_mechanics, "ENABLE_PARTNER_MECHANICS", json_cfg, "enable_partner_mechanics")
assert background == "Scion of Halaster"
assert partner_flag is True
def test_env_flag_enables_partner_mechanics(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ENABLE_PARTNER_MECHANICS", "1")
args = _parse_cli(
[
"--commander",
"Halana, Kessig Ranger",
"--secondary-commander",
"Alena, Kessig Trapper",
"--dry-run",
]
)
json_cfg: dict[str, object] = {}
partner_flag = hr._resolve_bool_option(args.enable_partner_mechanics, "ENABLE_PARTNER_MECHANICS", json_cfg, "enable_partner_mechanics")
assert partner_flag is True
def _extract_json_payload(stdout: str) -> dict[str, object]:
start = stdout.find("{")
end = stdout.rfind("}")
if start == -1 or end == -1 or end < start:
raise AssertionError(f"Expected JSON object in output, received: {stdout!r}")
snippet = stdout[start : end + 1]
return json.loads(snippet)
def test_json_config_secondary_commander_parsing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
cfg_dir = tmp_path / "cfg"
cfg_dir.mkdir()
config_path = cfg_dir / "deck.json"
config_payload = {
"commander": "Halana, Kessig Ranger",
"secondary_commander": "Alena, Kessig Trapper",
"enable_partner_mechanics": True,
}
config_path.write_text(json.dumps(config_payload), encoding="utf-8")
monkeypatch.setattr(hr, "_ensure_data_ready", lambda: None)
monkeypatch.delenv("DECK_SECONDARY_COMMANDER", raising=False)
monkeypatch.delenv("ENABLE_PARTNER_MECHANICS", raising=False)
monkeypatch.delenv("DECK_BACKGROUND", raising=False)
monkeypatch.setattr(sys, "argv", ["headless_runner.py", "--config", str(config_path), "--dry-run"])
exit_code = hr._main()
assert exit_code == 0
captured = capsys.readouterr()
payload = _extract_json_payload(captured.out.strip())
assert payload["secondary_commander"] == "Alena, Kessig Trapper"
assert payload["background"] is None
assert payload["enable_partner_mechanics"] is True
def test_json_config_background_parsing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
cfg_dir = tmp_path / "cfg"
cfg_dir.mkdir(exist_ok=True)
config_path = cfg_dir / "deck.json"
config_payload = {
"commander": "Lae'zel, Vlaakith's Champion",
"background": "Scion of Halaster",
"enable_partner_mechanics": True,
}
config_path.write_text(json.dumps(config_payload), encoding="utf-8")
monkeypatch.setattr(hr, "_ensure_data_ready", lambda: None)
monkeypatch.delenv("DECK_SECONDARY_COMMANDER", raising=False)
monkeypatch.delenv("ENABLE_PARTNER_MECHANICS", raising=False)
monkeypatch.delenv("DECK_BACKGROUND", raising=False)
monkeypatch.setattr(sys, "argv", ["headless_runner.py", "--config", str(config_path), "--dry-run"])
exit_code = hr._main()
assert exit_code == 0
captured = capsys.readouterr()
payload = _extract_json_payload(captured.out.strip())
assert payload["background"] == "Scion of Halaster"
assert payload["secondary_commander"] is None
assert payload["enable_partner_mechanics"] is True

View file

@ -0,0 +1,254 @@
from __future__ import annotations
from dataclasses import dataclass
import pytest
from code.deck_builder.combined_commander import (
CombinedCommander,
PartnerMode,
build_combined_commander,
)
from exceptions import CommanderPartnerError
@dataclass
class FakeCommander:
name: str
display_name: str
color_identity: tuple[str, ...]
themes: tuple[str, ...] = ()
partner_with: tuple[str, ...] = ()
is_partner: bool = False
supports_backgrounds: bool = False
is_background: bool = False
oracle_text: str = ""
type_line: str = "Legendary Creature"
@dataclass
class FakeBackground:
name: str
display_name: str
color_identity: tuple[str, ...]
theme_tags: tuple[str, ...] = ()
is_background: bool = True
oracle_text: str = "Commander creatures you own have menace."
type_line: str = "Legendary Enchantment — Background"
def test_build_combined_commander_none_mode() -> None:
primary = FakeCommander(
name="Primary",
display_name="Primary",
color_identity=("R", "G"),
themes=("Aggro", "Tokens"),
)
combined = build_combined_commander(primary, None, PartnerMode.NONE)
assert isinstance(combined, CombinedCommander)
assert combined.secondary_name is None
assert combined.color_identity == ("R", "G")
assert combined.theme_tags == ("Aggro", "Tokens")
assert combined.warnings == tuple()
assert combined.raw_tags_secondary == tuple()
def test_build_combined_commander_partner_mode() -> None:
primary = FakeCommander(
name="Halana",
display_name="Halana",
color_identity=("G",),
themes=("Aggro",),
is_partner=True,
)
secondary = FakeCommander(
name="Alena",
display_name="Alena",
color_identity=("U",),
themes=("Control",),
is_partner=True,
)
combined = build_combined_commander(primary, secondary, PartnerMode.PARTNER)
assert combined.secondary_name == "Alena"
assert combined.color_identity == ("U", "G")
assert combined.theme_tags == ("Aggro", "Control")
assert combined.raw_tags_primary == ("Aggro",)
assert combined.raw_tags_secondary == ("Control",)
def test_partner_mode_requires_partner_keyword() -> None:
primary = FakeCommander(
name="Halana",
display_name="Halana",
color_identity=("G",),
themes=("Aggro",),
is_partner=True,
)
secondary = FakeCommander(
name="NonPartner",
display_name="NonPartner",
color_identity=("U",),
themes=("Control",),
is_partner=False,
)
with pytest.raises(CommanderPartnerError):
build_combined_commander(primary, secondary, PartnerMode.PARTNER)
def test_partner_with_mode_requires_matching_pairs() -> None:
primary = FakeCommander(
name="Commander A",
display_name="Commander A",
color_identity=("W",),
themes=("Value",),
partner_with=("Commander B",),
)
secondary = FakeCommander(
name="Commander B",
display_name="Commander B",
color_identity=("B",),
themes=("Graveyard",),
partner_with=("Commander A",),
)
combined = build_combined_commander(primary, secondary, PartnerMode.PARTNER_WITH)
assert combined.secondary_name == "Commander B"
assert combined.color_identity == ("W", "B")
assert combined.theme_tags == ("Value", "Graveyard")
def test_partner_with_mode_invalid_pair_raises() -> None:
primary = FakeCommander(
name="Commander A",
display_name="Commander A",
color_identity=("W",),
partner_with=("Commander X",),
)
secondary = FakeCommander(
name="Commander B",
display_name="Commander B",
color_identity=("B",),
partner_with=("Commander A",),
)
with pytest.raises(CommanderPartnerError):
build_combined_commander(primary, secondary, PartnerMode.PARTNER_WITH)
def test_background_mode_success() -> None:
primary = FakeCommander(
name="Lae'zel",
display_name="Lae'zel",
color_identity=("W",),
themes=("Counters",),
supports_backgrounds=True,
)
background = FakeBackground(
name="Scion of Halaster",
display_name="Scion of Halaster",
color_identity=("B",),
theme_tags=("Backgrounds Matter",),
)
combined = build_combined_commander(primary, background, PartnerMode.BACKGROUND)
assert combined.secondary_name == "Scion of Halaster"
assert combined.color_identity == ("W", "B")
assert combined.theme_tags == ("Counters", "Backgrounds Matter")
def test_background_mode_requires_support() -> None:
primary = FakeCommander(
name="Halana",
display_name="Halana",
color_identity=("G",),
themes=("Aggro",),
supports_backgrounds=False,
)
background = FakeBackground(
name="Scion of Halaster",
display_name="Scion of Halaster",
color_identity=("B",),
)
with pytest.raises(CommanderPartnerError):
build_combined_commander(primary, background, PartnerMode.BACKGROUND)
def test_duplicate_commander_not_allowed() -> None:
primary = FakeCommander(name="A", display_name="Same", color_identity=("G",), is_partner=True)
secondary = FakeCommander(name="B", display_name="Same", color_identity=("U",), is_partner=True)
with pytest.raises(CommanderPartnerError):
build_combined_commander(primary, secondary, PartnerMode.PARTNER)
def test_colorless_partner_with_colored_results_in_colored_identity_only() -> None:
primary = FakeCommander(name="Ulamog", display_name="Ulamog", color_identity=tuple(), is_partner=True)
secondary = FakeCommander(name="Tana", display_name="Tana", color_identity=("G",), is_partner=True)
combined = build_combined_commander(primary, secondary, PartnerMode.PARTNER)
assert combined.color_identity == ("G",)
def test_warning_emitted_for_multi_mode_primary() -> None:
primary = FakeCommander(
name="Wilson",
display_name="Wilson",
color_identity=("G",),
themes=("Aggro",),
is_partner=True,
supports_backgrounds=True,
)
combined = build_combined_commander(primary, None, PartnerMode.NONE)
assert combined.warnings == (
"Wilson has both Partner and Background abilities; ensure the selected mode is intentional.",
)
def test_partner_mode_rejects_background_secondary() -> None:
primary = FakeCommander(
name="Halana",
display_name="Halana",
color_identity=("G",),
themes=("Aggro",),
is_partner=True,
)
background = FakeBackground(
name="Scion of Halaster",
display_name="Scion of Halaster",
color_identity=("B",),
)
with pytest.raises(CommanderPartnerError):
build_combined_commander(primary, background, PartnerMode.PARTNER)
def test_theme_tags_deduplicate_preserving_order() -> None:
primary = FakeCommander(
name="Commander A",
display_name="Commander A",
color_identity=("W",),
themes=("Value", "Control"),
is_partner=True,
)
secondary = FakeCommander(
name="Commander B",
display_name="Commander B",
color_identity=("U",),
themes=("Control", "Tempo"),
is_partner=True,
)
combined = build_combined_commander(primary, secondary, PartnerMode.PARTNER)
assert combined.theme_tags == ("Value", "Control", "Tempo")

View file

@ -5,10 +5,11 @@ from pathlib import Path
import pandas as pd
import pytest
import commander_exclusions
import headless_runner as hr
from exceptions import CommanderValidationError
from file_setup import setup_utils as su
from file_setup.setup_utils import filter_dataframe, process_legendary_cards
from file_setup.setup_utils import process_legendary_cards
import settings
@ -118,16 +119,62 @@ def test_primary_face_retained_and_log_cleared(tmp_csv_dir):
assert len(processed) == 1
assert processed.iloc[0]["faceName"] == "Birgi, God of Storytelling"
# Downstream filter should continue to succeed with a single primary row
filtered = filter_dataframe(processed, [])
assert len(filtered) == 1
exclusion_path = tmp_csv_dir / ".commander_exclusions.json"
assert not exclusion_path.exists(), "No exclusion log expected when primary face remains"
def test_determine_commanders_generates_background_catalog(tmp_csv_dir, monkeypatch):
import importlib
setup_module = importlib.import_module("file_setup.setup")
monkeypatch.setattr(setup_module, "filter_dataframe", lambda df, banned: df)
commander_row = _make_card_row(
name="Hero of the Realm",
face_name="Hero of the Realm",
type_line="Legendary Creature — Human Knight",
side=None,
layout="normal",
power="3",
toughness="3",
text="Vigilance",
)
background_row = _make_card_row(
name="Mentor of Courage",
face_name="Mentor of Courage",
type_line="Legendary Enchantment — Background",
side=None,
layout="normal",
text="Commander creatures you own have vigilance.",
)
cards_df = pd.DataFrame([commander_row, background_row])
cards_df.to_csv(tmp_csv_dir / "cards.csv", index=False)
color_df = pd.DataFrame(
[
{
"name": "Hero of the Realm",
"faceName": "Hero of the Realm",
"themeTags": "['Valor']",
"creatureTypes": "['Human', 'Knight']",
"roleTags": "['Commander']",
}
]
)
color_df.to_csv(tmp_csv_dir / "white_cards.csv", index=False)
setup_module.determine_commanders()
background_path = tmp_csv_dir / "background_cards.csv"
assert background_path.exists(), "Expected background catalog to be generated"
lines = background_path.read_text(encoding="utf-8").splitlines()
assert lines, "Background catalog should not be empty"
assert lines[0].startswith("# ")
assert any("Mentor of Courage" in line for line in lines[1:])
def test_headless_validation_reports_secondary_face(monkeypatch):
monkeypatch.setattr(hr, "_load_commander_name_lookup", lambda: set())
monkeypatch.setattr(hr, "_load_commander_name_lookup", lambda: (set(), tuple()))
exclusion_entry = {
"name": "Elbrus, the Binding Blade // Withengar Unbound",
@ -135,7 +182,11 @@ def test_headless_validation_reports_secondary_face(monkeypatch):
"eligible_faces": ["Withengar Unbound"],
}
monkeypatch.setattr(hr, "lookup_commander_detail", lambda name: exclusion_entry if "Withengar" in name else None)
monkeypatch.setattr(
commander_exclusions,
"lookup_commander_detail",
lambda name: exclusion_entry if "Withengar" in name else None,
)
with pytest.raises(CommanderValidationError) as excinfo:
hr._validate_commander_available("Withengar Unbound")

View file

@ -4,6 +4,8 @@ import types
import pytest
from starlette.testclient import TestClient
from code.deck_builder.summary_telemetry import _reset_metrics_for_test, record_partner_summary
fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installed
@ -121,3 +123,51 @@ def test_commanders_nav_visible_by_default():
assert r.status_code == 200
body = r.text
assert '<a href="/commanders"' in body
def test_partner_metrics_endpoint_reports_color_sources():
app_module = load_app_with_env(SHOW_DIAGNOSTICS="1")
_reset_metrics_for_test()
record_partner_summary(
{
"primary": "Tana, the Bloodsower",
"secondary": "Nadir Kraken",
"names": ["Tana, the Bloodsower", "Nadir Kraken"],
"partner_mode": "partner",
"combined": {
"partner_mode": "partner",
"color_identity": ["G", "U"],
"color_code": "GU",
"color_label": "Simic (GU)",
"color_sources": [
{"color": "G", "providers": [{"name": "Tana, the Bloodsower", "role": "primary"}]},
{"color": "U", "providers": [{"name": "Nadir Kraken", "role": "partner"}]},
],
"color_delta": {
"added": ["U"],
"removed": [],
"primary": ["G"],
"secondary": ["U"],
},
"secondary_role": "partner",
"secondary_role_label": "Partner commander",
},
}
)
client = TestClient(app_module.app)
resp = client.get("/status/partner_metrics")
assert resp.status_code == 200
payload = resp.json()
assert payload.get("ok") is True
metrics = payload.get("metrics") or {}
assert metrics.get("total_pairs", 0) >= 1
last = metrics.get("last_summary")
assert last is not None
sources = last.get("color_sources") or []
assert any(entry.get("color") == "G" for entry in sources)
assert any(
provider.get("role") == "partner"
for entry in sources
for provider in entry.get("providers", [])
)

View file

@ -0,0 +1,110 @@
from __future__ import annotations
import csv
from pathlib import Path
import sys
import types
import pytest
from code.deck_builder.combined_commander import CombinedCommander, PartnerMode
from code.deck_builder.phases.phase6_reporting import ReportingMixin
class MetadataBuilder(ReportingMixin):
def __init__(self) -> None:
self.card_library = {
"Halana, Kessig Ranger": {
"Card Type": "Legendary Creature",
"Count": 1,
"Mana Cost": "{3}{G}",
"Mana Value": "4",
"Role": "Commander",
"Tags": ["Partner"],
},
"Alena, Kessig Trapper": {
"Card Type": "Legendary Creature",
"Count": 1,
"Mana Cost": "{4}{R}",
"Mana Value": "5",
"Role": "Commander",
"Tags": ["Partner"],
},
"Gruul Signet": {
"Card Type": "Artifact",
"Count": 1,
"Mana Cost": "{2}",
"Mana Value": "2",
"Role": "Ramp",
"Tags": [],
},
}
self.output_func = lambda *_args, **_kwargs: None
self.combined_commander = CombinedCommander(
primary_name="Halana, Kessig Ranger",
secondary_name="Alena, Kessig Trapper",
partner_mode=PartnerMode.PARTNER,
color_identity=("G", "R"),
theme_tags=("counters", "aggro"),
raw_tags_primary=("counters",),
raw_tags_secondary=("aggro",),
warnings=(),
)
self.commander_name = "Halana, Kessig Ranger"
self.secondary_commander = "Alena, Kessig Trapper"
self.partner_mode = PartnerMode.PARTNER
self.combined_color_identity = ("G", "R")
self.color_identity = ["G", "R"]
self.selected_tags = ["Counters", "Aggro"]
self.primary_tag = "Counters"
self.secondary_tag = "Aggro"
self.tertiary_tag = None
self.custom_export_base = "metadata_builder"
def _suppress_color_matrix(monkeypatch: pytest.MonkeyPatch) -> None:
stub = types.ModuleType("deck_builder.builder_utils")
stub.compute_color_source_matrix = lambda *_args, **_kwargs: {}
stub.multi_face_land_info = lambda *_args, **_kwargs: {}
monkeypatch.setitem(sys.modules, "deck_builder.builder_utils", stub)
def test_csv_header_includes_commander_names(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_suppress_color_matrix(monkeypatch)
builder = MetadataBuilder()
csv_path = Path(builder.export_decklist_csv(directory=str(tmp_path), filename="deck.csv"))
with csv_path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
assert reader.fieldnames is not None
assert reader.fieldnames[-1] == "Commanders: Halana, Kessig Ranger, Alena, Kessig Trapper"
rows = list(reader)
assert any(row["Name"] == "Gruul Signet" for row in rows)
def test_text_export_includes_commander_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_suppress_color_matrix(monkeypatch)
builder = MetadataBuilder()
text_path = Path(builder.export_decklist_text(directory=str(tmp_path), filename="deck.txt"))
lines = text_path.read_text(encoding="utf-8").splitlines()
assert lines[0] == "# Commanders: Halana, Kessig Ranger, Alena, Kessig Trapper"
assert lines[1] == "# Partner Mode: partner"
assert lines[2] == "# Colors: G, R"
assert lines[4].startswith("1 Halana, Kessig Ranger")
def test_summary_contains_combined_commander_block(monkeypatch: pytest.MonkeyPatch) -> None:
_suppress_color_matrix(monkeypatch)
builder = MetadataBuilder()
summary = builder.build_deck_summary()
commander_block = summary["commander"]
assert commander_block["names"] == [
"Halana, Kessig Ranger",
"Alena, Kessig Trapper",
]
assert commander_block["partner_mode"] == "partner"
assert commander_block["color_identity"] == ["G", "R"]
combined = commander_block["combined"]
assert combined["primary_name"] == "Halana, Kessig Ranger"
assert combined["secondary_name"] == "Alena, Kessig Trapper"
assert combined["partner_mode"] == "partner"
assert combined["color_identity"] == ["G", "R"]

View file

@ -47,7 +47,10 @@ class TestJSONRoundTrip:
"exclude_cards": ["Chaos Orb", "Shahrazad", "Time Walk"],
"enforcement_mode": "strict",
"allow_illegal": True,
"fuzzy_matching": False
"fuzzy_matching": False,
"secondary_commander": "Alena, Kessig Trapper",
"background": None,
"enable_partner_mechanics": True,
}
with tempfile.TemporaryDirectory() as temp_dir:
@ -65,6 +68,9 @@ class TestJSONRoundTrip:
assert loaded_config["enforcement_mode"] == "strict"
assert loaded_config["allow_illegal"] is True
assert loaded_config["fuzzy_matching"] is False
assert loaded_config["secondary_commander"] == "Alena, Kessig Trapper"
assert loaded_config["background"] is None
assert loaded_config["enable_partner_mechanics"] is True
# Create a DeckBuilder with this config and export again
builder = DeckBuilder()
@ -75,6 +81,10 @@ class TestJSONRoundTrip:
builder.allow_illegal = loaded_config["allow_illegal"]
builder.fuzzy_matching = loaded_config["fuzzy_matching"]
builder.bracket_level = loaded_config["bracket_level"]
builder.partner_feature_enabled = loaded_config["enable_partner_mechanics"]
builder.partner_mode = "partner"
builder.secondary_commander = loaded_config["secondary_commander"]
builder.requested_secondary_commander = loaded_config["secondary_commander"]
# Export the configuration
exported_path = builder.export_run_config_json(directory=temp_dir, suppress_output=True)
@ -94,6 +104,9 @@ class TestJSONRoundTrip:
assert re_exported_config["theme_catalog_version"] is None
assert re_exported_config["userThemes"] == []
assert re_exported_config["themeCatalogVersion"] is None
assert re_exported_config["secondary_commander"] == "Alena, Kessig Trapper"
assert re_exported_config["background"] is None
assert re_exported_config["enable_partner_mechanics"] is True
def test_empty_lists_round_trip(self):
"""Test that empty include/exclude lists are handled correctly."""
@ -121,6 +134,9 @@ class TestJSONRoundTrip:
assert exported_config["fuzzy_matching"] is True
assert exported_config["userThemes"] == []
assert exported_config["themeCatalogVersion"] is None
assert exported_config["secondary_commander"] is None
assert exported_config["background"] is None
assert exported_config["enable_partner_mechanics"] is False
def test_default_values_export(self):
"""Test that default values are exported correctly."""
@ -145,6 +161,9 @@ class TestJSONRoundTrip:
assert exported_config["additional_themes"] == []
assert exported_config["theme_match_mode"] == "permissive"
assert exported_config["theme_catalog_version"] is None
assert exported_config["secondary_commander"] is None
assert exported_config["background"] is None
assert exported_config["enable_partner_mechanics"] is False
def test_backward_compatibility_no_include_exclude_fields(self):
"""Test that configs without include/exclude fields still work."""
@ -236,6 +255,24 @@ class TestJSONRoundTrip:
sanitized_hash = hashlib.sha256(json.dumps(sanitized_payload, sort_keys=True).encode("utf-8")).hexdigest()
assert sanitized_hash == legacy_hash
def test_export_background_fields(self):
builder = DeckBuilder()
builder.commander_name = "Test Commander"
builder.partner_feature_enabled = True
builder.partner_mode = "background"
builder.secondary_commander = "Scion of Halaster"
builder.requested_background = "Scion of Halaster"
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)
assert exported_config["enable_partner_mechanics"] is True
assert exported_config["background"] == "Scion of Halaster"
assert exported_config["secondary_commander"] is None
if __name__ == "__main__":
pytest.main([__file__])

View file

@ -0,0 +1,36 @@
from __future__ import annotations
from types import SimpleNamespace
import pandas as pd
from deck_builder.builder import DeckBuilder
from code.web.services.orchestrator import _add_secondary_commander_card
def test_add_secondary_commander_card_injects_partner() -> None:
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
partner_name = "Pir, Imaginative Rascal"
combined = SimpleNamespace(secondary_name=partner_name)
commander_df = pd.DataFrame(
[
{
"name": partner_name,
"type": "Legendary Creature — Human",
"manaCost": "{2}{G}",
"manaValue": 3,
"creatureTypes": ["Human", "Ranger"],
"themeTags": ["+1/+1 Counters"],
}
]
)
assert partner_name not in builder.card_library
_add_secondary_commander_card(builder, commander_df, combined)
assert partner_name in builder.card_library
entry = builder.card_library[partner_name]
assert entry["Commander"] is True
assert entry["Role"] == "commander"
assert entry["SubRole"] == "Partner"

View file

@ -0,0 +1,162 @@
from __future__ import annotations
from code.deck_builder.partner_background_utils import (
PartnerBackgroundInfo,
analyze_partner_background,
extract_partner_with_names,
)
def test_extract_partner_with_names_handles_multiple() -> None:
text = "Partner with Foo, Bar and Baz (Each half of the pair may be your commander.)"
assert extract_partner_with_names(text) == ("Foo", "Bar", "Baz")
def test_extract_partner_with_names_deduplicates() -> None:
text = "Partner with Foo, Foo, Bar. Partner with Baz"
assert extract_partner_with_names(text) == ("Foo", "Bar", "Baz")
def test_analyze_partner_background_detects_keywords() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Ally",
oracle_text="Partner (You can have two commanders if both have partner.)",
theme_tags=("Legends Matter",),
)
assert info == PartnerBackgroundInfo(
has_partner=True,
partner_with=tuple(),
choose_background=False,
is_background=False,
is_doctor=False,
is_doctors_companion=False,
has_plain_partner=True,
has_restricted_partner=False,
restricted_partner_labels=tuple(),
)
def test_analyze_partner_background_detects_choose_background_via_theme() -> None:
info = analyze_partner_background(
type_line="Legendary Creature",
oracle_text="",
theme_tags=("Choose a Background",),
)
assert info.choose_background is True
def test_choose_background_commander_not_marked_as_background() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Human Warrior",
oracle_text=(
"Choose a Background (You can have a Background as a second commander.)"
),
theme_tags=("Backgrounds Matter", "Choose a Background"),
)
assert info.choose_background is True
assert info.is_background is False
def test_analyze_partner_background_detects_background_from_type() -> None:
info = analyze_partner_background(
type_line="Legendary Enchantment — Background",
oracle_text="Commander creatures you own have menace.",
theme_tags=(),
)
assert info.is_background is True
def test_analyze_partner_background_rejects_false_positive() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Human",
oracle_text="This creature enjoys partnership events.",
theme_tags=("Legends Matter",),
)
assert info.has_partner is False
assert info.has_plain_partner is False
assert info.has_restricted_partner is False
def test_analyze_partner_background_detects_partner_with_as_restricted() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Human",
oracle_text="Partner with Foo (They go on adventures together.)",
theme_tags=(),
)
assert info.has_partner is True
assert info.has_plain_partner is False
assert info.has_restricted_partner is True
def test_analyze_partner_background_requires_time_lord_for_doctor() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Time Lord Doctor",
oracle_text="When you cast a spell, do the thing.",
theme_tags=(),
)
assert info.is_doctor is True
non_time_lord = analyze_partner_background(
type_line="Legendary Creature — Doctor",
oracle_text="When you cast a spell, do the other thing.",
theme_tags=("Doctor",),
)
assert non_time_lord.is_doctor is False
tagged_only = analyze_partner_background(
type_line="Legendary Creature — Doctor",
oracle_text="When you cast a spell, do the other thing.",
theme_tags=("Time Lord Doctor",),
)
assert tagged_only.is_doctor is False
def test_analyze_partner_background_extracts_dash_restriction_label() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Survivor",
oracle_text="Partner - Survivors (They can only team up with their own.)",
theme_tags=(),
)
assert info.restricted_partner_labels == ("Survivors",)
def test_analyze_partner_background_uses_theme_restriction_label() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — God Warrior",
oracle_text="Partner — Father & Son (They go to battle together.)",
theme_tags=("Partner - Father & Son",),
)
assert info.restricted_partner_labels[0].casefold() == "father & son"
def test_analyze_partner_background_detects_restricted_partner_keyword() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Survivor",
oracle_text="Partner — Survivors (They stand together.)",
theme_tags=(),
)
assert info.has_partner is True
assert info.has_plain_partner is False
assert info.has_restricted_partner is True
def test_analyze_partner_background_detects_ascii_dash_partner_restriction() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Survivor",
oracle_text="Partner - Survivors (They can only team up with their own.)",
theme_tags=(),
)
assert info.has_partner is True
assert info.has_plain_partner is False
assert info.has_restricted_partner is True
def test_analyze_partner_background_marks_friends_forever_as_restricted() -> None:
info = analyze_partner_background(
type_line="Legendary Creature — Human",
oracle_text="Friends forever (You can have two commanders if both have friends forever.)",
theme_tags=(),
)
assert info.has_partner is True
assert info.has_plain_partner is False
assert info.has_restricted_partner is True

View file

@ -0,0 +1,133 @@
from __future__ import annotations
from code.web.services.commander_catalog_loader import (
CommanderRecord,
_row_to_record,
shared_restricted_partner_label,
)
def _build_row(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"name": "Test Commander",
"faceName": "",
"side": "",
"colorIdentity": "G",
"colors": "G",
"manaCost": "",
"manaValue": "",
"type": "Legendary Creature — Human",
"creatureTypes": "Human",
"text": "",
"power": "",
"toughness": "",
"keywords": "",
"themeTags": "[]",
"edhrecRank": "",
"layout": "normal",
}
base.update(overrides)
return base
def test_row_to_record_marks_plain_partner() -> None:
row = _build_row(text="Partner (You can have two commanders if both have partner.)")
record = _row_to_record(row, used_slugs=set())
assert isinstance(record, CommanderRecord)
assert record.has_plain_partner is True
assert record.is_partner is True
assert record.partner_with == tuple()
def test_row_to_record_marks_partner_with_as_restricted() -> None:
row = _build_row(text="Partner with Foo (You can have two commanders if both have partner.)")
record = _row_to_record(row, used_slugs=set())
assert record.has_plain_partner is False
assert record.is_partner is True
assert record.partner_with == ("Foo",)
def test_row_to_record_marks_partner_dash_as_restricted() -> None:
row = _build_row(text="Partner — Survivors (You can have two commanders if both have partner.)")
record = _row_to_record(row, used_slugs=set())
assert record.has_plain_partner is False
assert record.is_partner is True
assert record.restricted_partner_labels == ("Survivors",)
def test_row_to_record_marks_ascii_dash_partner_as_restricted() -> None:
row = _build_row(text="Partner - Survivors (They have a unique bond.)")
record = _row_to_record(row, used_slugs=set())
assert record.has_plain_partner is False
assert record.is_partner is True
assert record.restricted_partner_labels == ("Survivors",)
def test_row_to_record_marks_friends_forever_as_restricted() -> None:
row = _build_row(text="Friends forever (You can have two commanders if both have friends forever.)")
record = _row_to_record(row, used_slugs=set())
assert record.has_plain_partner is False
assert record.is_partner is True
def test_row_to_record_excludes_doctors_companion_from_plain_partner() -> None:
row = _build_row(text="Doctor's companion (You can have two commanders if both have a Doctor.)")
record = _row_to_record(row, used_slugs=set())
assert record.has_plain_partner is False
assert record.is_partner is False
def test_shared_restricted_partner_label_detects_overlap() -> None:
used_slugs: set[str] = set()
primary = _row_to_record(
_build_row(
name="Abby, Merciless Soldier",
type="Legendary Creature — Human Survivor",
text="Partner - Survivors (They fight as one.)",
themeTags="['Partner - Survivors']",
),
used_slugs=used_slugs,
)
partner = _row_to_record(
_build_row(
name="Bruno, Stalwart Survivor",
type="Legendary Creature — Human Survivor",
text="Partner — Survivors (They rally the clan.)",
themeTags="['Partner - Survivors']",
),
used_slugs=used_slugs,
)
assert shared_restricted_partner_label(primary, partner) == "Survivors"
assert shared_restricted_partner_label(primary, primary) == "Survivors"
def test_row_to_record_decodes_literal_newlines() -> None:
row = _build_row(text="Partner with Foo\\nFirst strike")
record = _row_to_record(row, used_slugs=set())
assert record.partner_with == ("Foo",)
def test_row_to_record_does_not_mark_companion_as_doctor_when_type_line_lacks_subtype() -> None:
row = _build_row(
text="Doctor's companion (You can have two commanders if the other is a Doctor.)",
creatureTypes="['Doctor', 'Human']",
)
record = _row_to_record(row, used_slugs=set())
assert record.is_doctors_companion is True
assert record.is_doctor is False
def test_row_to_record_requires_time_lord_for_doctor_flag() -> None:
row = _build_row(type="Legendary Creature — Human Doctor")
record = _row_to_record(row, used_slugs=set())
assert record.is_doctor is False

View file

@ -0,0 +1,293 @@
"""Unit tests for partner suggestion scoring helper."""
from __future__ import annotations
from code.deck_builder.combined_commander import PartnerMode
from code.deck_builder.suggestions import (
PartnerSuggestionContext,
score_partner_candidate,
)
def _partner_meta(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"has_partner": False,
"partner_with": [],
"supports_backgrounds": False,
"choose_background": False,
"is_background": False,
"is_doctor": False,
"is_doctors_companion": False,
"has_plain_partner": False,
"has_restricted_partner": False,
"restricted_partner_labels": [],
}
base.update(overrides)
return base
def _commander(
name: str,
*,
color_identity: tuple[str, ...] = tuple(),
themes: tuple[str, ...] = tuple(),
role_tags: tuple[str, ...] = tuple(),
partner_meta: dict[str, object] | None = None,
) -> dict[str, object]:
return {
"name": name,
"display_name": name,
"color_identity": list(color_identity),
"themes": list(themes),
"role_tags": list(role_tags),
"partner": partner_meta or _partner_meta(),
"usage": {"primary": 0, "secondary": 0, "total": 0},
}
def test_partner_with_prefers_canonical_pairing() -> None:
context = PartnerSuggestionContext(
theme_cooccurrence={
"Counters": {"Ramp": 8, "Flyers": 3},
"Ramp": {"Counters": 8},
"Flyers": {"Counters": 3},
},
pairing_counts={
("partner_with", "Halana, Kessig Ranger", "Alena, Kessig Trapper"): 12,
("partner_with", "Halana, Kessig Ranger", "Ishai, Ojutai Dragonspeaker"): 1,
},
)
halana = _commander(
"Halana, Kessig Ranger",
color_identity=("G",),
themes=("Counters", "Removal"),
partner_meta=_partner_meta(
has_partner=True,
partner_with=["Alena, Kessig Trapper"],
has_plain_partner=True,
),
)
alena = _commander(
"Alena, Kessig Trapper",
color_identity=("R",),
themes=("Ramp", "Counters"),
role_tags=("Support",),
partner_meta=_partner_meta(
has_partner=True,
partner_with=["Halana, Kessig Ranger"],
has_plain_partner=True,
),
)
ishai = _commander(
"Ishai, Ojutai Dragonspeaker",
color_identity=("W", "U"),
themes=("Flyers", "Counters"),
partner_meta=_partner_meta(
has_partner=True,
has_plain_partner=True,
),
)
alena_score = score_partner_candidate(
halana,
alena,
mode=PartnerMode.PARTNER_WITH,
context=context,
)
ishai_score = score_partner_candidate(
halana,
ishai,
mode=PartnerMode.PARTNER_WITH,
context=context,
)
assert alena_score.score > ishai_score.score
assert "partner_with_match" in alena_score.notes
assert "missing_partner_with_link" in ishai_score.notes
def test_background_scoring_prioritizes_legal_backgrounds() -> None:
context = PartnerSuggestionContext(
theme_cooccurrence={
"Counters": {"Card Draw": 6, "Aggro": 2},
"Card Draw": {"Counters": 6},
"Treasure": {"Aggro": 2},
},
pairing_counts={
("background", "Lae'zel, Vlaakith's Champion", "Scion of Halaster"): 9,
},
)
laezel = _commander(
"Lae'zel, Vlaakith's Champion",
color_identity=("W",),
themes=("Counters", "Aggro"),
partner_meta=_partner_meta(
supports_backgrounds=True,
),
)
scion = _commander(
"Scion of Halaster",
color_identity=("B",),
themes=("Card Draw", "Dungeons"),
partner_meta=_partner_meta(
is_background=True,
),
)
guild = _commander(
"Guild Artisan",
color_identity=("R",),
themes=("Treasure",),
partner_meta=_partner_meta(
is_background=True,
),
)
not_background = _commander(
"Reyhan, Last of the Abzan",
color_identity=("B", "G"),
themes=("Counters",),
partner_meta=_partner_meta(
has_partner=True,
),
)
scion_score = score_partner_candidate(
laezel,
scion,
mode=PartnerMode.BACKGROUND,
context=context,
)
guild_score = score_partner_candidate(
laezel,
guild,
mode=PartnerMode.BACKGROUND,
context=context,
)
illegal_score = score_partner_candidate(
laezel,
not_background,
mode=PartnerMode.BACKGROUND,
context=context,
)
assert scion_score.score > guild_score.score
assert guild_score.score > illegal_score.score
assert "candidate_not_background" in illegal_score.notes
def test_doctor_companion_scoring_requires_complementary_roles() -> None:
context = PartnerSuggestionContext(
theme_cooccurrence={
"Time Travel": {"Card Draw": 4},
"Card Draw": {"Time Travel": 4},
},
pairing_counts={
("doctor_companion", "The Tenth Doctor", "Donna Noble"): 7,
},
)
tenth_doctor = _commander(
"The Tenth Doctor",
color_identity=("U", "R"),
themes=("Time Travel", "Card Draw"),
partner_meta=_partner_meta(
is_doctor=True,
),
)
donna = _commander(
"Donna Noble",
color_identity=("W",),
themes=("Card Draw",),
partner_meta=_partner_meta(
is_doctors_companion=True,
),
)
generic = _commander(
"Generic Companion",
color_identity=("G",),
themes=("Aggro",),
partner_meta=_partner_meta(
has_partner=True,
),
)
donna_score = score_partner_candidate(
tenth_doctor,
donna,
mode=PartnerMode.DOCTOR_COMPANION,
context=context,
)
generic_score = score_partner_candidate(
tenth_doctor,
generic,
mode=PartnerMode.DOCTOR_COMPANION,
context=context,
)
assert donna_score.score > generic_score.score
assert "doctor_companion_match" in donna_score.notes
assert "doctor_pairing_illegal" in generic_score.notes
def test_excluded_themes_do_not_inflate_overlap_or_trigger_theme_penalty() -> None:
context = PartnerSuggestionContext()
primary = _commander(
"Sisay, Weatherlight Captain",
themes=("Legends Matter",),
partner_meta=_partner_meta(has_partner=True, has_plain_partner=True),
)
candidate = _commander(
"Jodah, the Unifier",
themes=("Legends Matter",),
partner_meta=_partner_meta(has_partner=True, has_plain_partner=True),
)
result = score_partner_candidate(
primary,
candidate,
mode=PartnerMode.PARTNER,
context=context,
)
assert result.components["overlap"] == 0.0
assert "missing_theme_metadata" not in result.notes
def test_excluded_themes_removed_from_synergy_calculation() -> None:
context = PartnerSuggestionContext(
theme_cooccurrence={
"Legends Matter": {"Card Draw": 10},
"Card Draw": {"Legends Matter": 10},
}
)
primary = _commander(
"Dihada, Binder of Wills",
themes=("Legends Matter",),
partner_meta=_partner_meta(has_partner=True, has_plain_partner=True),
)
candidate = _commander(
"Tymna the Weaver",
themes=("Card Draw",),
partner_meta=_partner_meta(has_partner=True, has_plain_partner=True),
)
result = score_partner_candidate(
primary,
candidate,
mode=PartnerMode.PARTNER,
context=context,
)
assert result.components["synergy"] == 0.0

View file

@ -0,0 +1,324 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
import pandas as pd
import pytest
from deck_builder.combined_commander import PartnerMode
from deck_builder.partner_selection import apply_partner_inputs
from exceptions import CommanderPartnerError
class _StubBuilder:
def __init__(self, dataframe: pd.DataFrame) -> None:
self._df = dataframe
def load_commander_data(self) -> pd.DataFrame:
return self._df.copy(deep=True)
@pytest.fixture()
def builder() -> _StubBuilder:
data = [
{
"name": "Halana, Kessig Ranger",
"faceName": "Halana, Kessig Ranger",
"colorIdentity": ["G"],
"themeTags": ["Aggro"],
"text": "Reach\nPartner (You can have two commanders if both have partner.)",
"type": "Legendary Creature — Human Archer",
},
{
"name": "Alena, Kessig Trapper",
"faceName": "Alena, Kessig Trapper",
"colorIdentity": ["R"],
"themeTags": ["Aggro"],
"text": "First strike\nPartner",
"type": "Legendary Creature — Human Scout",
},
{
"name": "Lae'zel, Vlaakith's Champion",
"faceName": "Lae'zel, Vlaakith's Champion",
"colorIdentity": ["W"],
"themeTags": ["Counters"],
"text": "If you would put one or more counters on a creature... Choose a Background (You can have a Background as a second commander.)",
"type": "Legendary Creature — Gith Warrior",
},
{
"name": "Commander A",
"faceName": "Commander A",
"colorIdentity": ["W"],
"themeTags": ["Value"],
"text": "Partner with Commander B (When this creature enters the battlefield, target player may put Commander B into their hand from their library, then shuffle.)",
"type": "Legendary Creature — Advisor",
},
{
"name": "Commander B",
"faceName": "Commander B",
"colorIdentity": ["B"],
"themeTags": ["Graveyard"],
"text": "Partner with Commander A",
"type": "Legendary Creature — Advisor",
},
{
"name": "The Tenth Doctor",
"faceName": "The Tenth Doctor",
"colorIdentity": ["U", "R"],
"themeTags": ["Time", "Doctor"],
"text": "Whenever you cast a spell with cascade, put a time counter on target permanent",
"type": "Legendary Creature — Time Lord Doctor",
},
{
"name": "Donna Noble",
"faceName": "Donna Noble",
"colorIdentity": ["W"],
"themeTags": ["Support"],
"text": "Vigilance\nDoctor's companion (You can have two commanders if the other is a Doctor.)",
"type": "Legendary Creature — Human Advisor",
},
{
"name": "Amy Pond",
"faceName": "Amy Pond",
"colorIdentity": ["R"],
"themeTags": ["Aggro", "Doctor's Companion", "Partner With"],
"text": (
"Partner with Rory Williams\\nWhenever Amy Pond deals combat damage to a player, "
"choose a suspended card you own and remove that many time counters from it.\\n"
"Doctor's companion (You can have two commanders if the other is the Doctor.)"
),
"type": "Legendary Creature — Human",
},
{
"name": "Rory Williams",
"faceName": "Rory Williams",
"colorIdentity": ["W", "U"],
"themeTags": ["Human", "Doctor's Companion", "Partner With"],
"text": (
"Partner with Amy Pond\\nFirst strike, lifelink\\n"
"Doctor's companion (You can have two commanders if the other is a Doctor.)"
),
"type": "Legendary Creature — Human Soldier",
},
]
df = pd.DataFrame(data)
return _StubBuilder(df)
def _background_catalog() -> SimpleNamespace:
card = SimpleNamespace(
name="Scion of Halaster",
display_name="Scion of Halaster",
color_identity=("B",),
themes=("Backgrounds Matter",),
theme_tags=("Backgrounds Matter",),
oracle_text="Commander creatures you own have menace.",
type_line="Legendary Enchantment — Background",
is_background=True,
)
class _Catalog:
def __init__(self, entry: SimpleNamespace) -> None:
self._entry = entry
self.entries = (entry,)
def get(self, name: str) -> SimpleNamespace | None:
lowered = name.strip().casefold()
if lowered in {
self._entry.name.casefold(),
self._entry.display_name.casefold(),
}:
return self._entry
return None
return _Catalog(card)
def test_feature_disabled_returns_none(builder: _StubBuilder) -> None:
result = apply_partner_inputs(
builder,
primary_name="Halana, Kessig Ranger",
secondary_name="Alena, Kessig Trapper",
feature_enabled=False,
background_catalog=_background_catalog(),
)
assert result is None
def test_conflicting_inputs_raise_error(builder: _StubBuilder) -> None:
with pytest.raises(CommanderPartnerError):
apply_partner_inputs(
builder,
primary_name="Halana, Kessig Ranger",
secondary_name="Alena, Kessig Trapper",
background_name="Scion of Halaster",
feature_enabled=True,
background_catalog=_background_catalog(),
)
def test_background_requires_primary_support(builder: _StubBuilder) -> None:
with pytest.raises(CommanderPartnerError):
apply_partner_inputs(
builder,
primary_name="Halana, Kessig Ranger",
background_name="Scion of Halaster",
feature_enabled=True,
background_catalog=_background_catalog(),
)
def test_background_success(builder: _StubBuilder) -> None:
combined = apply_partner_inputs(
builder,
primary_name="Lae'zel, Vlaakith's Champion",
background_name="Scion of Halaster",
feature_enabled=True,
background_catalog=_background_catalog(),
)
assert combined is not None
assert combined.partner_mode is PartnerMode.BACKGROUND
assert combined.secondary_name == "Scion of Halaster"
assert combined.color_identity == ("W", "B")
def test_partner_with_detection(builder: _StubBuilder) -> None:
combined = apply_partner_inputs(
builder,
primary_name="Commander A",
secondary_name="Commander B",
feature_enabled=True,
background_catalog=_background_catalog(),
)
assert combined is not None
assert combined.partner_mode is PartnerMode.PARTNER_WITH
assert combined.color_identity == ("W", "B")
def test_partner_detection(builder: _StubBuilder) -> None:
combined = apply_partner_inputs(
builder,
primary_name="Halana, Kessig Ranger",
secondary_name="Alena, Kessig Trapper",
feature_enabled=True,
background_catalog=_background_catalog(),
)
assert combined is not None
assert combined.partner_mode is PartnerMode.PARTNER
assert combined.color_identity == ("R", "G")
def test_doctor_companion_pairing(builder: _StubBuilder) -> None:
combined = apply_partner_inputs(
builder,
primary_name="The Tenth Doctor",
secondary_name="Donna Noble",
feature_enabled=True,
background_catalog=_background_catalog(),
)
assert combined is not None
assert combined.partner_mode is PartnerMode.DOCTOR_COMPANION
assert combined.secondary_name == "Donna Noble"
assert combined.color_identity == ("W", "U", "R")
def test_doctor_requires_companion(builder: _StubBuilder) -> None:
with pytest.raises(CommanderPartnerError):
apply_partner_inputs(
builder,
primary_name="The Tenth Doctor",
secondary_name="Halana, Kessig Ranger",
feature_enabled=True,
background_catalog=_background_catalog(),
)
def test_companion_requires_doctor(builder: _StubBuilder) -> None:
with pytest.raises(CommanderPartnerError):
apply_partner_inputs(
builder,
primary_name="Donna Noble",
secondary_name="Commander A",
feature_enabled=True,
background_catalog=_background_catalog(),
)
def test_amy_prefers_partner_with_when_rory_selected(builder: _StubBuilder) -> None:
combined = apply_partner_inputs(
builder,
primary_name="Amy Pond",
secondary_name="Rory Williams",
feature_enabled=True,
background_catalog=_background_catalog(),
)
assert combined is not None
assert combined.partner_mode is PartnerMode.PARTNER_WITH
def test_amy_can_pair_with_the_doctor(builder: _StubBuilder) -> None:
combined = apply_partner_inputs(
builder,
primary_name="Amy Pond",
secondary_name="The Tenth Doctor",
feature_enabled=True,
background_catalog=_background_catalog(),
)
assert combined is not None
assert combined.partner_mode is PartnerMode.DOCTOR_COMPANION
def test_rory_can_partner_with_amy(builder: _StubBuilder) -> None:
combined = apply_partner_inputs(
builder,
primary_name="Rory Williams",
secondary_name="Amy Pond",
feature_enabled=True,
background_catalog=_background_catalog(),
)
assert combined is not None
assert combined.partner_mode is PartnerMode.PARTNER_WITH
def test_logging_emits_partner_mode_selected(caplog: pytest.LogCaptureFixture, builder: _StubBuilder) -> None:
with caplog.at_level(logging.INFO):
combined = apply_partner_inputs(
builder,
primary_name="Halana, Kessig Ranger",
secondary_name="Alena, Kessig Trapper",
feature_enabled=True,
background_catalog=_background_catalog(),
)
assert combined is not None
records = [record for record in caplog.records if getattr(record, "event", "") == "partner_mode_selected"]
assert records, "Expected partner_mode_selected log event"
payload = getattr(records[-1], "payload", {})
assert payload.get("mode") == PartnerMode.PARTNER.value
assert payload.get("commanders", {}).get("primary") == "Halana, Kessig Ranger"
assert payload.get("commanders", {}).get("secondary") == "Alena, Kessig Trapper"
assert payload.get("colors_before") == ["G"]
assert payload.get("colors_after") == ["R", "G"]
assert payload.get("color_delta", {}).get("added") == ["R"]
def test_logging_includes_selection_source(caplog: pytest.LogCaptureFixture, builder: _StubBuilder) -> None:
with caplog.at_level(logging.INFO):
combined = apply_partner_inputs(
builder,
primary_name="Halana, Kessig Ranger",
secondary_name="Alena, Kessig Trapper",
feature_enabled=True,
background_catalog=_background_catalog(),
selection_source="suggestion",
)
assert combined is not None
records = [record for record in caplog.records if getattr(record, "event", "") == "partner_mode_selected"]
assert records, "Expected partner_mode_selected log event"
payload = getattr(records[-1], "payload", {})
assert payload.get("selection_source") == "suggestion"

View file

@ -0,0 +1,304 @@
from __future__ import annotations
import asyncio
import json
import os
import sys
from pathlib import Path
from fastapi.testclient import TestClient
from starlette.requests import Request
def _write_dataset(path: Path) -> Path:
payload = {
"metadata": {
"generated_at": "2025-10-06T12:00:00Z",
"version": "test-fixture",
},
"commanders": {
"akiri_line_slinger": {
"name": "Akiri, Line-Slinger",
"display_name": "Akiri, Line-Slinger",
"color_identity": ["R", "W"],
"themes": ["Artifacts", "Aggro"],
"role_tags": ["Aggro"],
"partner": {
"has_partner": True,
"partner_with": ["Silas Renn, Seeker Adept"],
"supports_backgrounds": False,
},
},
"silas_renn_seeker_adept": {
"name": "Silas Renn, Seeker Adept",
"display_name": "Silas Renn, Seeker Adept",
"color_identity": ["U", "B"],
"themes": ["Artifacts", "Value"],
"role_tags": ["Value"],
"partner": {
"has_partner": True,
"partner_with": ["Akiri, Line-Slinger"],
"supports_backgrounds": False,
},
},
"ishai_ojutai_dragonspeaker": {
"name": "Ishai, Ojutai Dragonspeaker",
"display_name": "Ishai, Ojutai Dragonspeaker",
"color_identity": ["W", "U"],
"themes": ["Artifacts", "Counters"],
"role_tags": ["Aggro"],
"partner": {
"has_partner": True,
"partner_with": [],
"supports_backgrounds": False,
},
},
"reyhan_last_of_the_abzan": {
"name": "Reyhan, Last of the Abzan",
"display_name": "Reyhan, Last of the Abzan",
"color_identity": ["B", "G"],
"themes": ["Counters", "Artifacts"],
"role_tags": ["Counters"],
"partner": {
"has_partner": True,
"partner_with": [],
"supports_backgrounds": False,
},
},
},
"pairings": {
"records": [
{
"mode": "partner_with",
"primary_canonical": "akiri_line_slinger",
"secondary_canonical": "silas_renn_seeker_adept",
"count": 12,
},
{
"mode": "partner",
"primary_canonical": "akiri_line_slinger",
"secondary_canonical": "ishai_ojutai_dragonspeaker",
"count": 6,
},
{
"mode": "partner",
"primary_canonical": "akiri_line_slinger",
"secondary_canonical": "reyhan_last_of_the_abzan",
"count": 4,
},
]
},
}
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def _fresh_client(tmp_path: Path) -> tuple[TestClient, Path]:
dataset_path = _write_dataset(tmp_path / "partner_synergy.json")
os.environ["ENABLE_PARTNER_MECHANICS"] = "1"
os.environ["ENABLE_PARTNER_SUGGESTIONS"] = "1"
for module_name in (
"code.web.app",
"code.web.routes.partner_suggestions",
"code.web.services.partner_suggestions",
):
sys.modules.pop(module_name, None)
from code.web.services import partner_suggestions as partner_service
partner_service.configure_dataset_path(dataset_path)
from code.web.app import app
client = TestClient(app)
return client, dataset_path
async def _receive() -> dict[str, object]:
return {"type": "http.request", "body": b"", "more_body": False}
def _make_request(path: str = "/api/partner/suggestions", query_string: str = "") -> Request:
scope = {
"type": "http",
"method": "GET",
"scheme": "http",
"path": path,
"raw_path": path.encode("utf-8"),
"query_string": query_string.encode("utf-8"),
"headers": [],
"client": ("203.0.113.5", 52345),
"server": ("testserver", 80),
}
request = Request(scope, receive=_receive) # type: ignore[arg-type]
request.state.request_id = "req-telemetry"
return request
def test_partner_suggestions_api_returns_ranked_candidates(tmp_path: Path) -> None:
client, dataset_path = _fresh_client(tmp_path)
try:
params = {
"commander": "Akiri, Line-Slinger",
"visible_limit": 1,
"partner": [
"Silas Renn, Seeker Adept",
"Ishai, Ojutai Dragonspeaker",
"Reyhan, Last of the Abzan",
],
}
response = client.get("/api/partner/suggestions", params=params)
assert response.status_code == 200
data = response.json()
assert data["visible"], "expected at least one visible suggestion"
assert len(data["visible"]) == 1
assert data["hidden"], "expected hidden suggestions when visible_limit=1"
assert data["has_hidden"] is True
names = [item["name"] for item in data["visible"]]
assert names[0] == "Silas Renn, Seeker Adept"
assert data["metadata"]["generated_at"] == "2025-10-06T12:00:00Z"
response_all = client.get(
"/api/partner/suggestions",
params={**params, "include_hidden": 1},
)
assert response_all.status_code == 200
data_all = response_all.json()
assert len(data_all["visible"]) >= data_all["total"] or len(data_all["visible"]) >= 3
assert not data_all["hidden"]
assert data_all["available_modes"]
finally:
try:
client.close()
except Exception:
pass
try:
from code.web.services import partner_suggestions as partner_service
partner_service.configure_dataset_path(None)
except Exception:
pass
os.environ.pop("ENABLE_PARTNER_MECHANICS", None)
os.environ.pop("ENABLE_PARTNER_SUGGESTIONS", None)
for module_name in (
"code.web.app",
"code.web.routes.partner_suggestions",
"code.web.services.partner_suggestions",
):
sys.modules.pop(module_name, None)
if dataset_path.exists():
dataset_path.unlink()
def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkeypatch) -> None:
analytics_dir = tmp_path / "config" / "analytics"
analytics_dir.mkdir(parents=True)
dataset_path = (analytics_dir / "partner_synergy.json").resolve()
from code.web.services import partner_suggestions as partner_service
from code.web.services import orchestrator as orchestrator_service
original_default = partner_service.DEFAULT_DATASET_PATH
original_path = partner_service._DATASET_PATH # type: ignore[attr-defined]
original_cache = partner_service._DATASET_CACHE # type: ignore[attr-defined]
original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED # type: ignore[attr-defined]
partner_service.DEFAULT_DATASET_PATH = dataset_path
partner_service._DATASET_PATH = dataset_path # type: ignore[attr-defined]
partner_service._DATASET_CACHE = None # type: ignore[attr-defined]
partner_service._DATASET_REFRESH_ATTEMPTED = True # type: ignore[attr-defined]
calls = {"count": 0}
payload_path = tmp_path / "seed_dataset.json"
_write_dataset(payload_path)
def seeded_refresh(out_func=None, *, force=False, root=None): # type: ignore[override]
calls["count"] += 1
dataset_path.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8")
monkeypatch.setattr(orchestrator_service, "_maybe_refresh_partner_synergy", seeded_refresh)
try:
result_none = partner_service.load_dataset()
assert result_none is None
assert calls["count"] == 0
dataset = partner_service.load_dataset(refresh=True, force=True)
assert dataset is not None
assert calls["count"] == 1
finally:
partner_service.DEFAULT_DATASET_PATH = original_default
partner_service._DATASET_PATH = original_path # type: ignore[attr-defined]
partner_service._DATASET_CACHE = original_cache # type: ignore[attr-defined]
partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted # type: ignore[attr-defined]
try:
dataset_path.unlink()
except FileNotFoundError:
pass
try:
payload_path.unlink()
except FileNotFoundError:
pass
def test_partner_suggestions_api_refresh_flag(monkeypatch) -> None:
from code.web.routes import partner_suggestions as route
from code.web.services.partner_suggestions import PartnerSuggestionResult
monkeypatch.setattr(route, "ENABLE_PARTNER_MECHANICS", True)
monkeypatch.setattr(route, "ENABLE_PARTNER_SUGGESTIONS", True)
captured: dict[str, bool] = {"refresh": False}
def fake_get_partner_suggestions(
commander_name: str,
*,
limit_per_mode: int = 5,
include_modes=None,
min_score: float = 0.15,
refresh_dataset: bool = False,
) -> PartnerSuggestionResult:
captured["refresh"] = refresh_dataset
return PartnerSuggestionResult(
commander=commander_name,
display_name=commander_name,
canonical=commander_name.casefold(),
metadata={},
by_mode={},
total=0,
)
monkeypatch.setattr(route, "get_partner_suggestions", fake_get_partner_suggestions)
request = _make_request()
response = asyncio.run(
route.partner_suggestions_api(
request,
commander="Akiri, Line-Slinger",
limit=5,
visible_limit=3,
include_hidden=False,
partner=None,
background=None,
mode=None,
refresh=False,
)
)
assert response.status_code == 200
assert captured["refresh"] is False
response_refresh = asyncio.run(
route.partner_suggestions_api(
_make_request(query_string="refresh=1"),
commander="Akiri, Line-Slinger",
limit=5,
visible_limit=3,
include_hidden=False,
partner=None,
background=None,
mode=None,
refresh=True,
)
)
assert response_refresh.status_code == 200
assert captured["refresh"] is True

View file

@ -0,0 +1,163 @@
from __future__ import annotations
import json
from pathlib import Path
from code.scripts import build_partner_suggestions as pipeline
CSV_CONTENT = """name,faceName,colorIdentity,themeTags,roleTags,text,type,partnerWith,supportsBackgrounds,isPartner,isBackground,isDoctor,isDoctorsCompanion
"Halana, Kessig Ranger","Halana, Kessig Ranger","['G']","['Counters','Partner']","['Aggro']","Reach. Partner with Alena, Kessig Trapper.","Legendary Creature — Human Archer","['Alena, Kessig Trapper']",False,True,False,False,False
"Alena, Kessig Trapper","Alena, Kessig Trapper","['R']","['Aggro','Partner']","['Ramp']","First strike. Partner with Halana, Kessig Ranger.","Legendary Creature — Human Scout","['Halana, Kessig Ranger']",False,True,False,False,False
"Wilson, Refined Grizzly","Wilson, Refined Grizzly","['G']","['Teamwork','Backgrounds Matter']","['Aggro']","Choose a Background (You can have a Background as a second commander.)","Legendary Creature — Bear Warrior","[]",True,False,False,False,False
"Guild Artisan","Guild Artisan","['R']","['Background']","[]","Commander creatures you own have \"Whenever this creature attacks...\"","Legendary Enchantment — Background","[]",False,False,True,False,False
"The Tenth Doctor","The Tenth Doctor","['U','R','G']","['Time Travel']","[]","Doctor's companion (You can have two commanders if the other is a Doctor's companion.)","Legendary Creature — Time Lord Doctor","[]",False,False,False,True,False
"Rose Tyler","Rose Tyler","['W']","['Companions']","[]","Doctor's companion","Legendary Creature — Human","[]",False,False,False,False,True
"""
def _write_summary(path: Path, primary: str, secondary: str | None, mode: str, tags: list[str]) -> None:
payload = {
"meta": {
"commander": primary,
"tags": tags,
},
"summary": {
"commander": {
"names": [name for name in [primary, secondary] if name],
"primary": primary,
"secondary": secondary,
"partner_mode": mode,
"color_identity": [],
"combined": {
"primary_name": primary,
"secondary_name": secondary,
"partner_mode": mode,
"color_identity": [],
},
}
},
}
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _write_text(path: Path, primary: str, secondary: str | None, mode: str) -> None:
lines = []
if secondary:
lines.append(f"# Commanders: {primary}, {secondary}")
else:
lines.append(f"# Commander: {primary}")
lines.append(f"# Partner Mode: {mode}")
lines.append(f"1 {primary}")
if secondary:
lines.append(f"1 {secondary}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_build_partner_suggestions_creates_dataset(tmp_path: Path) -> None:
commander_csv = tmp_path / "commander_cards.csv"
commander_csv.write_text(CSV_CONTENT, encoding="utf-8")
deck_dir = tmp_path / "deck_files"
deck_dir.mkdir()
# Partner deck
_write_summary(
deck_dir / "halana_partner.summary.json",
primary="Halana, Kessig Ranger",
secondary="Alena, Kessig Trapper",
mode="partner",
tags=["Counters", "Aggro"],
)
_write_text(
deck_dir / "halana_partner.txt",
primary="Halana, Kessig Ranger",
secondary="Alena, Kessig Trapper",
mode="partner",
)
# Background deck
_write_summary(
deck_dir / "wilson_background.summary.json",
primary="Wilson, Refined Grizzly",
secondary="Guild Artisan",
mode="background",
tags=["Teamwork", "Aggro"],
)
_write_text(
deck_dir / "wilson_background.txt",
primary="Wilson, Refined Grizzly",
secondary="Guild Artisan",
mode="background",
)
# Doctor/Companion deck
_write_summary(
deck_dir / "doctor_companion.summary.json",
primary="The Tenth Doctor",
secondary="Rose Tyler",
mode="doctor_companion",
tags=["Time Travel", "Companions"],
)
_write_text(
deck_dir / "doctor_companion.txt",
primary="The Tenth Doctor",
secondary="Rose Tyler",
mode="doctor_companion",
)
output_path = tmp_path / "partner_synergy.json"
result = pipeline.build_partner_suggestions(
commander_csv=commander_csv,
deck_dir=deck_dir,
output_path=output_path,
max_examples=3,
)
assert output_path.exists(), "Expected partner synergy dataset to be created"
data = json.loads(output_path.read_text(encoding="utf-8"))
metadata = data["metadata"]
assert metadata["deck_exports_processed"] == 3
assert metadata["deck_exports_with_pairs"] == 3
assert "version_hash" in metadata
overrides = data["curated_overrides"]
assert overrides["version"] == metadata["version_hash"]
assert overrides["entries"] == {}
mode_counts = data["pairings"]["mode_counts"]
assert mode_counts == {
"background": 1,
"doctor_companion": 1,
"partner": 1,
}
records = data["pairings"]["records"]
partner_entry = next(item for item in records if item["mode"] == "partner")
assert partner_entry["primary"] == "Halana, Kessig Ranger"
assert partner_entry["secondary"] == "Alena, Kessig Trapper"
assert partner_entry["combined_colors"] == ["R", "G"]
commanders = data["commanders"]
halana = commanders["halana, kessig ranger"]
assert halana["partner"]["has_partner"] is True
guild_artisan = commanders["guild artisan"]
assert guild_artisan["partner"]["is_background"] is True
themes = data["themes"]
aggro = themes["aggro"]
assert aggro["deck_count"] == 2
assert set(aggro["co_occurrence"].keys()) == {"counters", "teamwork"}
doctor_usage = commanders["the tenth doctor"]["usage"]
assert doctor_usage == {"primary": 1, "secondary": 0, "total": 1}
rose_usage = commanders["rose tyler"]["usage"]
assert rose_usage == {"primary": 0, "secondary": 1, "total": 1}
partner_tags = partner_entry["tags"]
assert partner_tags == ["Aggro", "Counters"]
# round-trip result returned from function should mirror file payload
assert result == data

View file

@ -0,0 +1,133 @@
from __future__ import annotations
import json
from pathlib import Path
from code.web.services.partner_suggestions import (
configure_dataset_path,
get_partner_suggestions,
)
def _write_dataset(path: Path) -> Path:
payload = {
"metadata": {
"generated_at": "2025-10-06T12:00:00Z",
"version": "test-fixture",
},
"commanders": {
"akiri_line_slinger": {
"name": "Akiri, Line-Slinger",
"display_name": "Akiri, Line-Slinger",
"color_identity": ["R", "W"],
"themes": ["Artifacts", "Aggro", "Legends Matter", "Partner"],
"role_tags": ["Aggro"],
"partner": {
"has_partner": True,
"partner_with": ["Silas Renn, Seeker Adept"],
"supports_backgrounds": False,
},
},
"silas_renn_seeker_adept": {
"name": "Silas Renn, Seeker Adept",
"display_name": "Silas Renn, Seeker Adept",
"color_identity": ["U", "B"],
"themes": ["Artifacts", "Value"],
"role_tags": ["Value"],
"partner": {
"has_partner": True,
"partner_with": ["Akiri, Line-Slinger"],
"supports_backgrounds": False,
},
},
"ishai_ojutai_dragonspeaker": {
"name": "Ishai, Ojutai Dragonspeaker",
"display_name": "Ishai, Ojutai Dragonspeaker",
"color_identity": ["W", "U"],
"themes": ["Artifacts", "Counters", "Historics Matter", "Partner - Survivors"],
"role_tags": ["Aggro"],
"partner": {
"has_partner": True,
"partner_with": [],
"supports_backgrounds": False,
},
},
"reyhan_last_of_the_abzan": {
"name": "Reyhan, Last of the Abzan",
"display_name": "Reyhan, Last of the Abzan",
"color_identity": ["B", "G"],
"themes": ["Counters", "Artifacts", "Partner"],
"role_tags": ["Counters"],
"partner": {
"has_partner": True,
"partner_with": [],
"supports_backgrounds": False,
},
},
},
"pairings": {
"records": [
{
"mode": "partner_with",
"primary_canonical": "akiri_line_slinger",
"secondary_canonical": "silas_renn_seeker_adept",
"count": 12,
},
{
"mode": "partner",
"primary_canonical": "akiri_line_slinger",
"secondary_canonical": "ishai_ojutai_dragonspeaker",
"count": 6,
},
{
"mode": "partner",
"primary_canonical": "akiri_line_slinger",
"secondary_canonical": "reyhan_last_of_the_abzan",
"count": 4,
},
]
},
}
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def test_get_partner_suggestions_produces_visible_and_hidden(tmp_path: Path) -> None:
dataset_path = _write_dataset(tmp_path / "partner_synergy.json")
try:
configure_dataset_path(dataset_path)
result = get_partner_suggestions("Akiri, Line-Slinger", limit_per_mode=5)
assert result is not None
assert result.total >= 3
partner_names = [
"Silas Renn, Seeker Adept",
"Ishai, Ojutai Dragonspeaker",
"Reyhan, Last of the Abzan",
]
visible, hidden = result.flatten(partner_names, [], visible_limit=2)
assert len(visible) == 2
assert any(item["name"] == "Silas Renn, Seeker Adept" for item in visible)
assert hidden, "expected additional hidden suggestions"
assert result.metadata.get("generated_at") == "2025-10-06T12:00:00Z"
finally:
configure_dataset_path(None)
def test_noise_themes_suppressed_in_shared_theme_summary(tmp_path: Path) -> None:
dataset_path = _write_dataset(tmp_path / "partner_synergy.json")
try:
configure_dataset_path(dataset_path)
result = get_partner_suggestions("Akiri, Line-Slinger", limit_per_mode=5)
assert result is not None
partner_entries = result.by_mode.get("partner") or []
target = next((entry for entry in partner_entries if entry["name"] == "Ishai, Ojutai Dragonspeaker"), None)
assert target is not None, "expected Ishai suggestions to be present"
assert "Legends Matter" not in target["shared_themes"]
assert "Historics Matter" not in target["shared_themes"]
assert "Partner" not in target["shared_themes"]
assert "Partner - Survivors" not in target["shared_themes"]
assert all(theme not in {"Legends Matter", "Historics Matter", "Partner", "Partner - Survivors"} for theme in target["candidate_themes"])
assert "Legends Matter" not in target["summary"]
assert "Partner" not in target["summary"]
finally:
configure_dataset_path(None)

View file

@ -0,0 +1,98 @@
import json
import logging
from typing import Any, Dict
import pytest
from starlette.requests import Request
from code.web.services.telemetry import (
log_partner_suggestion_selected,
log_partner_suggestions_generated,
)
async def _receive() -> Dict[str, Any]:
return {"type": "http.request", "body": b"", "more_body": False}
def _make_request(path: str, method: str = "GET", query_string: str = "") -> Request:
scope = {
"type": "http",
"method": method,
"scheme": "http",
"path": path,
"raw_path": path.encode("utf-8"),
"query_string": query_string.encode("utf-8"),
"headers": [],
"client": ("203.0.113.5", 52345),
"server": ("testserver", 80),
}
request = Request(scope, receive=_receive)
request.state.request_id = "req-123"
return request
def test_log_partner_suggestions_generated_emits_payload(caplog: pytest.LogCaptureFixture) -> None:
request = _make_request("/api/partner/suggestions", query_string="commander=Akiri&mode=partner")
metadata = {"dataset_version": "2025-10-05", "record_count": 42}
with caplog.at_level(logging.INFO, logger="web.partner_suggestions"):
log_partner_suggestions_generated(
request,
commander_display="Akiri, Fearless Voyager",
commander_canonical="akiri, fearless voyager",
include_modes=["partner"],
available_modes=["partner"],
total=3,
mode_counts={"partner": 3},
visible_count=2,
hidden_count=1,
limit_per_mode=5,
visible_limit=3,
include_hidden=False,
refresh_requested=False,
dataset_metadata=metadata,
)
matching = [record for record in caplog.records if record.name == "web.partner_suggestions"]
assert matching, "Expected partner suggestions telemetry log"
payload = json.loads(matching[-1].message)
assert payload["event"] == "partner_suggestions.generated"
assert payload["commander"]["display"] == "Akiri, Fearless Voyager"
assert payload["filters"]["include_modes"] == ["partner"]
assert payload["result"]["mode_counts"]["partner"] == 3
assert payload["result"]["visible_count"] == 2
assert payload["result"]["metadata"]["dataset_version"] == "2025-10-05"
assert payload["query"]["mode"] == "partner"
def test_log_partner_suggestion_selected_emits_payload(caplog: pytest.LogCaptureFixture) -> None:
request = _make_request("/build/partner/preview", method="POST")
with caplog.at_level(logging.INFO, logger="web.partner_suggestions"):
log_partner_suggestion_selected(
request,
commander="Rograkh, Son of Rohgahh",
scope="partner",
partner_enabled=True,
auto_opt_out=False,
auto_assigned=False,
selection_source="suggestion",
secondary_candidate="Silas Renn, Seeker Adept",
background_candidate=None,
resolved_secondary="Silas Renn, Seeker Adept",
resolved_background=None,
partner_mode="partner",
has_preview=True,
warnings=["Color identity expanded"],
error=None,
)
matching = [record for record in caplog.records if record.name == "web.partner_suggestions"]
assert matching, "Expected partner suggestion selection telemetry log"
payload = json.loads(matching[-1].message)
assert payload["event"] == "partner_suggestions.selected"
assert payload["selection_source"] == "suggestion"
assert payload["resolved"]["partner_mode"] == "partner"
assert payload["warnings_count"] == 1
assert payload["has_error"] is False

View file

@ -0,0 +1,91 @@
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import Callable, Optional
from code.web.services import orchestrator
def _setup_fake_root(tmp_path: Path) -> Path:
root = tmp_path
scripts_dir = root / "code" / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
(scripts_dir / "build_partner_suggestions.py").write_text("print('noop')\n", encoding="utf-8")
(root / "config" / "themes").mkdir(parents=True, exist_ok=True)
(root / "csv_files").mkdir(parents=True, exist_ok=True)
(root / "deck_files").mkdir(parents=True, exist_ok=True)
(root / "config" / "themes" / "theme_list.json").write_text("{}\n", encoding="utf-8")
(root / "csv_files" / "commander_cards.csv").write_text("name\nTest Commander\n", encoding="utf-8")
return root
def _invoke_helper(
root: Path,
monkeypatch,
*,
force: bool = False,
out_func: Optional[Callable[[str], None]] = None,
) -> list[tuple[list[str], str]]:
calls: list[tuple[list[str], str]] = []
def _fake_run(cmd, check=False, cwd=None): # type: ignore[no-untyped-def]
calls.append((list(cmd), cwd))
class _Completed:
returncode = 0
return _Completed()
monkeypatch.setattr(orchestrator.subprocess, "run", _fake_run)
orchestrator._maybe_refresh_partner_synergy(out_func, force=force, root=str(root))
return calls
def test_partner_synergy_refresh_invokes_script_when_missing(tmp_path, monkeypatch) -> None:
root = _setup_fake_root(tmp_path)
calls = _invoke_helper(root, monkeypatch, force=False)
assert len(calls) == 1
cmd, cwd = calls[0]
assert cmd[0] == orchestrator.sys.executable
assert cmd[1].endswith("build_partner_suggestions.py")
assert cwd == str(root)
def test_partner_synergy_refresh_skips_when_dataset_fresh(tmp_path, monkeypatch) -> None:
root = _setup_fake_root(tmp_path)
analytics_dir = root / "config" / "analytics"
analytics_dir.mkdir(parents=True, exist_ok=True)
dataset = analytics_dir / "partner_synergy.json"
dataset.write_text("{}\n", encoding="utf-8")
now = time.time()
os.utime(dataset, (now, now))
source_time = now - 120
for rel in ("config/themes/theme_list.json", "csv_files/commander_cards.csv"):
src = root / rel
os.utime(src, (source_time, source_time))
calls = _invoke_helper(root, monkeypatch, force=False)
assert calls == []
def test_partner_synergy_refresh_honors_force_flag(tmp_path, monkeypatch) -> None:
root = _setup_fake_root(tmp_path)
analytics_dir = root / "config" / "analytics"
analytics_dir.mkdir(parents=True, exist_ok=True)
dataset = analytics_dir / "partner_synergy.json"
dataset.write_text("{}\n", encoding="utf-8")
now = time.time()
os.utime(dataset, (now, now))
for rel in ("config/themes/theme_list.json", "csv_files/commander_cards.csv"):
src = root / rel
os.utime(src, (now, now))
calls = _invoke_helper(root, monkeypatch, force=True)
assert len(calls) == 1
cmd, cwd = calls[0]
assert cmd[1].endswith("build_partner_suggestions.py")
assert cwd == str(root)

View file

@ -0,0 +1,27 @@
"""Tests for background option fallback logic in the web build route."""
from __future__ import annotations
from code.web import app # noqa: F401 # Ensure app is initialized prior to build import
from code.web.routes import build
from code.web.services.commander_catalog_loader import find_commander_record
def test_build_background_options_falls_back_to_commander_catalog(monkeypatch):
"""When the background CSV is unavailable, commander catalog data is used."""
def _raise_missing(*_args, **_kwargs):
raise FileNotFoundError("missing background csv")
monkeypatch.setattr(build, "load_background_cards", _raise_missing)
options = build._build_background_options()
assert options, "Expected fallback to provide background options"
names = [opt["name"] for opt in options]
assert len(names) == len(set(name.casefold() for name in names)), "Background options should be unique"
for name in names:
record = find_commander_record(name)
assert record is not None, f"Commander catalog missing background record for {name}"
assert record.is_background, f"Expected {name} to be marked as a Background"

View file

@ -0,0 +1,299 @@
from __future__ import annotations
import os
import re
import sys
from typing import Iterable
from fastapi.testclient import TestClient
from deck_builder.builder import DeckBuilder
from deck_builder.partner_selection import apply_partner_inputs
def _fresh_client() -> TestClient:
os.environ["ENABLE_PARTNER_MECHANICS"] = "1"
# Ensure a fresh app import so feature flags are applied
for module in ("code.web.app", "code.web.routes.build"):
if module in sys.modules:
del sys.modules[module]
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
clear_commander_catalog_cache()
from code.web.app import app # type: ignore
client = TestClient(app)
from code.web.services import tasks
tasks._SESSIONS.clear()
return client
def _first_commander_tag(commander_name: str) -> str | None:
from code.web.services import orchestrator as orch
tags: Iterable[str] = orch.tags_for_commander(commander_name) or []
for tag in tags:
value = str(tag).strip()
if value:
return value
return None
_OPTION_PATTERN = re.compile(r'<option value="([^\"]*)" data-pairing-mode="([^\"]]*)"[^>]*data-role-label="([^\"]*)"', re.IGNORECASE)
_OPTION_PATTERN = re.compile(r'<option[^>]*value="([^"]+)"[^>]*data-pairing-mode="([^"]+)"[^>]*data-role-label="([^"]+)"', re.IGNORECASE)
def _partner_option_rows(html: str) -> list[tuple[str, str, str]]:
rows = []
for name, mode, role in _OPTION_PATTERN.findall(html or ""):
clean_name = name.strip()
if not clean_name:
continue
rows.append((clean_name, mode.strip(), role.strip()))
return rows
def test_new_deck_inspect_includes_partner_controls() -> None:
client = _fresh_client()
with client:
client.get("/build/new")
resp = client.get("/build/new/inspect", params={"name": "Akiri, Line-Slinger"})
assert resp.status_code == 200
body = resp.text
assert "Partner commander" in body
assert "type=\"checkbox\"" not in body
assert "Silas Renn" in body # partner list should surface another partner option
assert 'data-image-url="' in body
def test_partner_with_dropdown_limits_to_pair() -> None:
client = _fresh_client()
with client:
client.get("/build/new")
resp = client.get("/build/new/inspect", params={"name": "Evie Frye"})
assert resp.status_code == 200
body = resp.text
assert "Automatically paired with Jacob Frye" in body
partner_rows = re.findall(r'<option value="([^"]+)" data-pairing-mode="([^"]+)"', body)
assert partner_rows == [("Jacob Frye", "partner_with")]
assert "Silas Renn" not in body
def test_new_deck_submit_persists_partner_selection() -> None:
commander = "Akiri, Line-Slinger"
secondary = "Silas Renn, Seeker Adept"
client = _fresh_client()
with client:
client.get("/build/new")
primary_tag = _first_commander_tag(commander)
form_data = {
"name": "Akiri Partner Test",
"commander": commander,
"partner_enabled": "1",
"secondary_commander": secondary,
"partner_auto_opt_out": "0",
"bracket": "3",
}
if primary_tag:
form_data["primary_tag"] = primary_tag
resp = client.post("/build/new", data=form_data)
assert resp.status_code == 200
assert "Stage complete" in resp.text or "Build complete" in resp.text
from code.web.services import tasks
sid = client.cookies.get("sid")
assert sid, "expected sid cookie after submission"
sess = tasks._SESSIONS.get(sid)
assert sess is not None, "session should exist for sid"
assert sess.get("partner_enabled") is True
assert sess.get("secondary_commander") == secondary
assert sess.get("partner_mode") in {"partner", "partner_with"}
combined = sess.get("combined_commander")
assert isinstance(combined, dict)
assert combined.get("secondary_name") == secondary
assert sess.get("partner_auto_opt_out") is False
assert sess.get("partner_auto_assigned") is False
# cleanup
tasks._SESSIONS.pop(sid, None)
def test_doctor_companion_flow() -> None:
commander = "The Tenth Doctor"
companion = "Donna Noble"
client = _fresh_client()
with client:
client.get("/build/new")
inspect = client.get("/build/new/inspect", params={"name": commander})
assert inspect.status_code == 200
body = inspect.text
assert "Companion" in body
assert companion in body
assert re.search(r"<button[^>]*data-partner-autotoggle", body) is None # Doctor pairings should not auto-toggle
primary_tag = _first_commander_tag(commander)
form_data = {
"name": "Doctor Companion Test",
"commander": commander,
"partner_enabled": "1",
"secondary_commander": companion,
"partner_auto_opt_out": "0",
"bracket": "3",
}
if primary_tag:
form_data["primary_tag"] = primary_tag
resp = client.post("/build/new", data=form_data)
assert resp.status_code == 200
from code.web.services import tasks
sid = client.cookies.get("sid")
assert sid, "expected sid cookie after submission"
sess = tasks._SESSIONS.get(sid)
assert sess is not None
assert sess.get("partner_mode") == "doctor_companion"
assert sess.get("secondary_commander") == companion
tasks._SESSIONS.pop(sid, None)
def test_amy_partner_options_include_rory_and_only_doctors() -> None:
client = _fresh_client()
with client:
client.get("/build/new")
resp = client.get("/build/new/inspect", params={"name": "Amy Pond"})
assert resp.status_code == 200
rows = _partner_option_rows(resp.text)
partner_with_rows = [row for row in rows if row[1] == "partner_with"]
assert any(name == "Rory Williams" for name, _, _ in partner_with_rows)
assert len(partner_with_rows) == 1
for name, mode, role in rows:
if name == "Rory Williams":
continue
assert mode == "doctor_companion"
assert "Doctor" in role
assert "Companion" not in role
def test_donna_partner_options_only_list_doctors() -> None:
client = _fresh_client()
with client:
client.get("/build/new")
resp = client.get("/build/new/inspect", params={"name": "Donna Noble"})
assert resp.status_code == 200
rows = _partner_option_rows(resp.text)
assert rows, "expected Doctor options for Donna"
for name, mode, role in rows:
assert mode == "doctor_companion"
assert "Doctor" in role
assert "Companion" not in role
def test_rory_partner_options_only_include_amy() -> None:
client = _fresh_client()
with client:
client.get("/build/new")
resp = client.get("/build/new/inspect", params={"name": "Rory Williams"})
assert resp.status_code == 200
rows = _partner_option_rows(resp.text)
assert rows == [("Amy Pond", "partner_with", "Partner With")]
def test_step2_tags_merge_partner_union() -> None:
commander = "Akiri, Line-Slinger"
secondary = "Silas Renn, Seeker Adept"
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
combined = apply_partner_inputs(
builder,
primary_name=commander,
secondary_name=secondary,
feature_enabled=True,
)
expected_tags = set(combined.theme_tags if combined else ())
assert expected_tags, "expected combined commander to produce theme tags"
client = _fresh_client()
with client:
client.get("/build/new")
primary_tag = _first_commander_tag(commander)
form_data = {
"name": "Tag Merge",
"commander": commander,
"partner_enabled": "1",
"secondary_commander": secondary,
"partner_auto_opt_out": "0",
"bracket": "3",
}
if primary_tag:
form_data["primary_tag"] = primary_tag
client.post("/build/new", data=form_data)
resp = client.get("/build/step2")
assert resp.status_code == 200
body = resp.text
for tag in expected_tags:
assert tag in body
def test_step5_summary_displays_combined_partner_details() -> None:
commander = "Halana, Kessig Ranger"
secondary = "Alena, Kessig Trapper"
client = _fresh_client()
with client:
client.get("/build/new")
primary_tag = _first_commander_tag(commander)
form_data = {
"name": "Halana Alena Partner",
"commander": commander,
"partner_enabled": "1",
"secondary_commander": secondary,
"partner_auto_opt_out": "0",
"bracket": "3",
}
if primary_tag:
form_data["primary_tag"] = primary_tag
resp = client.post("/build/new", data=form_data)
assert resp.status_code == 200
body = resp.text
assert "Halana, Kessig Ranger + Alena, Kessig Trapper" in body
assert "mana-R" in body and "mana-G" in body
assert "Burn" in body
assert "commander-card partner-card" in body
assert 'data-card-name="Alena, Kessig Trapper"' in body
assert 'width="320"' in body
def test_partner_preview_endpoint_returns_theme_tags() -> None:
commander = "Akiri, Line-Slinger"
secondary = "Silas Renn, Seeker Adept"
client = _fresh_client()
with client:
client.get("/build/new")
resp = client.post(
"/build/partner/preview",
data={
"commander": commander,
"partner_enabled": "1",
"secondary_commander": secondary,
"partner_auto_opt_out": "0",
"scope": "step2",
},
)
assert resp.status_code == 200
payload = resp.json()
assert payload.get("ok") is True
preview = payload.get("preview") or {}
assert preview.get("secondary_name") == secondary
assert preview.get("partner_mode") in {"partner", "partner_with"}
tags = payload.get("theme_tags") or []
assert isinstance(tags, list)
assert tags, "expected theme tags from partner preview"
assert payload.get("scope") == "step2"
assert preview.get("secondary_image_url")
assert preview.get("secondary_role_label")