Files
timetracker/e2e/test_widgets_e2e.py
T
lukas 82416e149d Convert onSwap widgets to custom elements (issue #18)
Replaces the four onSwap-based widgets with TypeScript custom elements
following the pattern from PR #16. Each widget gets a class extending
HTMLElement with connectedCallback/disconnectedCallback, typed props via
register_element + gen_element_types codegen, and lives in ts/elements/.

- range-slider: RangeSliderElement; Python uses _RangeSlider builder
- date-range-picker: DateRangePickerElement; Python uses _DateRangePicker builder
- search-select: SearchSelectElement; Python uses _SearchSelect builder;
  data-* attrs become plain attrs (data-name -> name, data-search-url -> search-url, etc.)
- filter-bar: FilterBarElement; props carry preset URLs; onclick/onsubmit
  attrs replaced with data-filter-bar-* sentinel attrs; all window.* globals removed

Deletes ts/range_slider.ts, ts/search_select.ts, ts/date_range_picker.ts,
ts/filter_bar.ts. Updates all tests and e2e pages to use the new element
selectors and script paths (dist/elements/<tag>.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:22:59 +02:00

245 lines
9.7 KiB
Python

"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
add_purchase.js) and their onSwap() initialization lifecycle.
These run a real Chromium via pytest-playwright against pytest-django's
``live_server``. All JavaScript under test is served locally from
``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
vendored), so no network access is needed beyond the live server itself.
Browser binaries must be installed once: ``uv run playwright install chromium``.
"""
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def open_filter_bar(page: Page) -> None:
page.click("#filter-bar button:has-text('Filters')")
expect(page.locator("#filter-bar-body")).to_be_visible()
def status_filter_widget(page: Page):
return page.locator('search-select[name="status"]')
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
"""Clicking into a FilterSelect search box opens its options panel —
proof that onSwap ran the widget initializer on the initial page load."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
options_panel = widget.locator("[data-search-select-options]")
expect(options_panel).to_be_visible()
# The pinned "(Any)" modifier pseudo-option is rendered server-side and
# only becomes interactable through the initialized panel.
expect(
options_panel.locator("[data-search-select-modifier-option]").first
).to_have_text("(Any)")
def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
"""Clicking an enum option row adds an include pill (full widget wiring)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
widget.locator('[data-search-select-option][data-label="Finished"]').click()
pill = widget.locator("[data-search-select-pills] [data-pill]")
expect(pill).to_have_count(1)
expect(pill).to_contain_text("Finished")
def test_range_slider_mode_toggle_fires_exactly_once(
authenticated_page: Page, live_server
):
"""One click on the mode toggle flips the slider from range to point mode
exactly once. Double-bound listeners (the old force-re-init bug) would
flip it twice, leaving data-mode unchanged."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
slider = page.locator("range-slider").first
expect(slider).to_have_attribute("mode", "range")
slider.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("mode", "point")
def test_widgets_initialize_inside_htmx_swapped_content(
authenticated_page: Page, live_server
):
"""Widgets arriving via an htmx swap initialize without a page load.
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
swapped-in slider must toggle exactly once, proving the htmx:load half of
onSwap and the once-per-element guard."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
page.evaluate(
"htmx.ajax('GET', window.location.pathname, "
"{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
)
# The swapped-in bar arrives collapsed again; opening it proves the swap
# happened and the fresh DOM is in place.
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
expect(widget.locator("[data-search-select-options]")).to_be_visible()
slider = page.locator("range-slider").first
expect(slider).to_have_attribute("mode", "range")
slider.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("mode", "point")
def test_add_purchase_type_toggles_disabled_fields(
authenticated_page: Page, live_server
):
"""add_purchase.js disables name/related-game while type is "game"
and re-enables them for other types."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
name_input = page.locator("#id_name")
expect(name_input).to_be_disabled()
# The Name field (a plain input) self-styles its disabled state via the
# INPUT_CLASS disabled: variants — not a global rule. not-allowed is
# mode-independent, so it holds in light and dark.
assert name_input.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
page.select_option("#id_type", "dlc")
expect(name_input).to_be_enabled()
assert name_input.evaluate("el => getComputedStyle(el).cursor") != "not-allowed"
page.select_option("#id_type", "game")
expect(name_input).to_be_disabled()
def test_add_purchase_related_game_is_flat_game_search(
authenticated_page: Page, live_server
):
"""The DLC/Season-Pass anchor is now a flat game search (related_game),
wired to the games search API and present regardless of which games are
selected — not the old parent-purchase dropdown filtered by chosen games."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
related = page.locator('search-select[name="related_game"]')
expect(related).to_have_count(1)
expect(related).to_have_attribute("search-url", "/api/games/search")
def test_searchselect_border_matches_native_input(
authenticated_page: Page, live_server
):
"""A SearchSelect's wrapper has the same border as a native input, and turns
brand on focus (via focus-within on the wrapper, since the inner search box
is what's focused)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
price = page.locator("#id_price") # always-enabled native input
wrapper = page.locator("#id_platform")
search = page.locator("#id_platform [data-search-select-search]")
border = "el => getComputedStyle(el).borderColor"
rest = price.evaluate(border)
assert wrapper.evaluate(border) == rest # same border at rest
search.focus()
focused_wrapper = wrapper.evaluate(border)
price.focus()
focused_input = price.evaluate(border)
assert focused_wrapper == focused_input # same brand border on focus
assert focused_wrapper != rest # focus actually changes it
def test_add_game_syncs_sort_name_from_name(authenticated_page: Page, live_server):
"""Typing into Name live-fills Sort name (sync bound to the add form, not
the navbar logout form which is the first <form> on the page)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_game')}")
page.locator("#id_name").click()
page.locator("#id_name").type("Halo")
expect(page.locator("#id_sort_name")).to_have_value("Halo")
def test_add_purchase_type_game_disables_related_game_search(
authenticated_page: Page, live_server
):
"""When Type is 'game', the related-game SearchSelect is disabled — the
real disable target is the inner search input, not the wrapper <div>
(a <div> ignores the disabled property)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
wrapper = page.locator("#id_related_game")
search = page.locator("#id_related_game [data-search-select-search]")
name = page.locator("#id_name")
opacity = "el => getComputedStyle(el).opacity"
bg = "el => getComputedStyle(el).backgroundColor"
page.select_option("#id_type", "game")
expect(search).to_be_disabled()
# A disabled SearchSelect must look identical to a disabled native input:
# both fade (opacity-50) over the same surface.
assert wrapper.evaluate(opacity) == "0.5"
assert name.evaluate(opacity) == "0.5"
assert wrapper.evaluate(bg) == name.evaluate(bg)
# The inner input stays transparent (no nested box) with the same not-allowed
# cursor (no flicker across the widget).
assert search.evaluate(bg) == "rgba(0, 0, 0, 0)"
assert search.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
page.select_option("#id_type", "dlc")
expect(search).to_be_enabled()
# Enabled, both return to full opacity.
assert wrapper.evaluate(opacity) == "1"
assert name.evaluate(opacity) == "1"
def test_add_game_sync_stops_once_sort_name_edited(
authenticated_page: Page, live_server
):
"""Name → Sort name mirrors live, but stops the moment the user edits Sort
name directly (the 'UntilChanged' contract). Editing Name afterwards must
not clobber the user's manual Sort name."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_game')}")
name = page.locator("#id_name")
sort = page.locator("#id_sort_name")
name.click()
name.type("Halo")
expect(sort).to_have_value("Halo") # live mirror before any manual edit
sort.fill("Custom Sort") # user takes over the target → sync drops
expect(sort).to_have_value("Custom Sort")
name.click()
name.press("End")
name.type(" 2")
expect(name).to_have_value("Halo 2")
expect(sort).to_have_value("Custom Sort") # not clobbered