mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-24 03:20:12 +01:00
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:
parent
be672ac5d2
commit
341a216ed3
20 changed files with 1271 additions and 21 deletions
11
code/tests/conftest.py
Normal file
11
code/tests/conftest.py
Normal 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)
|
||||
36
code/tests/test_multi_copy_detector.py
Normal file
36
code/tests/test_multi_copy_detector.py
Normal 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
|
||||
54
code/tests/test_multicopy_clamp_strong.py
Normal file
54
code/tests/test_multicopy_clamp_strong.py
Normal 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
|
||||
57
code/tests/test_multicopy_petitioners_clamp.py
Normal file
57
code/tests/test_multicopy_petitioners_clamp.py
Normal 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
|
||||
70
code/tests/test_multicopy_stage_runner.py
Normal file
70
code/tests/test_multicopy_stage_runner.py
Normal 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
|
||||
58
code/tests/test_multicopy_web_flow.py
Normal file
58
code/tests/test_multicopy_web_flow.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue