mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-01-11 12:08:51 +01:00
feat: Added Partners, Backgrounds, and related variation selections to commander building.
This commit is contained in:
parent
641b305955
commit
d416c9b238
65 changed files with 11835 additions and 691 deletions
62
code/tests/test_background_loader.py
Normal file
62
code/tests/test_background_loader.py
Normal 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
|
||||
147
code/tests/test_cli_partner_config.py
Normal file
147
code/tests/test_cli_partner_config.py
Normal 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
|
||||
254
code/tests/test_combined_commander.py
Normal file
254
code/tests/test_combined_commander.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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", [])
|
||||
)
|
||||
|
|
|
|||
110
code/tests/test_export_commander_metadata.py
Normal file
110
code/tests/test_export_commander_metadata.py
Normal 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"]
|
||||
|
|
@ -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__])
|
||||
|
|
|
|||
36
code/tests/test_orchestrator_partner_helpers.py
Normal file
36
code/tests/test_orchestrator_partner_helpers.py
Normal 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"
|
||||
162
code/tests/test_partner_background_utils.py
Normal file
162
code/tests/test_partner_background_utils.py
Normal 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
|
||||
133
code/tests/test_partner_option_filtering.py
Normal file
133
code/tests/test_partner_option_filtering.py
Normal 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
|
||||
293
code/tests/test_partner_scoring.py
Normal file
293
code/tests/test_partner_scoring.py
Normal 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
|
||||
324
code/tests/test_partner_selection.py
Normal file
324
code/tests/test_partner_selection.py
Normal 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"
|
||||
304
code/tests/test_partner_suggestions_api.py
Normal file
304
code/tests/test_partner_suggestions_api.py
Normal 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
|
||||
163
code/tests/test_partner_suggestions_pipeline.py
Normal file
163
code/tests/test_partner_suggestions_pipeline.py
Normal 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
|
||||
133
code/tests/test_partner_suggestions_service.py
Normal file
133
code/tests/test_partner_suggestions_service.py
Normal 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)
|
||||
98
code/tests/test_partner_suggestions_telemetry.py
Normal file
98
code/tests/test_partner_suggestions_telemetry.py
Normal 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
|
||||
91
code/tests/test_partner_synergy_refresh.py
Normal file
91
code/tests/test_partner_synergy_refresh.py
Normal 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)
|
||||
27
code/tests/test_web_background_fallback.py
Normal file
27
code/tests/test_web_background_fallback.py
Normal 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"
|
||||
299
code/tests/test_web_new_deck_partner.py
Normal file
299
code/tests/test_web_new_deck_partner.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue