From 32b6aae841a98c81d04f86e60ef213c40f56906f Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 6 Aug 2026 00:21:52 -0400 Subject: [PATCH] Audit round 7, upload cluster: evidence over inference at every exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five blind reviewers swept the real-data-era surface; this lands the upload findings, all verified against the code and the documented site behavior before fixing. The two HIGHs shared a root: logging outcomes the browser never proved. add_game waited for an "Add To" button that an owned game's page does not have — so a second-copy add could never succeed, and worse, an add that LANDED but missed the log became an unretryable failure loop (every retry: 30s timeout, logged failed, nothing ever settles). add_game now polls for either button state: "In Collections" without second_copy returns the previously-dead already_present status (the landed-but-unlogged case heals itself on retry); with second_copy it refuses loudly (that flow is unverified — add by hand). A save whose dialog is slow to hide reloads the page and asks for ownership evidence instead of guessing "failed". update_entry no longer trusts the editor merely closing: the cell must settle on text matching the CHOSEN version, else the AJAX save failed server-side and "updated" would mark a job done forever that never touched the site. Per-copy bookkeeping: stale_jobs endorsed per game, so rejecting one of two queued editions let the rejected copy upload on the survivor's endorsement — it now counts endorsements per (bgg_id, version) and retires the game with "re-run diff" when a copy loses its backing. annotate_queue stamped every row sharing a job key with the same log status, so one success marked both vetoed duplicates done; completions are now claimed one row per done log line. Smaller findings: the version-drift note queued a doomed re-add after warning about it (now skips — the entry exists on BGG; re-adding only duplicates); the one-update-per-game deferral rested on a claim the collid-exact editor disproves (removed — same-game updates run together); the 3-identical-failures abort compared exception class only, so three unrelated problems aborted a healthy run (now compares whole messages). Also from the test seat: run_upload's stale filtering finally executes against a real matches.csv in tests; rejected credentials pin that no anonymous storage state is saved; update_entry's three guarded exits each have a test; _scrub's newline flattening is pinned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- data/games.json | 415 +++++++++++++++++++++++++++--------------- data/matches.csv | 4 +- data/to_update.csv | 1 - data/upload_log.csv | 1 + src/bggpipe/upload.py | 118 ++++++++++-- tests/test_enrich.py | 20 ++ tests/test_models.py | 39 ++++ tests/test_upload.py | 379 +++++++++++++++++++++++++++++++++++++- 8 files changed, 803 insertions(+), 174 deletions(-) diff --git a/data/games.json b/data/games.json index 19dfdb5..0164141 100644 --- a/data/games.json +++ b/data/games.json @@ -1344,51 +1344,6 @@ ] } }, - "345770": { - "bgg_id": 345770, - "type": "rpgitem", - "name": ".dungeon", - "year": 2021, - "description": "Publisher's blurb:\n\n.dungeon is a book about traveling through a virtual, fantasy world. One person takes on the role of the World, filling in the nooks and crannies with dungeons and adventures that they think up or borrow from books they already own. Everyone else is a Player and creates a Character to explore those nooks, discover their treasure, and interact with NPCs and other flesh-and-blood folks in the virtual world.\n\nThe thing that sets .dungeon apart from other books is the inspiration it takes from social games like Werewolf and Munchkin. The real world can (and will) affect the game you create. Each class has different powers based on physical things you do, or different parts of your life. The witch uses their tattoos to cast spells while the wizard uses their favorite book. The knight makes vows while the leader can offer gifts to the other players to aid them. There are 10 classes total to choose from, including the bard, the troll, and the artist.", - "image": "https://cf.geekdo-images.com/k6Mpl_0HSRcE2aICW12qGg__original/img/abdC7c7hGDsswx5ZLnOYAiQ_hXY=/0x0/filters:format(png)/pic6358032.png", - "thumbnail": "https://cf.geekdo-images.com/k6Mpl_0HSRcE2aICW12qGg__small/img/-NVpXLCuGaKT24RTahEhdeoy_-M=/fit-in/200x150/filters:strip_icc()/pic6358032.png", - "min_players": null, - "max_players": null, - "best_player_counts": [], - "playtime": null, - "min_playtime": null, - "max_playtime": null, - "min_age": null, - "designers": [ - "John Battle" - ], - "artists": [ - "Micah Anderson", - "Kate Sheridan", - "Jared Sinclair" - ], - "publishers": [ - "Project Nerves" - ], - "categories": [ - "Fantasy", - "Core Rules (min needed to play)" - ], - "mechanics": [ - "Attribute/Stat Based (STR, CON, PER, etc)", - "Dice (Various)", - "Dice Step (variable dice vs. static target)" - ], - "producers": [ - "John Battle" - ], - "series": [], - "rating": 0.0, - "weight": 0.0, - "rank": null, - "users_owned": 31, - "version": null - }, "9209:294188": { "bgg_id": 9209, "type": "boardgame", @@ -6343,71 +6298,6 @@ ] } }, - "258849": { - "bgg_id": 258849, - "type": "rpgitem", - "name": "Parsely", - "year": 2018, - "description": "Publisher's blurb:\n\nRelive the glory days of floppy disks, dot-matrix printers and 128K RAM with this collection of party games inspired by the text-adventures of the 1980s.\n\nAs the parser, you’ll take on the role of a computer game with a limited vocabulary. One by one, the players will give you commands like GO NORTH, LIGHT LAMP or GET SWORD, which you’ll follow to the best of your ability. There’s no limit to the number of people that can play and no limit to the fun you’ll have. So what are you waiting for? It’s time to unplug from your computer and plug into Parsely!\n\nContains 10 classic Parsely games and two brand-new adventurers!\n\nFeatures a forward by Peter Adkison and includes the following Parsely games:\n\n Action Castle\n Action Castle II\n Action Castle III\n Blackboard Jungle\n Dangertown Beatdown\n Flaming Goat!\n Jungle Adventure\n Pumpkin Town\n Six-Gun Showdown\n Space Station\n Spooky Manor\n Z-Ward", - "image": "https://cf.geekdo-images.com/KypGSMwOACTbKnZNk1_nXQ__original/img/Blui7nSyq2Vr-0LpZS0C7AYD3xU=/0x0/filters:format(png)/pic4634784.png", - "thumbnail": "https://cf.geekdo-images.com/KypGSMwOACTbKnZNk1_nXQ__small/img/h1F5b8CM6T2TkjT9U60TuAHfhvk=/fit-in/200x150/filters:strip_icc()/pic4634784.png", - "min_players": null, - "max_players": null, - "best_player_counts": [], - "playtime": null, - "min_playtime": null, - "max_playtime": null, - "min_age": null, - "designers": [ - "Jared A. Sorensen" - ], - "artists": [ - "Alexey Andreyev", - "Samuel Araya", - "Rebekah Bennington", - "Alice K. Hansmann", - "Todd James", - "Manning Krull", - "Jon Morris", - "Keith Senkowski", - "Lukas Wong-Achorn", - "Jordan Worley", - "Ariel ZB" - ], - "publishers": [ - "Memento Mori Theatricks" - ], - "categories": [ - "Action / Adventure", - "Comedy / Satire", - "Fantasy", - "History (American West)", - "Horror", - "Horror (Supernatural)", - "Horror (Zombies)", - "Kid-Friendly", - "Modern", - "Science Fiction", - "Weird / Surreal", - "Core Rules (min needed to play)", - "Scenario / Adventure / Module" - ], - "mechanics": [ - "Description Based (Narrative more so than Dice)", - "'Diceless' (does not use dice or other randomizer)", - "Reading (for Periodicals, Adventure Books, etc)" - ], - "producers": [ - "Luke Crane", - "Radosław Drozdalski" - ], - "series": [], - "rating": 9.1875, - "weight": 2.3333, - "rank": null, - "users_owned": 50, - "version": null - }, "153999:280845": { "bgg_id": 153999, "type": "boardgame", @@ -7408,30 +7298,6 @@ "IMG_4516.jpeg" ] }, - "local:dungeons and dragons:IMG_4519.jpeg": { - "bgg_id": null, - "name": "DUNGEONS & DRAGONS", - "year": null, - "type": "localgame", - "publishers": [ - "TSR" - ], - "source_photos": [ - "IMG_4519.jpeg" - ] - }, - "local:dungeons and dragons fantasy game rules:IMG_4519.jpeg": { - "bgg_id": null, - "name": "DUNGEONS & DRAGONS FANTASY GAME RULES", - "year": null, - "type": "localgame", - "publishers": [ - "TSR" - ], - "source_photos": [ - "IMG_4519.jpeg" - ] - }, "local:elite sportz equipment table top curling:IMG_4526.jpeg": { "bgg_id": null, "name": "Elite Sportz Equipment Table Top Curling", @@ -7466,24 +7332,287 @@ "IMG_4544.jpeg" ] }, - "local:alice is missing silent falls expansion:IMG_4566.jpeg": { + "1339:34390": { + "bgg_id": 1339, + "type": "boardgame", + "name": "Dungeon!", + "year": 1975, + "description": "In many ways \"Dungeon!\" is similar to Dungeons & Dragons, although much simplified and transformed into a board game. Players explore a dungeon that is divided into levels of increasing difficulty, fighting monsters for valuable treasure. As players venture deeper into the dungeon, the monsters become more difficult and the treasure more valuable. Several character classes each have slightly different fighting abilities – most notably the wizard, who can cast spells. Combat is simulated using dice; players roll the dice to attack a monster, and if unsuccessful, the dice are rolled to determine the effect of the monster's counter-attack.\n\nThe winner is the first player to bring a certain amount of treasure back to the Dungeon's entrance.\n\nReimplemented by:\n\n\n The Classic Dungeon\n The New Dungeon", + "image": "https://cf.geekdo-images.com/fvhkcw4s6Q4-nMwwok1J6A__original/img/WJFmTRWQRzOInBnWAjH39F3RyMg=/0x0/filters:format(png)/pic6569894.png", + "thumbnail": "https://cf.geekdo-images.com/fvhkcw4s6Q4-nMwwok1J6A__small/img/zXW1s2ZXq1dZkvbAzYdr9o1xRNQ=/fit-in/200x150/filters:strip_icc()/pic6569894.png", + "min_players": 1, + "max_players": 8, + "best_player_counts": [ + "4", + "5" + ], + "playtime": 60, + "min_playtime": 30, + "max_playtime": 60, + "min_age": 8, + "designers": [ + "David R. Megarry" + ], + "artists": [ + "Daniel Gelon", + "Keith Hill", + "Richard Hill", + "James Holloway (I)", + "Larry Kessling", + "Michael Komarck", + "Tracy Lesch", + "Victoria Maderna", + "Erol Otus", + "Harry Quinn", + "Jim Roslof", + "Stephen Sullivan", + "Franz Vohwinkel" + ], + "publishers": [ + "Tactical Studies Rules (TSR)", + "ASS Altenburger Spielkarten", + "Jedko Games", + "Novedades Montecarlo", + "Parker Brothers", + "Wizards of the Coast" + ], + "categories": [ + "Adventure", + "Exploration", + "Fantasy", + "Fighting" + ], + "mechanics": [ + "Area Movement", + "Critical Hits and Failures", + "Dice Rolling", + "Events", + "Grid Movement", + "Role Playing", + "Simulation", + "Variable Player Powers" + ], + "producers": [], + "series": [], + "rating": 6.01294, + "weight": 1.5527, + "rank": 5417, + "users_owned": 10830, + "version": { + "version_id": 34390, + "name": "English first edition", + "year": 1975, + "publishers": [ + "Tactical Studies Rules (TSR)" + ], + "languages": [ + "English" + ] + } + }, + "380459:743266": { + "bgg_id": 380459, + "type": "rpgitem", + "name": "Alice is Missing: Silent Falls", + "year": 2023, + "description": "Publisher's blurb:\n\nThe Silent Falls expansion allows players to rediscover the game with additional cards that facilitate evocative new stories.\n\nWith the Silent Falls expansion, players are provided an opportunity to explore brand new stories with additional Suspect, Location, Character, Searching, and Clue cards, as well as new Relationship cards that help you to deepen your connections to the other characters.\n\nPlayers have the opportunity to explore uncharted locations such as the dark, echoing spaces of the Caloma Caves, confront new Suspects like the distant father John Briarwood, and uncover thrilling new conclusions to Alice’s disappearance as they reveal the new 10-minute clue cards.\n\nThe Silent Falls expansion introduces 38 new cards in total, which is more than half the amount of cards in the original Alice is Missing. 4 new playable characters, 2 new Relationship cards with a total of 12 new prompts, 3 new Suspects, 3 new Locations, 4 new Searching cards and 22 new Clue cards (two additional Clue cards for each standard timestamp, as well as three additional 10 minute cards, and a replacement 90 minute card).", + "image": "https://cf.geekdo-images.com/GzhipcGBA1UC8CzH85hF_A__original/img/wNgYFo-rrT0ZCYYXKLCQPEXclDY=/0x0/filters:format(jpeg)/pic8511647.jpg", + "thumbnail": "https://cf.geekdo-images.com/GzhipcGBA1UC8CzH85hF_A__small/img/UN0XWQ2UCXl0GSBANfZnjBozURU=/fit-in/200x150/filters:strip_icc()/pic8511647.jpg", + "min_players": null, + "max_players": null, + "best_player_counts": [], + "playtime": null, + "min_playtime": null, + "max_playtime": null, + "min_age": null, + "designers": [ + "Banana Chan", + "Spenser Starke" + ], + "artists": [ + "Julianne Griepp" + ], + "publishers": [ + "Alis Games", + "Devir", + "Hunters Entertainment", + "Origames", + "Renegade Game Studios" + ], + "categories": [ + "Crime (Mystery / Detective / Noir)", + "Sourcebook (rules/options to enhance play)" + ], + "mechanics": [ + "Cards (Specialized)", + "Description Based (Narrative more so than Dice)" + ], + "producers": [ + "Tomasz \"Sting\" Chmielik" + ], + "series": [], + "rating": 7.42857, + "weight": 0.0, + "rank": null, + "users_owned": 226, + "version": { + "version_id": 743266, + "name": "Alice ha desaparecido: Expansión Silent Falls", + "year": 2024, + "publishers": [], + "languages": [ + "Spanish" + ] + } + }, + "311654": { + "bgg_id": 311654, + "type": "rpgitem", + "name": "Alice is Missing", + "year": 2020, + "description": "From the kickstarter:\n\nAlice is Missing, a silent role-playing game about the disappearance of Alice Briarwood, a high school junior in the small town of Silent Falls.\n...\nThe game is played live and without verbal communication. Players inhabit their character for the entirety of the 90-minute play session, and instead of speaking, send text messages back and forth to the other characters in a group chat, as well as individually, as though they aren’t in the same place together.\n\nPublisher's blurb:\n\nThe game is played live and without verbal communication. Players inhabit their character for the entirety of the 90-minute play session, and instead of speaking, send text messages back and forth to the other characters in a group chat, as well as individually, as though they aren’t in the same place together.\n\nHaunting beautiful, deeply personal, and highly innovative Alice is Missing puts a strong focus on the emotional engagement between players, immersing them in a tense, dramatic mystery that unfolds organically through the text messages they send to one another. Right at home with games like Life Is Strange, Gone Home, Oxenfree, and Firewatch, it’s designed to feel as much like an event-style experience as it does a role-playing game.\n\nA microbadge is available", + "image": "https://cf.geekdo-images.com/211476emISgQLsa2h3BAYw__original/img/nGOu9LrFF5udRimbJrei6HExde8=/0x0/filters:format(png)/pic5625807.png", + "thumbnail": "https://cf.geekdo-images.com/211476emISgQLsa2h3BAYw__small/img/g9QMstNAY2uOVSR1rH5Hx7Gxquk=/fit-in/200x150/filters:strip_icc()/pic5625807.png", + "min_players": null, + "max_players": null, + "best_player_counts": [], + "playtime": null, + "min_playtime": null, + "max_playtime": null, + "min_age": null, + "designers": [ + "Spenser Starke" + ], + "artists": [ + "Caleb Cleveland", + "Christopher De La Rosa", + "Julianne Griepp" + ], + "publishers": [ + "Alis Games", + "Choo Choo Games", + "Devir", + "Hunters Entertainment", + "Origames", + "Raven Distribution", + "Renegade Game Studios", + "Schwerkraft-Verlag" + ], + "categories": [ + "Childhood", + "Crime (Mystery / Detective / Noir)", + "Social (Relationships / Romance)", + "Core Rules (min needed to play)" + ], + "mechanics": [ + "Cards (Specialized)", + "Description Based (Narrative more so than Dice)" + ], + "producers": [ + "Tomasz \"Sting\" Chmielik" + ], + "series": [], + "rating": 7.58289, + "weight": 1.9565, + "rank": null, + "users_owned": 2008, + "version": null + }, + "258849:449806": { + "bgg_id": 258849, + "type": "rpgitem", + "name": "Parsely", + "year": 2018, + "description": "Publisher's blurb:\n\nRelive the glory days of floppy disks, dot-matrix printers and 128K RAM with this collection of party games inspired by the text-adventures of the 1980s.\n\nAs the parser, you’ll take on the role of a computer game with a limited vocabulary. One by one, the players will give you commands like GO NORTH, LIGHT LAMP or GET SWORD, which you’ll follow to the best of your ability. There’s no limit to the number of people that can play and no limit to the fun you’ll have. So what are you waiting for? It’s time to unplug from your computer and plug into Parsely!\n\nContains 10 classic Parsely games and two brand-new adventurers!\n\nFeatures a forward by Peter Adkison and includes the following Parsely games:\n\n Action Castle\n Action Castle II\n Action Castle III\n Blackboard Jungle\n Dangertown Beatdown\n Flaming Goat!\n Jungle Adventure\n Pumpkin Town\n Six-Gun Showdown\n Space Station\n Spooky Manor\n Z-Ward", + "image": "https://cf.geekdo-images.com/KypGSMwOACTbKnZNk1_nXQ__original/img/Blui7nSyq2Vr-0LpZS0C7AYD3xU=/0x0/filters:format(png)/pic4634784.png", + "thumbnail": "https://cf.geekdo-images.com/KypGSMwOACTbKnZNk1_nXQ__small/img/h1F5b8CM6T2TkjT9U60TuAHfhvk=/fit-in/200x150/filters:strip_icc()/pic4634784.png", + "min_players": null, + "max_players": null, + "best_player_counts": [], + "playtime": null, + "min_playtime": null, + "max_playtime": null, + "min_age": null, + "designers": [ + "Jared A. Sorensen" + ], + "artists": [ + "Alexey Andreyev", + "Samuel Araya", + "Rebekah Bennington", + "Alice K. Hansmann", + "Todd James", + "Manning Krull", + "Jon Morris", + "Keith Senkowski", + "Lukas Wong-Achorn", + "Jordan Worley", + "Ariel ZB" + ], + "publishers": [ + "Memento Mori Theatricks" + ], + "categories": [ + "Action / Adventure", + "Comedy / Satire", + "Fantasy", + "History (American West)", + "Horror", + "Horror (Supernatural)", + "Horror (Zombies)", + "Kid-Friendly", + "Modern", + "Science Fiction", + "Weird / Surreal", + "Core Rules (min needed to play)", + "Scenario / Adventure / Module" + ], + "mechanics": [ + "Description Based (Narrative more so than Dice)", + "'Diceless' (does not use dice or other randomizer)", + "Reading (for Periodicals, Adventure Books, etc)" + ], + "producers": [ + "Luke Crane", + "Radosław Drozdalski" + ], + "series": [], + "rating": 9.1875, + "weight": 2.3333, + "rank": null, + "users_owned": 50, + "version": { + "version_id": 449806, + "name": "Hardcover version", + "year": 2018, + "publishers": [], + "languages": [ + "English" + ] + } + }, + "local:dungeons and dragons:IMG_4519.jpeg": { "bgg_id": null, - "name": "ALICE IS MISSING SILENT FALLS EXPANSION", + "name": "DUNGEONS & DRAGONS", "year": null, "type": "localgame", - "publishers": [], + "publishers": [ + "TSR" + ], "source_photos": [ - "IMG_4566.jpeg" + "IMG_4519.jpeg" ] }, - "local:alice is missing silent role playing game:IMG_4566.jpeg": { + "local:dungeons and dragons fantasy game rules:IMG_4519.jpeg": { "bgg_id": null, - "name": "ALICE IS MISSING A SILENT ROLE PLAYING GAME", - "year": 2021, + "name": "DUNGEONS & DRAGONS FANTASY GAME RULES", + "year": null, "type": "localgame", - "publishers": [], + "publishers": [ + "TSR" + ], "source_photos": [ - "IMG_4566.jpeg" + "IMG_4519.jpeg" ] } } diff --git a/data/matches.csv b/data/matches.csv index a3e586d..f5af7c6 100644 --- a/data/matches.csv +++ b/data/matches.csv @@ -63,8 +63,8 @@ Walk the Plank!,35505,Walk the Plank!,2013,boardgame,auto,781669,English MDG-431 AGRICOLA,31260,Agricola,2007,boardgame,auto,297589,Z-Man Games English fifth edition,version_auto,"[{""bgg_id"": 31260, ""name"": ""Agricola"", ""year"": 2007, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 297589, ""name"": ""Z-Man Games English fifth edition"", ""year"": 2013, ""publishers"": [""Filosofia Éditions"", ""Lookout Games"", ""Z-Man Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 165308, ""name"": ""999 Games Dutch second edition 2011"", ""year"": 2011, ""publishers"": [""999 Games"", ""Lookout Games""], ""languages"": [""Dutch""], ""score"": 2}, {""version_id"": 266028, ""name"": ""Hobby World Russian first edition"", ""year"": 2013, ""publishers"": [""Hobby World"", ""Lookout Games""], ""languages"": [""Russian""], ""score"": 2}, {""version_id"": 22673, ""name"": ""Lookout Games German first edition"", ""year"": 2007, ""publishers"": [""Lookout Games""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 64715, ""name"": ""Lookout Games German second edition 2008 with animeeples"", ""year"": 2008, ""publishers"": [""Lookout Games""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 219203, ""name"": ""Lookout Games German seventh edition 2012 with DSP and SdJ"", ""year"": 2012, ""publishers"": [""Lookout Games""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 64718, ""name"": ""Lookout Games German third edition 2009 with DSP and SdJ"", ""year"": 2009, ""publishers"": [""Lookout Games""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 113858, ""name"": ""Lookout Games Russian first edition 2011"", ""year"": 2011, ""publishers"": [""Lookout Games"", ""Smart Ltd""], ""languages"": [""Russian""], ""score"": 2}]",IMG_4518.jpeg,, METEOR,166109,Meteor,2014,boardgame,auto,,,version_unknown,"[{""bgg_id"": 25172, ""name"": ""Meteor"", ""year"": 1975, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 56, ""rank"": null}, {""bgg_id"": 166109, ""name"": ""Meteor"", ""year"": 2014, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 1396, ""rank"": 10371}, {""bgg_id"": 246740, ""name"": ""Meteors"", ""year"": 2018, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 92.3, ""owned"": 71, ""rank"": 25541}]",[],IMG_4518.jpeg,, DOCTOR WHO THE card GAME,125675,Doctor Who: The Card Game,2012,boardgame,auto,,,version_unknown,"[{""bgg_id"": 125675, ""name"": ""Doctor Who: The Card Game"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4518.jpeg,, -DUNGEONS & DRAGONS,,,,,ambiguous,,,,"[{""bgg_id"": 140509, ""name"": ""Dungeons & Dragons"", ""year"": 1985, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 65, ""rank"": null}]",[],IMG_4519.jpeg,, -DUNGEONS & DRAGONS FANTASY GAME RULES,,,,,ambiguous,,,,"[{""bgg_id"": 140509, ""name"": ""Dungeons & Dragons"", ""year"": 1985, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 67.8, ""owned"": 65, ""rank"": null}]",[],IMG_4519.jpeg,, +DUNGEONS & DRAGONS,,,,,local,,,,"[{""bgg_id"": 355720, ""name"": ""Las Arenas del Destino"", ""year"": 1998, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 1, ""rank"": null}, {""bgg_id"": 53384, ""name"": ""Der Berg der Spiegel"", ""year"": 1982, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 70, ""rank"": null}, {""bgg_id"": 153208, ""name"": ""\""Advanced Dungeons & Dragons (3rd Edition)\"": Dungeon Masters Guide (2013)"", ""year"": 2013, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 10, ""rank"": null}, {""bgg_id"": 66974, ""name"": ""\""Advanced Dungeons & Dragons (3rd Edition)\"": Players Handbook (2009)"", ""year"": 2009, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 32, ""rank"": null}, {""bgg_id"": 83387, ""name"": ""\""Advanced Dungeons & Dragons (3rd Edition)\"": Unearthed Arcana (2008)"", ""year"": 2008, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 17, ""rank"": null}, {""bgg_id"": 223765, ""name"": ""0-Level Characters: A Guide"", ""year"": 2012, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 1, ""rank"": null}, {""bgg_id"": 238233, ""name"": ""1,000 Rooms of Chaos"", ""year"": 2017, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 11, ""rank"": null}, {""bgg_id"": 239647, ""name"": ""1,000 Rooms of Chaos II"", ""year"": 2017, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 11, ""rank"": null}, {""bgg_id"": 360163, ""name"": ""10 Shark-themed Magical Items"", ""year"": 2022, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 0, ""rank"": null}, {""bgg_id"": 360164, ""name"": ""10 Shark-themed Unusual Items"", ""year"": 2022, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 0, ""rank"": null}, {""bgg_id"": 332919, ""name"": ""100 Weird Widgets"", ""year"": 2020, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 0, ""rank"": null}, {""bgg_id"": 278649, ""name"": ""10th Anniversary Dungeons & Dragons Collector's Set"", ""year"": 1984, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 2, ""rank"": null}]",[],IMG_4519.jpeg,, +DUNGEONS & DRAGONS FANTASY GAME RULES,,,,,local,,,,"[{""bgg_id"": 234007, ""name"": ""Alternate Fantasy Role Playing Game Rules"", ""year"": 2009, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 0, ""rank"": null}, {""bgg_id"": 271647, ""name"": ""Cantrips: Supplemental Magic Rules for OSR Roleplaying Games"", ""year"": 2019, ""type"": ""rpgitem"", ""exact"": false, ""fuzzy"": 0.0, ""owned"": 0, ""rank"": null}]",[],IMG_4519.jpeg,, "Before I Kill You, Mister Bond...",164,"Before I Kill You, Mister Bond",1996,boardgame,auto,28465,"Cheapass black and white ""Better Edition"" edition",version_auto,"[{""bgg_id"": 164, ""name"": ""Before I Kill You, Mister Bond"", ""year"": 1996, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 28465, ""name"": ""Cheapass black and white \""Better Edition\"" edition"", ""year"": 2001, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 5}, {""version_id"": 291316, ""name"": ""English edition 2016"", ""year"": 2016, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 520361, ""name"": ""English second edition"", ""year"": 1997, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 28459, ""name"": ""First \""Renamed\"" edition, First color edition"", ""year"": 2004, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 180392, ""name"": ""First black and white envelope edition"", ""year"": 1996, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 28462, ""name"": ""Third black and white edition"", ""year"": 1998, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4519.jpeg;IMG_4554.jpeg,, MYSTERIUM,181304,Mysterium,2015,boardgame,auto,536289,English edition 2018-2,version_approved,"[{""bgg_id"": 181304, ""name"": ""Mysterium"", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 85034, ""rank"": 454}, {""bgg_id"": 113997, ""name"": ""Mysterium"", ""year"": 2013, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 5494, ""rank"": 1143}]","[{""version_id"": 781306, ""name"": ""English 2025 Refresh Edition"", ""year"": 2025, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 280251, ""name"": ""English edition 2015-1"", ""year"": 2015, ""publishers"": [""Esdevium"", ""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 493543, ""name"": ""English edition 2015-2"", ""year"": 2015, ""publishers"": [""Esdevium"", ""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 475162, ""name"": ""English edition 2016-1"", ""year"": 2016, ""publishers"": [""Esdevium"", ""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 351093, ""name"": ""English edition 2017-1"", ""year"": 2017, ""publishers"": [""Esdevium"", ""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 467608, ""name"": ""English edition 2017-2"", ""year"": 2017, ""publishers"": [""Esdevium"", ""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 404443, ""name"": ""English edition 2017-3"", ""year"": 2017, ""publishers"": [""Esdevium"", ""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 536289, ""name"": ""English edition 2018-2"", ""year"": 2018, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}]",IMG_4520.jpeg,, Völuspá,128554,Völuspá,2012,boardgame,auto,,,version_unknown,"[{""bgg_id"": 128554, ""name"": ""Völuspá"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4520.jpeg,, diff --git a/data/to_update.csv b/data/to_update.csv index 7f9d3bf..a7cb84a 100644 --- a/data/to_update.csv +++ b/data/to_update.csv @@ -1,2 +1 @@ collid,bgg_id,bgg_name,version_id,version_name -53429642,104710,Wiz-War (Eighth Edition),117685,English first edition diff --git a/data/upload_log.csv b/data/upload_log.csv index 4e0ec07..1380dbd 100644 --- a/data/upload_log.csv +++ b/data/upload_log.csv @@ -114,3 +114,4 @@ update,231302,53430652,The Cat Game,361880,,updated,2026-08-06T03:20:36+00:00, update,40692,53430517,Small World,294178,,updated,2026-08-06T03:20:45+00:00, update,172503,53429718,Mage Wars Academy,265107,,updated,2026-08-06T03:20:49+00:00, update,104710,53429642,Wiz-War (Eighth Edition),117685,,updated,2026-08-06T03:20:53+00:00, +update,1339,53430097,Dungeon!,34390,,updated,2026-08-06T04:01:15+00:00, diff --git a/src/bggpipe/upload.py b/src/bggpipe/upload.py index 694476d..3e17dad 100644 --- a/src/bggpipe/upload.py +++ b/src/bggpipe/upload.py @@ -58,6 +58,16 @@ DONE_STATUSES = {"added", "added_no_version", "updated", "already_present"} MAX_VERSION_PAGES = 40 +_YEARISH = re.compile(r"[\s(]*\d{4}(?:-\d+)?[\s)]*") + + +def _loose_version_text(text: str) -> str: + """Version names as displayed differ from the API's by punctuation, + parens and year qualifiers — compare only the words that name it.""" + text = _YEARISH.sub(" ", text or "") + return " ".join(re.sub(r"[^\w\s]", " ", text.casefold()).split()) + + @dataclass(frozen=True) class UploadJob: action: str # "add" | "update" @@ -99,8 +109,14 @@ def annotate_queue( job succeeds — so a reader without the log sees finished work as outstanding forever.""" last: dict[tuple[str, str, str], str] = {} + done_count: Counter[tuple[str, str, str]] = Counter() for row in log_rows: last[_job_key(row)] = row["status"] + if row["status"] in DONE_STATUSES: + done_count[_job_key(row)] += 1 + # vetoed duplicate copies share a key: one success must mark ONE row + # done, not both, or the queue reports a copy uploaded that never was + claimed: Counter[tuple[str, str, str]] = Counter() out = [] for row in queue_rows: key = _key( @@ -110,6 +126,10 @@ def annotate_queue( row.get("version_id", ""), ) status = last.get(key, "") + if status in DONE_STATUSES and claimed[key] >= done_count[key]: + status = "" # completions exhausted: this copy is still pending + if status in DONE_STATUSES: + claimed[key] += 1 out.append( { **row, @@ -134,20 +154,34 @@ def stale_jobs(queue_rows: list[dict], match_rows: list[dict]) -> dict[str, str] # driving the queue directly): absence is not a verdict return {} live: dict[str, list[dict]] = {} + endorsed: Counter[tuple[str, str]] = Counter() for row in match_rows: if row.get("bgg_id"): live.setdefault(row["bgg_id"], []).append(row) + if is_recognized(row): + endorsed[(row["bgg_id"], row.get("version_id", ""))] += 1 + queued: Counter[tuple[str, str]] = Counter() + for row in queue_rows: + queued[(row.get("bgg_id") or "", row.get("version_id", ""))] += 1 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: + continue + if not any(is_recognized(r) for r in rows): statuses = sorted({r["match_status"] for r in rows}) stale[bgg_id] = f"now {', '.join(statuses)} in matches.csv" + continue + # the game survives, but does THIS copy? Rejecting one of two + # editions must not ride along on the other's endorsement. + pair = (bgg_id, row.get("version_id", "")) + if queued[pair] > endorsed[pair]: + stale[bgg_id] = ( + f"queued with version {row.get('version_id') or '(none)'} " + "but matches.csv no longer endorses that copy — re-run diff" + ) return stale @@ -248,8 +282,7 @@ def build_queue( jobs: list[UploadJob] = [] skipped_done = skipped_failed = 0 - deferred: list[UploadJob] = [] - update_game_seen: set[str] = set() + deferred: list[UploadJob] = [] # kept for callers; nothing defers now seen: Counter[tuple[str, str, str]] = Counter() queued_versions: dict[str, set[str]] = {} for job in candidates: @@ -266,11 +299,13 @@ def build_queue( prior = set() if prior and job.version_id not in prior: typer.echo( - f" note: {job.name} was previously {job.action}ed with a " + f" skipping {job.name}: previously {job.action}ed with a " f"different version ({', '.join(sorted(prior)) or 'none'}) — " - "if re-review changed the version, the BGG entry needs a " - "manual correction (additive-only rule)" + "the entry exists on BGG, so re-adding can only duplicate " + "it; correct the version by hand (additive-only rule)" ) + skipped_done += 1 + continue occurrence = seen[job.key] seen[job.key] += 1 if not job.name: @@ -285,15 +320,10 @@ def build_queue( skipped_done += 1 elif last_status.get(job.key) == "failed" and not retry_failed: skipped_failed += 1 - elif job.action == "update" and job.bgg_id in update_game_seen: - # The row-edit flow finds rows by game name, not collid — a - # second same-game update this run could reopen the copy the - # first one just versioned and overwrite it. One per run; the - # next run (after --verify) picks up the rest. - deferred.append(job) else: - if job.action == "update": - update_game_seen.add(job.bgg_id) + # same-game updates coexist in one run: update_entry addresses + # the copy by collid and the edition by radio value, so a + # second update cannot reopen what the first just saved jobs.append(job) return jobs, skipped_done, skipped_failed, deferred @@ -586,13 +616,36 @@ class PlaywrightUploader: self._page.wait_for_timeout(400) return False + def _owned_button(self): + """The owned-game page replaces "Add To" with "In Collections" — + positive evidence the collection already holds this game.""" + return self._page.get_by_role("button", name="In Collections") + def add_game(self, job: UploadJob) -> tuple[str, str]: self._ensure_logged_in() page = self._page # /boardgame/ redirects to the canonical slug for any subtype. 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) + add_btn = page.get_by_role("button", name="Add To") + for _ in range(60): # the header hydrates late: poll for EITHER state + if add_btn.count() and add_btn.first.is_visible(): + break + owned = self._owned_button() + if owned.count() and owned.first.is_visible(): + if job.second_copy: + # the second-copy path goes through the In Collections + # dialog, which this code has never driven live + raise RuntimeError( + "second copy of an owned game — the In Collections " + "add-a-copy flow is unverified; add this copy by hand" + ) + return ( + "already_present", + "the site already lists this game as owned — a previous " + "attempt likely landed without reaching the log", + ) + page.wait_for_timeout(500) + dialog = self._open_dialog(add_btn.first) # 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 @@ -612,7 +665,21 @@ class PlaywrightUploader: note = f"version {job.version_name!r}: {why}; added without version" dialog.get_by_role("button", name="Save").click() # The dialog is hidden after save, not removed from the DOM. - dialog.wait_for(state="hidden", timeout=15_000) + try: + dialog.wait_for(state="hidden", timeout=15_000) + except self._timeout_error: + # a slow hide is not a failed save: reload and ask the page. + # Logging "failed" for a landed add would double-add on retry. + self._goto(f"{BGG}/boardgame/{job.bgg_id}/") + owned = self._owned_button() + try: + owned.first.wait_for(timeout=15_000) + except self._timeout_error as err: + raise RuntimeError( + "the save dialog never closed and the page does not " + "show the game as owned — the add may not have landed" + ) from err + note = (note + "; " if note else "") + "save confirmed via page reload" return status, note def update_entry(self, job: UploadJob) -> tuple[str, str]: @@ -650,9 +717,20 @@ class PlaywrightUploader: f"{job.collid} — entry left untouched" ) from err radio.first.click() # fires CE_SaveData: no separate Save button + want = _loose_version_text(job.version_name) for _ in range(40): settled = " ".join((cell.first.text_content() or "").split()) if settled and "editing" not in settled.casefold(): + got = _loose_version_text(settled) + if want and want not in got and got not in want: + # the editor closed but re-rendered its OLD content: + # the AJAX save failed server-side. "updated" here + # would mark the job done forever without evidence. + raise RuntimeError( + f"the version cell settled on {settled!r}, not the " + f"chosen {job.version_name!r} — the save did not " + "land; safe to retry (same radio, same result)" + ) return "updated", "" page.wait_for_timeout(500) raise RuntimeError( @@ -706,7 +784,7 @@ def _process( suffix = f" — {note}" if note else "" typer.echo(f" {job.name}: {status}{suffix}") if status == "failed": - kind = note.split(":", 1)[0] # exception type from _scrub format + kind = note # IDENTICAL means the whole message, not the class consecutive = (kind, consecutive[1] + 1 if kind == consecutive[0] else 1) if consecutive[1] >= 3: typer.echo( diff --git a/tests/test_enrich.py b/tests/test_enrich.py index da3a12b..68d6e64 100644 --- a/tests/test_enrich.py +++ b/tests/test_enrich.py @@ -6,6 +6,7 @@ from __future__ import annotations import json import httpx +import pytest from bggpipe.bgg_client import BGGClient, cache_key from bggpipe.config import Config @@ -318,3 +319,22 @@ def test_summary_counts_local_and_api_entries_separately(tmp_path, capsys): import re as _re assert not _re.search(r"-\d", out) # no negative tallies + + +def test_corrupt_local_games_store_fails_loud(tmp_path): + """local_games.json is the ONLY source for off-BGG games — a tolerant + reader that skipped it would silently drop hand-written metadata.""" + from bggpipe.enrich import run_enrich + + cfg = Config(data_dir=tmp_path / "data") + rows = _matches_rows() + for row in rows: # all local: the store must be read before any fetch + row["match_status"] = "local" + row["bgg_id"] = "" + write_matches(cfg.matches_path, rows) + cfg.local_games_path.write_text("{torn") + bare = BGGClient( + cache_dir=tmp_path / "empty", transport=httpx.MockTransport(_no_network) + ) + with pytest.raises(ValueError, match="local_games.json is corrupt"): + run_enrich(cfg, client=bare) diff --git a/tests/test_models.py b/tests/test_models.py index 8e5b5e0..c3981c6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,6 +9,7 @@ from bggpipe.models import ( parse_collection, parse_search, parse_things, + parse_things_full, ) SEARCH_XML = """ @@ -134,3 +135,41 @@ def test_search_partial_malformed_tolerated_with_warning(): with pytest.warns(UserWarning, match="unparseable"): results = parse_search(xml) assert [r.bgg_id for r in results] == [13] + + +def test_parse_search_dedupes_multitype_duplicates_preferring_specific(): + """A multi-type search lists an expansion under BOTH types; keeping the + bare-boardgame copy would erode the base-vs-expansion guard.""" + xml = """ + + + + + + + """ + (result,) = parse_search(xml) + assert result.type == "boardgameexpansion" + + +def test_parse_things_full_reads_rpggeek_link_vocabulary(): + """RPGGeek items use their own link types; a board-game-only reader + silently returns nothing for them.""" + xml = """ + + + + + + + + + + + """ + (game,) = parse_things_full(xml) + assert game["designers"] == ["Spenser Starke"] + assert game["publishers"] == ["Hunters Entertainment"] + assert game["categories"] == ["Modern", "Core Rules (min needed to play)"] + assert game["mechanics"] == ["Card Play"] + assert game["producers"] == ["Someone"] diff --git a/tests/test_upload.py b/tests/test_upload.py index 0a6874d..d1d6827 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -18,7 +18,9 @@ from bggpipe.upload import ( LoginError, UploadJob, _scrub, + annotate_queue, build_queue, + stale_jobs, run_upload, verify_uploads, ) @@ -356,9 +358,9 @@ def test_missing_to_add_csv_is_a_loud_precondition_failure(tmp_path): run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=_now) -def test_second_update_for_same_game_is_deferred(tmp_path): - # the row-edit flow can't target a collid, so only one update per game - # per run is safe +def test_same_game_updates_run_together(tmp_path): + # update_entry addresses the copy by collid and the edition by radio + # value, so two copies of one game are safe in a single run cfg = _cfg(tmp_path) _seed_data( tmp_path, @@ -369,11 +371,7 @@ def test_second_update_for_same_game_is_deferred(tmp_path): ) fake = FakeUploader() results = run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now) - assert [r["collid"] for r in results] == ["9"] - # after the first lands, the next run picks up the deferred one - again = FakeUploader() - results = run_upload(cfg, uploader=again, sleep=lambda s: None, now=_now) - assert [j.collid for j in again.calls] == ["10"] + assert [r["collid"] for r in results] == ["9", "10"] def test_real_run_without_credentials_exits_before_any_browser(tmp_path, monkeypatch): @@ -861,3 +859,368 @@ def test_update_targets_collid_and_version_id_exactly(): assert "objectid=240" in seen["url"] assert any('onclick*="53429559"' in s for s in seen["clicked"]) # the copy assert any('value="24621"' in s for s in seen["clicked"]) # the edition + + +def test_run_upload_skips_jobs_the_review_retired(tmp_path, capsys): + """The queue is a snapshot from the last diff; a review decision taken + afterwards (local, rejected, re-matched) outranks it. This must hold + through run_upload's actual wiring, not just stale_jobs in isolation — + a filter that pruned only to_add would still pass the unit test while + uploading a retired version update.""" + from bggpipe.resolve import MATCH_COLUMNS + + cfg = _cfg(tmp_path) + _seed_data( + tmp_path, + to_add=[_add_row(bgg_id="140509", name="Dungeons & Dragons")], + to_update=[_update_row(collid="9", bgg_id="240", name="Britannia")], + ) + blank = dict.fromkeys(MATCH_COLUMNS, "") + _write_csv( + tmp_path / "matches.csv", + MATCH_COLUMNS, + [ + # 140509 was re-reviewed: it's the RPG blue box, kept local + {**blank, "title_raw": "D&D", "match_status": "local", "bgg_id": ""}, + # 240 vanished from matches entirely (removed from the catalog) + {**blank, "title_raw": "WINGSPAN", "match_status": "auto", "bgg_id": "1"}, + ], + ) + fake = FakeUploader() + run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now) + assert fake.calls == [] + out = capsys.readouterr().out + assert "Skipping 2" in out + # retired jobs leave no trace to be "already done" later + assert not (tmp_path / "upload_log.csv").exists() + + # control: an endorsed job still runs + _write_csv( + tmp_path / "matches.csv", + MATCH_COLUMNS, + [ + {**blank, "title_raw": "X", "match_status": "auto", "bgg_id": "140509"}, + { + **blank, + "title_raw": "Y", + "match_status": "approved", + "bgg_id": "240", + "version_id": "25", # per-copy endorsement: version must agree + }, + ], + ) + run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now) + assert [(j.action, j.bgg_id) for j in fake.calls] == [ + ("add", "140509"), + ("update", "240"), + ] + + +def test_rejected_credentials_raise_and_persist_no_session(tmp_path, monkeypatch): + """If the site still offers Sign In after the form was submitted, the + login FAILED — treating it as success would mark the run authed and + persist an anonymous storage state that poisons every later run.""" + from bggpipe.upload import LoginError, PlaywrightUploader + + monkeypatch.setenv("BGG_USERNAME", "someone") + monkeypatch.setenv("BGG_PASSWORD", "wrong-pw") + state_calls = [] + + class _Loc: + def __init__(self, n=1): + self._n = n + + @property + def first(self): + return self + + def count(self): + return self._n + + def is_visible(self): + return True + + def wait_for(self, **kw): + return None + + def fill(self, value): + pass + + def click(self): + pass + + class _Page: + title = staticmethod(lambda: "BoardGameGeek") + + def goto(self, url, **kw): + pass + + def locator(self, selector): + return _Loc() + + def get_by_role(self, role, **kw): + return _Loc() + + def wait_for_url(self, pred, **kw): + return None # the URL left /login (site redirects even on failure) + + def wait_for_timeout(self, ms): + pass + + class _Context: + def storage_state(self, path): + state_calls.append(path) + + up = PlaywrightUploader("someone", storage_state=tmp_path / "state.json") + up._page = _Page() + up._context = _Context() + # signed-out before AND after submitting the form: Sign In stays visible + monkeypatch.setattr(up, "_signed_out", lambda: True) + with pytest.raises(LoginError, match="credentials rejected"): + up._ensure_logged_in() + assert up._authed is False + assert state_calls == [] # no anonymous session saved for reuse + + +def test_update_entry_failure_paths_leave_the_entry_untouched(): + """The three guarded exits of the collection-cell editor: no cell for + the collid, the wanted version id never offered (must press Escape — + the editor is open on a REAL entry), and a save that never settles.""" + from bggpipe.upload import PlaywrightUploader, UploadJob + + class _Timeout(Exception): + pass + + def make_page(cell_count=1, radio_appears=True, settles=True): + pressed = [] + + class _Loc: + def __init__(self, selector): + self.selector = selector + + @property + def first(self): + return self + + def count(self): + return cell_count if "collection_version" in self.selector else 1 + + def wait_for(self, **kw): + if "radio" in self.selector and not radio_appears: + raise _Timeout("radio never appeared") + + def click(self): + pass + + def text_content(self): + return "Editing" if not settles else "Avalon Hill second" + + class _Keyboard: + def press(self, key): + pressed.append(key) + + class _Page: + keyboard = _Keyboard() + + def goto(self, url, **kw): + pass + + def locator(self, selector): + return _Loc(selector) + + def wait_for_timeout(self, ms): + pass + + return _Page(), pressed + + def uploader(page): + up = PlaywrightUploader("someone") + up._page = page + up._authed = True + up._timeout_error = _Timeout + return up + + job = UploadJob( + action="update", + bgg_id=240, + name="Britannia", + collid="53429559", + version_id="24621", + version_name="AH", + ) + + page, _ = make_page(cell_count=0) + with pytest.raises(RuntimeError, match="re-run diff"): + uploader(page).update_entry(job) + + page, pressed = make_page(radio_appears=False) + with pytest.raises(RuntimeError, match="entry left untouched"): + uploader(page).update_entry(job) + assert pressed == ["Escape"] # the open editor was closed, not abandoned + + page, _ = make_page(settles=False) + with pytest.raises(RuntimeError, match="never left its editing state"): + uploader(page).update_entry(job) + + +def test_scrub_flattens_multiline_errors_for_the_csv_log(): + text = "Call log:\n - waiting for locator\n - retrying click" + assert "\n" not in _scrub(text) + assert _scrub(text) == "Call log: - waiting for locator - retrying click" + + +def test_stale_jobs_retires_the_copy_not_just_the_game(): + """Rejecting ONE of two queued editions must not ride along on the + surviving edition's endorsement.""" + queue = [ + {"bgg_id": "589", "version_id": "100"}, + {"bgg_id": "589", "version_id": "200"}, + ] + matches = [ + {"bgg_id": "589", "match_status": "approved", "version_id": "100"}, + {"bgg_id": "589", "match_status": "rejected", "version_id": "200"}, + ] + stale = stale_jobs(queue, matches) + assert "589" in stale and "no longer endorses that copy" in stale["589"] + # both endorsed: nothing stale + matches[1]["match_status"] = "approved" + assert stale_jobs(queue, matches) == {} + + +def test_annotate_queue_marks_one_done_per_completion(): + """Vetoed duplicate copies share a job key; one success is one copy.""" + queue = [ + {"bgg_id": "13", "version_id": ""}, + {"bgg_id": "13", "version_id": ""}, + ] + log = [_log_row(bgg_id="13", status="added")] + states = [r["state"] for r in annotate_queue(queue, "add", log)] + assert states == ["done", ""] + log.append(_log_row(bgg_id="13", status="added")) + states = [r["state"] for r in annotate_queue(queue, "add", log)] + assert states == ["done", "done"] + + +def test_add_game_reports_already_present_on_owned_page(): + """A game page showing "In Collections" instead of "Add To" is positive + evidence a previous attempt landed — retrying the add would duplicate.""" + from bggpipe.upload import PlaywrightUploader, UploadJob + + class _Btn: + def __init__(self, present): + self._present = present + + @property + def first(self): + return self + + def count(self): + return 1 if self._present else 0 + + def is_visible(self): + return self._present + + class _Page: + def goto(self, url, **kw): + pass + + def get_by_role(self, role, name=None, **kw): + return _Btn(name == "In Collections") + + def wait_for_timeout(self, ms): + pass + + up = PlaywrightUploader("someone") + up._page = _Page() + up._authed = True + job = UploadJob(action="add", bgg_id="240", name="Britannia") + status, note = up.add_game(job) + assert status == "already_present" + assert "previous attempt" in note + + # a SECOND copy can't go through the unverified In Collections flow + job2 = UploadJob(action="add", bgg_id="240", name="Britannia", second_copy=True) + with pytest.raises(RuntimeError, match="add this copy by hand"): + up.add_game(job2) + + +def test_update_entry_rejects_a_settle_on_the_wrong_version(): + """The editor closing proves nothing: if the cell re-renders its OLD + content the AJAX save failed and "updated" would be a false record.""" + from bggpipe.upload import PlaywrightUploader, UploadJob + + class _Loc: + def __init__(self, selector): + self.selector = selector + + @property + def first(self): + return self + + def count(self): + return 1 + + def wait_for(self, **kw): + pass + + def click(self): + pass + + def text_content(self): + return "Some Other Edition (2001)" # the OLD content, not ours + + class _Page: + def goto(self, url, **kw): + pass + + def locator(self, selector): + return _Loc(selector) + + def wait_for_timeout(self, ms): + pass + + up = PlaywrightUploader("someone") + up._page = _Page() + up._authed = True + job = UploadJob( + action="update", + bgg_id="240", + name="Britannia", + collid="9", + version_id="24621", + version_name="Avalon Hill second edition", + ) + with pytest.raises(RuntimeError, match="did not land"): + up.update_entry(job) + + +def test_version_drift_skips_the_doomed_readd(tmp_path, capsys): + """A game the log shows added under a different version is already on + BGG — re-adding can only duplicate it, so the queue skips it.""" + cfg = _cfg(tmp_path) + _seed_data( + tmp_path, + to_add=[_add_row(bgg_id="1", name="Wingspan", version_id="99")], + log=[_log_row(bgg_id="1", version_id="55", status="added")], + ) + fake = FakeUploader() + run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now) + assert fake.calls == [] + assert "correct the version by hand" in capsys.readouterr().out + + +def test_abort_needs_identical_messages_not_just_same_class(tmp_path): + """Three DIFFERENT per-game failures are three unlucky games, not a + systemic outage; only identical messages abort the run.""" + cfg = _cfg(tmp_path) + rows = [_add_row(bgg_id=str(i), name=f"Game {i}") for i in (1, 2, 3, 4)] + _seed_data(tmp_path, to_add=rows) + + class _VariedFail(FakeUploader): + def add_game(self, job): + self.calls.append(job) + raise RuntimeError(f"distinct problem with {job.name}") + + varied = _VariedFail() + run_upload(cfg, uploader=varied, sleep=lambda s: None, now=_now) + assert len(varied.calls) == 4 # no abort: every message differed