Paging clicks only visible controls: First/Prev are mobile-only

Munchkin Big Box and Tang Garden hung 30s each on a "First Page" anchor
that exists but is invisible: BGG renders every paging control twice,
and the First/Prev pair lives only in the mobile set
(<li class="visible-xs-inline">). A desktop viewport can never click it.

Paging now selects the first VISIBLE match, and returning to page 1
closes and reopens the sub-view (which always opens on page 1) instead
of reaching for a control that isn't there. A test proves no hidden
control is ever clicked — the fake picker raises if one is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 22:41:42 -04:00
co-authored by Claude Fable 5
parent ba55863cef
commit 9283a3e980
4 changed files with 71 additions and 25 deletions
+24 -14
View File
@@ -368,8 +368,10 @@ class PlaywrightUploader:
# an AngularJS <ul class="pagination"> of anchors — NOT buttons named
# "next", which is why the old guess quit after page one.
_VERSION_ROWS = "li:has(.summary-item-thumbnail)"
# Paging controls render TWICE — a desktop set and a mobile set marked
# visible-xs-* — so every one of these selectors matches hidden nodes
# too; clicking one waits forever. Always pick the visible match.
_NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
_FIRST_PAGE = 'ul.pagination a[title="First Page"]'
# Rows read "<game name> (<version name>) (<year>)", so the version name
# is matched inside its parentheses — bare substrings would let
# "English edition" match "English edition, second printing".
@@ -381,13 +383,20 @@ class PlaywrightUploader:
for t in dialog.locator(self._VERSION_ROWS).all_text_contents()
]
def _visible(self, dialog, selector):
"""The first VISIBLE match, or None — see _NEXT_PAGE."""
loc = dialog.locator(selector)
for i in range(loc.count()):
candidate = loc.nth(i)
if candidate.is_visible():
return candidate
return None
def _has_next_page(self, dialog) -> bool:
nxt = dialog.locator(self._NEXT_PAGE)
if nxt.count() == 0:
nxt = self._visible(dialog, self._NEXT_PAGE)
if nxt is None:
return False # single-page list: the container is ng-show'd off
return "disabled" not in (
nxt.first.evaluate("e => e.closest('li').className") or ""
)
return "disabled" not in (nxt.evaluate("e => e.closest('li').className") or "")
def _select_version(self, dialog, version_name: str) -> tuple[bool, str]:
"""Pick the version whose name matches, paging the whole list first
@@ -422,7 +431,7 @@ class PlaywrightUploader:
loose.append((page_ix, text))
if not self._has_next_page(dialog):
break
dialog.locator(self._NEXT_PAGE).first.click()
self._visible(dialog, self._NEXT_PAGE).click()
self._page.wait_for_timeout(400) # client-side paging: no request
else:
# never saw the end of the list: "not in picker" would be a false
@@ -453,14 +462,15 @@ class PlaywrightUploader:
)
def _goto_picker_page(self, dialog, page_ix: int) -> None:
"""Back to page 1, then forward — the numbered anchors render twice
(mobile + desktop variants), so stepping is the unambiguous route."""
first = dialog.locator(self._FIRST_PAGE)
if first.count():
first.first.click()
self._page.wait_for_timeout(400)
"""Reopen the sub-view (it always starts on page 1) and step
forward. The First/Prev anchors exist only in the mobile variant,
so they can never be clicked on a desktop viewport."""
dialog.get_by_role("button", name="Cancel").first.click()
self._page.wait_for_timeout(300)
dialog.get_by_role("button", name="Set version/edition").click()
dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
for _ in range(page_ix):
dialog.locator(self._NEXT_PAGE).first.click()
self._visible(dialog, self._NEXT_PAGE).click()
self._page.wait_for_timeout(400)
def add_game(self, job: UploadJob) -> tuple[str, str]: