diff --git a/data/upload_log.csv b/data/upload_log.csv index 89fc117..f710a20 100644 --- a/data/upload_log.csv +++ b/data/upload_log.csv @@ -20,3 +20,48 @@ 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, +add,255984,,Sleeping Gods,701034,,added,2026-08-06T02:54:05+00:00, +add,393672,,Gloomhaven: Buttons & Bugs,669782,,added,2026-08-06T02:54:12+00:00, +add,9666,,Greed Quest,215137,,added,2026-08-06T02:54:19+00:00, +add,40508,,Scrabble Slam!,644326,,added,2026-08-06T02:54:27+00:00, +add,3181,,Farkle,87931,,added,2026-08-06T02:54:36+00:00, +add,193485,,Dastardly Dirigibles,300446,,added,2026-08-06T02:54:54+00:00, +add,226065,,Sheriff of Nottingham: Merry Men,353389,,added,2026-08-06T02:55:01+00:00, +add,143884,,Machi Koro,453051,,added,2026-08-06T02:55:09+00:00, +add,358880,,Etherfields: Kittenburg Expansion,603694,,added,2026-08-06T02:55:15+00:00, +add,432858,,Artistry,734890,,added,2026-08-06T02:55:21+00:00, +add,417060,,Botany: Tantalizing Trees,706506,,added,2026-08-06T02:55:27+00:00, +add,417064,,Botany: Perilous Perfumes,706510,,added,2026-08-06T02:55:33+00:00, +add,161928,,Utter Nonsense,,,added,2026-08-06T02:55:40+00:00, +add,50381,,Cards Against Humanity,,,added,2026-08-06T02:55:44+00:00, +add,1269,,Skip-Bo,,,added,2026-08-06T02:55:50+00:00, +add,202982,,Scrawl,,,added,2026-08-06T02:55:56+00:00, +add,195314,,Herbaceous,,,added,2026-08-06T02:56:02+00:00, +add,320202,,Dragon Land,,,added,2026-08-06T02:56:07+00:00, +add,2452,,Jenga,,,added,2026-08-06T02:56:12+00:00, +add,42063,,Code Master,,,added,2026-08-06T02:56:18+00:00, +add,340420,,Throw Throw Avocado,,,added,2026-08-06T02:56:23+00:00, +add,1260,,Rook,,,added,2026-08-06T02:56:28+00:00, +add,116,,Guillotine,,,added,2026-08-06T02:56:34+00:00, +add,169124,,Flick 'em Up!,,,added,2026-08-06T02:56:40+00:00, +add,20866,,The Rocky Horror Trivia Game,,,added,2026-08-06T02:56:45+00:00, +add,269564,,Captain Marvel: Secret Skrulls,,,added,2026-08-06T02:57:37+00:00, +add,18755,,Pie Face!,,,added,2026-08-06T02:57:41+00:00, +add,140509,,Dungeons & Dragons,,,failed,2026-08-06T02:58:01+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""Dungeons\ \&\ Dragons"", re.IGNORECASE)) to be visible" +add,201248,,Evolution: The Beginning,,,added,2026-08-06T02:58:05+00:00, +add,362205,,Sleeping Gods: Primeval Peril,,,added,2026-08-06T02:58:11+00:00, +add,453845,,Murder at the Manor,,,added,2026-08-06T02:58:16+00:00, +add,329230,,Bluffaneer,,,added,2026-08-06T02:58:20+00:00, +add,358320,,Sleeping Gods: Distant Skies,,,added,2026-08-06T02:58:26+00:00, +add,389113,,Rivals,,,added,2026-08-06T02:58:31+00:00, +add,324937,,Wiz-War (9th Edition),,,added,2026-08-06T02:58:35+00:00, +add,295770,,Frosthaven,,,added,2026-08-06T02:58:40+00:00, +add,174430,,Gloomhaven,,,added,2026-08-06T02:58:46+00:00, +add,442312,,Deadly Doodles,,,added,2026-08-06T02:58:51+00:00, +add,332398,,Everdell: The Complete Collection,,,added,2026-08-06T02:58:56+00:00, +add,334065,,Verdant,,,added,2026-08-06T02:59:01+00:00, +add,283155,,Calico,,,added,2026-08-06T02:59:06+00:00, +add,291457,,Gloomhaven: Jaws of the Lion,,,added,2026-08-06T02:59:12+00:00, +add,150925,,Hold Your Breath!,,,added,2026-08-06T02:59:18+00:00, +add,2980,,The Simpsons Trivia Game,,,added,2026-08-06T02:59:23+00:00, +add,1197,,Blink,,,added,2026-08-06T02:59:29+00:00, diff --git a/src/bggpipe/upload.py b/src/bggpipe/upload.py index 092d018..79faa14 100644 --- a/src/bggpipe/upload.py +++ b/src/bggpipe/upload.py @@ -39,7 +39,8 @@ import typer from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for from bggpipe.config import Config from bggpipe.fsio import atomic_write_text -from bggpipe.models import CollectionItem +from bggpipe.models import CollectionItem, is_recognized +from bggpipe.resolve import read_matches BGG = "https://boardgamegeek.com" UPLOAD_LOG_COLUMNS = [ @@ -89,6 +90,33 @@ def _job_key(row: dict) -> tuple[str, str, str]: return _key(row["action"], row["bgg_id"], row["collid"], row["version_id"]) +def stale_jobs(queue_rows: list[dict], match_rows: list[dict]) -> dict[str, str]: + """bgg_id -> why, for queued games the CURRENT matches.csv no longer + endorses. to_add.csv is a snapshot from the last diff; a review + decision taken afterwards (marking a game local, rejecting it, calling + a match wrong) must not still upload.""" + if not match_rows: + # nothing to compare against (no matches.csv, or a test harness + # driving the queue directly): absence is not a verdict + return {} + live: dict[str, list[dict]] = {} + for row in match_rows: + if row.get("bgg_id"): + live.setdefault(row["bgg_id"], []).append(row) + stale = {} + for row in queue_rows: + bgg_id = row.get("bgg_id") or "" + rows = live.get(bgg_id, []) + if any(is_recognized(r) for r in rows): + continue + if not rows: + stale[bgg_id] = "no longer matched to this game in matches.csv" + else: + statuses = sorted({r["match_status"] for r in rows}) + stale[bgg_id] = f"now {', '.join(statuses)} in matches.csv" + return stale + + 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 @@ -515,10 +543,12 @@ class PlaywrightUploader: self._goto(f"{BGG}/boardgame/{job.bgg_id}/") add_btn = page.get_by_role("button", name="Add To").first dialog = self._open_dialog(add_btn) - # Content settles when the game-name heading replaces "Loading...". - dialog.get_by_role( - "heading", name=re.compile(re.escape(job.name), re.I) - ).wait_for(timeout=15_000) + # Wait for the form itself, NOT for a heading matching our stored + # name: a match made through an ALTERNATE name (BGG 140509 is + # "Dungeons & Dragons" to search, "Dragones Y Mazmorras" on the + # page) would never show it. The /boardgame// URL already + # guarantees which game this is. + dialog.get_by_role("checkbox", name="Own", exact=True).wait_for(timeout=15_000) # exact: "Own" is a substring of "Prev. Owned", and a loose label # match resolves to both checkboxes (strict-mode violation) dialog.get_by_role("checkbox", name="Own", exact=True).check() @@ -747,6 +777,18 @@ def run_upload( to_update = _read_csv(cfg.to_update_path) log_rows = _read_csv(log_path) + # The queue is a snapshot; review decisions since the last diff win. + stale = stale_jobs(to_add + to_update, read_matches(cfg.matches_path)) + if stale: + to_add = [r for r in to_add if r.get("bgg_id") not in stale] + to_update = [r for r in to_update if r.get("bgg_id") not in stale] + typer.echo( + f"Skipping {len(stale)} queued game(s) whose review decision " + "changed since the last diff — re-run diff to refresh the queue:" + ) + for bgg_id, why in stale.items(): + typer.echo(f" {bgg_id}: {why}") + jobs, skipped_done, skipped_failed, deferred = build_queue( to_add, to_update, log_rows, retry_failed=retry_failed ) diff --git a/tests/test_upload.py b/tests/test_upload.py index 98615ea..c17e403 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -754,3 +754,22 @@ def test_paging_state_survives_reopen_so_page_one_is_clicked(): assert picked is True assert why == "" assert dialog.clicked.startswith("Sleeping Gods (English Gamefound") + + +def test_stale_queue_jobs_are_skipped(tmp_path): + """to_add.csv is a snapshot from the last diff. A review decision made + afterwards — marking a game local, rejecting it, calling the match + wrong — must win over the stale queue.""" + from bggpipe.upload import stale_jobs + + queue = [{"bgg_id": "13"}, {"bgg_id": "140509"}, {"bgg_id": "999"}] + matches = [ + {"bgg_id": "13", "match_status": "auto"}, + {"bgg_id": "140509", "match_status": "local"}, + ] + stale = stale_jobs(queue, matches) + assert "13" not in stale # still endorsed + assert "now local" in stale["140509"] + assert "no longer matched" in stale["999"] + # unverifiable (no matches.csv at all) never condemns a job + assert stale_jobs(queue, []) == {}