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:
matt 2025-09-23 09:19:23 -07:00
parent 8f47dfbb81
commit c4a7fc48ea
40 changed files with 6092 additions and 17312 deletions

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

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

View 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

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

View 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

View 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

View 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

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

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

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

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