2025-09-23 09:19:23 -07:00
""" 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 < = 120 ms 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
2025-10-31 10:11:00 -07:00
from code . web . app import app
2025-09-23 09:19:23 -07:00
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
2025-10-31 10:11:00 -07:00
import code . web . services . theme_preview as tp
2025-09-23 09:19:23 -07:00
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
2025-10-31 10:11:00 -07:00
import code . web . services . theme_preview as tp
2025-09-23 09:19:23 -07:00
# 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 "