mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: locks/replace/compare/permalinks; perf: virtualization, LQIP, caching, diagnostics; add tests, docs, and issue/PR templates (flags OFF)
This commit is contained in:
parent
f8c6b5c07e
commit
721e1884af
41 changed files with 2960 additions and 143 deletions
68
code/tests/test_alternatives_filters.py
Normal file
68
code/tests/test_alternatives_filters.py
Normal 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
|
||||
52
code/tests/test_compare_diffs.py
Normal file
52
code/tests/test_compare_diffs.py
Normal 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
|
||||
12
code/tests/test_compare_metadata.py
Normal file
12
code/tests/test_compare_metadata.py
Normal 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
|
||||
44
code/tests/test_permalinks_and_locks.py
Normal file
44
code/tests/test_permalinks_and_locks.py
Normal 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"}
|
||||
68
code/tests/test_replace_and_locks_flow.py
Normal file
68
code/tests/test_replace_and_locks_flow.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue