From fa394c1f7a747767b9933bf02f04c7e04f5b5b72 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Tue, 19 May 2026 00:35:38 +0200 Subject: [PATCH] style(overlays): redesign workshop items section Drops the input-mode radio; the single textarea now accepts any mix of items and collection URLs (backend autodetect landed in 5c56f18). Refresh button moves below the items table into a .table-actions row that also shows an item count + total size summary. Adds .workshop-input mono font rule and a _humanize_bytes helper alongside the overlay_detail view. Plan deviation: PageAssertions has no not_to_contain_text method, so the e2e test scopes those checks to a body locator instead. Caught in review. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/l4d2web/routes/page_routes.py | 16 ++++ l4d2web/l4d2web/static/css/components.css | 5 ++ l4d2web/l4d2web/templates/overlay_detail.html | 30 +++++--- l4d2web/tests/e2e/conftest.py | 39 ++++++++++ l4d2web/tests/e2e/test_workshop_section.py | 75 +++++++++++++++++++ 5 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 l4d2web/tests/e2e/test_workshop_section.py diff --git a/l4d2web/l4d2web/routes/page_routes.py b/l4d2web/l4d2web/routes/page_routes.py index 8437835..646e9e2 100644 --- a/l4d2web/l4d2web/routes/page_routes.py +++ b/l4d2web/l4d2web/routes/page_routes.py @@ -480,6 +480,17 @@ def _build_overlay_build_status_context(db, overlay) -> dict: } +def _humanize_bytes(n: int) -> str: + if n < 1024: + return f"{n} B" + size = float(n) + for unit in ("KB", "MB", "GB"): + size /= 1024 + if size < 1024 or unit == "GB": + return f"{size:.1f} {unit}" + return f"{size:.1f} GB" + + @bp.get("/overlays/") @require_login def overlay_detail(overlay_id: int): @@ -515,11 +526,16 @@ def overlay_detail(overlay_id: int): file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay) + total_bytes = sum((wi.file_size or 0) for wi in workshop_items) + workshop_items_total_size = _humanize_bytes(total_bytes) if total_bytes else "" + return render_template( "overlay_detail.html", overlay=overlay, using_blueprints=using_blueprints, workshop_items=workshop_items, + workshop_items_count=len(workshop_items), + workshop_items_total_size=workshop_items_total_size, file_tree_root_entries=file_tree_root_entries, file_tree_truncated=file_tree_truncated_count > 0 if file_tree_root_entries is not None diff --git a/l4d2web/l4d2web/static/css/components.css b/l4d2web/l4d2web/static/css/components.css index 68d238d..16861c6 100644 --- a/l4d2web/l4d2web/static/css/components.css +++ b/l4d2web/l4d2web/static/css/components.css @@ -1333,3 +1333,8 @@ div.modal.modal-wide { align-items: center; margin-top: var(--space-m); } + +.workshop-input { + font-family: var(--font-mono); + font-size: 0.875rem; +} diff --git a/l4d2web/l4d2web/templates/overlay_detail.html b/l4d2web/l4d2web/templates/overlay_detail.html index 4ffa616..27ee1e7 100644 --- a/l4d2web/l4d2web/templates/overlay_detail.html +++ b/l4d2web/l4d2web/templates/overlay_detail.html @@ -45,17 +45,15 @@ {% if can_edit and not latest_build_is_running %}
-
- Input mode - - -
- - -
-
- - +
+ +

Paste Steam Workshop IDs, item URLs, or collection URLs — one per line. Collections expand automatically.

+ +
+
+ +
{% endif %} @@ -63,6 +61,16 @@ {% include "_overlay_item_table.html" with context %} + {% if can_edit and not latest_build_is_running %} +
+ {% if workshop_items_count %}{{ workshop_items_count }} item{{ "s" if workshop_items_count != 1 else "" }}{% if workshop_items_total_size %} · {{ workshop_items_total_size }} total{% endif %}{% else %}0 items{% endif %} +
+ + +
+
+ {% endif %} + {% include "_overlay_build_status.html" %} {% endif %} diff --git a/l4d2web/tests/e2e/conftest.py b/l4d2web/tests/e2e/conftest.py index 1fd1195..e0b95ac 100644 --- a/l4d2web/tests/e2e/conftest.py +++ b/l4d2web/tests/e2e/conftest.py @@ -170,6 +170,45 @@ def files_overlay_server(tmp_path, monkeypatch): shutdown() +@pytest.fixture(scope="function") +def workshop_overlay_server(tmp_path, monkeypatch): + """live_server + a workshop-type Overlay owned by alice. The overlay + starts with zero items — tests that need items should seed them via + direct DB writes or via UI actions inside the test.""" + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + app = _boot_app(tmp_path, monkeypatch) + + with session_scope() as session: + user = User( + username="alice", + password_digest=hash_password("secret"), + admin=False, + ) + session.add(user) + session.flush() + overlay = Overlay( + name="my-maps", + path="_pending", + type="workshop", + user_id=user.id, + ) + session.add(overlay) + session.flush() + overlay.path = str(overlay.id) + user_id = user.id + overlay_id = overlay.id + + base_url, shutdown = _serve(app) + try: + yield { + "base_url": base_url, + "user_id": user_id, + "overlay_id": overlay_id, + } + finally: + shutdown() + + @pytest.fixture(scope="function") def server_with_files(tmp_path, monkeypatch): """live_server + a Server owned by alice with a populated runtime diff --git a/l4d2web/tests/e2e/test_workshop_section.py b/l4d2web/tests/e2e/test_workshop_section.py new file mode 100644 index 0000000..77de7bd --- /dev/null +++ b/l4d2web/tests/e2e/test_workshop_section.py @@ -0,0 +1,75 @@ +"""E2E test for the redesigned workshop items section.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from playwright.sync_api import Page, expect + +from l4d2web.services import steam_workshop + +from .conftest import login + +pytestmark = pytest.mark.e2e + + +def _meta(steam_id: str) -> steam_workshop.WorkshopMetadata: + return steam_workshop.WorkshopMetadata( + steam_id=steam_id, + title=f"Item {steam_id}", + filename=f"{steam_id}.vpk", + file_url=f"https://example.com/{steam_id}.vpk", + file_size=1024, + time_updated=1700000000, + preview_url="", + consumer_app_id=550, + result=1, + ) + + +def test_workshop_section_renders_without_input_mode_radio(page: Page, workshop_overlay_server) -> None: + base_url = workshop_overlay_server["base_url"] + overlay_id = workshop_overlay_server["overlay_id"] + login(page, base_url) + page.goto(f"{base_url}/overlays/{overlay_id}") + + # Legacy input-mode fieldset is gone. + expect(page.locator("fieldset.workshop-input-mode")).to_have_count(0) + body = page.locator("body") + expect(body).not_to_contain_text("Input mode") + expect(body).not_to_contain_text("Items (paste IDs or URLs; one or many)") + expect(body).not_to_contain_text("Collection (one ID or URL)") + + # Single textarea + Add button. + expect(page.locator('textarea[name="input"]')).to_be_visible() + expect(page.locator('button:has-text("Add")')).to_be_visible() + + # Refresh button is below the items table. + table = page.locator("#overlay-item-table") + refresh = page.locator('button:has-text("Refresh from Steam")') + expect(table).to_be_visible() + expect(refresh).to_be_visible() + table_y = table.bounding_box()["y"] + refresh_y = refresh.bounding_box()["y"] + assert table_y < refresh_y, "Refresh button should sit below the items table" + + +def test_workshop_autodetect_expands_collection_url(page: Page, workshop_overlay_server) -> None: + """Pasting a collection URL into the textarea (no mode toggle) expands to + children server-side. Verified by patching the Steam helpers.""" + base_url = workshop_overlay_server["base_url"] + overlay_id = workshop_overlay_server["overlay_id"] + login(page, base_url) + + with patch.object( + steam_workshop, "expand_collections", return_value=["1001", "1002"] + ), patch.object( + steam_workshop, "fetch_metadata_batch", return_value=[_meta("1001"), _meta("1002")] + ): + page.goto(f"{base_url}/overlays/{overlay_id}") + page.locator('textarea[name="input"]').fill( + "https://steamcommunity.com/sharedfiles/filedetails/?id=555" + ) + page.locator('button:has-text("Add")').click() + # Handler redirects to /jobs/. + page.wait_for_url("**/jobs/*", timeout=5000)