The picker's paging state outlives a reopen — navigate, don't assume
Sleeping Gods and Gloomhaven "vanished on the second pass" because the second pass began wherever the first ended: closing and reopening the version sub-view does NOT reset it to page 1 (Angular keeps the scope), so the rescan started mid-list and never revisited the earlier pages holding the row. Verified against the live picker. The second pass now clicks the visible numbered "1" anchor first — and so does the initial scan, since paging state can outlive anything. The reopen is gone entirely. Docs record both this and the has_text whitespace trap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
6f2f2dac53
commit
6e72ef22d1
@@ -15,3 +15,8 @@ add,419687,,Munchkin Big Box,711400,,added,2026-08-06T02:45:16+00:00,
|
|||||||
add,252153,,Tang Garden,404702,,added,2026-08-06T02:45:23+00:00,
|
add,252153,,Tang Garden,404702,,added,2026-08-06T02:45:23+00:00,
|
||||||
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:45:58+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""li:has(.summary-item-thumbnail)"").filter(has_text=re.compile(r""Sleeping\ Gods\ \(English\ Gamefound\ edition\)\ \(2023\)"", re.IGNORECASE)).first"
|
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:45:58+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""li:has(.summary-item-thumbnail)"").filter(has_text=re.compile(r""Sleeping\ Gods\ \(English\ Gamefound\ edition\)\ \(2023\)"", re.IGNORECASE)).first"
|
||||||
add,283766,,Sleeping Gods: Tides of Ruin,463140,,added,2026-08-06T02:46:07+00:00,
|
add,283766,,Sleeping Gods: Tides of Ruin,463140,,added,2026-08-06T02:46:07+00:00,
|
||||||
|
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:49:44+00:00,RuntimeError: 'Sleeping Gods (English Gamefound edition) (2023)' was listed a moment ago but vanished on the second pass — retryable
|
||||||
|
add,280794,,Etherfields,458300,,added,2026-08-06T02:49:53+00:00,
|
||||||
|
add,380837,,Botany,650545,,added,2026-08-06T02:50:00+00:00,
|
||||||
|
add,393672,,Gloomhaven: Buttons & Bugs,669782,,failed,2026-08-06T02:50:07+00:00,RuntimeError: 'Gloomhaven: Buttons & Bugs (English edition) (2024)' was listed a moment ago but vanished on the second pass — retryable
|
||||||
|
add,14535,,SPANC: Space Pirate Amazon Ninja Catgirls,29075,,added,2026-08-06T02:50:15+00:00,
|
||||||
|
|||||||
|
@@ -128,9 +128,15 @@ earlier walkthrough had marked *verified*, and the parts marked
|
|||||||
inside `<li class="visible-xs-*">`. A selector matches both, and
|
inside `<li class="visible-xs-*">`. A selector matches both, and
|
||||||
`.first` may be the hidden one — Playwright then waits for it to become
|
`.first` may be the hidden one — Playwright then waits for it to become
|
||||||
visible until it times out. Always click the first *visible* match.
|
visible until it times out. Always click the first *visible* match.
|
||||||
First/Prev exist ONLY in the mobile set, so they are unclickable on a
|
First/Prev may be unclickable on a desktop viewport (mobile-only
|
||||||
desktop viewport: to return to page 1, close and reopen the sub-view
|
variant). To return to page 1, click the visible numbered **"1"**
|
||||||
(it always opens on page 1) and step forward with Next.
|
anchor: the sub-view's paging state SURVIVES closing and reopening it
|
||||||
|
(Angular keeps the scope), so a reopen lands wherever it was left, not
|
||||||
|
on page 1.
|
||||||
|
- **Match row text in Python, not with `has_text`.** Playwright's
|
||||||
|
`has_text` regex tests raw `textContent`, which carries the markup's
|
||||||
|
tabs and newlines; a whitespace-normalized capture will never equal it.
|
||||||
|
Normalize both sides yourself and click the row by index.
|
||||||
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
|
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
|
||||||
name is localized (a Czech edition's row starts "Spící bohové"). Match
|
name is localized (a Czech edition's row starts "Spící bohové"). Match
|
||||||
the version name inside its parentheses.
|
the version name inside its parentheses.
|
||||||
|
|||||||
+24
-11
@@ -382,6 +382,7 @@ class PlaywrightUploader:
|
|||||||
# visible-xs-* — so every one of these selectors matches hidden nodes
|
# visible-xs-* — so every one of these selectors matches hidden nodes
|
||||||
# too; clicking one waits forever. Always pick the visible match.
|
# too; clicking one waits forever. Always pick the visible match.
|
||||||
_NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
|
_NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
|
||||||
|
_PAGE_LINKS = "ul.pagination a"
|
||||||
# Rows read "<game name> (<version name>) (<year>)", so the version name
|
# Rows read "<game name> (<version name>) (<year>)", so the version name
|
||||||
# is matched inside its parentheses — bare substrings would let
|
# is matched inside its parentheses — bare substrings would let
|
||||||
# "English edition" match "English edition, second printing".
|
# "English edition" match "English edition, second printing".
|
||||||
@@ -428,6 +429,8 @@ class PlaywrightUploader:
|
|||||||
# BGG's API name can carry a printing qualifier the picker omits
|
# BGG's API name can carry a printing qualifier the picker omits
|
||||||
# ("English edition 2018-2" vs "(English edition) (2018)")
|
# ("English edition 2018-2" vs "(English edition) (2018)")
|
||||||
relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold()
|
relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold()
|
||||||
|
self._goto_first_page(dialog) # a fresh sub-view opens on page 1,
|
||||||
|
# but never assume it: paging state outlives a close/reopen
|
||||||
exact: list[tuple[int, str]] = []
|
exact: list[tuple[int, str]] = []
|
||||||
loose: list[tuple[int, str]] = []
|
loose: list[tuple[int, str]] = []
|
||||||
pages = 0
|
pages = 0
|
||||||
@@ -472,18 +475,28 @@ class PlaywrightUploader:
|
|||||||
f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess"
|
f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _click_row(self, dialog, text: str) -> bool:
|
def _goto_first_page(self, dialog) -> None:
|
||||||
"""Reopen the sub-view (it always starts on page 1) and hunt for the
|
"""Back to page 1 via the numbered "1" anchor. Closing and
|
||||||
row again by its NORMALIZED text, clicking by index.
|
reopening the sub-view does NOT reset Angular's paging state (it
|
||||||
|
reopens wherever it was left), and First/Prev render in a
|
||||||
|
mobile-only variant that a desktop viewport can never click."""
|
||||||
|
pager = dialog.locator(self._PAGE_LINKS)
|
||||||
|
for i in range(pager.count()):
|
||||||
|
anchor = pager.nth(i)
|
||||||
|
if anchor.is_visible() and (
|
||||||
|
" ".join((anchor.text_content() or "").split()) == "1"
|
||||||
|
):
|
||||||
|
anchor.click()
|
||||||
|
self._page.wait_for_timeout(400)
|
||||||
|
return
|
||||||
|
# no pagination rendered: a single-page list is already page 1
|
||||||
|
|
||||||
Two traps this avoids: First/Prev exist only in the mobile variant
|
def _click_row(self, dialog, text: str) -> bool:
|
||||||
(unclickable on a desktop viewport), and Playwright's has_text
|
"""Find the row again by its NORMALIZED text and click it by index.
|
||||||
regex matches raw textContent — whose tabs and newlines a
|
Playwright's has_text regex matches raw textContent — whose tabs and
|
||||||
whitespace-normalized capture will never equal."""
|
newlines a normalized capture will never equal — so the comparison
|
||||||
dialog.get_by_role("button", name="Cancel").first.click()
|
happens in Python, and the click addresses a position."""
|
||||||
self._page.wait_for_timeout(300)
|
self._goto_first_page(dialog)
|
||||||
dialog.get_by_role("button", name="Set version/edition").click()
|
|
||||||
dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
|
|
||||||
for _ in range(MAX_VERSION_PAGES):
|
for _ in range(MAX_VERSION_PAGES):
|
||||||
for i, row_text in enumerate(self._rows_on_page(dialog)):
|
for i, row_text in enumerate(self._rows_on_page(dialog)):
|
||||||
if row_text == text:
|
if row_text == text:
|
||||||
|
|||||||
+41
-18
@@ -551,8 +551,9 @@ def test_undeterminable_state_raises_instead_of_guessing(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
class _FakePicker:
|
class _FakePicker:
|
||||||
"""Minimal stand-in for the picker sub-view: pages of row texts, a
|
"""Stand-in for the picker sub-view. Models the two traps the real one
|
||||||
Next anchor that disables on the last page."""
|
sets: paging controls render twice (a hidden mobile duplicate first),
|
||||||
|
and paging state SURVIVES closing and reopening the sub-view."""
|
||||||
|
|
||||||
def __init__(self, pages):
|
def __init__(self, pages):
|
||||||
self.pages = pages
|
self.pages = pages
|
||||||
@@ -562,9 +563,12 @@ class _FakePicker:
|
|||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
self.reopened = 0
|
self.reopened = 0
|
||||||
|
|
||||||
# -- locator plumbing the uploader uses ---------------------------
|
|
||||||
def locator(self, selector):
|
def locator(self, selector):
|
||||||
picker = self
|
picker = self
|
||||||
|
is_next = 'title="Next Page"' in selector
|
||||||
|
is_pager = "pagination" in selector
|
||||||
|
# anchors: [hidden mobile "1", "1", "2", ...] mirroring the real DOM
|
||||||
|
anchors = ["1"] + [str(n + 1) for n in range(len(picker.pages))]
|
||||||
|
|
||||||
class _Loc:
|
class _Loc:
|
||||||
def __init__(self, index=0):
|
def __init__(self, index=0):
|
||||||
@@ -574,22 +578,25 @@ class _FakePicker:
|
|||||||
return picker.pages[picker.page]
|
return picker.pages[picker.page]
|
||||||
|
|
||||||
def count(self):
|
def count(self):
|
||||||
if "pagination" in selector:
|
if is_next:
|
||||||
return 2 # BGG renders a mobile AND a desktop control
|
return 2 # hidden mobile + visible desktop
|
||||||
|
if is_pager:
|
||||||
|
return len(anchors)
|
||||||
return len(picker.pages[picker.page])
|
return len(picker.pages[picker.page])
|
||||||
|
|
||||||
def nth(self, i):
|
def nth(self, i):
|
||||||
return _Loc(i)
|
return _Loc(i)
|
||||||
|
|
||||||
def is_visible(self):
|
|
||||||
# among PAGING controls, index 0 is the mobile
|
|
||||||
# (visible-xs-*) duplicate — present but not clickable
|
|
||||||
return "pagination" not in selector or self.index != 0
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def first(self):
|
def first(self):
|
||||||
return _Loc(0)
|
return _Loc(0)
|
||||||
|
|
||||||
|
def is_visible(self):
|
||||||
|
return not (is_pager and self.index == 0)
|
||||||
|
|
||||||
|
def text_content(self):
|
||||||
|
return anchors[self.index] if is_pager and not is_next else ""
|
||||||
|
|
||||||
def wait_for(self, **kw):
|
def wait_for(self, **kw):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -598,16 +605,17 @@ class _FakePicker:
|
|||||||
return "ng-scope disabled" if last else "ng-scope"
|
return "ng-scope disabled" if last else "ng-scope"
|
||||||
|
|
||||||
def click(self):
|
def click(self):
|
||||||
if "pagination" in selector and not self.is_visible():
|
if is_pager and not self.is_visible():
|
||||||
raise AssertionError("clicked a HIDDEN paging control")
|
raise AssertionError("clicked a HIDDEN paging control")
|
||||||
if "Next Page" in selector:
|
if is_next:
|
||||||
picker.page += 1
|
picker.page += 1
|
||||||
elif "summary-item-thumbnail" in selector:
|
elif is_pager:
|
||||||
|
picker.page = int(anchors[self.index]) - 1
|
||||||
|
else:
|
||||||
picker.clicked_index = self.index
|
picker.clicked_index = self.index
|
||||||
picker.clicked = picker.pages[picker.page][self.index]
|
picker.clicked = picker.pages[picker.page][self.index]
|
||||||
|
|
||||||
def filter(self, has_text=None):
|
def filter(self, has_text=None):
|
||||||
picker.clicked = has_text.pattern if has_text else None
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
return _Loc()
|
return _Loc()
|
||||||
@@ -624,8 +632,7 @@ class _FakePicker:
|
|||||||
if name == "Cancel":
|
if name == "Cancel":
|
||||||
picker.cancelled = True
|
picker.cancelled = True
|
||||||
elif name == "Set version/edition":
|
elif name == "Set version/edition":
|
||||||
picker.page = 0 # the sub-view reopens on page 1
|
picker.reopened += 1 # note: does NOT reset the page
|
||||||
picker.reopened += 1
|
|
||||||
|
|
||||||
return _Btn()
|
return _Btn()
|
||||||
|
|
||||||
@@ -697,8 +704,7 @@ def test_paging_never_clicks_a_hidden_mobile_control():
|
|||||||
)
|
)
|
||||||
picked, _ = up._select_version(dialog, "English edition 2009")
|
picked, _ = up._select_version(dialog, "English edition 2009")
|
||||||
assert picked is True # no AssertionError from the fake = no hidden click
|
assert picked is True # no AssertionError from the fake = no hidden click
|
||||||
# the initial open plus ONE reopen — never a "First Page" click
|
assert dialog.clicked_index == 0 # found on the later page, clicked
|
||||||
assert dialog.reopened == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_outstanding_failures_ignores_retried_jobs():
|
def test_outstanding_failures_ignores_retried_jobs():
|
||||||
@@ -731,3 +737,20 @@ def test_outstanding_failures_ignores_retried_jobs():
|
|||||||
]
|
]
|
||||||
assert outstanding_failures(rows) == 1 # game 1 was retried and landed
|
assert outstanding_failures(rows) == 1 # game 1 was retried and landed
|
||||||
assert outstanding_failures([]) == 0
|
assert outstanding_failures([]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_paging_state_survives_reopen_so_page_one_is_clicked():
|
||||||
|
"""The real sub-view reopens wherever it was left, so the second pass
|
||||||
|
must navigate back to page 1 explicitly — Sleeping Gods and Gloomhaven
|
||||||
|
both "vanished" when it didn't."""
|
||||||
|
up, dialog = _picker_uploader(
|
||||||
|
[
|
||||||
|
["Sleeping Gods (English Gamefound edition) (2023)"],
|
||||||
|
["Sleeping Gods (German edition) (2022)"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
dialog.page = 1 # left on the last page by a previous scan
|
||||||
|
picked, why = up._select_version(dialog, "English Gamefound edition")
|
||||||
|
assert picked is True
|
||||||
|
assert why == ""
|
||||||
|
assert dialog.clicked.startswith("Sleeping Gods (English Gamefound")
|
||||||
|
|||||||
Reference in New Issue
Block a user