mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
Finalize MDFC follow-ups, docs, and diagnostics tooling
document deck summary DFC badges, exporter annotations, and per-face metadata across README/DOCKER/release notes record completion of all MDFC roadmap follow-ups and add the authoring guide for multi-face CSV entries wire in optional DFC_PER_FACE_SNAPSHOT env support, exporter regression tests, and diagnostics updates noted in the changelog
This commit is contained in:
parent
6fefda714e
commit
88cf832bf2
46 changed files with 3292 additions and 86 deletions
|
|
@ -19,6 +19,7 @@ def _fake_session(**kw):
|
|||
"prefer_combos": False,
|
||||
"combo_target_count": 2,
|
||||
"combo_balance": "mix",
|
||||
"swap_mdfc_basics": False,
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
|
@ -47,6 +48,7 @@ def test_start_ctx_from_session_minimal(monkeypatch):
|
|||
assert "builder" in ctx
|
||||
assert "stages" in ctx
|
||||
assert "idx" in ctx
|
||||
assert calls.get("swap_mdfc_basics") is False
|
||||
|
||||
|
||||
def test_start_ctx_from_session_sets_on_session(monkeypatch):
|
||||
|
|
|
|||
77
code/tests/test_commander_exclusion_warnings.py
Normal file
77
code/tests/test_commander_exclusion_warnings.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from code.web.app import app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> Iterator[TestClient]:
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_candidate_list_includes_exclusion_warning(monkeypatch: pytest.MonkeyPatch, client: TestClient) -> None:
|
||||
def fake_candidates(_: str, limit: int = 8):
|
||||
return [("Sample Front", 10, ["G"])]
|
||||
|
||||
def fake_lookup(name: str):
|
||||
if name == "Sample Front":
|
||||
return {
|
||||
"primary_face": "Sample Front",
|
||||
"eligible_faces": ["Sample Back"],
|
||||
"reason": "secondary_face_only",
|
||||
}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("code.web.routes.build.orch.commander_candidates", fake_candidates)
|
||||
monkeypatch.setattr("code.web.routes.build.lookup_commander_detail", fake_lookup)
|
||||
|
||||
response = client.get("/build/new/candidates", params={"commander": "Sample"})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "Use the back face 'Sample Back' when building" in body
|
||||
assert "data-name=\"Sample Back\"" in body
|
||||
assert "data-display=\"Sample Front\"" in body
|
||||
|
||||
|
||||
def test_front_face_submit_returns_modal_error(monkeypatch: pytest.MonkeyPatch, client: TestClient) -> None:
|
||||
def fake_lookup(name: str):
|
||||
if "Budoka" in name:
|
||||
return {
|
||||
"primary_face": "Budoka Gardener",
|
||||
"eligible_faces": ["Dokai, Weaver of Life"],
|
||||
"reason": "secondary_face_only",
|
||||
}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("code.web.routes.build.lookup_commander_detail", fake_lookup)
|
||||
monkeypatch.setattr("code.web.routes.build.orch.bracket_options", lambda: [{"level": 3, "name": "Upgraded"}])
|
||||
monkeypatch.setattr("code.web.routes.build.orch.ideal_labels", lambda: {})
|
||||
monkeypatch.setattr("code.web.routes.build.orch.ideal_defaults", lambda: {})
|
||||
|
||||
def fail_select(name: str): # pragma: no cover - should not trigger
|
||||
raise AssertionError(f"commander_select should not be called for {name}")
|
||||
|
||||
monkeypatch.setattr("code.web.routes.build.orch.commander_select", fail_select)
|
||||
|
||||
client.get("/build")
|
||||
response = client.post(
|
||||
"/build/new",
|
||||
data={
|
||||
"name": "",
|
||||
"commander": "Budoka Gardener",
|
||||
"bracket": "3",
|
||||
"include_cards": "",
|
||||
"exclude_cards": "",
|
||||
"enforcement_mode": "warn",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "can't lead a deck" in body
|
||||
assert "Use 'Dokai, Weaver of Life' as the commander instead" in body
|
||||
assert "value=\"Dokai, Weaver of Life\"" in body
|
||||
221
code/tests/test_commander_primary_face_filter.py
Normal file
221
code/tests/test_commander_primary_face_filter.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
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
|
||||
import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_csv_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(su, "CSV_DIRECTORY", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "CSV_DIRECTORY", str(tmp_path))
|
||||
import importlib
|
||||
|
||||
setup_module = importlib.import_module("file_setup.setup")
|
||||
monkeypatch.setattr(setup_module, "CSV_DIRECTORY", str(tmp_path))
|
||||
return Path(tmp_path)
|
||||
|
||||
|
||||
def _make_card_row(
|
||||
*,
|
||||
name: str,
|
||||
face_name: str,
|
||||
type_line: str,
|
||||
side: str | None,
|
||||
layout: str,
|
||||
text: str = "",
|
||||
power: str | None = None,
|
||||
toughness: str | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"faceName": face_name,
|
||||
"edhrecRank": 1000,
|
||||
"colorIdentity": "B",
|
||||
"colors": "B",
|
||||
"manaCost": "3B",
|
||||
"manaValue": 4,
|
||||
"type": type_line,
|
||||
"creatureTypes": "['Demon']" if "Creature" in type_line else "[]",
|
||||
"text": text,
|
||||
"power": power,
|
||||
"toughness": toughness,
|
||||
"keywords": "",
|
||||
"themeTags": "[]",
|
||||
"layout": layout,
|
||||
"side": side,
|
||||
"availability": "paper",
|
||||
"promoTypes": "",
|
||||
"securityStamp": "",
|
||||
"printings": "SET",
|
||||
}
|
||||
|
||||
|
||||
def test_secondary_face_only_commander_removed(tmp_csv_dir):
|
||||
name = "Elbrus, the Binding Blade // Withengar Unbound"
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name="Elbrus, the Binding Blade",
|
||||
type_line="Legendary Artifact — Equipment",
|
||||
side="a",
|
||||
layout="transform",
|
||||
),
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name="Withengar Unbound",
|
||||
type_line="Legendary Creature — Demon",
|
||||
side="b",
|
||||
layout="transform",
|
||||
power="13",
|
||||
toughness="13",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
processed = process_legendary_cards(df)
|
||||
assert processed.empty
|
||||
|
||||
exclusion_path = tmp_csv_dir / ".commander_exclusions.json"
|
||||
assert exclusion_path.exists(), "Expected commander exclusion diagnostics to be written"
|
||||
data = json.loads(exclusion_path.read_text(encoding="utf-8"))
|
||||
entries = data.get("secondary_face_only", [])
|
||||
assert any(entry.get("name") == name for entry in entries)
|
||||
|
||||
|
||||
def test_primary_face_retained_and_log_cleared(tmp_csv_dir):
|
||||
name = "Birgi, God of Storytelling // Harnfel, Horn of Bounty"
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name="Birgi, God of Storytelling",
|
||||
type_line="Legendary Creature — God",
|
||||
side="a",
|
||||
layout="modal_dfc",
|
||||
power="3",
|
||||
toughness="3",
|
||||
),
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name="Harnfel, Horn of Bounty",
|
||||
type_line="Legendary Artifact",
|
||||
side="b",
|
||||
layout="modal_dfc",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
processed = process_legendary_cards(df)
|
||||
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_headless_validation_reports_secondary_face(monkeypatch):
|
||||
monkeypatch.setattr(hr, "_load_commander_name_lookup", lambda: set())
|
||||
|
||||
exclusion_entry = {
|
||||
"name": "Elbrus, the Binding Blade // Withengar Unbound",
|
||||
"primary_face": "Elbrus, the Binding Blade",
|
||||
"eligible_faces": ["Withengar Unbound"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(hr, "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")
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "secondary face" in message.lower()
|
||||
assert "Withengar" in message
|
||||
|
||||
|
||||
def test_commander_theme_tags_enriched(tmp_csv_dir):
|
||||
import importlib
|
||||
|
||||
setup_module = importlib.import_module("file_setup.setup")
|
||||
|
||||
name = "Eddie Brock // Venom, Lethal Protector"
|
||||
front_face = "Venom, Eddie Brock"
|
||||
back_face = "Venom, Lethal Protector"
|
||||
|
||||
cards_df = pd.DataFrame(
|
||||
[
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name=front_face,
|
||||
type_line="Legendary Creature — Symbiote",
|
||||
side="a",
|
||||
layout="modal_dfc",
|
||||
power="3",
|
||||
toughness="3",
|
||||
text="Other creatures you control get +1/+1.",
|
||||
),
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name=back_face,
|
||||
type_line="Legendary Creature — Horror",
|
||||
side="b",
|
||||
layout="modal_dfc",
|
||||
power="5",
|
||||
toughness="5",
|
||||
text="Menace",
|
||||
),
|
||||
]
|
||||
)
|
||||
cards_df.to_csv(tmp_csv_dir / "cards.csv", index=False)
|
||||
|
||||
color_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"name": name,
|
||||
"faceName": front_face,
|
||||
"themeTags": "['Aggro', 'Counters']",
|
||||
"creatureTypes": "['Human', 'Warrior']",
|
||||
"roleTags": "['Commander']",
|
||||
},
|
||||
{
|
||||
"name": name,
|
||||
"faceName": back_face,
|
||||
"themeTags": "['Graveyard']",
|
||||
"creatureTypes": "['Demon']",
|
||||
"roleTags": "['Finisher']",
|
||||
},
|
||||
]
|
||||
)
|
||||
color_df.to_csv(tmp_csv_dir / "black_cards.csv", index=False)
|
||||
|
||||
setup_module.determine_commanders()
|
||||
|
||||
commander_path = tmp_csv_dir / "commander_cards.csv"
|
||||
assert commander_path.exists(), "Expected commander CSV to be generated"
|
||||
|
||||
commander_df = pd.read_csv(
|
||||
commander_path,
|
||||
converters={
|
||||
"themeTags": ast.literal_eval,
|
||||
"creatureTypes": ast.literal_eval,
|
||||
"roleTags": ast.literal_eval,
|
||||
},
|
||||
)
|
||||
assert "themeTags" in commander_df.columns
|
||||
|
||||
row = commander_df[commander_df["faceName"] == front_face].iloc[0]
|
||||
assert set(row["themeTags"]) == {"Aggro", "Counters", "Graveyard"}
|
||||
assert set(row["creatureTypes"]) == {"Human", "Warrior", "Demon"}
|
||||
assert set(row["roleTags"]) == {"Commander", "Finisher"}
|
||||
80
code/tests/test_export_mdfc_annotations.py
Normal file
80
code/tests/test_export_mdfc_annotations.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.phases.phase6_reporting import ReportingMixin
|
||||
|
||||
|
||||
class DummyBuilder(ReportingMixin):
|
||||
def __init__(self) -> None:
|
||||
self.card_library = {
|
||||
"Valakut Awakening // Valakut Stoneforge": {
|
||||
"Card Type": "Instant",
|
||||
"Count": 2,
|
||||
"Mana Cost": "{2}{R}",
|
||||
"Mana Value": "3",
|
||||
"Role": "",
|
||||
"Tags": [],
|
||||
},
|
||||
"Mountain": {
|
||||
"Card Type": "Land",
|
||||
"Count": 1,
|
||||
"Mana Cost": "",
|
||||
"Mana Value": "0",
|
||||
"Role": "",
|
||||
"Tags": [],
|
||||
},
|
||||
}
|
||||
self.color_identity = ["R"]
|
||||
self.output_func = lambda *_args, **_kwargs: None # silence export logs
|
||||
self._full_cards_df = None
|
||||
self._combined_cards_df = None
|
||||
self.custom_export_base = "test_dfc_export"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def builder(monkeypatch: pytest.MonkeyPatch) -> DummyBuilder:
|
||||
matrix = {
|
||||
"Valakut Awakening // Valakut Stoneforge": {
|
||||
"R": 1,
|
||||
"_dfc_land": True,
|
||||
"_dfc_counts_as_extra": True,
|
||||
},
|
||||
"Mountain": {"R": 1},
|
||||
}
|
||||
|
||||
def _fake_compute(card_library, *_args, **_kwargs):
|
||||
return matrix
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deck_builder.builder_utils.compute_color_source_matrix",
|
||||
_fake_compute,
|
||||
)
|
||||
return DummyBuilder()
|
||||
|
||||
|
||||
def test_export_decklist_csv_includes_dfc_note(tmp_path: Path, builder: DummyBuilder) -> None:
|
||||
csv_path = Path(builder.export_decklist_csv(directory=str(tmp_path)))
|
||||
with csv_path.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
rows = {row["Name"]: row for row in reader}
|
||||
|
||||
valakut_row = rows["Valakut Awakening // Valakut Stoneforge"]
|
||||
assert valakut_row["DFCNote"] == "MDFC: Adds extra land slot"
|
||||
|
||||
mountain_row = rows["Mountain"]
|
||||
assert mountain_row["DFCNote"] == ""
|
||||
|
||||
|
||||
def test_export_decklist_text_appends_dfc_annotation(tmp_path: Path, builder: DummyBuilder) -> None:
|
||||
text_path = Path(builder.export_decklist_text(directory=str(tmp_path)))
|
||||
lines = text_path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
valakut_line = next(line for line in lines if line.startswith("2 Valakut Awakening"))
|
||||
assert "[MDFC: Adds extra land slot]" in valakut_line
|
||||
|
||||
mountain_line = next(line for line in lines if line.strip().endswith("Mountain"))
|
||||
assert "MDFC" not in mountain_line
|
||||
150
code/tests/test_land_summary_totals.py
Normal file
150
code/tests/test_land_summary_totals.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import pytest
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from code.deck_builder.phases.phase6_reporting import ReportingMixin
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics, _reset_metrics_for_test
|
||||
|
||||
|
||||
class DummyBuilder(ReportingMixin):
|
||||
def __init__(self, card_library: Dict[str, Dict[str, Any]], colors: List[str]):
|
||||
self.card_library = card_library
|
||||
self.color_identity = colors
|
||||
self.output_lines: List[str] = []
|
||||
self.output_func = self.output_lines.append # type: ignore[assignment]
|
||||
self._full_cards_df = None
|
||||
self._combined_cards_df = None
|
||||
self.include_exclude_diagnostics = None
|
||||
self.include_cards = []
|
||||
self.exclude_cards = []
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_card_library() -> Dict[str, Dict[str, Any]]:
|
||||
return {
|
||||
"Mountain": {"Card Type": "Land", "Count": 35, "Mana Cost": "", "Role": "", "Tags": []},
|
||||
"Branchloft Pathway // Boulderloft Pathway": {
|
||||
"Card Type": "Land",
|
||||
"Count": 1,
|
||||
"Mana Cost": "",
|
||||
"Role": "",
|
||||
"Tags": [],
|
||||
},
|
||||
"Valakut Awakening // Valakut Stoneforge": {
|
||||
"Card Type": "Instant",
|
||||
"Count": 2,
|
||||
"Mana Cost": "{2}{R}",
|
||||
"Role": "",
|
||||
"Tags": [],
|
||||
},
|
||||
"Cultivate": {"Card Type": "Sorcery", "Count": 1, "Mana Cost": "{2}{G}", "Role": "", "Tags": []},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_matrix(monkeypatch):
|
||||
matrix = {
|
||||
"Mountain": {"R": 1},
|
||||
"Branchloft Pathway // Boulderloft Pathway": {"G": 1, "W": 1, "_dfc_land": True},
|
||||
"Valakut Awakening // Valakut Stoneforge": {
|
||||
"R": 1,
|
||||
"_dfc_land": True,
|
||||
"_dfc_counts_as_extra": True,
|
||||
},
|
||||
"Cultivate": {},
|
||||
}
|
||||
|
||||
def _fake_compute(card_library, *_):
|
||||
return matrix
|
||||
|
||||
monkeypatch.setattr("deck_builder.builder_utils.compute_color_source_matrix", _fake_compute)
|
||||
return matrix
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_mdfc_metrics():
|
||||
_reset_metrics_for_test()
|
||||
yield
|
||||
_reset_metrics_for_test()
|
||||
|
||||
|
||||
def test_build_deck_summary_includes_mdfc_totals(sample_card_library, fake_matrix):
|
||||
builder = DummyBuilder(sample_card_library, ["R", "G"])
|
||||
summary = builder.build_deck_summary()
|
||||
|
||||
land_summary = summary.get("land_summary")
|
||||
assert land_summary["traditional"] == 36
|
||||
assert land_summary["dfc_lands"] == 2
|
||||
assert land_summary["with_dfc"] == 38
|
||||
assert land_summary["headline"] == "Lands: 36 (38 with DFC)"
|
||||
|
||||
dfc_cards = {card["name"]: card for card in land_summary["dfc_cards"]}
|
||||
branch = dfc_cards["Branchloft Pathway // Boulderloft Pathway"]
|
||||
assert branch["count"] == 1
|
||||
assert set(branch["colors"]) == {"G", "W"}
|
||||
assert branch["adds_extra_land"] is False
|
||||
assert branch["counts_as_land"] is True
|
||||
assert branch["note"] == "Counts as land slot"
|
||||
assert "faces" in branch
|
||||
assert isinstance(branch["faces"], list) and branch["faces"]
|
||||
assert all("mana_cost" in face for face in branch["faces"])
|
||||
|
||||
valakut = dfc_cards["Valakut Awakening // Valakut Stoneforge"]
|
||||
assert valakut["count"] == 2
|
||||
assert valakut["colors"] == ["R"]
|
||||
assert valakut["adds_extra_land"] is True
|
||||
assert valakut["counts_as_land"] is False
|
||||
assert valakut["note"] == "Adds extra land slot"
|
||||
assert any(face.get("produces_mana") for face in valakut.get("faces", []))
|
||||
|
||||
mana_cards = summary["mana_generation"]["cards"]
|
||||
red_sources = {item["name"]: item for item in mana_cards["R"]}
|
||||
assert red_sources["Valakut Awakening // Valakut Stoneforge"]["dfc"] is True
|
||||
assert red_sources["Mountain"]["dfc"] is False
|
||||
|
||||
|
||||
def test_cli_summary_mentions_mdfc_totals(sample_card_library, fake_matrix):
|
||||
builder = DummyBuilder(sample_card_library, ["R", "G"])
|
||||
builder.print_type_summary()
|
||||
joined = "\n".join(builder.output_lines)
|
||||
assert "Lands: 36 (38 with DFC)" in joined
|
||||
assert "MDFC sources:" in joined
|
||||
|
||||
|
||||
def test_deck_summary_template_renders_land_copy(sample_card_library, fake_matrix):
|
||||
builder = DummyBuilder(sample_card_library, ["R", "G"])
|
||||
summary = builder.build_deck_summary()
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader("code/web/templates"),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
template = env.get_template("partials/deck_summary.html")
|
||||
html = template.render(
|
||||
summary=summary,
|
||||
synergies=[],
|
||||
game_changers=[],
|
||||
owned_set=set(),
|
||||
combos=[],
|
||||
commander=None,
|
||||
)
|
||||
|
||||
assert "Lands: 36 (38 with DFC)" in html
|
||||
assert "DFC land" in html
|
||||
|
||||
|
||||
def test_deck_summary_records_mdfc_telemetry(sample_card_library, fake_matrix):
|
||||
builder = DummyBuilder(sample_card_library, ["R", "G"])
|
||||
builder.build_deck_summary()
|
||||
|
||||
metrics = get_mdfc_metrics()
|
||||
assert metrics["total_builds"] == 1
|
||||
assert metrics["builds_with_mdfc"] == 1
|
||||
assert metrics["total_mdfc_lands"] == 2
|
||||
assert metrics["last_summary"]["dfc_lands"] == 2
|
||||
top_cards = metrics.get("top_cards") or {}
|
||||
assert top_cards.get("Valakut Awakening // Valakut Stoneforge") == 2
|
||||
assert top_cards.get("Branchloft Pathway // Boulderloft Pathway") == 1
|
||||
45
code/tests/test_mdfc_basic_swap.py
Normal file
45
code/tests/test_mdfc_basic_swap.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import MethodType
|
||||
|
||||
from deck_builder.builder import DeckBuilder
|
||||
|
||||
|
||||
def _builder_with_forest() -> DeckBuilder:
|
||||
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||
builder.card_library = {
|
||||
"Forest": {"Card Name": "Forest", "Card Type": "Land", "Count": 5},
|
||||
}
|
||||
return builder
|
||||
|
||||
|
||||
def _stub_modal_matrix(builder: DeckBuilder) -> None:
|
||||
def fake_matrix(self: DeckBuilder):
|
||||
return {
|
||||
"Bala Ged Recovery": {"G": 1, "_dfc_counts_as_extra": True},
|
||||
"Forest": {"G": 1},
|
||||
}
|
||||
|
||||
builder._compute_color_source_matrix = MethodType(fake_matrix, builder) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_modal_dfc_swaps_basic_when_enabled():
|
||||
builder = _builder_with_forest()
|
||||
builder.swap_mdfc_basics = True
|
||||
_stub_modal_matrix(builder)
|
||||
|
||||
builder.add_card("Bala Ged Recovery", card_type="Instant")
|
||||
|
||||
assert builder.card_library["Forest"]["Count"] == 4
|
||||
assert "Bala Ged Recovery" in builder.card_library
|
||||
|
||||
|
||||
def test_modal_dfc_does_not_swap_when_disabled():
|
||||
builder = _builder_with_forest()
|
||||
builder.swap_mdfc_basics = False
|
||||
_stub_modal_matrix(builder)
|
||||
|
||||
builder.add_card("Bala Ged Recovery", card_type="Instant")
|
||||
|
||||
assert builder.card_library["Forest"]["Count"] == 5
|
||||
assert "Bala Ged Recovery" in builder.card_library
|
||||
192
code/tests/test_multi_face_merge.py
Normal file
192
code/tests/test_multi_face_merge.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from code.tagging.multi_face_merger import merge_multi_face_rows
|
||||
|
||||
|
||||
def _build_dataframe() -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"name": "Eddie Brock // Venom, Lethal Protector",
|
||||
"faceName": "Eddie Brock",
|
||||
"edhrecRank": 12345.0,
|
||||
"colorIdentity": "B",
|
||||
"colors": "B",
|
||||
"manaCost": "{3}{B}{B}",
|
||||
"manaValue": 5.0,
|
||||
"type": "Legendary Creature — Human",
|
||||
"creatureTypes": ["Human"],
|
||||
"text": "When Eddie Brock enters...",
|
||||
"power": 3,
|
||||
"toughness": 4,
|
||||
"keywords": "Transform",
|
||||
"themeTags": ["Aggro", "Control"],
|
||||
"layout": "transform",
|
||||
"side": "a",
|
||||
"roleTags": ["Value Engine"],
|
||||
},
|
||||
{
|
||||
"name": "Eddie Brock // Venom, Lethal Protector",
|
||||
"faceName": "Venom, Lethal Protector",
|
||||
"edhrecRank": 12345.0,
|
||||
"colorIdentity": "B",
|
||||
"colors": "B",
|
||||
"manaCost": "",
|
||||
"manaValue": 5.0,
|
||||
"type": "Legendary Creature — Symbiote",
|
||||
"creatureTypes": ["Symbiote"],
|
||||
"text": "Whenever Venom attacks...",
|
||||
"power": 5,
|
||||
"toughness": 5,
|
||||
"keywords": "Menace, Transform",
|
||||
"themeTags": ["Menace", "Legends Matter"],
|
||||
"layout": "transform",
|
||||
"side": "b",
|
||||
"roleTags": ["Finisher"],
|
||||
},
|
||||
{
|
||||
"name": "Bonecrusher Giant // Stomp",
|
||||
"faceName": "Bonecrusher Giant",
|
||||
"edhrecRank": 6789.0,
|
||||
"colorIdentity": "R",
|
||||
"colors": "R",
|
||||
"manaCost": "{2}{R}",
|
||||
"manaValue": 3.0,
|
||||
"type": "Creature — Giant",
|
||||
"creatureTypes": ["Giant"],
|
||||
"text": "Whenever this creature becomes the target...",
|
||||
"power": 4,
|
||||
"toughness": 3,
|
||||
"keywords": "",
|
||||
"themeTags": ["Aggro"],
|
||||
"layout": "adventure",
|
||||
"side": "a",
|
||||
"roleTags": [],
|
||||
},
|
||||
{
|
||||
"name": "Bonecrusher Giant // Stomp",
|
||||
"faceName": "Stomp",
|
||||
"edhrecRank": 6789.0,
|
||||
"colorIdentity": "R",
|
||||
"colors": "R",
|
||||
"manaCost": "{1}{R}",
|
||||
"manaValue": 2.0,
|
||||
"type": "Instant — Adventure",
|
||||
"creatureTypes": [],
|
||||
"text": "Stomp deals 2 damage to any target.",
|
||||
"power": None,
|
||||
"toughness": None,
|
||||
"keywords": "Instant",
|
||||
"themeTags": ["Removal"],
|
||||
"layout": "adventure",
|
||||
"side": "b",
|
||||
"roleTags": [],
|
||||
},
|
||||
{
|
||||
"name": "Expansion // Explosion",
|
||||
"faceName": "Expansion",
|
||||
"edhrecRank": 4321.0,
|
||||
"colorIdentity": "U, R",
|
||||
"colors": "U, R",
|
||||
"manaCost": "{U/R}{U/R}",
|
||||
"manaValue": 2.0,
|
||||
"type": "Instant",
|
||||
"creatureTypes": [],
|
||||
"text": "Copy target instant or sorcery spell...",
|
||||
"power": None,
|
||||
"toughness": None,
|
||||
"keywords": "",
|
||||
"themeTags": ["Spell Copy"],
|
||||
"layout": "split",
|
||||
"side": "a",
|
||||
"roleTags": ["Copy Enabler"],
|
||||
},
|
||||
{
|
||||
"name": "Expansion // Explosion",
|
||||
"faceName": "Explosion",
|
||||
"edhrecRank": 4321.0,
|
||||
"colorIdentity": "U, R",
|
||||
"colors": "U, R",
|
||||
"manaCost": "{X}{X}{U}{R}",
|
||||
"manaValue": 4.0,
|
||||
"type": "Instant",
|
||||
"creatureTypes": [],
|
||||
"text": "Explosion deals X damage to any target...",
|
||||
"power": None,
|
||||
"toughness": None,
|
||||
"keywords": "",
|
||||
"themeTags": ["Burn", "Card Draw"],
|
||||
"layout": "split",
|
||||
"side": "b",
|
||||
"roleTags": ["Finisher"],
|
||||
},
|
||||
{
|
||||
"name": "Persistent Petitioners",
|
||||
"faceName": "Persistent Petitioners",
|
||||
"edhrecRank": 5555.0,
|
||||
"colorIdentity": "U",
|
||||
"colors": "U",
|
||||
"manaCost": "{1}{U}",
|
||||
"manaValue": 2.0,
|
||||
"type": "Creature — Human Advisor",
|
||||
"creatureTypes": ["Human", "Advisor"],
|
||||
"text": "{1}{U}, Tap four untapped Advisors you control: Mill 12.",
|
||||
"power": 1,
|
||||
"toughness": 3,
|
||||
"keywords": "",
|
||||
"themeTags": ["Mill"],
|
||||
"layout": "normal",
|
||||
"side": "",
|
||||
"roleTags": ["Mill Enabler"],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_merge_multi_face_rows_combines_themes_and_keywords():
|
||||
df = _build_dataframe()
|
||||
|
||||
merged = merge_multi_face_rows(df, "grixis", logger=None)
|
||||
|
||||
# Eddie Brock merge assertions
|
||||
eddie = merged[merged["name"] == "Eddie Brock // Venom, Lethal Protector"].iloc[0]
|
||||
assert set(eddie["themeTags"]) == {
|
||||
"Aggro",
|
||||
"Control",
|
||||
"Legends Matter",
|
||||
"Menace",
|
||||
}
|
||||
assert set(eddie["creatureTypes"]) == {"Human", "Symbiote"}
|
||||
assert eddie["keywords"] == "Menace, Transform"
|
||||
|
||||
assert (merged["faceName"] == "Venom, Lethal Protector").sum() == 0
|
||||
|
||||
# Bonecrusher Giant adventure merge assertions
|
||||
bonecrusher = merged[merged["name"] == "Bonecrusher Giant // Stomp"].iloc[0]
|
||||
assert set(bonecrusher["themeTags"]) == {"Aggro", "Removal"}
|
||||
assert set(bonecrusher["creatureTypes"]) == {"Giant"}
|
||||
assert bonecrusher["keywords"] == "Instant"
|
||||
assert (merged["faceName"] == "Stomp").sum() == 0
|
||||
|
||||
# Split card merge assertions
|
||||
explosion = merged[merged["name"] == "Expansion // Explosion"].iloc[0]
|
||||
assert set(explosion["themeTags"]) == {"Spell Copy", "Burn", "Card Draw"}
|
||||
assert set(explosion["roleTags"]) == {"Copy Enabler", "Finisher"}
|
||||
assert (merged["faceName"] == "Explosion").sum() == 0
|
||||
|
||||
# Persistent Petitioners should remain untouched
|
||||
petitioners = merged[merged["name"] == "Persistent Petitioners"].iloc[0]
|
||||
assert petitioners["themeTags"] == ["Mill"]
|
||||
assert petitioners["roleTags"] == ["Mill Enabler"]
|
||||
assert "faceDetails" not in merged.columns
|
||||
assert len(merged) == 4
|
||||
|
||||
|
||||
def test_merge_multi_face_rows_is_idempotent():
|
||||
df = _build_dataframe()
|
||||
once = merge_multi_face_rows(df, "izzet", logger=None)
|
||||
twice = merge_multi_face_rows(once, "izzet", logger=None)
|
||||
|
||||
pd.testing.assert_frame_equal(once, twice)
|
||||
Loading…
Add table
Add a link
Reference in a new issue