mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat(random): multi-theme groundwork, locked reroll export parity, duplicate export fix, expanded diagnostics and test coverage
This commit is contained in:
parent
a029d430c5
commit
73685f22c8
39 changed files with 2671 additions and 271 deletions
77
code/tests/test_random_attempts_and_timeout.py
Normal file
77
code/tests/test_random_attempts_and_timeout.py
Normal 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)
|
||||
37
code/tests/test_random_determinism_delta.py
Normal file
37
code/tests/test_random_determinism_delta.py
Normal 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
|
||||
72
code/tests/test_random_end_to_end_flow.py
Normal file
72
code/tests/test_random_end_to_end_flow.py
Normal 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")
|
||||
43
code/tests/test_random_fallback_and_constraints.py
Normal file
43
code/tests/test_random_fallback_and_constraints.py
Normal 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)
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
31
code/tests/test_random_full_build_exports.py
Normal file
31
code/tests/test_random_full_build_exports.py
Normal 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
|
||||
32
code/tests/test_random_metrics_and_seed_history.py
Normal file
32
code/tests/test_random_metrics_and_seed_history.py
Normal 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
|
||||
63
code/tests/test_random_performance_p95.py
Normal file
63
code/tests/test_random_performance_p95.py
Normal 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})"
|
||||
57
code/tests/test_random_permalink_reproduction.py
Normal file
57
code/tests/test_random_permalink_reproduction.py
Normal 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
|
||||
54
code/tests/test_random_permalink_roundtrip.py
Normal file
54
code/tests/test_random_permalink_roundtrip.py
Normal 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
|
||||
82
code/tests/test_random_rate_limit_headers.py
Normal file
82
code/tests/test_random_rate_limit_headers.py
Normal 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
|
||||
25
code/tests/test_random_reroll_diagnostics_parity.py
Normal file
25
code/tests/test_random_reroll_diagnostics_parity.py
Normal 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
|
||||
43
code/tests/test_random_reroll_idempotency.py
Normal file
43
code/tests/test_random_reroll_idempotency.py
Normal 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")
|
||||
45
code/tests/test_random_reroll_locked_artifacts.py
Normal file
45
code/tests/test_random_reroll_locked_artifacts.py
Normal 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'
|
||||
36
code/tests/test_random_reroll_locked_commander.py
Normal file
36
code/tests/test_random_reroll_locked_commander.py
Normal 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
|
||||
31
code/tests/test_random_reroll_locked_commander_form.py
Normal file
31
code/tests/test_random_reroll_locked_commander_form.py
Normal 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
|
||||
27
code/tests/test_random_reroll_locked_no_duplicate_exports.py
Normal file
27
code/tests/test_random_reroll_locked_no_duplicate_exports.py
Normal 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}"
|
||||
42
code/tests/test_random_seed_persistence.py
Normal file
42
code/tests/test_random_seed_persistence.py
Normal 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
|
||||
22
code/tests/test_random_ui_page.py
Normal file
22
code/tests/test_random_ui_page.py
Normal 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
|
||||
43
code/tests/test_theme_catalog_mapping_and_samples.py
Normal file
43
code/tests/test_theme_catalog_mapping_and_samples.py
Normal 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)
|
||||
|
||||
16
code/tests/test_theme_catalog_schema_validation.py
Normal file
16
code/tests/test_theme_catalog_schema_validation.py
Normal 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)
|
||||
35
code/tests/test_theme_input_validation.py
Normal file
35
code/tests/test_theme_input_validation.py
Normal 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'
|
||||
Loading…
Add table
Add a link
Reference in a new issue