import sys from pathlib import Path import pytest from fastapi.testclient import TestClient from code.web.app import app # 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 '' 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