feat(web): Multi-Copy modal earlier; Multi-Copy stage before lands; bump version to 2.1.1; update CHANGELOG\n\n- Modal triggers after commander selection (Step 2)\n- Multi-Copy applied first in Step 5, lands next\n- Keep mc_summary/clamp/adjustments wiring intact\n- Tests green

This commit is contained in:
matt 2025-08-29 09:19:03 -07:00
parent be672ac5d2
commit 341a216ed3
20 changed files with 1271 additions and 21 deletions

11
code/tests/conftest.py Normal file
View file

@ -0,0 +1,11 @@
"""Pytest configuration and sys.path adjustments for local runs."""
# Ensure package imports resolve when running tests directly
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CODE_DIR = os.path.join(ROOT, 'code')
# Add the repo root and the 'code' package directory to sys.path if missing
for p in (ROOT, CODE_DIR):
if p not in sys.path:
sys.path.insert(0, p)

View file

@ -0,0 +1,36 @@
from deck_builder import builder_utils as bu
class DummyBuilder:
def __init__(self, color_identity, selected_tags=None, commander_dict=None):
self.color_identity = color_identity
self.selected_tags = selected_tags or []
self.commander_dict = commander_dict or {"Themes": []}
def test_detector_dragon_approach_minimal():
b = DummyBuilder(color_identity=['R'], selected_tags=['Spellslinger'])
results = bu.detect_viable_multi_copy_archetypes(b)
ids = [r['id'] for r in results]
assert 'dragons_approach' in ids
da = next(r for r in results if r['id']=='dragons_approach')
assert da['name'] == "Dragon's Approach"
assert da['type_hint'] == 'noncreature'
assert da['default_count'] == 25
def test_detector_exclusive_rats_only_one():
b = DummyBuilder(color_identity=['B'], selected_tags=['rats','aristocrats'])
results = bu.detect_viable_multi_copy_archetypes(b)
rat_ids = [r['id'] for r in results if r.get('exclusive_group')=='rats']
# Detector should keep only one rats archetype in the ranked output
assert len(rat_ids) == 1
assert rat_ids[0] in ('relentless_rats','rat_colony')
def test_detector_color_gate_blocks():
b = DummyBuilder(color_identity=['G'], selected_tags=['Spellslinger'])
results = bu.detect_viable_multi_copy_archetypes(b)
ids = [r['id'] for r in results]
# DA is red, shouldn't appear in mono-G
assert 'dragons_approach' not in ids

View file

@ -0,0 +1,54 @@
import importlib
def test_multicopy_clamp_trims_current_stage_additions_only():
"""
Pre-seed the library to 95, add a 20x multi-copy package, and ensure:
- clamped_overflow == 15
- total_cards == 100
- added delta for the package reflects 5 (20 - 15) after clamping
- pre-seeded cards are untouched
"""
orch = importlib.import_module('code.web.services.orchestrator')
logs = []
def out(msg: str):
logs.append(msg)
from deck_builder.builder import DeckBuilder
b = DeckBuilder(output_func=out, input_func=lambda *_: "", headless=True)
# Preseed 95 cards in the library
b.card_library = {"Filler": {"Count": 95, "Role": "Test", "SubRole": "", "AddedBy": "Test"}}
# Set a multi-copy selection that would exceed 100 by 15
b._web_multi_copy = { # type: ignore[attr-defined]
"id": "persistent_petitioners",
"name": "Persistent Petitioners",
"count": 20,
"thrumming": False,
}
ctx = {
"builder": b,
"logs": logs,
"stages": [{"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}],
"idx": 0,
"last_log_idx": 0,
"csv_path": None,
"txt_path": None,
"snapshot": None,
"history": [],
"locks": set(),
"custom_export_base": None,
}
res = orch.run_stage(ctx, rerun=False, show_skipped=False)
assert res.get("done") is False
assert res.get("label") == "Multi-Copy Package"
# Clamp assertions
assert int(res.get("clamped_overflow") or 0) == 15
assert int(res.get("total_cards") or 0) == 100
added = res.get("added_cards") or []
# Only the Petitioners row should be present, and it should show 5 added
assert len(added) == 1
row = added[0]
assert row.get("name") == "Persistent Petitioners"
assert int(row.get("count") or 0) == 5
# Ensure the preseeded 95 remain
lib = ctx["builder"].card_library
assert lib.get("Filler", {}).get("Count") == 95

View file

@ -0,0 +1,57 @@
import importlib
def test_petitioners_clamp_to_100_and_reduce_creature_slots():
"""
Ensure that when a large multi-copy creature package is added (e.g., Persistent Petitioners),
the deck does not exceed 100 after the multi-copy stage and ideal creature targets are reduced.
This uses the staged orchestrator flow to exercise the clamp and adjustments, but avoids
full dataset loading by using a minimal builder context and a dummy DF where possible.
"""
orch = importlib.import_module('code.web.services.orchestrator')
# Start a minimal staged context with only the multi-copy stage
logs = []
def out(msg: str):
logs.append(msg)
from deck_builder.builder import DeckBuilder
b = DeckBuilder(output_func=out, input_func=lambda *_: "", headless=True)
# Seed ideal_counts with a typical creature target so we can observe reduction
b.ideal_counts = {
"ramp": 10, "lands": 35, "basic_lands": 20,
"fetch_lands": 3, "creatures": 28, "removal": 10, "wipes": 2,
"card_advantage": 8, "protection": 4,
}
# Thread multi-copy selection for Petitioners as a creature archetype
b._web_multi_copy = { # type: ignore[attr-defined]
"id": "persistent_petitioners",
"name": "Persistent Petitioners",
"count": 40, # intentionally large to trigger clamp/adjustments
"thrumming": False,
}
# Minimal library
b.card_library = {}
ctx = {
"builder": b,
"logs": logs,
"stages": [{"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}],
"idx": 0,
"last_log_idx": 0,
"csv_path": None,
"txt_path": None,
"snapshot": None,
"history": [],
"locks": set(),
"custom_export_base": None,
}
res = orch.run_stage(ctx, rerun=False, show_skipped=False)
# Should show the stage with added cards
assert res.get("done") is False
assert res.get("label") == "Multi-Copy Package"
# Clamp should be applied if over 100; however with only one name in library, it won't clamp yet.
# We'll at least assert that mc_adjustments exist and creatures target reduced by ~count.
mc_adj = res.get("mc_adjustments") or []
assert any(a.startswith("creatures ") for a in mc_adj), f"mc_adjustments missing creature reduction: {mc_adj}"
# Verify deck total does not exceed 100 when a follow-up 100 baseline exists; here just sanity check the number present
total_cards = int(res.get("total_cards") or 0)
assert total_cards >= 1

View file

@ -0,0 +1,70 @@
import importlib
def _minimal_ctx(selection: dict):
"""Build a minimal orchestrator context to run only the multi-copy stage.
This avoids loading commander data or datasets; we only exercise the special
runner path (__add_multi_copy__) and the added-cards diff logic.
"""
logs: list[str] = []
def out(msg: str) -> None:
logs.append(msg)
# Create a DeckBuilder with no-op IO; no setup required for this unit test
from deck_builder.builder import DeckBuilder
b = DeckBuilder(output_func=out, input_func=lambda *_: "", headless=True)
# Thread selection and ensure empty library
b._web_multi_copy = selection # type: ignore[attr-defined]
b.card_library = {}
ctx = {
"builder": b,
"logs": logs,
"stages": [
{"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}
],
"idx": 0,
"last_log_idx": 0,
"csv_path": None,
"txt_path": None,
"snapshot": None,
"history": [],
"locks": set(),
"custom_export_base": None,
}
return ctx
def test_multicopy_stage_adds_selected_card_only():
sel = {"id": "dragons_approach", "name": "Dragon's Approach", "count": 25, "thrumming": False}
ctx = _minimal_ctx(sel)
orch = importlib.import_module('code.web.services.orchestrator')
res = orch.run_stage(ctx, rerun=False, show_skipped=False)
assert res.get("done") is False
assert res.get("label") == "Multi-Copy Package"
added = res.get("added_cards") or []
names = [c.get("name") for c in added]
# Should include the selected card and not Thrumming Stone
assert "Dragon's Approach" in names
assert all(n != "Thrumming Stone" for n in names)
# Count delta should reflect the selection quantity
det = next(c for c in added if c.get("name") == "Dragon's Approach")
assert int(det.get("count") or 0) == 25
def test_multicopy_stage_adds_thrumming_when_requested():
sel = {"id": "dragons_approach", "name": "Dragon's Approach", "count": 20, "thrumming": True}
ctx = _minimal_ctx(sel)
orch = importlib.import_module('code.web.services.orchestrator')
res = orch.run_stage(ctx, rerun=False, show_skipped=False)
assert res.get("done") is False
added = res.get("added_cards") or []
names = {c.get("name") for c in added}
assert "Dragon's Approach" in names
assert "Thrumming Stone" in names
# Thrumming Stone should be exactly one copy added in this stage
thr = next(c for c in added if c.get("name") == "Thrumming Stone")
assert int(thr.get("count") or 0) == 1

View file

@ -0,0 +1,58 @@
import importlib
import pytest
try:
from starlette.testclient import TestClient # type: ignore
except Exception: # pragma: no cover - optional dep in CI
TestClient = None # type: ignore
def _inject_minimal_ctx(client, selection: dict):
# Touch session to get sid
r = client.get('/build')
assert r.status_code == 200
sid = r.cookies.get('sid')
assert sid
tasks = importlib.import_module('code.web.services.tasks')
sess = tasks.get_session(sid)
# Minimal commander/tag presence to satisfy route guards
sess['commander'] = 'Dummy Commander'
sess['tags'] = []
# Build a minimal staged context with only the builder object; no stages yet
from deck_builder.builder import DeckBuilder
b = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
b.card_library = {}
ctx = {
'builder': b,
'logs': [],
'stages': [],
'idx': 0,
'last_log_idx': 0,
'csv_path': None,
'txt_path': None,
'snapshot': None,
'history': [],
'locks': set(),
'custom_export_base': None,
}
sess['build_ctx'] = ctx
# Persist multi-copy selection so the route injects the stage on continue
sess['multi_copy'] = selection
return sid
def test_step5_continue_runs_multicopy_stage_and_renders_additions():
if TestClient is None:
pytest.skip("starlette not available")
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
sel = {"id": "dragons_approach", "name": "Dragon's Approach", "count": 12, "thrumming": True}
_inject_minimal_ctx(client, sel)
r = client.post('/build/step5/continue')
assert r.status_code == 200
body = r.text
# Should show the stage label and added cards including quantities and Thrumming Stone
assert "Dragon's Approach" in body
assert "×12" in body or "x12" in body or "× 12" in body
assert "Thrumming Stone" in body