feat(random): multi-theme groundwork, locked reroll export parity, duplicate export fix, expanded diagnostics and test coverage

This commit is contained in:
matt 2025-09-25 15:14:15 -07:00
parent a029d430c5
commit 73685f22c8
39 changed files with 2671 additions and 271 deletions

View file

@ -0,0 +1,77 @@
from __future__ import annotations
import importlib
import os
from starlette.testclient import TestClient
def _mk_client(monkeypatch):
# Enable Random Modes and point to test CSVs
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("RANDOM_UI", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
# Keep defaults small for speed
monkeypatch.setenv("RANDOM_MAX_ATTEMPTS", "3")
monkeypatch.setenv("RANDOM_TIMEOUT_MS", "200")
# Re-import app to pick up env
app_module = importlib.import_module('code.web.app')
importlib.reload(app_module)
return TestClient(app_module.app)
def test_retries_exhausted_flag_propagates(monkeypatch):
client = _mk_client(monkeypatch)
# Force rejection of every candidate to simulate retries exhaustion
payload = {"seed": 1234, "constraints": {"reject_all": True}, "attempts": 2, "timeout_ms": 200}
r = client.post('/api/random_full_build', json=payload)
assert r.status_code == 200
data = r.json()
diag = data.get("diagnostics") or {}
assert diag.get("attempts") >= 1
assert diag.get("retries_exhausted") is True
assert diag.get("timeout_hit") in {True, False}
def test_timeout_hit_flag_propagates(monkeypatch):
client = _mk_client(monkeypatch)
# Force the time source in random_entrypoint to advance rapidly so the loop times out immediately
re = importlib.import_module('deck_builder.random_entrypoint')
class _FakeClock:
def __init__(self):
self.t = 0.0
def time(self):
# Advance time by 0.2s every call
self.t += 0.2
return self.t
fake = _FakeClock()
monkeypatch.setattr(re, 'time', fake, raising=True)
# Use small timeout and large attempts; timeout path should be taken deterministically
payload = {"seed": 4321, "attempts": 1000, "timeout_ms": 100}
r = client.post('/api/random_full_build', json=payload)
assert r.status_code == 200
data = r.json()
diag = data.get("diagnostics") or {}
assert diag.get("attempts") >= 1
assert diag.get("timeout_hit") is True
def test_hx_fragment_includes_diagnostics_when_enabled(monkeypatch):
client = _mk_client(monkeypatch)
# Enable diagnostics in templates
monkeypatch.setenv("SHOW_DIAGNOSTICS", "1")
monkeypatch.setenv("RANDOM_UI", "1")
app_module = importlib.import_module('code.web.app')
importlib.reload(app_module)
client = TestClient(app_module.app)
headers = {
"HX-Request": "true",
"Content-Type": "application/json",
"Accept": "text/html, */*; q=0.1",
}
r = client.post("/hx/random_reroll", data='{"seed": 10, "constraints": {"reject_all": true}, "attempts": 2, "timeout_ms": 200}', headers=headers)
assert r.status_code == 200
html = r.text
# Should include attempts and at least one of the diagnostics flags text when enabled
assert "attempts=" in html
assert ("Retries exhausted" in html) or ("Timeout hit" in html)

View file

@ -0,0 +1,37 @@
from __future__ import annotations
import importlib
import os
from starlette.testclient import TestClient
def _client(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')
return TestClient(app_module.app)
def test_same_seed_same_theme_same_constraints_identical(monkeypatch):
client = _client(monkeypatch)
body = {'seed': 2025, 'theme': 'Tokens'}
r1 = client.post('/api/random_full_build', json=body)
r2 = client.post('/api/random_full_build', json=body)
assert r1.status_code == 200 and r2.status_code == 200
d1, d2 = r1.json(), r2.json()
assert d1['commander'] == d2['commander']
assert d1['decklist'] == d2['decklist']
def test_different_seed_yields_difference(monkeypatch):
client = _client(monkeypatch)
b1 = {'seed': 1111}
b2 = {'seed': 1112}
r1 = client.post('/api/random_full_build', json=b1)
r2 = client.post('/api/random_full_build', json=b2)
assert r1.status_code == 200 and r2.status_code == 200
d1, d2 = r1.json(), r2.json()
# Commander or at least one decklist difference
if d1['commander'] == d2['commander']:
assert d1['decklist'] != d2['decklist'], 'Expected decklist difference for different seeds'
else:
assert True

View file

@ -0,0 +1,72 @@
from __future__ import annotations
import os
import base64
import json
from fastapi.testclient import TestClient
# End-to-end scenario test for Random Modes.
# Flow:
# 1. Full build with seed S and (optional) theme.
# 2. Reroll from that seed (seed+1) and capture deck.
# 3. Replay permalink from step 1 (decode token) to reproduce original deck.
# Assertions:
# - Initial and reproduced decks identical (permalink determinism).
# - Reroll seed increments.
# - Reroll deck differs from original unless dataset too small (allow equality but tolerate identical for tiny pool).
def _decode_state(token: str) -> dict:
pad = "=" * (-len(token) % 4)
raw = base64.urlsafe_b64decode((token + pad).encode("ascii")).decode("utf-8")
return json.loads(raw)
def test_random_end_to_end_flow(monkeypatch):
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("RANDOM_UI", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
from code.web.app import app
client = TestClient(app)
seed = 5150
# Step 1: Full build
r1 = client.post("/api/random_full_build", json={"seed": seed, "theme": "Tokens"})
assert r1.status_code == 200, r1.text
d1 = r1.json()
assert d1.get("seed") == seed
deck1 = d1.get("decklist")
assert isinstance(deck1, list)
permalink = d1.get("permalink")
assert permalink and permalink.startswith("/build/from?state=")
# Step 2: Reroll
r2 = client.post("/api/random_reroll", json={"seed": seed})
assert r2.status_code == 200, r2.text
d2 = r2.json()
assert d2.get("seed") == seed + 1
deck2 = d2.get("decklist")
assert isinstance(deck2, list)
# Allow equality for tiny dataset; but typically expect difference
if d2.get("commander") == d1.get("commander"):
# At least one card difference ideally
# If exact decklist same, just accept (document small test pool)
pass
else:
assert d2.get("commander") != d1.get("commander") or deck2 != deck1
# Step 3: Replay permalink
token = permalink.split("state=", 1)[1]
decoded = _decode_state(token)
rnd = decoded.get("random") or {}
r3 = client.post("/api/random_full_build", json={
"seed": rnd.get("seed"),
"theme": rnd.get("theme"),
"constraints": rnd.get("constraints"),
})
assert r3.status_code == 200, r3.text
d3 = r3.json()
# Deck reproduced
assert d3.get("decklist") == deck1
assert d3.get("commander") == d1.get("commander")

View file

@ -0,0 +1,43 @@
from __future__ import annotations
import importlib
import os
from starlette.testclient import TestClient
def _mk_client(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')
return TestClient(app_module.app)
def test_invalid_theme_triggers_fallback_and_echoes_original_theme(monkeypatch):
client = _mk_client(monkeypatch)
payload = {"seed": 777, "theme": "this theme does not exist"}
r = client.post('/api/random_full_build', json=payload)
assert r.status_code == 200
data = r.json()
# Fallback flag should be set with original_theme echoed
assert data.get("fallback") is True
assert data.get("original_theme") == payload["theme"]
# Theme is still the provided theme (we indicate fallback via the flag)
assert data.get("theme") == payload["theme"]
# Commander/decklist should be present
assert isinstance(data.get("commander"), str) and data["commander"]
assert isinstance(data.get("decklist"), list)
def test_constraints_impossible_returns_422_with_detail(monkeypatch):
client = _mk_client(monkeypatch)
# Set an unrealistically high requirement to force impossible constraint
payload = {"seed": 101, "constraints": {"require_min_candidates": 1000000}}
r = client.post('/api/random_full_build', json=payload)
assert r.status_code == 422
data = r.json()
# Structured error payload
assert data.get("status") == 422
detail = data.get("detail")
assert isinstance(detail, dict)
assert detail.get("error") == "constraints_impossible"
assert isinstance(detail.get("pool_size"), int)

View file

@ -1,9 +1,32 @@
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
from deck_builder.random_entrypoint import build_random_full_deck
@pytest.fixture(scope="module")
def client():
os.environ["RANDOM_MODES"] = "1"
os.environ["CSV_FILES_DIR"] = os.path.join("csv_files", "testdata")
from web.app import app
with TestClient(app) as c:
yield c
def test_full_build_same_seed_produces_same_deck(client: TestClient):
body = {"seed": 4242}
r1 = client.post("/api/random_full_build", json=body)
assert r1.status_code == 200, r1.text
d1 = r1.json()
r2 = client.post("/api/random_full_build", json=body)
assert r2.status_code == 200, r2.text
d2 = r2.json()
assert d1.get("seed") == d2.get("seed") == 4242
assert d1.get("decklist") == d2.get("decklist")
def test_random_full_build_is_deterministic_on_frozen_dataset(monkeypatch):
# Use frozen dataset for determinism
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))

View file

@ -0,0 +1,31 @@
import os
import json
from deck_builder.random_entrypoint import build_random_full_deck
def test_random_full_build_writes_sidecars():
# Run build in real project context so CSV inputs exist
os.makedirs('deck_files', exist_ok=True)
res = build_random_full_deck(theme="Goblin Kindred", seed=12345)
assert res.csv_path is not None, "CSV path should be returned"
assert os.path.isfile(res.csv_path), f"CSV not found: {res.csv_path}"
base, _ = os.path.splitext(res.csv_path)
summary_path = base + '.summary.json'
assert os.path.isfile(summary_path), "Summary sidecar missing"
with open(summary_path,'r',encoding='utf-8') as f:
data = json.load(f)
assert 'meta' in data and 'summary' in data, "Malformed summary sidecar"
comp_path = base + '_compliance.json'
# Compliance may be empty dict depending on bracket policy; ensure file exists when compliance object returned
if res.compliance:
assert os.path.isfile(comp_path), "Compliance file missing despite compliance object"
# Basic CSV sanity: contains header Name
with open(res.csv_path,'r',encoding='utf-8') as f:
head = f.read(200)
assert 'Name' in head, "CSV appears malformed"
# Cleanup artifacts to avoid polluting workspace (best effort)
for p in [res.csv_path, summary_path, comp_path]:
try:
if os.path.isfile(p):
os.remove(p)
except Exception:
pass

View file

@ -0,0 +1,32 @@
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)
# 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
# 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
# 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

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import os
from typing import List
from fastapi.testclient import TestClient
"""Lightweight performance smoke test for Random Modes.
Runs a small number of builds (SURPRISE_COUNT + THEMED_COUNT) using the frozen
CSV test dataset and asserts that the p95 elapsed_ms is under the configured
threshold (default 1000ms) unless PERF_SKIP=1 is set.
This is intentionally lenient and should not be treated as a microbenchmark; it
serves as a regression guard for accidental O(N^2) style slowdowns.
"""
SURPRISE_COUNT = int(os.getenv("PERF_SURPRISE_COUNT", "15"))
THEMED_COUNT = int(os.getenv("PERF_THEMED_COUNT", "15"))
THRESHOLD_MS = int(os.getenv("PERF_P95_THRESHOLD_MS", "1000"))
SKIP = os.getenv("PERF_SKIP") == "1"
THEME = os.getenv("PERF_SAMPLE_THEME", "Tokens")
def _elapsed(diag: dict) -> int:
try:
return int(diag.get("elapsed_ms") or 0)
except Exception:
return 0
def test_random_performance_p95(monkeypatch): # pragma: no cover - performance heuristic
if SKIP:
return # allow opt-out in CI or constrained environments
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
from code.web.app import app
client = TestClient(app)
samples: List[int] = []
# Surprise (no theme)
for i in range(SURPRISE_COUNT):
r = client.post("/api/random_full_build", json={"seed": 10000 + i})
assert r.status_code == 200, r.text
samples.append(_elapsed(r.json().get("diagnostics") or {}))
# Themed
for i in range(THEMED_COUNT):
r = client.post("/api/random_full_build", json={"seed": 20000 + i, "theme": THEME})
assert r.status_code == 200, r.text
samples.append(_elapsed(r.json().get("diagnostics") or {}))
# Basic sanity: no zeros for all entries (some builds may be extremely fast; allow zeros but not all)
assert len(samples) == SURPRISE_COUNT + THEMED_COUNT
if all(s == 0 for s in samples): # degenerate path
return
# p95
sorted_samples = sorted(samples)
idx = max(0, int(round(0.95 * (len(sorted_samples) - 1))))
p95 = sorted_samples[idx]
assert p95 < THRESHOLD_MS, f"p95 {p95}ms exceeds threshold {THRESHOLD_MS}ms (samples={samples})"

View file

@ -0,0 +1,57 @@
import os
import base64
import json
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="module")
def client():
# Ensure flags and frozen dataset
os.environ["RANDOM_MODES"] = "1"
os.environ["RANDOM_UI"] = "1"
os.environ["CSV_FILES_DIR"] = os.path.join("csv_files", "testdata")
from web.app import app
with TestClient(app) as c:
yield c
def _decode_state_token(token: str) -> dict:
pad = "=" * (-len(token) % 4)
raw = base64.urlsafe_b64decode((token + pad).encode("ascii")).decode("utf-8")
return json.loads(raw)
def test_permalink_reproduces_random_full_build(client: TestClient):
# Build once with a fixed seed
seed = 1111
r1 = client.post("/api/random_full_build", json={"seed": seed})
assert r1.status_code == 200, r1.text
data1 = r1.json()
assert data1.get("seed") == seed
assert data1.get("permalink")
deck1 = data1.get("decklist")
# Extract and decode permalink token
permalink: str = data1["permalink"]
assert permalink.startswith("/build/from?state=")
token = permalink.split("state=", 1)[1]
decoded = _decode_state_token(token)
# Validate token contains the random payload
rnd = decoded.get("random") or {}
assert rnd.get("seed") == seed
# Rebuild using only the fields contained in the permalink random payload
r2 = client.post("/api/random_full_build", json={
"seed": rnd.get("seed"),
"theme": rnd.get("theme"),
"constraints": rnd.get("constraints"),
})
assert r2.status_code == 200, r2.text
data2 = r2.json()
deck2 = data2.get("decklist")
# Reproduction should be identical
assert deck2 == deck1

View file

@ -0,0 +1,54 @@
import os
import base64
import json
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="module")
def client():
# Ensure flags and frozen dataset
os.environ["RANDOM_MODES"] = "1"
os.environ["RANDOM_UI"] = "1"
os.environ["CSV_FILES_DIR"] = os.path.join("csv_files", "testdata")
from web.app import app
with TestClient(app) as c:
yield c
def _decode_state_token(token: str) -> dict:
pad = "=" * (-len(token) % 4)
raw = base64.urlsafe_b64decode((token + pad).encode("ascii")).decode("utf-8")
return json.loads(raw)
def test_permalink_roundtrip_via_build_routes(client: TestClient):
# Create a permalink via random full build
r1 = client.post("/api/random_full_build", json={"seed": 777})
assert r1.status_code == 200, r1.text
p1 = r1.json().get("permalink")
assert p1 and p1.startswith("/build/from?state=")
token = p1.split("state=", 1)[1]
state1 = _decode_state_token(token)
rnd1 = state1.get("random") or {}
# Visit the permalink (server should rehydrate session from token)
r_page = client.get(p1)
assert r_page.status_code == 200
# Ask server to produce a permalink from current session
r2 = client.get("/build/permalink")
assert r2.status_code == 200, r2.text
body2 = r2.json()
assert body2.get("ok") is True
p2 = body2.get("permalink")
assert p2 and p2.startswith("/build/from?state=")
token2 = p2.split("state=", 1)[1]
state2 = _decode_state_token(token2)
rnd2 = state2.get("random") or {}
# The random payload should survive the roundtrip unchanged
assert rnd2 == rnd1

View file

@ -0,0 +1,82 @@
import os
import time
from typing import Optional
import pytest
from fastapi.testclient import TestClient
import sys
def _client_with_flags(window_s: int = 2, limit_random: int = 2, limit_build: int = 2, limit_suggest: int = 2) -> TestClient:
# Ensure flags are set prior to importing app
os.environ['RANDOM_MODES'] = '1'
os.environ['RANDOM_UI'] = '1'
os.environ['RANDOM_RATE_LIMIT'] = '1'
os.environ['RATE_LIMIT_WINDOW_S'] = str(window_s)
os.environ['RANDOM_RATE_LIMIT_RANDOM'] = str(limit_random)
os.environ['RANDOM_RATE_LIMIT_BUILD'] = str(limit_build)
os.environ['RANDOM_RATE_LIMIT_SUGGEST'] = str(limit_suggest)
# Force fresh import so RATE_LIMIT_* constants reflect env
sys.modules.pop('code.web.app', None)
from code.web import app as app_module # type: ignore
# Force override constants for deterministic test
try:
app_module.RATE_LIMIT_ENABLED = True # type: ignore[attr-defined]
app_module.RATE_LIMIT_WINDOW_S = window_s # type: ignore[attr-defined]
app_module.RATE_LIMIT_RANDOM = limit_random # type: ignore[attr-defined]
app_module.RATE_LIMIT_BUILD = limit_build # type: ignore[attr-defined]
app_module.RATE_LIMIT_SUGGEST = limit_suggest # type: ignore[attr-defined]
# Reset in-memory counters
if hasattr(app_module, '_RL_COUNTS'):
app_module._RL_COUNTS.clear() # type: ignore[attr-defined]
except Exception:
pass
return TestClient(app_module.app)
@pytest.mark.parametrize("path, method, payload, header_check", [
("/api/random_reroll", "post", {"seed": 1}, True),
("/themes/api/suggest?q=to", "get", None, True),
])
def test_rate_limit_emits_headers_and_429(path: str, method: str, payload: Optional[dict], header_check: bool):
client = _client_with_flags(window_s=5, limit_random=1, limit_suggest=1)
# first call should be OK or at least emit rate-limit headers
if method == 'post':
r1 = client.post(path, json=payload)
else:
r1 = client.get(path)
assert 'X-RateLimit-Reset' in r1.headers
assert 'X-RateLimit-Remaining' in r1.headers or r1.status_code == 429
# Drive additional requests to exceed the remaining budget deterministically
rem = None
try:
if 'X-RateLimit-Remaining' in r1.headers:
rem = int(r1.headers['X-RateLimit-Remaining'])
except Exception:
rem = None
attempts = (rem + 1) if isinstance(rem, int) else 5
rN = r1
for _ in range(attempts):
if method == 'post':
rN = client.post(path, json=payload)
else:
rN = client.get(path)
if rN.status_code == 429:
break
assert rN.status_code == 429
assert 'Retry-After' in rN.headers
# Wait for window to pass, then call again and expect success
time.sleep(5.2)
if method == 'post':
r3 = client.post(path, json=payload)
else:
r3 = client.get(path)
assert r3.status_code != 429
assert 'X-RateLimit-Remaining' in r3.headers

View file

@ -0,0 +1,25 @@
from __future__ import annotations
import importlib
import os
from starlette.testclient import TestClient
def _client(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')
return TestClient(app_module.app)
def test_reroll_diagnostics_match_full_build(monkeypatch):
client = _client(monkeypatch)
base = client.post('/api/random_full_build', json={'seed': 321})
assert base.status_code == 200
seed = base.json()['seed']
reroll = client.post('/api/random_reroll', json={'seed': seed})
assert reroll.status_code == 200
d_base = base.json().get('diagnostics') or {}
d_reroll = reroll.json().get('diagnostics') or {}
# Allow reroll to omit elapsed_ms difference but keys should at least cover attempts/timeouts flags
for k in ['attempts', 'timeout_hit', 'retries_exhausted']:
assert k in d_base and k in d_reroll

View file

@ -0,0 +1,43 @@
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="module")
def client():
# Ensure flags and frozen dataset
os.environ["RANDOM_MODES"] = "1"
os.environ["RANDOM_UI"] = "1"
os.environ["CSV_FILES_DIR"] = os.path.join("csv_files", "testdata")
from web.app import app
with TestClient(app) as c:
yield c
def test_reroll_idempotency_and_progression(client: TestClient):
# Initial build
base_seed = 2024
r1 = client.post("/api/random_full_build", json={"seed": base_seed})
assert r1.status_code == 200, r1.text
d1 = r1.json()
deck1 = d1.get("decklist")
assert isinstance(deck1, list) and deck1
# Rebuild with the same seed should produce identical result
r_same = client.post("/api/random_full_build", json={"seed": base_seed})
assert r_same.status_code == 200, r_same.text
deck_same = r_same.json().get("decklist")
assert deck_same == deck1
# Reroll (seed+1) should typically change the result
r2 = client.post("/api/random_reroll", json={"seed": base_seed})
assert r2.status_code == 200, r2.text
d2 = r2.json()
assert d2.get("seed") == base_seed + 1
deck2 = d2.get("decklist")
# It is acceptable that a small dataset could still coincide, but in practice should differ
assert deck2 != deck1 or d2.get("commander") != d1.get("commander")

View file

@ -0,0 +1,45 @@
import os
import time
from glob import glob
from fastapi.testclient import TestClient
def _client():
os.environ['RANDOM_UI'] = '1'
os.environ['RANDOM_MODES'] = '1'
os.environ['CSV_FILES_DIR'] = os.path.join('csv_files','testdata')
from web.app import app
return TestClient(app)
def _recent_files(pattern: str, since: float):
out = []
for p in glob(pattern):
try:
if os.path.getmtime(p) >= since:
out.append(p)
except Exception:
pass
return out
def test_locked_reroll_generates_summary_and_compliance():
c = _client()
# First random build (api) to establish commander/seed
r = c.post('/api/random_reroll', json={})
assert r.status_code == 200, r.text
data = r.json()
commander = data['commander']
seed = data['seed']
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'})
assert r2.status_code == 200, r2.text
# Look for new sidecar/compliance created after start
recent_summary = _recent_files('deck_files/*_*.summary.json', start)
recent_compliance = _recent_files('deck_files/*_compliance.json', start)
assert recent_summary, 'Expected at least one new summary json after locked reroll'
assert recent_compliance, 'Expected at least one new compliance json after locked reroll'

View file

@ -0,0 +1,36 @@
import json
import os
from fastapi.testclient import TestClient
def _new_client():
os.environ['RANDOM_MODES'] = '1'
os.environ['RANDOM_UI'] = '1'
os.environ['CSV_FILES_DIR'] = os.path.join('csv_files','testdata')
from web.app import app
return TestClient(app)
def test_reroll_keeps_commander():
client = _new_client()
# Initial random build (api path) to get commander + seed
r1 = client.post('/api/random_reroll', json={})
assert r1.status_code == 200
data1 = r1.json()
commander = data1['commander']
seed = data1['seed']
# 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)
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)
assert r3.status_code == 200
html2 = r3.text
assert commander in html2

View file

@ -0,0 +1,31 @@
from fastapi.testclient import TestClient
from urllib.parse import quote_plus
import os
def _new_client():
os.environ['RANDOM_MODES'] = '1'
os.environ['RANDOM_UI'] = '1'
os.environ['CSV_FILES_DIR'] = os.path.join('csv_files','testdata')
from web.app import app
return TestClient(app)
def test_reroll_keeps_commander_form_encoded():
client = _new_client()
r1 = client.post('/api/random_reroll', json={})
assert r1.status_code == 200
data1 = r1.json()
commander = data1['commander']
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'})
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'})
assert r3.status_code == 200
assert commander in r3.text

View file

@ -0,0 +1,27 @@
import os
import glob
from fastapi.testclient import TestClient
def _client():
os.environ['RANDOM_UI'] = '1'
os.environ['RANDOM_MODES'] = '1'
os.environ['CSV_FILES_DIR'] = os.path.join('csv_files','testdata')
from web.app import app
return TestClient(app)
def test_locked_reroll_single_export():
c = _client()
# Initial surprise build
r = c.post('/api/random_reroll', json={})
assert r.status_code == 200
seed = r.json()['seed']
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'})
assert r2.status_code == 200
after_csvs = set(glob.glob('deck_files/*.csv'))
new_csvs = after_csvs - before_csvs
# Expect exactly 1 new csv file for the reroll (not two)
assert len(new_csvs) == 1, f"Expected 1 new csv, got {len(new_csvs)}: {new_csvs}"

View file

@ -0,0 +1,42 @@
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="module")
def client():
os.environ["RANDOM_MODES"] = "1"
os.environ["RANDOM_UI"] = "1"
os.environ["CSV_FILES_DIR"] = os.path.join("csv_files", "testdata")
from web.app import app
with TestClient(app) as c:
yield c
def test_recent_seeds_flow(client: TestClient):
# Initially empty
r0 = client.get("/api/random/seeds")
assert r0.status_code == 200, r0.text
data0 = r0.json()
assert data0.get("seeds") == [] or data0.get("seeds") is not None
# Run a full build with a specific seed
r1 = client.post("/api/random_full_build", json={"seed": 1001})
assert r1.status_code == 200, r1.text
d1 = r1.json()
assert d1.get("seed") == 1001
# Reroll (should increment to 1002) and be stored
r2 = client.post("/api/random_reroll", json={"seed": 1001})
assert r2.status_code == 200, r2.text
d2 = r2.json()
assert d2.get("seed") == 1002
# Fetch recent seeds; expect to include both 1001 and 1002, with last==1002
r3 = client.get("/api/random/seeds")
assert r3.status_code == 200, r3.text
d3 = r3.json()
seeds = d3.get("seeds") or []
assert 1001 in seeds and 1002 in seeds
assert d3.get("last") == 1002

View file

@ -0,0 +1,22 @@
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="module")
def client():
os.environ["RANDOM_MODES"] = "1"
os.environ["RANDOM_UI"] = "1"
os.environ["CSV_FILES_DIR"] = os.path.join("csv_files", "testdata")
from web.app import app
with TestClient(app) as c:
yield c
def test_random_modes_page_renders(client: TestClient):
r = client.get("/random")
assert r.status_code == 200
assert "Random Modes" in r.text

View file

@ -0,0 +1,43 @@
from __future__ import annotations
import json
import os
import importlib
from pathlib import Path
from starlette.testclient import TestClient
from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore
CATALOG_PATH = Path('config/themes/theme_list.json')
def _load_catalog():
raw = json.loads(CATALOG_PATH.read_text(encoding='utf-8'))
return ThemeCatalog(**raw)
def test_catalog_schema_parses_and_has_minimum_themes():
cat = _load_catalog()
assert len(cat.themes) >= 5 # sanity floor
# Validate each theme has canonical name and synergy list is list
for t in cat.themes:
assert isinstance(t.theme, str) and t.theme
assert isinstance(t.synergies, list)
def test_sample_seeds_produce_non_empty_decks(monkeypatch):
# Use test data to keep runs fast/deterministic
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)
cat = _load_catalog()
# Choose up to 5 themes (deterministic ordering/selection) for smoke check
themes = sorted([t.theme for t in cat.themes])[:5]
for th in themes:
r = client.post('/api/random_full_build', json={'theme': th, 'seed': 999})
assert r.status_code == 200
data = r.json()
# Decklist should exist (may be empty if headless not available, allow fallback leniency)
assert 'seed' in data
assert data.get('theme') == th or data.get('theme') == th # explicit equality for clarity
assert isinstance(data.get('commander'), str)

View file

@ -0,0 +1,16 @@
from pathlib import Path
import json
def test_theme_list_json_validates_against_pydantic_and_fast_path():
# Load JSON
p = Path('config/themes/theme_list.json')
raw = json.loads(p.read_text(encoding='utf-8'))
# Pydantic validation
from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore
catalog = ThemeCatalog(**raw)
assert isinstance(catalog.themes, list) and len(catalog.themes) > 0
# Basic fields exist on entries
first = catalog.themes[0]
assert first.theme and isinstance(first.synergies, list)

View file

@ -0,0 +1,35 @@
from __future__ import annotations
import importlib
import os
from starlette.testclient import TestClient
def _client(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')
return TestClient(app_module.app)
def test_theme_rejects_disallowed_chars(monkeypatch):
client = _client(monkeypatch)
bad = {"seed": 10, "theme": "Bad;DROP TABLE"}
r = client.post('/api/random_full_build', json=bad)
assert r.status_code == 200
data = r.json()
# Theme should be None or absent because it was rejected
assert data.get('theme') in (None, '')
def test_theme_rejects_long(monkeypatch):
client = _client(monkeypatch)
long_theme = 'X'*200
r = client.post('/api/random_full_build', json={"seed": 11, "theme": long_theme})
assert r.status_code == 200
assert r.json().get('theme') in (None, '')
def test_theme_accepts_normal(monkeypatch):
client = _client(monkeypatch)
r = client.post('/api/random_full_build', json={"seed": 12, "theme": "Tokens"})
assert r.status_code == 200
assert r.json().get('theme') == 'Tokens'