feat: locks/replace/compare/permalinks; perf: virtualization, LQIP, caching, diagnostics; add tests, docs, and issue/PR templates (flags OFF)

This commit is contained in:
matt 2025-08-28 14:57:22 -07:00
parent f8c6b5c07e
commit 721e1884af
41 changed files with 2960 additions and 143 deletions

View file

@ -0,0 +1,68 @@
import importlib
from starlette.testclient import TestClient
class FakeBuilder:
def __init__(self):
# Minimal attributes accessed by /build/alternatives
self._card_name_tags_index = {
'target card': ['ramp', 'mana'],
'alt good': ['ramp', 'mana'],
'alt owned': ['ramp'],
'alt commander': ['ramp'],
'alt in deck': ['ramp'],
'alt locked': ['ramp'],
'unrelated': ['draw'],
}
# Simulate pandas DataFrame mapping to preserve display casing
# Represented as a simple mock object with .empty and .iterrows() for keys above
class DF:
empty = False
def __init__(self, names):
self._names = names
def __getattr__(self, name):
if name == 'empty':
return False
raise AttributeError
def __iter__(self):
return iter(self._names)
# We'll emulate minimal API used: df[ df["name"].astype(str).str.lower().isin(pool) ]
# To keep it simple, we won't rely on DF in this test; display falls back to lower-case names.
self._combined_cards_df = None
self.card_library = {}
# Simulate deck names containing 'alt in deck'
self.current_names = ['alt in deck']
def _inject_fake_ctx(client: TestClient, commander: str, locks: list[str]):
# Touch session to get sid cookie
r = client.get('/build')
assert r.status_code == 200
sid = r.cookies.get('sid')
assert sid
# Import session service and mutate directly
tasks = importlib.import_module('code.web.services.tasks')
sess = tasks.get_session(sid)
sess['commander'] = commander
sess['locks'] = locks
sess['build_ctx'] = {
'builder': FakeBuilder(),
'locks': {s.lower() for s in locks},
}
return sid
def test_alternatives_filters_out_commander_in_deck_and_locked():
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
_inject_fake_ctx(client, commander='Alt Commander', locks=['alt locked'])
# owned_only off
r = client.get('/build/alternatives?name=Target%20Card&owned_only=0')
assert r.status_code == 200
body = r.text.lower()
# Should include alt good and alt owned, but not commander, in deck, or locked
assert 'alt good' in body or 'alt%20good' in body
assert 'alt owned' in body or 'alt%20owned' in body
assert 'alt commander' not in body
assert 'alt in deck' not in body
assert 'alt locked' not in body

View file

@ -0,0 +1,52 @@
import os
import tempfile
from pathlib import Path
import importlib
from starlette.testclient import TestClient
def _write_csv(p: Path, rows):
p.write_text('\n'.join(rows), encoding='utf-8')
def test_compare_diffs_with_temp_exports(monkeypatch):
with tempfile.TemporaryDirectory() as tmpd:
tmp = Path(tmpd)
# Create two CSV exports with small differences
a = tmp / 'A.csv'
b = tmp / 'B.csv'
header = 'Name,Count,Type,ManaValue\n'
_write_csv(a, [
header.rstrip('\n'),
'Card One,1,Creature,2',
'Card Two,2,Instant,1',
'Card Three,1,Sorcery,3',
])
_write_csv(b, [
header.rstrip('\n'),
'Card Two,1,Instant,1', # decreased in B
'Card Four,1,Creature,2', # only in B
'Card Three,1,Sorcery,3',
])
# Touch mtime so B is newer
os.utime(a, None)
os.utime(b, None)
# Point DECK_EXPORTS at this temp dir
monkeypatch.setenv('DECK_EXPORTS', str(tmp))
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
# Compare A vs B
r = client.get(f'/decks/compare?A={a.name}&B={b.name}')
assert r.status_code == 200
body = r.text
# Only in A: Card One
assert 'Only in A' in body
assert 'Card One' in body
# Only in B: Card Four
assert 'Only in B' in body
assert 'Card Four' in body
# Changed list includes Card Two with delta -1
assert 'Card Two' in body
assert 'Decreased' in body or '( -1' in body or '(-1)' in body

View file

@ -0,0 +1,12 @@
import importlib
from starlette.testclient import TestClient
def test_compare_options_include_mtime_attribute():
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
r = client.get('/decks/compare')
assert r.status_code == 200
body = r.text
# Ensure at least one option contains data-mtime attribute (present even with empty list structure)
assert 'data-mtime' in body

View file

@ -0,0 +1,44 @@
import base64
import json
from starlette.testclient import TestClient
def test_permalink_includes_locks_and_restores_notice(monkeypatch):
# Lazy import to ensure fresh app state
import importlib
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
# Seed a session with a commander and locks by calling /build and directly touching session via cookie path
# Start a session
r = client.get('/build')
assert r.status_code == 200
# Now set some session state by invoking endpoints that mutate session
# Simulate selecting commander and a lock
# Use /build/from to load a permalink-like payload directly
payload = {
"commander": "Atraxa, Praetors' Voice",
"tags": ["proliferate"],
"bracket": 3,
"ideals": {"ramp": 10, "lands": 36, "basic_lands": 18, "creatures": 28, "removal": 10, "wipes": 3, "card_advantage": 8, "protection": 4},
"tag_mode": "AND",
"flags": {"owned_only": False, "prefer_owned": False},
"locks": ["Swords to Plowshares", "Sol Ring"],
}
raw = json.dumps(payload, separators=(",", ":")).encode('utf-8')
token = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
r2 = client.get(f'/build/from?state={token}')
assert r2.status_code == 200
# Step 4 should contain the locks restored chip
body = r2.text
assert 'locks restored' in body.lower()
# Ask the server for a permalink now and ensure locks are present
r3 = client.get('/build/permalink')
assert r3.status_code == 200
data = r3.json()
# Prefer decoded state when token not provided
state = data.get('state') or {}
assert 'locks' in state
assert set([s.lower() for s in state.get('locks', [])]) == {"swords to plowshares", "sol ring"}

View file

@ -0,0 +1,68 @@
import base64
import json
import importlib
from starlette.testclient import TestClient
def _decode_permalink_state(client: TestClient) -> dict:
r = client.get('/build/permalink')
assert r.status_code == 200
data = r.json()
if data.get('state'):
return data['state']
# If only permalink token provided, decode it for inspection
url = data.get('permalink') or ''
assert '/build/from?state=' in url
token = url.split('state=', 1)[1]
pad = '=' * (-len(token) % 4)
raw = base64.urlsafe_b64decode((token + pad).encode('ascii')).decode('utf-8')
return json.loads(raw)
def test_replace_updates_locks_and_undo_restores(monkeypatch):
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
# Start session
r = client.get('/build')
assert r.status_code == 200
# Replace Old -> New (locks: add new, remove old)
r2 = client.post('/build/replace', data={'old': 'Old Card', 'new': 'New Card'})
assert r2.status_code == 200
body = r2.text
assert 'Locked <strong>New Card</strong> and unlocked <strong>Old Card</strong>' in body
state = _decode_permalink_state(client)
locks = {s.lower() for s in state.get('locks', [])}
assert 'new card' in locks
assert 'old card' not in locks
# Undo should remove new and re-add old
r3 = client.post('/build/replace/undo', data={'old': 'Old Card', 'new': 'New Card'})
assert r3.status_code == 200
state2 = _decode_permalink_state(client)
locks2 = {s.lower() for s in state2.get('locks', [])}
assert 'old card' in locks2
assert 'new card' not in locks2
def test_lock_from_list_unlock_emits_oob_updates():
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
# Initialize session
r = client.get('/build')
assert r.status_code == 200
# Lock a name
r1 = client.post('/build/lock', data={'name': 'Test Card', 'locked': '1'})
assert r1.status_code == 200
# Now unlock from the locked list path (from_list=1)
r2 = client.post('/build/lock', data={'name': 'Test Card', 'locked': '0', 'from_list': '1'})
assert r2.status_code == 200
body = r2.text
# Should include out-of-band updates so UI can refresh the locks chip/section
assert 'hx-swap-oob' in body
assert 'id="locks-chip"' in body or "id='locks-chip'" in body