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:
matt 2025-10-02 15:31:05 -07:00
parent 6fefda714e
commit 88cf832bf2
46 changed files with 3292 additions and 86 deletions

View file

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

View 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

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

View 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

View 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

View 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

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