mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-20 09:30:13 +01:00
feat(random): finalize multi-theme telemetry and polish
Some checks failed
Editorial Lint / lint-editorial (push) Has been cancelled
Some checks failed
Editorial Lint / lint-editorial (push) Has been cancelled
- document random theme exclusions, perf guard tooling, and roadmap completion - tighten random reroll UX: strict theme persistence, throttle handling, export parity, diagnostics updates - add regression coverage for telemetry counters, multi-theme flows, and locked rerolls; refresh README and notes Tests: pytest -q (fast random + telemetry suites)
This commit is contained in:
parent
73685f22c8
commit
49f1f8b2eb
28 changed files with 4888 additions and 251 deletions
|
|
@ -11,6 +11,7 @@ def test_random_build_api_commander_and_seed(monkeypatch):
|
|||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
app_module = importlib.reload(app_module)
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {"seed": 12345, "theme": "Goblin Kindred"}
|
||||
|
|
@ -20,3 +21,122 @@ def test_random_build_api_commander_and_seed(monkeypatch):
|
|||
assert data["seed"] == 12345
|
||||
assert isinstance(data.get("commander"), str)
|
||||
assert data.get("commander")
|
||||
assert "auto_fill_enabled" in data
|
||||
assert "auto_fill_secondary_enabled" in data
|
||||
assert "auto_fill_tertiary_enabled" in data
|
||||
assert "auto_fill_applied" in data
|
||||
assert "auto_filled_themes" in data
|
||||
assert "display_themes" in data
|
||||
|
||||
|
||||
def test_random_build_api_auto_fill_toggle(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {"seed": 54321, "primary_theme": "Aggro", "auto_fill_enabled": True}
|
||||
r = client.post('/api/random_build', json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["seed"] == 54321
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
assert isinstance(data.get("display_themes"), list)
|
||||
|
||||
|
||||
def test_random_build_api_partial_auto_fill(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {
|
||||
"seed": 98765,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_secondary_enabled": True,
|
||||
"auto_fill_tertiary_enabled": False,
|
||||
}
|
||||
r = client.post('/api/random_build', json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["seed"] == 98765
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is False
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
|
||||
|
||||
def test_random_build_api_tertiary_requires_secondary(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {
|
||||
"seed": 192837,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_secondary_enabled": False,
|
||||
"auto_fill_tertiary_enabled": True,
|
||||
}
|
||||
r = client.post('/api/random_build', json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["seed"] == 192837
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
|
||||
|
||||
def test_random_build_api_reports_auto_filled_themes(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
import code.web.app as app_module
|
||||
import code.deck_builder.random_entrypoint as random_entrypoint
|
||||
import deck_builder.random_entrypoint as random_entrypoint_pkg
|
||||
|
||||
def fake_auto_fill(
|
||||
df,
|
||||
commander,
|
||||
rng,
|
||||
*,
|
||||
primary_theme,
|
||||
secondary_theme,
|
||||
tertiary_theme,
|
||||
allowed_pool,
|
||||
fill_secondary,
|
||||
fill_tertiary,
|
||||
):
|
||||
return "Tokens", "Sacrifice", ["Tokens", "Sacrifice"]
|
||||
|
||||
monkeypatch.setattr(random_entrypoint, "_auto_fill_missing_themes", fake_auto_fill)
|
||||
monkeypatch.setattr(random_entrypoint_pkg, "_auto_fill_missing_themes", fake_auto_fill)
|
||||
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {
|
||||
"seed": 654321,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_enabled": True,
|
||||
"auto_fill_secondary_enabled": True,
|
||||
"auto_fill_tertiary_enabled": True,
|
||||
}
|
||||
r = client.post('/api/random_build', json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["seed"] == 654321
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_applied") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_filled_themes") == ["Tokens", "Sacrifice"]
|
||||
|
|
|
|||
|
|
@ -1,32 +1,66 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_metrics_and_seed_history(monkeypatch):
|
||||
monkeypatch.setenv('RANDOM_MODES', '1')
|
||||
monkeypatch.setenv('RANDOM_UI', '1')
|
||||
monkeypatch.setenv('RANDOM_TELEMETRY', '1')
|
||||
monkeypatch.setenv('CSV_FILES_DIR', os.path.join('csv_files', 'testdata'))
|
||||
from code.web.app import app
|
||||
client = TestClient(app)
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("RANDOM_UI", "1")
|
||||
monkeypatch.setenv("RANDOM_TELEMETRY", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
# Build + reroll to generate metrics and seed history
|
||||
r1 = client.post('/api/random_full_build', json={'seed': 9090})
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.post('/api/random_reroll', json={'seed': 9090})
|
||||
assert r2.status_code == 200, r2.text
|
||||
import code.web.app as app_module
|
||||
|
||||
# Metrics
|
||||
m = client.get('/status/random_metrics')
|
||||
assert m.status_code == 200, m.text
|
||||
mj = m.json()
|
||||
assert mj.get('ok') is True
|
||||
metrics = mj.get('metrics') or {}
|
||||
assert 'full_build' in metrics and 'reroll' in metrics
|
||||
# Reset in-memory telemetry so assertions are deterministic
|
||||
app_module.RANDOM_TELEMETRY = True
|
||||
app_module.RATE_LIMIT_ENABLED = False
|
||||
for bucket in app_module._RANDOM_METRICS.values():
|
||||
for key in bucket:
|
||||
bucket[key] = 0
|
||||
for key in list(app_module._RANDOM_USAGE_METRICS.keys()):
|
||||
app_module._RANDOM_USAGE_METRICS[key] = 0
|
||||
for key in list(app_module._RANDOM_FALLBACK_METRICS.keys()):
|
||||
app_module._RANDOM_FALLBACK_METRICS[key] = 0
|
||||
app_module._RANDOM_FALLBACK_REASONS.clear()
|
||||
app_module._RL_COUNTS.clear()
|
||||
|
||||
# Seed history
|
||||
sh = client.get('/api/random/seeds')
|
||||
assert sh.status_code == 200
|
||||
sj = sh.json()
|
||||
seeds = sj.get('seeds') or []
|
||||
assert any(s == 9090 for s in seeds) and sj.get('last') in seeds
|
||||
prev_ms = app_module.RANDOM_REROLL_THROTTLE_MS
|
||||
prev_seconds = app_module._REROLL_THROTTLE_SECONDS
|
||||
app_module.RANDOM_REROLL_THROTTLE_MS = 0
|
||||
app_module._REROLL_THROTTLE_SECONDS = 0.0
|
||||
|
||||
try:
|
||||
with TestClient(app_module.app) as client:
|
||||
# Build + reroll to generate metrics and seed history
|
||||
r1 = client.post("/api/random_full_build", json={"seed": 9090, "primary_theme": "Aggro"})
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.post("/api/random_reroll", json={"seed": 9090})
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
# Metrics
|
||||
m = client.get("/status/random_metrics")
|
||||
assert m.status_code == 200, m.text
|
||||
mj = m.json()
|
||||
assert mj.get("ok") is True
|
||||
metrics = mj.get("metrics") or {}
|
||||
assert "full_build" in metrics and "reroll" in metrics
|
||||
|
||||
usage = mj.get("usage") or {}
|
||||
modes = usage.get("modes") or {}
|
||||
fallbacks = usage.get("fallbacks") or {}
|
||||
assert set(modes.keys()) >= {"theme", "reroll", "surprise", "reroll_same_commander"}
|
||||
assert modes.get("theme", 0) >= 2
|
||||
assert "none" in fallbacks
|
||||
assert isinstance(usage.get("fallback_reasons"), dict)
|
||||
|
||||
# Seed history
|
||||
sh = client.get("/api/random/seeds")
|
||||
assert sh.status_code == 200
|
||||
sj = sh.json()
|
||||
seeds = sj.get("seeds") or []
|
||||
assert any(s == 9090 for s in seeds) and sj.get("last") in seeds
|
||||
finally:
|
||||
app_module.RANDOM_REROLL_THROTTLE_MS = prev_ms
|
||||
app_module._REROLL_THROTTLE_SECONDS = prev_seconds
|
||||
|
|
|
|||
236
code/tests/test_random_multi_theme_filtering.py
Normal file
236
code/tests/test_random_multi_theme_filtering.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deck_builder import random_entrypoint
|
||||
|
||||
|
||||
def _patch_commanders(monkeypatch, rows: Sequence[dict[str, object]]) -> None:
|
||||
df = pd.DataFrame(rows)
|
||||
monkeypatch.setattr(random_entrypoint, "_load_commanders_df", lambda: df)
|
||||
|
||||
|
||||
def _make_row(name: str, tags: Iterable[str]) -> dict[str, object]:
|
||||
return {"name": name, "themeTags": list(tags)}
|
||||
|
||||
|
||||
def test_random_multi_theme_exact_triple_success(monkeypatch) -> None:
|
||||
_patch_commanders(
|
||||
monkeypatch,
|
||||
[_make_row("Triple Threat", ["aggro", "tokens", "equipment"])],
|
||||
)
|
||||
|
||||
res = random_entrypoint.build_random_deck(
|
||||
primary_theme="aggro",
|
||||
secondary_theme="tokens",
|
||||
tertiary_theme="equipment",
|
||||
seed=1313,
|
||||
)
|
||||
|
||||
assert res.commander == "Triple Threat"
|
||||
assert res.resolved_themes == ["aggro", "tokens", "equipment"]
|
||||
assert res.combo_fallback is False
|
||||
assert res.synergy_fallback is False
|
||||
assert res.fallback_reason is None
|
||||
|
||||
|
||||
def test_random_multi_theme_fallback_to_ps(monkeypatch) -> None:
|
||||
_patch_commanders(
|
||||
monkeypatch,
|
||||
[
|
||||
_make_row("PrimarySecondary", ["Aggro", "Tokens"]),
|
||||
_make_row("Other Commander", ["Tokens", "Equipment"]),
|
||||
],
|
||||
)
|
||||
|
||||
res = random_entrypoint.build_random_deck(
|
||||
primary_theme="Aggro",
|
||||
secondary_theme="Tokens",
|
||||
tertiary_theme="Equipment",
|
||||
seed=2024,
|
||||
)
|
||||
|
||||
assert res.commander == "PrimarySecondary"
|
||||
assert res.resolved_themes == ["Aggro", "Tokens"]
|
||||
assert res.combo_fallback is True
|
||||
assert res.synergy_fallback is False
|
||||
assert "Primary+Secondary" in (res.fallback_reason or "")
|
||||
|
||||
|
||||
def test_random_multi_theme_fallback_to_pt(monkeypatch) -> None:
|
||||
_patch_commanders(
|
||||
monkeypatch,
|
||||
[
|
||||
_make_row("PrimaryTertiary", ["Aggro", "Equipment"]),
|
||||
_make_row("Tokens Only", ["Tokens"]),
|
||||
],
|
||||
)
|
||||
|
||||
res = random_entrypoint.build_random_deck(
|
||||
primary_theme="Aggro",
|
||||
secondary_theme="Tokens",
|
||||
tertiary_theme="Equipment",
|
||||
seed=777,
|
||||
)
|
||||
|
||||
assert res.commander == "PrimaryTertiary"
|
||||
assert res.resolved_themes == ["Aggro", "Equipment"]
|
||||
assert res.combo_fallback is True
|
||||
assert res.synergy_fallback is False
|
||||
assert "Primary+Tertiary" in (res.fallback_reason or "")
|
||||
|
||||
|
||||
def test_random_multi_theme_fallback_primary_only(monkeypatch) -> None:
|
||||
_patch_commanders(
|
||||
monkeypatch,
|
||||
[
|
||||
_make_row("PrimarySolo", ["Aggro"]),
|
||||
_make_row("Tokens Solo", ["Tokens"]),
|
||||
],
|
||||
)
|
||||
|
||||
res = random_entrypoint.build_random_deck(
|
||||
primary_theme="Aggro",
|
||||
secondary_theme="Tokens",
|
||||
tertiary_theme="Equipment",
|
||||
seed=9090,
|
||||
)
|
||||
|
||||
assert res.commander == "PrimarySolo"
|
||||
assert res.resolved_themes == ["Aggro"]
|
||||
assert res.combo_fallback is True
|
||||
assert res.synergy_fallback is False
|
||||
assert "Primary only" in (res.fallback_reason or "")
|
||||
|
||||
|
||||
def test_random_multi_theme_synergy_fallback(monkeypatch) -> None:
|
||||
_patch_commanders(
|
||||
monkeypatch,
|
||||
[
|
||||
_make_row("Synergy Commander", ["aggro surge"]),
|
||||
_make_row("Unrelated", ["tokens"]),
|
||||
],
|
||||
)
|
||||
|
||||
res = random_entrypoint.build_random_deck(
|
||||
primary_theme="aggro swarm",
|
||||
secondary_theme="treasure",
|
||||
tertiary_theme="artifacts",
|
||||
seed=5150,
|
||||
)
|
||||
|
||||
assert res.commander == "Synergy Commander"
|
||||
assert res.resolved_themes == ["aggro", "swarm"]
|
||||
assert res.combo_fallback is True
|
||||
assert res.synergy_fallback is True
|
||||
assert "synergy overlap" in (res.fallback_reason or "")
|
||||
|
||||
|
||||
def test_random_multi_theme_full_pool_fallback(monkeypatch) -> None:
|
||||
_patch_commanders(
|
||||
monkeypatch,
|
||||
[_make_row("Any Commander", ["control"])],
|
||||
)
|
||||
|
||||
res = random_entrypoint.build_random_deck(
|
||||
primary_theme="nonexistent",
|
||||
secondary_theme="made up",
|
||||
tertiary_theme="imaginary",
|
||||
seed=6060,
|
||||
)
|
||||
|
||||
assert res.commander == "Any Commander"
|
||||
assert res.resolved_themes == []
|
||||
assert res.combo_fallback is True
|
||||
assert res.synergy_fallback is True
|
||||
assert "full commander pool" in (res.fallback_reason or "")
|
||||
|
||||
|
||||
def test_random_multi_theme_sidecar_fields_present(monkeypatch, tmp_path) -> None:
|
||||
export_dir = tmp_path / "exports"
|
||||
export_dir.mkdir()
|
||||
|
||||
commander_name = "Tri Commander"
|
||||
_patch_commanders(
|
||||
monkeypatch,
|
||||
[_make_row(commander_name, ["Aggro", "Tokens", "Equipment"])],
|
||||
)
|
||||
|
||||
import headless_runner
|
||||
|
||||
def _fake_run(
|
||||
command_name: str,
|
||||
seed: int | None = None,
|
||||
primary_choice: int | None = None,
|
||||
secondary_choice: int | None = None,
|
||||
tertiary_choice: int | None = None,
|
||||
):
|
||||
base_path = export_dir / command_name.replace(" ", "_")
|
||||
csv_path = base_path.with_suffix(".csv")
|
||||
txt_path = base_path.with_suffix(".txt")
|
||||
csv_path.write_text("Name\nCard\n", encoding="utf-8")
|
||||
txt_path.write_text("Decklist", encoding="utf-8")
|
||||
|
||||
class DummyBuilder:
|
||||
def __init__(self) -> None:
|
||||
self.commander_name = command_name
|
||||
self.commander = command_name
|
||||
self.selected_tags = ["Aggro", "Tokens", "Equipment"]
|
||||
self.primary_tag = "Aggro"
|
||||
self.secondary_tag = "Tokens"
|
||||
self.tertiary_tag = "Equipment"
|
||||
self.bracket_level = 3
|
||||
self.last_csv_path = str(csv_path)
|
||||
self.last_txt_path = str(txt_path)
|
||||
self.custom_export_base = command_name
|
||||
|
||||
def build_deck_summary(self) -> dict[str, object]:
|
||||
return {"meta": {"existing": True}, "counts": {"total": 100}}
|
||||
|
||||
def compute_and_print_compliance(self, base_stem: str | None = None):
|
||||
return {"ok": True}
|
||||
|
||||
return DummyBuilder()
|
||||
|
||||
monkeypatch.setattr(headless_runner, "run", _fake_run)
|
||||
|
||||
result = random_entrypoint.build_random_full_deck(
|
||||
primary_theme="Aggro",
|
||||
secondary_theme="Tokens",
|
||||
tertiary_theme="Equipment",
|
||||
seed=4242,
|
||||
)
|
||||
|
||||
assert result.summary is not None
|
||||
meta = result.summary.get("meta")
|
||||
assert meta is not None
|
||||
assert meta["primary_theme"] == "Aggro"
|
||||
assert meta["secondary_theme"] == "Tokens"
|
||||
assert meta["tertiary_theme"] == "Equipment"
|
||||
assert meta["resolved_themes"] == ["aggro", "tokens", "equipment"]
|
||||
assert meta["combo_fallback"] is False
|
||||
assert meta["synergy_fallback"] is False
|
||||
assert meta["fallback_reason"] is None
|
||||
|
||||
assert result.csv_path is not None
|
||||
sidecar_path = Path(result.csv_path).with_suffix(".summary.json")
|
||||
assert sidecar_path.is_file()
|
||||
|
||||
payload = json.loads(sidecar_path.read_text(encoding="utf-8"))
|
||||
sidecar_meta = payload["meta"]
|
||||
assert sidecar_meta["primary_theme"] == "Aggro"
|
||||
assert sidecar_meta["secondary_theme"] == "Tokens"
|
||||
assert sidecar_meta["tertiary_theme"] == "Equipment"
|
||||
assert sidecar_meta["resolved_themes"] == ["aggro", "tokens", "equipment"]
|
||||
assert sidecar_meta["random_primary_theme"] == "Aggro"
|
||||
assert sidecar_meta["random_resolved_themes"] == ["aggro", "tokens", "equipment"]
|
||||
|
||||
# cleanup
|
||||
sidecar_path.unlink(missing_ok=True)
|
||||
Path(result.csv_path).unlink(missing_ok=True)
|
||||
txt_candidate = Path(result.csv_path).with_suffix(".txt")
|
||||
txt_candidate.unlink(missing_ok=True)
|
||||
46
code/tests/test_random_multi_theme_seed_stability.py
Normal file
46
code/tests/test_random_multi_theme_seed_stability.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from deck_builder.random_entrypoint import build_random_deck
|
||||
|
||||
|
||||
def _use_testdata(monkeypatch) -> None:
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
|
||||
def test_multi_theme_same_seed_same_result(monkeypatch) -> None:
|
||||
_use_testdata(monkeypatch)
|
||||
kwargs = {
|
||||
"primary_theme": "Goblin Kindred",
|
||||
"secondary_theme": "Token Swarm",
|
||||
"tertiary_theme": "Treasure Support",
|
||||
"seed": 4040,
|
||||
}
|
||||
res_a = build_random_deck(**kwargs)
|
||||
res_b = build_random_deck(**kwargs)
|
||||
|
||||
assert res_a.seed == res_b.seed == 4040
|
||||
assert res_a.commander == res_b.commander
|
||||
assert res_a.resolved_themes == res_b.resolved_themes
|
||||
|
||||
|
||||
def test_legacy_theme_and_primary_equivalence(monkeypatch) -> None:
|
||||
_use_testdata(monkeypatch)
|
||||
|
||||
legacy = build_random_deck(theme="Goblin Kindred", seed=5151)
|
||||
multi = build_random_deck(primary_theme="Goblin Kindred", seed=5151)
|
||||
|
||||
assert legacy.commander == multi.commander
|
||||
assert legacy.seed == multi.seed == 5151
|
||||
|
||||
|
||||
def test_string_seed_coerces_to_int(monkeypatch) -> None:
|
||||
_use_testdata(monkeypatch)
|
||||
|
||||
result = build_random_deck(primary_theme="Goblin Kindred", seed="6262")
|
||||
|
||||
assert result.seed == 6262
|
||||
# Sanity check that commander selection remains deterministic once coerced
|
||||
repeat = build_random_deck(primary_theme="Goblin Kindred", seed="6262")
|
||||
assert repeat.commander == result.commander
|
||||
204
code/tests/test_random_multi_theme_webflows.py
Normal file
204
code/tests/test_random_multi_theme_webflows.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Iterator, List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import importlib
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from deck_builder.random_entrypoint import RandomFullBuildResult
|
||||
|
||||
|
||||
def _decode_state_token(token: str) -> Dict[str, Any]:
|
||||
pad = "=" * (-len(token) % 4)
|
||||
raw = base64.urlsafe_b64decode((token + pad).encode("ascii")).decode("utf-8")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("RANDOM_UI", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
web_app_module = importlib.import_module("code.web.app")
|
||||
web_app_module = importlib.reload(web_app_module)
|
||||
from code.web.services import tasks
|
||||
|
||||
tasks._SESSIONS.clear()
|
||||
with TestClient(web_app_module.app) as test_client:
|
||||
yield test_client
|
||||
tasks._SESSIONS.clear()
|
||||
|
||||
|
||||
def _make_full_result(seed: int) -> RandomFullBuildResult:
|
||||
return RandomFullBuildResult(
|
||||
seed=seed,
|
||||
commander=f"Commander-{seed}",
|
||||
theme="Aggro",
|
||||
constraints={},
|
||||
primary_theme="Aggro",
|
||||
secondary_theme="Tokens",
|
||||
tertiary_theme="Equipment",
|
||||
resolved_themes=["aggro", "tokens", "equipment"],
|
||||
combo_fallback=False,
|
||||
synergy_fallback=False,
|
||||
fallback_reason=None,
|
||||
decklist=[{"name": "Sample Card", "count": 1}],
|
||||
diagnostics={"elapsed_ms": 5},
|
||||
summary={"meta": {"existing": True}},
|
||||
csv_path=None,
|
||||
txt_path=None,
|
||||
compliance=None,
|
||||
)
|
||||
|
||||
|
||||
def test_random_multi_theme_reroll_same_commander_preserves_resolved(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import deck_builder.random_entrypoint as random_entrypoint
|
||||
import headless_runner
|
||||
from code.web.services import tasks
|
||||
|
||||
build_calls: List[Dict[str, Any]] = []
|
||||
|
||||
def fake_build_random_full_deck(*, theme, constraints, seed, attempts, timeout_s, primary_theme, secondary_theme, tertiary_theme):
|
||||
build_calls.append(
|
||||
{
|
||||
"theme": theme,
|
||||
"primary": primary_theme,
|
||||
"secondary": secondary_theme,
|
||||
"tertiary": tertiary_theme,
|
||||
"seed": seed,
|
||||
}
|
||||
)
|
||||
return _make_full_result(int(seed))
|
||||
|
||||
monkeypatch.setattr(random_entrypoint, "build_random_full_deck", fake_build_random_full_deck)
|
||||
|
||||
class DummyBuilder:
|
||||
def __init__(self, commander: str, seed: int) -> None:
|
||||
self.commander_name = commander
|
||||
self.commander = commander
|
||||
self.deck_list_final: List[Dict[str, Any]] = []
|
||||
self.last_csv_path = None
|
||||
self.last_txt_path = None
|
||||
self.custom_export_base = commander
|
||||
|
||||
def build_deck_summary(self) -> Dict[str, Any]:
|
||||
return {"meta": {"rebuild": True}}
|
||||
|
||||
def export_decklist_csv(self) -> str:
|
||||
return "deck_files/placeholder.csv"
|
||||
|
||||
def export_decklist_text(self, filename: str | None = None) -> str:
|
||||
return "deck_files/placeholder.txt"
|
||||
|
||||
def compute_and_print_compliance(self, base_stem: str | None = None) -> Dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
reroll_runs: List[Dict[str, Any]] = []
|
||||
|
||||
def fake_run(command_name: str, seed: int | None = None):
|
||||
reroll_runs.append({"commander": command_name, "seed": seed})
|
||||
return DummyBuilder(command_name, seed or 0)
|
||||
|
||||
monkeypatch.setattr(headless_runner, "run", fake_run)
|
||||
|
||||
tasks._SESSIONS.clear()
|
||||
|
||||
resp1 = client.post(
|
||||
"/hx/random_reroll",
|
||||
json={
|
||||
"mode": "surprise",
|
||||
"primary_theme": "Aggro",
|
||||
"secondary_theme": "Tokens",
|
||||
"tertiary_theme": "Equipment",
|
||||
"seed": 1010,
|
||||
},
|
||||
)
|
||||
assert resp1.status_code == 200, resp1.text
|
||||
assert build_calls and build_calls[0]["primary"] == "Aggro"
|
||||
assert "value=\"aggro||tokens||equipment\"" in resp1.text
|
||||
|
||||
sid = client.cookies.get("sid")
|
||||
assert sid
|
||||
session = tasks.get_session(sid)
|
||||
resolved_list = session.get("random_build", {}).get("resolved_theme_info", {}).get("resolved_list")
|
||||
assert resolved_list == ["aggro", "tokens", "equipment"]
|
||||
|
||||
commander = f"Commander-{build_calls[0]['seed']}"
|
||||
form_payload = [
|
||||
("mode", "reroll_same_commander"),
|
||||
("commander", commander),
|
||||
("seed", str(build_calls[0]["seed"])),
|
||||
("resolved_themes", "aggro||tokens||equipment"),
|
||||
]
|
||||
encoded = urlencode(form_payload, doseq=True)
|
||||
resp2 = client.post(
|
||||
"/hx/random_reroll",
|
||||
content=encoded,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert resp2.status_code == 200, resp2.text
|
||||
assert len(build_calls) == 1
|
||||
assert reroll_runs and reroll_runs[0]["commander"] == commander
|
||||
assert "value=\"aggro||tokens||equipment\"" in resp2.text
|
||||
|
||||
session_after = tasks.get_session(sid)
|
||||
resolved_after = session_after.get("random_build", {}).get("resolved_theme_info", {}).get("resolved_list")
|
||||
assert resolved_after == ["aggro", "tokens", "equipment"]
|
||||
|
||||
|
||||
def test_random_multi_theme_permalink_roundtrip(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import deck_builder.random_entrypoint as random_entrypoint
|
||||
from code.web.services import tasks
|
||||
|
||||
seeds_seen: List[int] = []
|
||||
|
||||
def fake_build_random_full_deck(*, theme, constraints, seed, attempts, timeout_s, primary_theme, secondary_theme, tertiary_theme):
|
||||
seeds_seen.append(int(seed))
|
||||
return _make_full_result(int(seed))
|
||||
|
||||
monkeypatch.setattr(random_entrypoint, "build_random_full_deck", fake_build_random_full_deck)
|
||||
|
||||
tasks._SESSIONS.clear()
|
||||
|
||||
resp = client.post(
|
||||
"/api/random_full_build",
|
||||
json={
|
||||
"seed": 4242,
|
||||
"primary_theme": "Aggro",
|
||||
"secondary_theme": "Tokens",
|
||||
"tertiary_theme": "Equipment",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["primary_theme"] == "Aggro"
|
||||
assert body["secondary_theme"] == "Tokens"
|
||||
assert body["tertiary_theme"] == "Equipment"
|
||||
assert body["resolved_themes"] == ["aggro", "tokens", "equipment"]
|
||||
permalink = body["permalink"]
|
||||
assert permalink and permalink.startswith("/build/from?state=")
|
||||
|
||||
visit = client.get(permalink)
|
||||
assert visit.status_code == 200
|
||||
|
||||
state_resp = client.get("/build/permalink")
|
||||
assert state_resp.status_code == 200, state_resp.text
|
||||
state_payload = state_resp.json()
|
||||
token = state_payload["permalink"].split("state=", 1)[1]
|
||||
decoded = _decode_state_token(token)
|
||||
random_section = decoded.get("random") or {}
|
||||
assert random_section.get("primary_theme") == "Aggro"
|
||||
assert random_section.get("secondary_theme") == "Tokens"
|
||||
assert random_section.get("tertiary_theme") == "Equipment"
|
||||
assert random_section.get("resolved_themes") == ["aggro", "tokens", "equipment"]
|
||||
requested = random_section.get("requested_themes") or {}
|
||||
assert requested.get("primary") == "Aggro"
|
||||
assert requested.get("secondary") == "Tokens"
|
||||
assert requested.get("tertiary") == "Equipment"
|
||||
assert seeds_seen == [4242]
|
||||
|
|
@ -32,9 +32,76 @@ def test_api_random_reroll_increments_seed(client: TestClient):
|
|||
assert data2.get("permalink")
|
||||
|
||||
|
||||
def test_api_random_reroll_auto_fill_metadata(client: TestClient):
|
||||
r1 = client.post("/api/random_full_build", json={"seed": 555, "primary_theme": "Aggro"})
|
||||
assert r1.status_code == 200, r1.text
|
||||
|
||||
r2 = client.post(
|
||||
"/api/random_reroll",
|
||||
json={"seed": 555, "primary_theme": "Aggro", "auto_fill_enabled": True},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
data = r2.json()
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
assert data.get("requested_themes", {}).get("auto_fill_enabled") is True
|
||||
assert data.get("requested_themes", {}).get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("requested_themes", {}).get("auto_fill_tertiary_enabled") is True
|
||||
assert "display_themes" in data
|
||||
|
||||
|
||||
def test_api_random_reroll_secondary_only_auto_fill(client: TestClient):
|
||||
r1 = client.post(
|
||||
"/api/random_reroll",
|
||||
json={
|
||||
"seed": 777,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_secondary_enabled": True,
|
||||
"auto_fill_tertiary_enabled": False,
|
||||
},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
data = r1.json()
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is False
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
requested = data.get("requested_themes", {})
|
||||
assert requested.get("auto_fill_enabled") is True
|
||||
assert requested.get("auto_fill_secondary_enabled") is True
|
||||
assert requested.get("auto_fill_tertiary_enabled") is False
|
||||
|
||||
|
||||
def test_api_random_reroll_tertiary_requires_secondary(client: TestClient):
|
||||
r1 = client.post(
|
||||
"/api/random_reroll",
|
||||
json={
|
||||
"seed": 778,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_secondary_enabled": False,
|
||||
"auto_fill_tertiary_enabled": True,
|
||||
},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
data = r1.json()
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
requested = data.get("requested_themes", {})
|
||||
assert requested.get("auto_fill_enabled") is True
|
||||
assert requested.get("auto_fill_secondary_enabled") is True
|
||||
assert requested.get("auto_fill_tertiary_enabled") is True
|
||||
|
||||
|
||||
def test_hx_random_reroll_returns_html(client: TestClient):
|
||||
headers = {"HX-Request": "true", "Content-Type": "application/json"}
|
||||
r = client.post("/hx/random_reroll", data=json.dumps({"seed": 42}), headers=headers)
|
||||
r = client.post("/hx/random_reroll", content=json.dumps({"seed": 42}), headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
# Accept either HTML fragment or JSON fallback
|
||||
content_type = r.headers.get("content-type", "")
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def test_locked_reroll_generates_summary_and_compliance():
|
|||
start = time.time()
|
||||
# Locked reroll via HTMX path (form style)
|
||||
form_body = f"seed={seed}&commander={commander}&mode=reroll_same_commander"
|
||||
r2 = c.post('/hx/random_reroll', data=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
|
||||
r2 = c.post('/hx/random_reroll', content=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
# Look for new sidecar/compliance created after start
|
||||
|
|
|
|||
|
|
@ -23,14 +23,14 @@ def test_reroll_keeps_commander():
|
|||
# First reroll with commander lock
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
body = json.dumps({'seed': seed, 'commander': commander, 'mode': 'reroll_same_commander'})
|
||||
r2 = client.post('/hx/random_reroll', data=body, headers=headers)
|
||||
r2 = client.post('/hx/random_reroll', content=body, headers=headers)
|
||||
assert r2.status_code == 200
|
||||
html1 = r2.text
|
||||
assert commander in html1
|
||||
|
||||
# Second reroll should keep same commander (seed increments so prior +1 used on server)
|
||||
body2 = json.dumps({'seed': seed + 1, 'commander': commander, 'mode': 'reroll_same_commander'})
|
||||
r3 = client.post('/hx/random_reroll', data=body2, headers=headers)
|
||||
r3 = client.post('/hx/random_reroll', content=body2, headers=headers)
|
||||
assert r3.status_code == 200
|
||||
html2 = r3.text
|
||||
assert commander in html2
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ def test_reroll_keeps_commander_form_encoded():
|
|||
seed = data1['seed']
|
||||
|
||||
form_body = f"seed={seed}&commander={quote_plus(commander)}&mode=reroll_same_commander"
|
||||
r2 = client.post('/hx/random_reroll', data=form_body, headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
r2 = client.post('/hx/random_reroll', content=form_body, headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
assert r2.status_code == 200
|
||||
assert commander in r2.text
|
||||
|
||||
# second reroll with incremented seed
|
||||
form_body2 = f"seed={seed+1}&commander={quote_plus(commander)}&mode=reroll_same_commander"
|
||||
r3 = client.post('/hx/random_reroll', data=form_body2, headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
r3 = client.post('/hx/random_reroll', content=form_body2, headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
assert r3.status_code == 200
|
||||
assert commander in r3.text
|
||||
|
|
@ -19,7 +19,7 @@ def test_locked_reroll_single_export():
|
|||
commander = r.json()['commander']
|
||||
before_csvs = set(glob.glob('deck_files/*.csv'))
|
||||
form_body = f"seed={seed}&commander={commander}&mode=reroll_same_commander"
|
||||
r2 = c.post('/hx/random_reroll', data=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
|
||||
r2 = c.post('/hx/random_reroll', content=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
|
||||
assert r2.status_code == 200
|
||||
after_csvs = set(glob.glob('deck_files/*.csv'))
|
||||
new_csvs = after_csvs - before_csvs
|
||||
|
|
|
|||
65
code/tests/test_random_reroll_throttle.py
Normal file
65
code/tests/test_random_reroll_throttle.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def throttle_client(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("RANDOM_UI", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
import code.web.app as app_module
|
||||
|
||||
# Ensure feature flags and globals reflect the test configuration
|
||||
app_module.RANDOM_MODES = True
|
||||
app_module.RANDOM_UI = True
|
||||
app_module.RATE_LIMIT_ENABLED = False
|
||||
|
||||
# Keep existing values so we can restore after the test
|
||||
prev_ms = app_module.RANDOM_REROLL_THROTTLE_MS
|
||||
prev_seconds = app_module._REROLL_THROTTLE_SECONDS
|
||||
|
||||
app_module.RANDOM_REROLL_THROTTLE_MS = 50
|
||||
app_module._REROLL_THROTTLE_SECONDS = 0.05
|
||||
|
||||
app_module._RL_COUNTS.clear()
|
||||
|
||||
with TestClient(app_module.app) as client:
|
||||
yield client, app_module
|
||||
|
||||
# Restore globals for other tests
|
||||
app_module.RANDOM_REROLL_THROTTLE_MS = prev_ms
|
||||
app_module._REROLL_THROTTLE_SECONDS = prev_seconds
|
||||
app_module._RL_COUNTS.clear()
|
||||
|
||||
|
||||
def test_random_reroll_session_throttle(throttle_client):
|
||||
client, app_module = throttle_client
|
||||
|
||||
# First reroll succeeds and seeds the session timestamp
|
||||
first = client.post("/api/random_reroll", json={"seed": 5000})
|
||||
assert first.status_code == 200, first.text
|
||||
assert "sid" in client.cookies
|
||||
|
||||
# Immediate follow-up should hit the throttle guard
|
||||
second = client.post("/api/random_reroll", json={"seed": 5001})
|
||||
assert second.status_code == 429
|
||||
retry_after = second.headers.get("Retry-After")
|
||||
assert retry_after is not None
|
||||
assert int(retry_after) >= 1
|
||||
|
||||
# After waiting slightly longer than the throttle window, requests succeed again
|
||||
time.sleep(0.06)
|
||||
third = client.post("/api/random_reroll", json={"seed": 5002})
|
||||
assert third.status_code == 200, third.text
|
||||
assert int(third.json().get("seed")) >= 5002
|
||||
|
||||
# Telemetry shouldn't record fallback for the throttle rejection
|
||||
metrics_snapshot = app_module._RANDOM_METRICS.get("reroll")
|
||||
assert metrics_snapshot is not None
|
||||
assert metrics_snapshot.get("error", 0) == 0
|
||||
178
code/tests/test_random_surprise_reroll_behavior.py
Normal file
178
code/tests/test_random_surprise_reroll_behavior.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import itertools
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _make_stub_result(seed: int | None, theme: Any, primary: Any, secondary: Any = None, tertiary: Any = None):
|
||||
class _Result:
|
||||
pass
|
||||
|
||||
res = _Result()
|
||||
res.seed = int(seed) if seed is not None else 0
|
||||
res.commander = f"Commander-{res.seed}"
|
||||
res.decklist = []
|
||||
res.theme = theme
|
||||
res.primary_theme = primary
|
||||
res.secondary_theme = secondary
|
||||
res.tertiary_theme = tertiary
|
||||
res.resolved_themes = [t for t in [primary, secondary, tertiary] if t]
|
||||
res.combo_fallback = True if primary and primary != theme else False
|
||||
res.synergy_fallback = False
|
||||
res.fallback_reason = "fallback" if res.combo_fallback else None
|
||||
res.constraints = {}
|
||||
res.diagnostics = {}
|
||||
res.summary = None
|
||||
res.theme_fallback = bool(res.combo_fallback or res.synergy_fallback)
|
||||
res.csv_path = None
|
||||
res.txt_path = None
|
||||
res.compliance = None
|
||||
res.original_theme = theme
|
||||
return res
|
||||
|
||||
|
||||
def test_surprise_reuses_requested_theme(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("RANDOM_UI", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
random_util = importlib.import_module("random_util")
|
||||
seed_iter = itertools.count(1000)
|
||||
monkeypatch.setattr(random_util, "generate_seed", lambda: next(seed_iter))
|
||||
|
||||
random_entrypoint = importlib.import_module("deck_builder.random_entrypoint")
|
||||
build_calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_build_random_full_deck(*, theme, constraints, seed, attempts, timeout_s, primary_theme, secondary_theme, tertiary_theme):
|
||||
build_calls.append({
|
||||
"theme": theme,
|
||||
"primary": primary_theme,
|
||||
"secondary": secondary_theme,
|
||||
"tertiary": tertiary_theme,
|
||||
"seed": seed,
|
||||
})
|
||||
return _make_stub_result(seed, theme, "ResolvedTokens")
|
||||
|
||||
monkeypatch.setattr(random_entrypoint, "build_random_full_deck", fake_build_random_full_deck)
|
||||
|
||||
web_app_module = importlib.import_module("code.web.app")
|
||||
web_app_module = importlib.reload(web_app_module)
|
||||
|
||||
client = TestClient(web_app_module.app)
|
||||
|
||||
# Initial surprise request with explicit theme
|
||||
resp1 = client.post("/hx/random_reroll", json={"mode": "surprise", "primary_theme": "Tokens"})
|
||||
assert resp1.status_code == 200
|
||||
assert build_calls[0]["primary"] == "Tokens"
|
||||
assert build_calls[0]["theme"] == "Tokens"
|
||||
|
||||
# Subsequent surprise request without providing themes should reuse requested input, not resolved fallback
|
||||
resp2 = client.post("/hx/random_reroll", json={"mode": "surprise"})
|
||||
assert resp2.status_code == 200
|
||||
assert len(build_calls) == 2
|
||||
assert build_calls[1]["primary"] == "Tokens"
|
||||
assert build_calls[1]["theme"] == "Tokens"
|
||||
|
||||
|
||||
def test_reroll_same_commander_uses_resolved_cache(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("RANDOM_UI", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
random_util = importlib.import_module("random_util")
|
||||
seed_iter = itertools.count(2000)
|
||||
monkeypatch.setattr(random_util, "generate_seed", lambda: next(seed_iter))
|
||||
|
||||
random_entrypoint = importlib.import_module("deck_builder.random_entrypoint")
|
||||
build_calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_build_random_full_deck(*, theme, constraints, seed, attempts, timeout_s, primary_theme, secondary_theme, tertiary_theme):
|
||||
build_calls.append({
|
||||
"theme": theme,
|
||||
"primary": primary_theme,
|
||||
"seed": seed,
|
||||
})
|
||||
return _make_stub_result(seed, theme, "ResolvedArtifacts")
|
||||
|
||||
monkeypatch.setattr(random_entrypoint, "build_random_full_deck", fake_build_random_full_deck)
|
||||
|
||||
headless_runner = importlib.import_module("headless_runner")
|
||||
locked_runs: list[dict[str, Any]] = []
|
||||
|
||||
class DummyBuilder:
|
||||
def __init__(self, commander: str):
|
||||
self.commander_name = commander
|
||||
self.commander = commander
|
||||
self.deck_list_final: list[Any] = []
|
||||
self.last_csv_path = None
|
||||
self.last_txt_path = None
|
||||
self.custom_export_base = None
|
||||
|
||||
def build_deck_summary(self):
|
||||
return None
|
||||
|
||||
def export_decklist_csv(self):
|
||||
return None
|
||||
|
||||
def export_decklist_text(self, filename: str | None = None): # pragma: no cover - optional path
|
||||
return None
|
||||
|
||||
def compute_and_print_compliance(self, base_stem: str | None = None): # pragma: no cover - optional path
|
||||
return None
|
||||
|
||||
def fake_run(command_name: str, seed: int | None = None):
|
||||
locked_runs.append({"commander": command_name, "seed": seed})
|
||||
return DummyBuilder(command_name)
|
||||
|
||||
monkeypatch.setattr(headless_runner, "run", fake_run)
|
||||
|
||||
web_app_module = importlib.import_module("code.web.app")
|
||||
web_app_module = importlib.reload(web_app_module)
|
||||
from code.web.services import tasks
|
||||
|
||||
tasks._SESSIONS.clear()
|
||||
client = TestClient(web_app_module.app)
|
||||
|
||||
# Initial surprise build to populate session cache
|
||||
resp1 = client.post("/hx/random_reroll", json={"mode": "surprise", "primary_theme": "Artifacts"})
|
||||
assert resp1.status_code == 200
|
||||
assert build_calls[0]["primary"] == "Artifacts"
|
||||
commander_name = f"Commander-{build_calls[0]['seed']}"
|
||||
first_seed = build_calls[0]["seed"]
|
||||
|
||||
form_payload = [
|
||||
("mode", "reroll_same_commander"),
|
||||
("commander", commander_name),
|
||||
("seed", str(first_seed)),
|
||||
("primary_theme", "ResolvedArtifacts"),
|
||||
("primary_theme", "UserOverride"),
|
||||
("resolved_themes", "ResolvedArtifacts"),
|
||||
]
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
encoded = urlencode(form_payload, doseq=True)
|
||||
resp2 = client.post(
|
||||
"/hx/random_reroll",
|
||||
content=encoded,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.request.headers.get("Content-Type") == "application/x-www-form-urlencoded"
|
||||
assert len(locked_runs) == 1 # headless runner invoked once
|
||||
assert len(build_calls) == 1 # no additional filter build
|
||||
|
||||
# Hidden input should reflect resolved theme, not user override
|
||||
assert 'id="current-primary-theme"' in resp2.text
|
||||
assert 'value="ResolvedArtifacts"' in resp2.text
|
||||
assert "UserOverride" not in resp2.text
|
||||
|
||||
sid = client.cookies.get("sid")
|
||||
assert sid
|
||||
session = tasks.get_session(sid)
|
||||
requested = session.get("random_build", {}).get("requested_themes") or {}
|
||||
assert requested.get("primary") == "Artifacts"
|
||||
37
code/tests/test_random_theme_stats_diagnostics.py
Normal file
37
code/tests/test_random_theme_stats_diagnostics.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from code.web import app as web_app # type: ignore
|
||||
from code.web.app import app # type: ignore
|
||||
|
||||
# Ensure project root on sys.path for absolute imports
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def _make_client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_theme_stats_requires_diagnostics_flag(monkeypatch):
|
||||
monkeypatch.setattr(web_app, "SHOW_DIAGNOSTICS", False)
|
||||
client = _make_client()
|
||||
resp = client.get("/status/random_theme_stats")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_theme_stats_payload_includes_core_fields(monkeypatch):
|
||||
monkeypatch.setattr(web_app, "SHOW_DIAGNOSTICS", True)
|
||||
client = _make_client()
|
||||
resp = client.get("/status/random_theme_stats")
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert payload.get("ok") is True
|
||||
stats = payload.get("stats") or {}
|
||||
assert "commanders" in stats
|
||||
assert "unique_tokens" in stats
|
||||
assert "total_assignments" in stats
|
||||
assert isinstance(stats.get("top_tokens"), list)
|
||||
39
code/tests/test_random_theme_tag_cache.py
Normal file
39
code/tests/test_random_theme_tag_cache.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import pandas as pd
|
||||
|
||||
from deck_builder.random_entrypoint import _ensure_theme_tag_cache, _filter_multi
|
||||
|
||||
|
||||
def _build_df() -> pd.DataFrame:
|
||||
data = {
|
||||
"name": ["Alpha", "Beta", "Gamma"],
|
||||
"themeTags": [
|
||||
["Aggro", "Tokens"],
|
||||
["LifeGain", "Control"],
|
||||
["Artifacts", "Combo"],
|
||||
],
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
return _ensure_theme_tag_cache(df)
|
||||
|
||||
|
||||
def test_and_filter_uses_cached_index():
|
||||
df = _build_df()
|
||||
filtered, diag = _filter_multi(df, "Aggro", "Tokens", None)
|
||||
|
||||
assert list(filtered["name"].values) == ["Alpha"]
|
||||
assert diag["resolved_themes"] == ["Aggro", "Tokens"]
|
||||
assert not diag["combo_fallback"]
|
||||
assert "aggro" in df.attrs["_ltag_index"]
|
||||
assert "tokens" in df.attrs["_ltag_index"]
|
||||
|
||||
|
||||
def test_synergy_fallback_partial_match_uses_index_union():
|
||||
df = _build_df()
|
||||
|
||||
filtered, diag = _filter_multi(df, "Life Gain", None, None)
|
||||
|
||||
assert list(filtered["name"].values) == ["Beta"]
|
||||
assert diag["combo_fallback"]
|
||||
assert diag["synergy_fallback"]
|
||||
assert diag["resolved_themes"] == ["life", "gain"]
|
||||
assert diag["fallback_reason"] is not None
|
||||
Loading…
Add table
Add a link
Reference in a new issue