mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
feat(preview): sampling, metrics, governance, server mana data
Preview endpoint + fast caches; curated pins + role quotas + rarity/overlap tuning; catalog+preview metrics; governance enforcement flags; server mana/color identity fields; docs/tests/scripts updated.
This commit is contained in:
parent
8f47dfbb81
commit
c4a7fc48ea
40 changed files with 6092 additions and 17312 deletions
30
code/tests/test_fast_theme_list_regression.py
Normal file
30
code/tests/test_fast_theme_list_regression.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import json
|
||||
from code.web.routes.themes import _load_fast_theme_list
|
||||
|
||||
def test_fast_theme_list_derives_ids(monkeypatch, tmp_path):
|
||||
# Create a minimal theme_list.json without explicit 'id' fields to simulate current build output
|
||||
data = {
|
||||
"themes": [
|
||||
{"theme": "+1/+1 Counters", "description": "Foo desc that is a bit longer to ensure trimming works properly and demonstrates snippet logic."},
|
||||
{"theme": "Artifacts", "description": "Artifacts matter deck."},
|
||||
],
|
||||
"generated_from": "merge"
|
||||
}
|
||||
# Write to a temporary file and monkeypatch THEME_LIST_PATH to point there
|
||||
theme_json = tmp_path / 'theme_list.json'
|
||||
theme_json.write_text(json.dumps(data), encoding='utf-8')
|
||||
|
||||
from code.web.routes import themes as themes_module
|
||||
monkeypatch.setattr(themes_module, 'THEME_LIST_PATH', theme_json)
|
||||
|
||||
lst = _load_fast_theme_list()
|
||||
assert lst is not None
|
||||
# Should derive slug ids
|
||||
ids = {e['id'] for e in lst}
|
||||
assert 'plus1-plus1-counters' in ids
|
||||
assert 'artifacts' in ids
|
||||
# Should generate short_description
|
||||
for e in lst:
|
||||
assert 'short_description' in e
|
||||
assert e['short_description']
|
||||
|
||||
20
code/tests/test_preview_curated_examples_regression.py
Normal file
20
code/tests/test_preview_curated_examples_regression.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import json
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from code.web.app import app # type: ignore
|
||||
|
||||
|
||||
def test_preview_includes_curated_examples_regression():
|
||||
"""Regression test (2025-09-20): After P2 changes the preview lost curated
|
||||
example cards because theme_list.json lacks example_* arrays. We added YAML
|
||||
fallback in project_detail; ensure at least one 'example' role appears for
|
||||
a theme known to have example_cards in its YAML (aggro.yml)."""
|
||||
client = TestClient(app)
|
||||
r = client.get('/themes/api/theme/aggro/preview?limit=12')
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data.get('ok') is True
|
||||
sample = data.get('preview', {}).get('sample', [])
|
||||
# Collect roles
|
||||
roles = { (it.get('roles') or [''])[0] for it in sample }
|
||||
assert 'example' in roles, f"expected at least one curated example card role; roles present: {roles} sample={json.dumps(sample, indent=2)[:400]}"
|
||||
22
code/tests/test_preview_error_rate_metrics.py
Normal file
22
code/tests/test_preview_error_rate_metrics.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from fastapi.testclient import TestClient
|
||||
from code.web.app import app
|
||||
|
||||
def test_preview_error_rate_metrics(monkeypatch):
|
||||
monkeypatch.setenv('WEB_THEME_PICKER_DIAGNOSTICS', '1')
|
||||
client = TestClient(app)
|
||||
# Trigger one preview to ensure request counter increments
|
||||
themes_resp = client.get('/themes/api/themes?limit=1')
|
||||
assert themes_resp.status_code == 200
|
||||
theme_id = themes_resp.json()['items'][0]['id']
|
||||
pr = client.get(f'/themes/fragment/preview/{theme_id}')
|
||||
assert pr.status_code == 200
|
||||
# Simulate two client fetch error structured log events
|
||||
for _ in range(2):
|
||||
r = client.post('/themes/log', json={'event':'preview_fetch_error'})
|
||||
assert r.status_code == 200
|
||||
metrics = client.get('/themes/metrics').json()
|
||||
assert metrics['ok'] is True
|
||||
preview_block = metrics['preview']
|
||||
assert 'preview_client_fetch_errors' in preview_block
|
||||
assert preview_block['preview_client_fetch_errors'] >= 2
|
||||
assert 'preview_error_rate_pct' in preview_block
|
||||
35
code/tests/test_preview_metrics_percentiles.py
Normal file
35
code/tests/test_preview_metrics_percentiles.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from fastapi.testclient import TestClient
|
||||
from code.web.app import app
|
||||
|
||||
|
||||
def test_preview_metrics_percentiles_present(monkeypatch):
|
||||
# Enable diagnostics for metrics endpoint
|
||||
monkeypatch.setenv('WEB_THEME_PICKER_DIAGNOSTICS', '1')
|
||||
# Force logging on (not required but ensures code path safe)
|
||||
monkeypatch.setenv('WEB_THEME_PREVIEW_LOG', '0')
|
||||
client = TestClient(app)
|
||||
# Hit a few previews to generate durations
|
||||
# We need an existing theme id; fetch list API first
|
||||
r = client.get('/themes/api/themes?limit=3')
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
# API returns 'items' not 'themes'
|
||||
assert 'items' in data
|
||||
themes = data['items']
|
||||
assert themes, 'Expected at least one theme for metrics test'
|
||||
theme_id = themes[0]['id']
|
||||
for _ in range(3):
|
||||
pr = client.get(f'/themes/fragment/preview/{theme_id}')
|
||||
assert pr.status_code == 200
|
||||
mr = client.get('/themes/metrics')
|
||||
assert mr.status_code == 200, mr.text
|
||||
metrics = mr.json()
|
||||
assert metrics['ok'] is True
|
||||
per_theme = metrics['preview']['per_theme']
|
||||
# pick first entry in per_theme stats
|
||||
# Validate new percentile fields exist (p50_ms, p95_ms) and are numbers
|
||||
any_entry = next(iter(per_theme.values())) if per_theme else None
|
||||
assert any_entry, 'Expected at least one per-theme metrics entry'
|
||||
assert 'p50_ms' in any_entry and 'p95_ms' in any_entry, any_entry
|
||||
assert isinstance(any_entry['p50_ms'], (int, float))
|
||||
assert isinstance(any_entry['p95_ms'], (int, float))
|
||||
13
code/tests/test_preview_minimal_variant.py
Normal file
13
code/tests/test_preview_minimal_variant.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from fastapi.testclient import TestClient
|
||||
from code.web.app import app # type: ignore
|
||||
|
||||
|
||||
def test_minimal_variant_hides_controls_and_headers():
|
||||
client = TestClient(app)
|
||||
r = client.get('/themes/fragment/preview/aggro?suppress_curated=1&minimal=1')
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
assert 'Curated Only' not in html
|
||||
assert 'Commander Overlap & Diversity Rationale' not in html
|
||||
# Ensure sample cards still render
|
||||
assert 'card-sample' in html
|
||||
17
code/tests/test_preview_suppress_curated_flag.py
Normal file
17
code/tests/test_preview_suppress_curated_flag.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from fastapi.testclient import TestClient
|
||||
from code.web.app import app # type: ignore
|
||||
|
||||
|
||||
def test_preview_fragment_suppress_curated_removes_examples():
|
||||
client = TestClient(app)
|
||||
# Get HTML fragment with suppress_curated
|
||||
r = client.get('/themes/fragment/preview/aggro?suppress_curated=1&limit=14')
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
# Should not contain group label Curated Examples
|
||||
assert 'Curated Examples' not in html
|
||||
# Should still contain payoff/enabler group labels
|
||||
assert 'Payoffs' in html or 'Enablers & Support' in html
|
||||
# No example role chips: role-example occurrences removed
|
||||
# Ensure no rendered span with curated example role (avoid style block false positive)
|
||||
assert '<span class="mini-badge role-example"' not in html
|
||||
204
code/tests/test_theme_api_phase_e.py
Normal file
204
code/tests/test_theme_api_phase_e.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
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))
|
||||
|
||||
|
||||
CATALOG_PATH = ROOT / 'config' / 'themes' / 'theme_list.json'
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_list_basic_ok():
|
||||
client = TestClient(app)
|
||||
r = client.get('/themes/api/themes')
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data['ok'] is True
|
||||
assert 'items' in data and isinstance(data['items'], list)
|
||||
if data['items']:
|
||||
sample = data['items'][0]
|
||||
assert 'id' in sample and 'theme' in sample
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_list_query_substring():
|
||||
client = TestClient(app)
|
||||
r = client.get('/themes/api/themes', params={'q': 'Counters'})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert all('Counters'.lower() in ('|'.join(it.get('synergies', []) + [it['theme']]).lower()) for it in data['items']) or not data['items']
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_list_filter_bucket_and_archetype():
|
||||
client = TestClient(app)
|
||||
base = client.get('/themes/api/themes').json()
|
||||
if not base['items']:
|
||||
pytest.skip('No themes to filter')
|
||||
# Find first item with both bucket & archetype
|
||||
candidate = None
|
||||
for it in base['items']:
|
||||
if it.get('popularity_bucket') and it.get('deck_archetype'):
|
||||
candidate = it
|
||||
break
|
||||
if not candidate:
|
||||
pytest.skip('No item with bucket+archetype to test')
|
||||
r = client.get('/themes/api/themes', params={'bucket': candidate['popularity_bucket']})
|
||||
assert r.status_code == 200
|
||||
data_bucket = r.json()
|
||||
assert all(i.get('popularity_bucket') == candidate['popularity_bucket'] for i in data_bucket['items'])
|
||||
r2 = client.get('/themes/api/themes', params={'archetype': candidate['deck_archetype']})
|
||||
assert r2.status_code == 200
|
||||
data_arch = r2.json()
|
||||
assert all(i.get('deck_archetype') == candidate['deck_archetype'] for i in data_arch['items'])
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_fragment_endpoints():
|
||||
client = TestClient(app)
|
||||
# Page
|
||||
pg = client.get('/themes/picker')
|
||||
assert pg.status_code == 200 and 'Theme Catalog' in pg.text
|
||||
# List fragment
|
||||
frag = client.get('/themes/fragment/list')
|
||||
assert frag.status_code == 200
|
||||
# Snippet hover presence (short_description used as title attribute on first theme cell if available)
|
||||
if '<table>' in frag.text:
|
||||
assert 'title="' in frag.text # coarse check; ensures at least one title attr present for snippet
|
||||
# If there is at least one row, request detail fragment
|
||||
base = client.get('/themes/api/themes').json()
|
||||
if base['items']:
|
||||
tid = base['items'][0]['id']
|
||||
dfrag = client.get(f'/themes/fragment/detail/{tid}')
|
||||
assert dfrag.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_detail_ok_and_not_found():
|
||||
client = TestClient(app)
|
||||
listing = client.get('/themes/api/themes').json()
|
||||
if not listing['items']:
|
||||
pytest.skip('No themes to test detail')
|
||||
first_id = listing['items'][0]['id']
|
||||
r = client.get(f'/themes/api/theme/{first_id}')
|
||||
assert r.status_code == 200
|
||||
detail = r.json()['theme']
|
||||
assert detail['id'] == first_id
|
||||
r404 = client.get('/themes/api/theme/does-not-exist-xyz')
|
||||
assert r404.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_diagnostics_gating(monkeypatch):
|
||||
client = TestClient(app)
|
||||
# Without flag -> diagnostics fields absent
|
||||
r = client.get('/themes/api/themes', params={'diagnostics': '1'})
|
||||
sample = r.json()['items'][0] if r.json()['items'] else {}
|
||||
assert 'has_fallback_description' not in sample
|
||||
# Enable flag
|
||||
monkeypatch.setenv('WEB_THEME_PICKER_DIAGNOSTICS', '1')
|
||||
r2 = client.get('/themes/api/themes', params={'diagnostics': '1'})
|
||||
sample2 = r2.json()['items'][0] if r2.json()['items'] else {}
|
||||
if sample2:
|
||||
assert 'has_fallback_description' in sample2
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_uncapped_requires_diagnostics(monkeypatch):
|
||||
client = TestClient(app)
|
||||
listing = client.get('/themes/api/themes').json()
|
||||
if not listing['items']:
|
||||
pytest.skip('No themes available')
|
||||
tid = listing['items'][0]['id']
|
||||
# Request uncapped without diagnostics -> should not include
|
||||
d = client.get(f'/themes/api/theme/{tid}', params={'uncapped': '1'}).json()['theme']
|
||||
assert 'uncapped_synergies' not in d
|
||||
# Enable diagnostics
|
||||
monkeypatch.setenv('WEB_THEME_PICKER_DIAGNOSTICS', '1')
|
||||
d2 = client.get(f'/themes/api/theme/{tid}', params={'diagnostics': '1', 'uncapped': '1'}).json()['theme']
|
||||
# Uncapped may equal capped if no difference, but key must exist
|
||||
assert 'uncapped_synergies' in d2
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_preview_endpoint_basic():
|
||||
client = TestClient(app)
|
||||
listing = client.get('/themes/api/themes').json()
|
||||
if not listing['items']:
|
||||
pytest.skip('No themes available')
|
||||
tid = listing['items'][0]['id']
|
||||
preview = client.get(f'/themes/api/theme/{tid}/preview', params={'limit': 5}).json()
|
||||
assert preview['ok'] is True
|
||||
sample = preview['preview']['sample']
|
||||
assert len(sample) <= 5
|
||||
# Scores should be non-increasing for first curated entries (simple heuristic)
|
||||
scores = [it['score'] for it in sample]
|
||||
assert all(isinstance(s, (int, float)) for s in scores)
|
||||
# Synthetic placeholders (if any) should have role 'synthetic'
|
||||
for it in sample:
|
||||
assert 'roles' in it and isinstance(it['roles'], list)
|
||||
# Color filter invocation (may reduce or keep size; ensure no crash)
|
||||
preview_color = client.get(f'/themes/api/theme/{tid}/preview', params={'limit': 4, 'colors': 'U'}).json()
|
||||
assert preview_color['ok'] is True
|
||||
# Fragment version
|
||||
frag = client.get(f'/themes/fragment/preview/{tid}')
|
||||
assert frag.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_preview_commander_bias(): # lightweight heuristic validation
|
||||
client = TestClient(app)
|
||||
listing = client.get('/themes/api/themes').json()
|
||||
if not listing['items']:
|
||||
pytest.skip('No themes available')
|
||||
tid = listing['items'][0]['id']
|
||||
# Use an arbitrary commander name – depending on dataset may not be found; test tolerant
|
||||
commander_name = 'Atraxa, Praetors Voice' # attempt full name; if absent test remains soft
|
||||
preview = client.get(f'/themes/api/theme/{tid}/preview', params={'limit': 6, 'commander': commander_name}).json()
|
||||
assert preview['ok'] is True
|
||||
sample = preview['preview']['sample']
|
||||
# If commander card was discovered at least one item should have commander_bias reason
|
||||
any_commander_reason = any('commander_bias' in it.get('reasons', []) for it in sample)
|
||||
# It's acceptable if not found (dataset subset) but reasons structure must exist
|
||||
assert all('reasons' in it for it in sample)
|
||||
# Soft assertion (no failure if commander not present) – if discovered we assert overlap marker
|
||||
if any_commander_reason:
|
||||
assert any('commander_overlap' in it.get('reasons', []) for it in sample)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
|
||||
def test_preview_curated_synergy_ordering():
|
||||
"""Curated synergy example cards (role=curated_synergy) must appear after role=example
|
||||
cards but before any sampled payoff/enabler/support/wildcard entries.
|
||||
"""
|
||||
client = TestClient(app)
|
||||
listing = client.get('/themes/api/themes').json()
|
||||
if not listing['items']:
|
||||
pytest.skip('No themes available')
|
||||
tid = listing['items'][0]['id']
|
||||
preview = client.get(f'/themes/api/theme/{tid}/preview', params={'limit': 12}).json()
|
||||
assert preview['ok'] is True
|
||||
sample = preview['preview']['sample']
|
||||
roles_sequence = [it['roles'][0] if it.get('roles') else None for it in sample]
|
||||
if 'curated_synergy' not in roles_sequence:
|
||||
pytest.skip('No curated synergy cards present in sample (data-dependent)')
|
||||
first_non_example_index = None
|
||||
first_curated_synergy_index = None
|
||||
first_sampled_index = None
|
||||
sampled_roles = {'payoff', 'enabler', 'support', 'wildcard'}
|
||||
for idx, role in enumerate(roles_sequence):
|
||||
if role != 'example' and first_non_example_index is None:
|
||||
first_non_example_index = idx
|
||||
if role == 'curated_synergy' and first_curated_synergy_index is None:
|
||||
first_curated_synergy_index = idx
|
||||
if role in sampled_roles and first_sampled_index is None:
|
||||
first_sampled_index = idx
|
||||
# Ensure ordering: examples (if any) -> curated_synergy -> sampled roles
|
||||
if first_curated_synergy_index is not None and first_sampled_index is not None:
|
||||
assert first_curated_synergy_index < first_sampled_index
|
||||
247
code/tests/test_theme_picker_gaps.py
Normal file
247
code/tests/test_theme_picker_gaps.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""Tests covering Section H (Testing Gaps) & related Phase F items.
|
||||
|
||||
These are backend-oriented approximations for browser behaviors. Where full
|
||||
JS execution would be required (keyboard event dispatch, sessionStorage), we
|
||||
simulate or validate server produced HTML attributes / ordering contracts.
|
||||
|
||||
Contained tests:
|
||||
- test_fast_path_load_time: ensure catalog list fragment renders quickly using
|
||||
fixture dataset (budget <= 120ms on CI hardware; relaxed if env override)
|
||||
- test_colors_filter_constraint: applying colors=G restricts primary/secondary
|
||||
colors to subset including 'G'
|
||||
- test_preview_placeholder_fill: themes with insufficient real cards are
|
||||
padded with synthetic placeholders (role synthetic & name bracketed)
|
||||
- test_preview_cache_hit_timing: second call served from cache faster (uses
|
||||
monkeypatch to force _now progression minimal)
|
||||
- test_navigation_state_preservation_roundtrip: simulate list fetch then
|
||||
detail fetch and ensure detail HTML contains theme id while list fragment
|
||||
params persist in constructed URL logic (server side approximation)
|
||||
- test_mana_cost_parser_variants: port of client JS mana parser implemented
|
||||
in Python to validate hybrid / phyrexian / X handling does not crash.
|
||||
|
||||
NOTE: Pure keyboard navigation & sessionStorage cache skip paths require a
|
||||
JS runtime; we assert presence of required attributes (tabindex, role=option)
|
||||
as a smoke proxy until an integration (playwright) layer is added.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _get_app(): # local import to avoid heavy import cost if file unused
|
||||
from code.web.app import app # type: ignore
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
# Enable diagnostics to allow /themes/metrics access if gated
|
||||
os.environ.setdefault("WEB_THEME_PICKER_DIAGNOSTICS", "1")
|
||||
return TestClient(_get_app())
|
||||
|
||||
|
||||
def test_fast_path_load_time(client):
|
||||
# First load may include startup warm logic; allow generous budget, tighten later in CI ratchet
|
||||
budget_ms = int(os.getenv("TEST_THEME_FAST_PATH_BUDGET_MS", "2500"))
|
||||
t0 = time.perf_counter()
|
||||
r = client.get("/themes/fragment/list?limit=20")
|
||||
dt_ms = (time.perf_counter() - t0) * 1000
|
||||
assert r.status_code == 200
|
||||
# Basic sanity: table rows present
|
||||
assert "theme-row" in r.text
|
||||
assert dt_ms <= budget_ms, f"Fast path list fragment exceeded budget {dt_ms:.2f}ms > {budget_ms}ms"
|
||||
|
||||
|
||||
def test_colors_filter_constraint(client):
|
||||
r = client.get("/themes/fragment/list?limit=50&colors=G")
|
||||
assert r.status_code == 200
|
||||
rows = [m.group(0) for m in re.finditer(r"<tr[^>]*class=\"theme-row\"[\s\S]*?</tr>", r.text)]
|
||||
assert rows, "Expected some rows for colors filter"
|
||||
greenish = 0
|
||||
considered = 0
|
||||
for row in rows:
|
||||
tds = re.findall(r"<td>(.*?)</td>", row)
|
||||
if len(tds) < 3:
|
||||
continue
|
||||
primary = tds[1]
|
||||
secondary = tds[2]
|
||||
if primary or secondary:
|
||||
considered += 1
|
||||
if ("G" in primary) or ("G" in secondary):
|
||||
greenish += 1
|
||||
# Expect at least half of colored themes to include G (soft assertion due to multi-color / secondary logic on backend)
|
||||
if considered:
|
||||
assert greenish / considered >= 0.5, f"Expected >=50% green presence, got {greenish}/{considered}"
|
||||
|
||||
|
||||
def test_preview_placeholder_fill(client):
|
||||
# Find a theme likely to have low card pool by requesting high limit and then checking for synthetic placeholders '['
|
||||
# Use first theme id from list fragment
|
||||
list_html = client.get("/themes/fragment/list?limit=1").text
|
||||
m = re.search(r'data-theme-id=\"([^\"]+)\"', list_html)
|
||||
assert m, "Could not extract theme id"
|
||||
theme_id = m.group(1)
|
||||
# Request preview with high limit to likely force padding
|
||||
pv = client.get(f"/themes/fragment/preview/{theme_id}?limit=30")
|
||||
assert pv.status_code == 200
|
||||
# Synthetic placeholders appear as names inside brackets (server template), search raw HTML
|
||||
bracketed = re.findall(r"\[[^\]]+\]", pv.text)
|
||||
# Not all themes will pad; if none found try a second theme
|
||||
if not bracketed:
|
||||
list_html2 = client.get("/themes/fragment/list?limit=5").text
|
||||
ids = re.findall(r'data-theme-id=\"([^\"]+)\"', list_html2)
|
||||
for tid in ids[1:]:
|
||||
pv2 = client.get(f"/themes/fragment/preview/{tid}?limit=30")
|
||||
if pv2.status_code == 200 and re.search(r"\[[^\]]+\]", pv2.text):
|
||||
bracketed = ["ok"]
|
||||
break
|
||||
assert bracketed, "Expected at least one synthetic placeholder bracketed item in high-limit preview"
|
||||
|
||||
|
||||
def test_preview_cache_hit_timing(monkeypatch, client):
|
||||
# Warm first
|
||||
list_html = client.get("/themes/fragment/list?limit=1").text
|
||||
m = re.search(r'data-theme-id=\"([^\"]+)\"', list_html)
|
||||
assert m, "Theme id missing"
|
||||
theme_id = m.group(1)
|
||||
# First build (miss)
|
||||
r1 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
|
||||
assert r1.status_code == 200
|
||||
# Monkeypatch theme_preview._now to freeze time so second call counts as hit
|
||||
import code.web.services.theme_preview as tp # type: ignore
|
||||
orig_now = tp._now
|
||||
monkeypatch.setattr(tp, "_now", lambda: orig_now())
|
||||
r2 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
|
||||
assert r2.status_code == 200
|
||||
# Deterministic service-level verification: second direct function call should short-circuit via cache
|
||||
import code.web.services.theme_preview as tp # type: ignore
|
||||
# Snapshot counters
|
||||
pre_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0)
|
||||
first_payload = tp.get_theme_preview(theme_id, limit=12)
|
||||
second_payload = tp.get_theme_preview(theme_id, limit=12)
|
||||
post_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0)
|
||||
assert first_payload.get("sample"), "Missing sample items in preview"
|
||||
# Cache hit should have incremented hits counter
|
||||
assert post_hits >= pre_hits + 1 or post_hits > 0, "Expected cache hits counter to increase"
|
||||
# Items list identity (names) should be identical even if build_ms differs (second call cached has no build_ms recompute)
|
||||
first_names = [i.get("name") for i in first_payload.get("sample", [])]
|
||||
second_names = [i.get("name") for i in second_payload.get("sample", [])]
|
||||
assert first_names == second_names, "Item ordering changed between cached calls"
|
||||
# Metrics cache hit counter is best-effort; do not hard fail if not exposed yet
|
||||
metrics_resp = client.get("/themes/metrics")
|
||||
if metrics_resp.status_code == 200:
|
||||
metrics = metrics_resp.json()
|
||||
# Soft assertion
|
||||
if metrics.get("preview_cache_hits", 0) == 0:
|
||||
pytest.skip("Preview cache hit not reflected in metrics (soft skip)")
|
||||
|
||||
|
||||
def test_navigation_state_preservation_roundtrip(client):
|
||||
# Simulate list fetch with search & filters appended
|
||||
r = client.get("/themes/fragment/list?q=counters&limit=20&bucket=Common")
|
||||
assert r.status_code == 200
|
||||
# Extract a theme id then fetch detail fragment to simulate navigation
|
||||
m = re.search(r'data-theme-id=\"([^\"]+)\"', r.text)
|
||||
assert m, "Missing theme id in filtered list"
|
||||
theme_id = m.group(1)
|
||||
detail = client.get(f"/themes/fragment/detail/{theme_id}")
|
||||
assert detail.status_code == 200
|
||||
# Detail fragment should include theme display name or id in heading
|
||||
assert theme_id in detail.text or "Theme Detail" in detail.text
|
||||
# Ensure list fragment contained highlighted mark for query
|
||||
assert "<mark>" in r.text, "Expected search term highlighting for state preservation"
|
||||
|
||||
|
||||
# --- Mana cost parser parity (mirror of client JS simplified) ---
|
||||
def _parse_mana_symbols(raw: str) -> List[str]:
|
||||
# Emulate JS regex /\{([^}]+)\}/g
|
||||
return re.findall(r"\{([^}]+)\}", raw or "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mana,expected_syms",
|
||||
[
|
||||
("{X}{2}{U}{B/P}", ["X", "2", "U", "B/P"]),
|
||||
("{G/U}{G/U}{1}{G}", ["G/U", "G/U", "1", "G"]),
|
||||
("{R}{R}{R}{R}{R}", ["R", "R", "R", "R", "R"]),
|
||||
("{2/W}{2/W}{W}", ["2/W", "2/W", "W"]),
|
||||
("{G}{G/P}{X}{C}", ["G", "G/P", "X", "C"]),
|
||||
],
|
||||
)
|
||||
def test_mana_cost_parser_variants(mana, expected_syms):
|
||||
assert _parse_mana_symbols(mana) == expected_syms
|
||||
|
||||
|
||||
def test_lazy_load_img_attributes(client):
|
||||
# Grab a preview and ensure loading="lazy" present on card images
|
||||
list_html = client.get("/themes/fragment/list?limit=1").text
|
||||
m = re.search(r'data-theme-id=\"([^\"]+)\"', list_html)
|
||||
assert m
|
||||
theme_id = m.group(1)
|
||||
pv = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
|
||||
assert pv.status_code == 200
|
||||
# At least one img tag with loading="lazy" attribute
|
||||
assert re.search(r"<img[^>]+loading=\"lazy\"", pv.text), "Expected lazy-loading images in preview"
|
||||
|
||||
|
||||
def test_list_fragment_accessibility_tokens(client):
|
||||
# Smoke test for role=listbox and row role=option presence (accessibility baseline)
|
||||
r = client.get("/themes/fragment/list?limit=10")
|
||||
assert r.status_code == 200
|
||||
assert "role=\"option\"" in r.text
|
||||
|
||||
|
||||
def test_accessibility_live_region_and_listbox(client):
|
||||
r = client.get("/themes/fragment/list?limit=5")
|
||||
assert r.status_code == 200
|
||||
# List container should have role listbox and aria-live removed in fragment (fragment may omit outer wrapper) – allow either present or absent gracefully
|
||||
# We assert at least one aria-label attribute referencing themes count OR presence of pager text
|
||||
assert ("aria-label=\"" in r.text) or ("Showing" in r.text)
|
||||
|
||||
|
||||
def test_keyboard_nav_script_presence(client):
|
||||
# Fetch full picker page (not just fragment) to inspect embedded JS for Arrow key handling
|
||||
page = client.get("/themes/picker")
|
||||
assert page.status_code == 200
|
||||
body = page.text
|
||||
assert "ArrowDown" in body and "ArrowUp" in body and "Enter" in body and "Escape" in body, "Keyboard nav handlers missing"
|
||||
|
||||
|
||||
def test_list_fragment_filter_cache_fallback_timing(client):
|
||||
# First call (likely cold) vs second call (cached by etag + filter cache)
|
||||
import time as _t
|
||||
t0 = _t.perf_counter()
|
||||
client.get("/themes/fragment/list?limit=25&q=a")
|
||||
first_ms = (_t.perf_counter() - t0) * 1000
|
||||
t1 = _t.perf_counter()
|
||||
client.get("/themes/fragment/list?limit=25&q=a")
|
||||
second_ms = (_t.perf_counter() - t1) * 1000
|
||||
# Soft assertion: second should not be dramatically slower; allow equality but fail if slower by >50%
|
||||
if second_ms > first_ms * 1.5:
|
||||
pytest.skip(f"Second call slower (cold path variance) first={first_ms:.1f}ms second={second_ms:.1f}ms")
|
||||
|
||||
|
||||
def test_intersection_observer_lazy_fallback(client):
|
||||
# Preview fragment should include script referencing IntersectionObserver (fallback path implied by try/catch) and images with loading lazy
|
||||
list_html = client.get("/themes/fragment/list?limit=1").text
|
||||
m = re.search(r'data-theme-id="([^"]+)"', list_html)
|
||||
assert m
|
||||
theme_id = m.group(1)
|
||||
pv = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
|
||||
assert pv.status_code == 200
|
||||
html = pv.text
|
||||
assert 'IntersectionObserver' in html or 'loading="lazy"' in html
|
||||
assert re.search(r"<img[^>]+loading=\"lazy\"", html)
|
||||
|
||||
|
||||
def test_session_storage_cache_script_tokens_present(client):
|
||||
# Ensure list fragment contains cache_hit / cache_miss tokens for sessionStorage path instrumentation
|
||||
frag = client.get("/themes/fragment/list?limit=5").text
|
||||
assert 'cache_hit' in frag and 'cache_miss' in frag, "Expected cache_hit/cache_miss tokens in fragment script"
|
||||
62
code/tests/test_theme_preview_additional.py
Normal file
62
code/tests/test_theme_preview_additional.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import importlib
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _new_client(prewarm: bool = False) -> TestClient:
|
||||
# Ensure fresh import with desired env flags
|
||||
if prewarm:
|
||||
os.environ['WEB_THEME_FILTER_PREWARM'] = '1'
|
||||
else:
|
||||
os.environ.pop('WEB_THEME_FILTER_PREWARM', None)
|
||||
# Remove existing module (if any) so lifespan runs again
|
||||
if 'code.web.app' in list(importlib.sys.modules.keys()):
|
||||
importlib.sys.modules.pop('code.web.app')
|
||||
from code.web.app import app # type: ignore
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _first_theme_id(client: TestClient) -> str:
|
||||
html = client.get('/themes/fragment/list?limit=1').text
|
||||
m = re.search(r'data-theme-id="([^"]+)"', html)
|
||||
assert m, 'No theme id found'
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_role_group_separators_and_role_chips():
|
||||
client = _new_client()
|
||||
theme_id = _first_theme_id(client)
|
||||
pv_html = client.get(f'/themes/fragment/preview/{theme_id}?limit=18').text
|
||||
# Ensure at least one role chip exists
|
||||
assert 'role-chip' in pv_html, 'Expected role-chip elements in preview fragment'
|
||||
# Capture group separator ordering
|
||||
groups = re.findall(r'data-group="(examples|curated_synergy|payoff|enabler_support|wildcard)"', pv_html)
|
||||
if groups:
|
||||
# Remove duplicates preserving order
|
||||
seen = []
|
||||
for g in groups:
|
||||
if g not in seen:
|
||||
seen.append(g)
|
||||
# Expected relative order subset prefix list
|
||||
expected_order = ['examples', 'curated_synergy', 'payoff', 'enabler_support', 'wildcard']
|
||||
# Filter expected list to those actually present and compare ordering
|
||||
filtered_expected = [g for g in expected_order if g in seen]
|
||||
assert seen == filtered_expected, f'Group separators out of order: {seen} vs expected subset {filtered_expected}'
|
||||
|
||||
|
||||
def test_prewarm_flag_metrics():
|
||||
client = _new_client(prewarm=True)
|
||||
# Trigger at least one list request (though prewarm runs in lifespan already)
|
||||
client.get('/themes/fragment/list?limit=5')
|
||||
metrics_resp = client.get('/themes/metrics')
|
||||
if metrics_resp.status_code != 200:
|
||||
pytest.skip('Metrics endpoint unavailable')
|
||||
metrics = metrics_resp.json()
|
||||
# Soft assertion: if key missing, skip (older build)
|
||||
if 'filter_prewarmed' not in metrics:
|
||||
pytest.skip('filter_prewarmed metric not present')
|
||||
assert metrics['filter_prewarmed'] in (True, 1), 'Expected filter_prewarmed to be True after prewarm'
|
||||
38
code/tests/test_theme_preview_ordering.py
Normal file
38
code/tests/test_theme_preview_ordering.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from code.web.services.theme_preview import get_theme_preview # type: ignore
|
||||
from code.web.services.theme_catalog_loader import load_index, slugify, project_detail # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [8, 12])
|
||||
def test_preview_role_ordering(limit):
|
||||
# Pick a deterministic existing theme (first catalog theme)
|
||||
idx = load_index()
|
||||
assert idx.catalog.themes, "No themes available for preview test"
|
||||
theme = idx.catalog.themes[0].theme
|
||||
preview = get_theme_preview(theme, limit=limit)
|
||||
# Ensure curated examples (role=example) all come before any curated_synergy, which come before any payoff/enabler/support/wildcard
|
||||
roles = [c["roles"][0] for c in preview["sample"] if c.get("roles")]
|
||||
# Find first indices
|
||||
first_curated_synergy = next((i for i, r in enumerate(roles) if r == "curated_synergy"), None)
|
||||
first_non_curated = next((i for i, r in enumerate(roles) if r not in {"example", "curated_synergy"}), None)
|
||||
# If both present, ordering constraints
|
||||
if first_curated_synergy is not None and first_non_curated is not None:
|
||||
assert first_curated_synergy < first_non_curated, "curated_synergy block should precede sampled roles"
|
||||
# All example indices must be < any curated_synergy index
|
||||
if first_curated_synergy is not None:
|
||||
for i, r in enumerate(roles):
|
||||
if r == "example":
|
||||
assert i < first_curated_synergy, "example card found after curated_synergy block"
|
||||
|
||||
|
||||
def test_synergy_commanders_no_overlap_with_examples():
|
||||
idx = load_index()
|
||||
theme_entry = idx.catalog.themes[0]
|
||||
slug = slugify(theme_entry.theme)
|
||||
detail = project_detail(slug, idx.slug_to_entry[slug], idx.slug_to_yaml, uncapped=False)
|
||||
examples = set(detail.get("example_commanders") or [])
|
||||
synergy_commanders = detail.get("synergy_commanders") or []
|
||||
assert not (examples.intersection(synergy_commanders)), "synergy_commanders should not include example_commanders"
|
||||
72
code/tests/test_theme_preview_p0_new.py
Normal file
72
code/tests/test_theme_preview_p0_new.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import os
|
||||
import time
|
||||
import json
|
||||
from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache # type: ignore
|
||||
|
||||
|
||||
def test_colors_filter_constraint_green_subset():
|
||||
"""colors=G should only return cards whose color identities are subset of {G} or colorless ('' list)."""
|
||||
payload = get_theme_preview('Blink', limit=8, colors='G') # pick any theme; data-driven
|
||||
for card in payload['sample']:
|
||||
if not card['colors']:
|
||||
continue
|
||||
assert set(card['colors']).issubset({'G'}), f"Card {card['name']} had colors {card['colors']} outside filter"
|
||||
|
||||
|
||||
def test_synthetic_placeholder_fill_present_when_short():
|
||||
# Force scarcity via impossible color filter letter ensuring empty real pool -> synthetic placeholders
|
||||
payload = get_theme_preview('Blink', limit=50, colors='Z')
|
||||
# All real cards filtered out; placeholders must appear
|
||||
synthetic_roles = [c for c in payload['sample'] if 'synthetic' in (c.get('roles') or [])]
|
||||
assert synthetic_roles, 'Expected at least one synthetic placeholder entry under restrictive color filter'
|
||||
assert any('synthetic_synergy_placeholder' in (c.get('reasons') or []) for c in synthetic_roles), 'Missing synthetic placeholder reason'
|
||||
|
||||
|
||||
def test_cache_hit_timing_and_log(monkeypatch, capsys):
|
||||
os.environ['WEB_THEME_PREVIEW_LOG'] = '1'
|
||||
# Force fresh build
|
||||
bust_preview_cache()
|
||||
payload1 = get_theme_preview('Blink', limit=6)
|
||||
assert payload1['cache_hit'] is False
|
||||
# Second call should hit cache
|
||||
payload2 = get_theme_preview('Blink', limit=6)
|
||||
assert payload2['cache_hit'] is True
|
||||
captured = capsys.readouterr().out.splitlines()
|
||||
assert any('theme_preview_build' in line for line in captured), 'Missing build log'
|
||||
assert any('theme_preview_cache_hit' in line for line in captured), 'Missing cache hit log'
|
||||
|
||||
|
||||
def test_per_theme_percentiles_and_raw_counts():
|
||||
bust_preview_cache()
|
||||
for _ in range(5):
|
||||
get_theme_preview('Blink', limit=6)
|
||||
metrics = preview_metrics()
|
||||
per = metrics['per_theme']
|
||||
assert 'blink' in per, 'Expected theme slug in per_theme metrics'
|
||||
blink_stats = per['blink']
|
||||
assert 'p50_ms' in blink_stats and 'p95_ms' in blink_stats, 'Missing percentile metrics'
|
||||
assert 'curated_total' in blink_stats and 'sampled_total' in blink_stats, 'Missing raw curated/sample per-theme totals'
|
||||
|
||||
|
||||
def test_structured_log_contains_new_fields(capsys):
|
||||
os.environ['WEB_THEME_PREVIEW_LOG'] = '1'
|
||||
bust_preview_cache()
|
||||
get_theme_preview('Blink', limit=5)
|
||||
out_lines = capsys.readouterr().out.splitlines()
|
||||
build_lines = [line for line in out_lines if 'theme_preview_build' in line]
|
||||
assert build_lines, 'No build log lines found'
|
||||
parsed = [json.loads(line) for line in build_lines]
|
||||
obj = parsed[-1]
|
||||
assert 'curated_total' in obj and 'sampled_total' in obj and 'role_counts' in obj, 'Missing expected structured log fields'
|
||||
|
||||
|
||||
def test_warm_index_latency_reduction():
|
||||
bust_preview_cache()
|
||||
t0 = time.time()
|
||||
get_theme_preview('Blink', limit=6)
|
||||
cold = time.time() - t0
|
||||
t1 = time.time()
|
||||
get_theme_preview('Blink', limit=6)
|
||||
warm = time.time() - t1
|
||||
# Warm path should generally be faster; allow flakiness with generous factor
|
||||
assert warm <= cold * 1.2, f"Expected warm path faster or near equal (cold={cold}, warm={warm})"
|
||||
Loading…
Add table
Add a link
Reference in a new issue