feat(web): launch commander browser with deck builder CTA

This commit is contained in:
matt 2025-09-30 15:49:08 -07:00
parent 6e9ba244c9
commit 8e57588f40
27 changed files with 1960 additions and 45 deletions

View file

@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
import html as _html
import re
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
@pytest.fixture
def client(monkeypatch):
csv_dir = Path("csv_files/testdata").resolve()
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
clear_commander_catalog_cache()
with TestClient(app) as test_client:
yield test_client
clear_commander_catalog_cache()
def test_commander_row_has_build_cta_with_return_url(client: TestClient) -> None:
# Load the commanders page
resp = client.get("/commanders", params={"q": "atraxa"})
assert resp.status_code == 200
body = resp.text
# Ensure the Build link includes the builder path with commander and return params
match = re.search(r'href="(/build\?[^\"]+)"', body)
assert match is not None
href = _html.unescape(match.group(1))
assert href.startswith("/build?commander=")
parsed = urlparse(href)
params = parse_qs(parsed.query)
assert "return" in params
return_value = params["return"][0]
assert return_value.startswith("/commanders")
parsed_return = urlparse(return_value)
assert parsed_return.path.rstrip("/") == "/commanders"
parsed_return_params = parse_qs(parsed_return.query)
assert parsed_return_params.get("q") == ["atraxa"]
# Ensure no absolute scheme slipped through
assert not return_value.startswith("http")
def test_build_page_includes_back_link_for_safe_return(client: TestClient) -> None:
resp = client.get("/build", params={"return": "/commanders?page=2&color=W"})
assert resp.status_code == 200
body = resp.text
match = re.search(r'href="(/commanders[^\"]+)"', body)
assert match is not None
href = _html.unescape(match.group(1))
parsed = urlparse(href)
assert parsed.path == "/commanders"
params = parse_qs(parsed.query)
assert params.get("page") == ["2"]
assert params.get("color") == ["W"]
def test_build_page_ignores_external_return(client: TestClient) -> None:
resp = client.get("/build", params={"return": "https://evil.example.com"})
assert resp.status_code == 200
body = resp.text
assert "Back to Commanders" not in body
def test_commander_launch_preselects_commander_and_requires_theme(client: TestClient) -> None:
commander_name = "Atraxa, Praetors' Voice"
resp = client.get(
"/build",
params={"commander": commander_name, "return": "/commanders?page=2"},
)
assert resp.status_code == 200
body = resp.text
init_match = re.search(r'<span id="builder-init"[^>]*data-commander="([^"]+)"', body)
assert init_match is not None
assert _html.unescape(init_match.group(1)) == commander_name
assert "Back to Commanders" in body
step2 = client.get("/build/step2")
assert step2.status_code == 200
step2_body = step2.text
assert commander_name in _html.unescape(step2_body)
assert 'name="primary_tag"' in step2_body
submit = client.post(
"/build/step2",
data={
"commander": commander_name,
"bracket": "3",
"tag_mode": "AND",
},
)
assert submit.status_code == 200
assert "Please choose a primary theme." in submit.text

View file

