feat(random): finalize multi-theme telemetry and polish
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:
matt 2025-09-26 18:15:52 -07:00
parent 73685f22c8
commit 49f1f8b2eb
28 changed files with 4888 additions and 251 deletions

View file

@ -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"]

View file

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

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

View 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

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

View file

@ -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", "")

View file

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

View file

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

View file

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

View file

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

View 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

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

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

View 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