Row clicks survive whitespace; the failure badge stops inflating

Sleeping Gods failed where three siblings passed: the second-pass row
click matched captured text with Playwright's has_text regex, which
tests raw textContent — tabs and newlines included — against a capture
that was whitespace-normalized. Rows whose markup happened to be tidy
matched; that one didn't. The picker now re-finds the row by NORMALIZED
text and clicks it by index, which also survives the list re-rendering
in a different order between openings.

And the UI's failure count only ever grew: upload_log.csv is an
append-only audit trail, so a retry that succeeds leaves its old
'failed' line in place. outstanding_failures() counts the LAST status
per job key — the same rule _plan_jobs already uses to decide what to
skip — so a landed retry clears the badge. On Eric's log: 6 'failed'
rows, 1 job actually outstanding.

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:49:02 -04:00
co-authored by Claude Fable 5
parent e49d1234d6
commit 6f2f2dac53
4 changed files with 81 additions and 13 deletions
+32 -10
View File
@@ -89,6 +89,16 @@ def _job_key(row: dict) -> tuple[str, str, str]:
return _key(row["action"], row["bgg_id"], row["collid"], row["version_id"])
def outstanding_failures(log_rows: list[dict]) -> int:
"""Jobs whose LATEST attempt failed. upload_log.csv is an append-only
audit trail, so counting every 'failed' row ever written would keep
growing after a successful retry."""
last: dict[tuple[str, str, str], str] = {}
for row in log_rows:
last[_job_key(row)] = row["status"]
return sum(1 for status in last.values() if status == "failed")
def _read_csv(path: Path) -> list[dict]:
if not path.exists():
return []
@@ -443,11 +453,12 @@ class PlaywrightUploader:
hits = exact or loose
if len(hits) == 1:
page_ix, text = hits[0]
self._goto_picker_page(dialog, page_ix)
dialog.locator(self._VERSION_ROWS).filter(
has_text=re.compile(re.escape(text), re.I)
).first.click()
_, text = hits[0]
if not self._click_row(dialog, text):
raise RuntimeError(
f"{text!r} was listed a moment ago but vanished on the "
"second pass — retryable"
)
how = "" if exact else f" (matched loosely as {text!r})"
return True, how
@@ -461,17 +472,28 @@ class PlaywrightUploader:
f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess"
)
def _goto_picker_page(self, dialog, page_ix: int) -> None:
"""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."""
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.
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)
for _ in range(page_ix):
for _ in range(MAX_VERSION_PAGES):
for i, row_text in enumerate(self._rows_on_page(dialog)):
if row_text == text:
dialog.locator(self._VERSION_ROWS).nth(i).click()
return True
if not self._has_next_page(dialog):
return False
self._visible(dialog, self._NEXT_PAGE).click()
self._page.wait_for_timeout(400)
return False
def add_game(self, job: UploadJob) -> tuple[str, str]:
self._ensure_logged_in()
+7 -2
View File
@@ -785,10 +785,15 @@ def create_app(
freshen()
match_counts = Counter(row["match_status"] for row in session.rows)
log_counts: Counter[str] = Counter()
failed_now = 0
log_path = cfg.upload_log_path
if log_path.exists():
from bggpipe.upload import outstanding_failures
with log_path.open(newline="") as f:
log_counts = Counter(row["status"] for row in csv.DictReader(f))
log_rows = list(csv.DictReader(f))
log_counts = Counter(row["status"] for row in log_rows)
failed_now = outstanding_failures(log_rows)
games = len(read_games())
return {
"boot": boot,
@@ -829,7 +834,7 @@ def create_app(
"to_add": _csv_count(cfg.to_add_path),
"to_update": _csv_count(cfg.to_update_path),
"upload_log": dict(log_counts),
"upload_failed": log_counts.get("failed", 0),
"upload_failed": failed_now,
"games": games,
"job": jobs.snapshot(),
}