diff --git a/data/upload_log.csv b/data/upload_log.csv index 21a4138..89fc117 100644 --- a/data/upload_log.csv +++ b/data/upload_log.csv @@ -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,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,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, diff --git a/docs/bgg-upload-flow.md b/docs/bgg-upload-flow.md index 35a4ac5..096ef46 100644 --- a/docs/bgg-upload-flow.md +++ b/docs/bgg-upload-flow.md @@ -128,9 +128,15 @@ earlier walkthrough had marked *verified*, and the parts marked inside `
  • `. A selector matches both, and `.first` may be the hidden one — Playwright then waits for it to become 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 - desktop viewport: to return to page 1, close and reopen the sub-view - (it always opens on page 1) and step forward with Next. + First/Prev may be unclickable on a desktop viewport (mobile-only + variant). To return to page 1, click the visible numbered **"1"** + 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 ` () ()`**, and the game name is localized (a Czech edition's row starts "Spící bohové"). Match the version name inside its parentheses. diff --git a/src/bggpipe/upload.py b/src/bggpipe/upload.py index 02bdc73..092d018 100644 --- a/src/bggpipe/upload.py +++ b/src/bggpipe/upload.py @@ -382,6 +382,7 @@ class PlaywrightUploader: # 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"]' + _PAGE_LINKS = "ul.pagination a" # Rows read " () ()", so the version name # is matched inside its parentheses — bare substrings would let # "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 # ("English edition 2018-2" vs "(English edition) (2018)") 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]] = [] loose: list[tuple[int, str]] = [] pages = 0 @@ -472,18 +475,28 @@ class PlaywrightUploader: f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess" ) - def _click_row(self, dialog, text: str) -> bool: - """Reopen the sub-view (it always starts on page 1) and hunt for the - row again by its NORMALIZED text, clicking by index. + def _goto_first_page(self, dialog) -> None: + """Back to page 1 via the numbered "1" anchor. Closing and + 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 - (unclickable on a desktop viewport), and Playwright's has_text - regex matches raw textContent — whose tabs and newlines a - whitespace-normalized capture will never equal.""" - 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) + def _click_row(self, dialog, text: str) -> bool: + """Find the row again by its NORMALIZED text and click it by index. + Playwright's has_text regex matches raw textContent — whose tabs and + newlines a normalized capture will never equal — so the comparison + happens in Python, and the click addresses a position.""" + self._goto_first_page(dialog) for _ in range(MAX_VERSION_PAGES): for i, row_text in enumerate(self._rows_on_page(dialog)): if row_text == text: diff --git a/tests/test_upload.py b/tests/test_upload.py index d256ef8..98615ea 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -551,8 +551,9 @@ def test_undeterminable_state_raises_instead_of_guessing(monkeypatch): class _FakePicker: - """Minimal stand-in for the picker sub-view: pages of row texts, a - Next anchor that disables on the last page.""" + """Stand-in for the picker sub-view. Models the two traps the real one + sets: paging controls render twice (a hidden mobile duplicate first), + and paging state SURVIVES closing and reopening the sub-view.""" def __init__(self, pages): self.pages = pages @@ -562,9 +563,12 @@ class _FakePicker: self.cancelled = False self.reopened = 0 - # -- locator plumbing the uploader uses --------------------------- def locator(self, selector): 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: def __init__(self, index=0): @@ -574,22 +578,25 @@ class _FakePicker: return picker.pages[picker.page] def count(self): - if "pagination" in selector: - return 2 # BGG renders a mobile AND a desktop control + if is_next: + return 2 # hidden mobile + visible desktop + if is_pager: + return len(anchors) return len(picker.pages[picker.page]) def nth(self, 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 def first(self): 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): return None @@ -598,16 +605,17 @@ class _FakePicker: return "ng-scope disabled" if last else "ng-scope" 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") - if "Next Page" in selector: + if is_next: 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 = picker.pages[picker.page][self.index] def filter(self, has_text=None): - picker.clicked = has_text.pattern if has_text else None return self return _Loc() @@ -624,8 +632,7 @@ class _FakePicker: if name == "Cancel": picker.cancelled = True elif name == "Set version/edition": - picker.page = 0 # the sub-view reopens on page 1 - picker.reopened += 1 + picker.reopened += 1 # note: does NOT reset the page return _Btn() @@ -697,8 +704,7 @@ def test_paging_never_clicks_a_hidden_mobile_control(): ) picked, _ = up._select_version(dialog, "English edition 2009") 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.reopened == 2 + assert dialog.clicked_index == 0 # found on the later page, clicked 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([]) == 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")