@ -0,0 +1,71 @@
from __future__ import annotations
import time
from pathlib import Path
import pytest
from web.services import commander_catalog_loader as loader
FIXTURE_DIR = Path(__file__).resolve().parents[2] / "csv_files" / "testdata"
def _set_csv_dir(monkeypatch: pytest.MonkeyPatch, path: Path) -> None:
monkeypatch.setenv("CSV_FILES_DIR", str(path))
loader.clear_commander_catalog_cache()
def test_commander_catalog_basic_normalization(monkeypatch: pytest.MonkeyPatch) -> None:
_set_csv_dir(monkeypatch, FIXTURE_DIR)
catalog = loader.load_commander_catalog()
assert catalog.source_path.name == "commander_cards.csv"
assert len(catalog.entries) == 4
krenko = catalog.by_slug["krenko-mob-boss"]
assert krenko.display_name == "Krenko, Mob Boss"
assert krenko.color_identity == ("R",)
assert krenko.color_identity_key == "R"
assert not krenko.is_colorless
assert krenko.themes == ("Goblin Kindred",)
assert "goblin kindred" in krenko.theme_tokens
assert "version=small" in krenko.image_small_url
assert "exact=Krenko%2C%20Mob%20Boss" in krenko.image_small_url
traxos = catalog.by_slug["traxos-scourge-of-kroog"]
assert traxos.is_colorless
assert traxos.color_identity == ()
assert traxos.color_identity_key == "C"
atraxa = catalog.by_slug["atraxa-praetors-voice"]
assert atraxa.color_identity == ("W", "U", "B", "G")
assert atraxa.color_identity_key == "WUBG"
assert atraxa.is_partner is False
assert atraxa.supports_backgrounds is False
def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
fixture_csv = FIXTURE_DIR / "commander_cards.csv"
work_dir = tmp_path / "csv"
work_dir.mkdir()
target_csv = work_dir / "commander_cards.csv"
target_csv.write_text(fixture_csv.read_text(encoding="utf-8"), encoding="utf-8")
_set_csv_dir(monkeypatch, work_dir)
first = loader.load_commander_catalog()
again = loader.load_commander_catalog()
assert again is first
time.sleep(1.1) # ensure mtime tick on systems with 1s resolution
target_csv.write_text(
fixture_csv.read_text(encoding="utf-8")
+ "\"Zada, Hedron Grinder\",\"Zada, Hedron Grinder\",9999,R,R,{3}{R},4,\"Legendary Creature — Goblin\",\"['Goblin']\",\"Test\",3,3,,\"['Goblin Kindred']\",normal,\n",
encoding="utf-8",
)
updated = loader.load_commander_catalog()
assert updated is not first
assert "zada-hedron-grinder" in updated.by_slug

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
from code.web.services import telemetry
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch):
csv_dir = Path("csv_files/testdata").resolve()
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
clear_commander_catalog_cache()
with TestClient(app) as test_client:
yield test_client
clear_commander_catalog_cache()
def test_commander_page_logs_event(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
events: list[dict] = []
def capture(_logger, payload):
events.append(payload)
monkeypatch.setattr(telemetry, "_emit", capture)
response = client.get("/commanders", params={"q": "atraxa"})
assert response.status_code == 200
assert events, "expected telemetry events to be emitted"
event = events[-1]
assert event["event"] == "commander_browser.page_view"
assert event["page"] == 1
assert event["query"]["q"] == "atraxa"
assert event["is_htmx"] is False
def test_commander_create_deck_logs_event(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
events: list[dict] = []
def capture(_logger, payload):
events.append(payload)
monkeypatch.setattr(telemetry, "_emit", capture)
response = client.get("/build", params={"commander": "Atraxa", "return": "/commanders"})
assert response.status_code == 200
assert events, "expected telemetry events to be emitted"
event = events[-1]
assert event["event"] == "commander_browser.create_deck"
assert event["commander"] == "Atraxa"
assert event["has_return"] is True
assert event["return_url"] == "/commanders"

View file

@ -0,0 +1,137 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
from code.web.routes import commanders
from code.web.services import commander_catalog_loader
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog
@pytest.fixture
def client(monkeypatch):
csv_dir = Path("csv_files/testdata").resolve()
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
clear_commander_catalog_cache()
with TestClient(app) as test_client:
yield test_client
clear_commander_catalog_cache()
def test_commanders_page_renders(client: TestClient) -> None:
response = client.get("/commanders")
assert response.status_code == 200
body = response.text
assert "data-commander-slug=\"atraxa-praetors-voice\"" in body
assert "data-commander-slug=\"krenko-mob-boss\"" in body
assert "data-theme-summary=\"" in body
assert 'id="commander-loading"' in body
def test_commanders_search_filters(client: TestClient) -> None:
response = client.get("/commanders", params={"q": "krenko"})
assert response.status_code == 200
body = response.text
assert "data-commander-slug=\"krenko-mob-boss\"" in body
assert "data-commander-slug=\"atraxa-praetors-voice\"" not in body
def test_commanders_color_filter(client: TestClient) -> None:
response = client.get("/commanders", params={"color": "W"})
assert response.status_code == 200
body = response.text
assert "data-commander-slug=\"isamaru-hound-of-konda\"" in body
assert "data-commander-slug=\"krenko-mob-boss\"" not in body
def test_commanders_htmx_fragment(client: TestClient) -> None:
response = client.get(
"/commanders",
params={"q": "atraxa"},
headers={"HX-Request": "true"},
)
assert response.status_code == 200
body = response.text
assert "commander-row" in body
assert "<section class=\"commander-page\"" not in body
def _install_paginated_catalog(monkeypatch: pytest.MonkeyPatch, total: int) -> None:
base_catalog = load_commander_catalog()
sample = base_catalog.entries[0]
records = []
for index in range(total):
name = f"Pagination Test {index:02d}"
record = replace(
sample,
name=name,
face_name=name,
display_name=name,
slug=f"pagination-test-{index:02d}",
search_haystack=f"{name.lower()}"
)
records.append(record)
fake_catalog = SimpleNamespace(entries=tuple(records))
def loader() -> SimpleNamespace:
return fake_catalog
monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader)
monkeypatch.setattr(commanders, "load_commander_catalog", loader)
def test_commanders_pagination_limits_results(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
_install_paginated_catalog(monkeypatch, total=35)
response = client.get("/commanders")
assert response.status_code == 200
body = response.text
assert "Page 1 of 2" in body
assert "Showing 1&nbsp;&ndash;&nbsp;20 of 35" in body
assert body.count('href="/commanders?page=2"') == 2
assert body.count('data-commander-slug="pagination-test-') == 20
def test_commanders_second_page_shows_remaining_results(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
_install_paginated_catalog(monkeypatch, total=35)
response = client.get("/commanders", params={"page": 2})
assert response.status_code == 200
body = response.text
assert "Page 2 of 2" in body
assert 'data-commander-slug="pagination-test-00"' not in body
assert 'data-commander-slug="pagination-test-20"' in body
assert 'data-commander-slug="pagination-test-34"' in body
assert 'href="/commanders?page=1"' in body
def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
catalog = load_commander_catalog()
sample = catalog.entries[0]
themes = tuple(f"Theme {idx}" for idx in range(1, 9))
enriched = replace(
sample,
themes=themes,
theme_tokens=tuple(theme.lower() for theme in themes),
)
fake_catalog = SimpleNamespace(entries=(enriched,))
def loader() -> SimpleNamespace:
return fake_catalog
monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader)
monkeypatch.setattr(commanders, "load_commander_catalog", loader)
response = client.get("/commanders")
assert response.status_code == 200
body = response.text
assert "commander-theme-chip-more" not in body # no overflow badge rendered
for name in themes:
assert name in body

View file

@ -8,6 +8,18 @@ fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installe
def load_app_with_env(**env: str) -> types.ModuleType:
for key in (
"SHOW_LOGS",
"SHOW_DIAGNOSTICS",
"SHOW_SETUP",
"SHOW_COMMANDERS",
"ENABLE_THEMES",
"ENABLE_PWA",
"ENABLE_PRESETS",
"APP_VERSION",
"THEME",
):
os.environ.pop(key, None)
for k, v in env.items():
os.environ[k] = v
import code.web.app as app_module # type: ignore
@ -67,6 +79,7 @@ def test_status_sys_summary_and_flags():
SHOW_LOGS="1",
SHOW_DIAGNOSTICS="1",
SHOW_SETUP="1",
SHOW_COMMANDERS="1",
ENABLE_THEMES="1",
ENABLE_PWA="1",
ENABLE_PRESETS="1",
@ -84,8 +97,27 @@ def test_status_sys_summary_and_flags():
assert flags.get("SHOW_LOGS") is True
assert flags.get("SHOW_DIAGNOSTICS") is True
assert flags.get("SHOW_SETUP") is True
assert flags.get("SHOW_COMMANDERS") is True
# Theme-related flags
assert flags.get("ENABLE_THEMES") is True
assert flags.get("ENABLE_PWA") is True
assert flags.get("ENABLE_PRESETS") is True
assert flags.get("DEFAULT_THEME") == "dark"
def test_commanders_nav_hidden_when_flag_disabled():
app_module = load_app_with_env(SHOW_COMMANDERS="0")
client = TestClient(app_module.app)
r = client.get("/")
assert r.status_code == 200
body = r.text
assert '<a href="/commanders"' not in body
def test_commanders_nav_visible_by_default():
app_module = load_app_with_env()
client = TestClient(app_module.app)
r = client.get("/")
assert r.status_code == 200
body = r.text
assert '<a href="/commanders"' in body

View file

@ -0,0 +1,61 @@
import os
import importlib
import types
from starlette.testclient import TestClient
def load_app_with_env(**env: str) -> types.ModuleType:
for key in (
"SHOW_LOGS",
"SHOW_DIAGNOSTICS",
"SHOW_SETUP",
"SHOW_COMMANDERS",
"ENABLE_THEMES",
"ENABLE_PWA",
"ENABLE_PRESETS",
"APP_VERSION",
"THEME",
"RANDOM_UI",
):
os.environ.pop(key, None)
for k, v in env.items():
os.environ[k] = v
import code.web.app as app_module # type: ignore
importlib.reload(app_module)
return app_module
def test_home_actions_show_all_enabled_buttons():
app_module = load_app_with_env(
SHOW_LOGS="1",
SHOW_DIAGNOSTICS="1",
SHOW_SETUP="1",
SHOW_COMMANDERS="1",
RANDOM_UI="1",
)
client = TestClient(app_module.app)
response = client.get("/")
body = response.text
assert 'href="/setup"' in body
assert 'href="/commanders"' in body
assert 'href="/random"' in body
assert 'href="/diagnostics"' in body
assert 'href="/logs"' in body
def test_home_actions_hides_disabled_sections():
app_module = load_app_with_env(
SHOW_LOGS="0",
SHOW_DIAGNOSTICS="0",
SHOW_SETUP="0",
SHOW_COMMANDERS="0",
RANDOM_UI="0",
)
client = TestClient(app_module.app)
response = client.get("/")
body = response.text
assert 'href="/setup"' not in body
assert 'href="/commanders"' not in body
assert 'href="/random"' not in body
assert 'href="/diagnostics"' not in body
assert 'href="/logs"' not in body