From ab0e3c5ae2adfed95e5bbeecd2da323168003e22 Mon Sep 17 00:00:00 2001 From: Sebastian Dine Date: Thu, 23 Jul 2026 17:58:00 +0200 Subject: [PATCH] Fix: replace api.pokemontcg.io with api.tcgdex.net (#20) --- AGENTS.md | 2 +- cmake/Dependencies.cmake | 2 +- core/AGENTS.md | 4 +- core/CMakeLists.txt | 2 + .../pokemon/PokemonCardPreviewSource.hpp | 53 +-- .../pokemon/PokemonCollectionSetSync.hpp | 21 + .../ccm/games/pokemon/PokemonGameModule.hpp | 2 +- .../ccm/games/pokemon/PokemonSetSource.hpp | 43 +- .../ccm/games/pokemon/PokemonWestSetId.hpp | 16 + .../ccm/services/CollectionService.hpp | 9 + core/src/domain/PokemonCard.cpp | 7 + .../pokemon/PokemonCardPreviewSource.cpp | 320 ++++++++------ .../pokemon/PokemonCollectionSetSync.cpp | 70 +++ core/src/games/pokemon/PokemonSetSource.cpp | 311 ++++++------- core/src/games/pokemon/PokemonWestSetId.cpp | 92 ++++ core/src/services/PokemonSetCompletion.cpp | 17 +- docs/assets-and-info-apis.md | 29 +- tests/AGENTS.md | 6 +- tests/CMakeLists.txt | 2 + tests/collection_service_tests.cpp | 18 + tests/domain_json_tests.cpp | 42 ++ tests/pokemon_card_preview_source_tests.cpp | 411 +++++------------- tests/pokemon_collection_set_sync_tests.cpp | 74 ++++ tests/pokemon_set_completion_tests.cpp | 22 + tests/pokemon_set_source_tests.cpp | 188 ++++---- tests/pokemon_west_set_id_tests.cpp | 41 ++ ui_wx/src/PokemonGameView.cpp | 47 +- 27 files changed, 1067 insertions(+), 784 deletions(-) create mode 100644 core/include/ccm/games/pokemon/PokemonCollectionSetSync.hpp create mode 100644 core/include/ccm/games/pokemon/PokemonWestSetId.hpp create mode 100644 core/src/games/pokemon/PokemonCollectionSetSync.cpp create mode 100644 core/src/games/pokemon/PokemonWestSetId.cpp create mode 100644 tests/pokemon_collection_set_sync_tests.cpp create mode 100644 tests/pokemon_west_set_id_tests.cpp diff --git a/AGENTS.md b/AGENTS.md index be09f33..371dc8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ Run from the **workspace root**. - Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically. - `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically. - `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `/.cache/preview-cache/` — **next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path. - - `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview. + - `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview. ## Windows UI theming guardrails diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 6e455f8..a24a10b 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -31,7 +31,7 @@ FetchContent_MakeAvailable(nlohmann_json) # --------------------------------------------------------------------------- # cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need -# a system libcurl. Used for Scryfall + pokemontcg.io REST calls. +# a system libcurl. Used for Scryfall + TCGdex REST calls. # # Pinned at 1.10.5 deliberately. 1.11.x adds an `install(EXPORT cprTargets)` # rule that references `libcurl_shared`, which isn't in any export set when diff --git a/core/AGENTS.md b/core/AGENTS.md index c220ca9..9c7fe41 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -8,7 +8,7 @@ - `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns. - `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. - `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain. -- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `parseCatalog` / `fetchAllWithCatalog` (or Asia equivalents) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill. +- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill. - `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested). - `src/` mirrors `include/ccm/` for non-template implementations. @@ -27,7 +27,7 @@ 8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result` is a binary buffer, not text, so callers can use it for image payloads directly. 9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`----.`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `||||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface. 10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path. -11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact. +11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact. ## Adding a new game diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index f9e1647..c60676e 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -36,6 +36,8 @@ add_library(ccm_core STATIC src/games/magic/MagicSetSource.cpp src/games/magic/MagicCardPreviewSource.cpp src/games/magic/MagicGameModule.cpp + src/games/pokemon/PokemonWestSetId.cpp + src/games/pokemon/PokemonCollectionSetSync.cpp src/games/pokemon/PokemonSetSource.cpp src/games/pokemon/PokemonCardPreviewSource.cpp src/games/pokemon/PokemonGameModule.cpp diff --git a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp index 2b6c764..5962d8e 100644 --- a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp +++ b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp @@ -1,11 +1,9 @@ #pragma once -// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon -// TCG. When set id + collector number are both known, prefers -// GET https://api.pokemontcg.io/v2/cards/{setId}-{number} -// then falls back to a name-less search `set.id:… number:…`. Name-based -// search is kept for lookups that lack a set number (or set id). Returns -// `images.large` (with `images.small` as a graceful fallback). +// PokemonCardPreviewSource: West Pokemon previews via TCGdex EN. +// Prefers GET /v2/en/cards/{setId}-{localId}, then filtered card search, then +// set-detail name match for auto-detect. Image URLs append /high.png (wxImage +// decodes PNG, not webp). #include "ccm/ports/ICardPreviewSource.hpp" #include "ccm/ports/IHttpClient.hpp" @@ -31,40 +29,33 @@ public: Result> detectPrintVariants(std::string_view name, std::string_view setId) override; - // Build the fully URL-encoded Pokemon TCG search URL for the given card. - // When both setId and setNo are non-empty, omits the name: clause so the - // Lucene query cannot miss on name∩number intersections. - // Exposed for unit testing and to keep encoding rules in one place. + // Strip everything after the first '/' (e.g. "4/102" -> "4"). + static std::string normalizeCollectorNumber(std::string_view setNo); + + static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo); + static std::string buildSetDetailUrl(std::string_view setId); static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo); + static std::string imageUrlFromBase(std::string_view imageBase); - // Direct card endpoint: /v2/cards/{setId}-{normalizedNumber}. - static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo); + struct SetCardRow { + std::string localId; + std::string name; + std::string imageBase; + std::string rarity; + }; - // Strip everything after the first '/' (e.g. "4/102" -> "4"). Used by - // preview lookups, auto-detect, and set-completion ownership matching. - static std::string normalizeCollectorNumber(std::string_view setNo); + static Result, PreviewLookupError> + parseSetCards(const std::string& body); - // Slimmer search URL for auto-detect: omits the number clause and asks the - // API for only the fields the print-variant parser needs. - static std::string buildDetectSearchUrl(std::string_view name, - std::string_view setId); - - // Parse a Pokemon TCG /v2/cards *search* response body (`data` array) and - // pull out the image URL for the first matching card. Prefers - // `images.large`, falls back to `images.small`. Errors are classified: - // - JSON parse failure or missing/non-array `data` => Transient. - // - Empty `data` array or missing image variants => NotFound. - static Result - parseResponse(const std::string& body); - - // Parse a Pokemon TCG /v2/cards/{id} response (`data` object). static Result parseCardByIdResponse(const std::string& body); - // Enumerate distinct collector numbers (and rarities) for an exact card - // name inside the chosen set. Exposed for unit testing without HTTP. + // Parse a slim TCGdex cards-array search response; prefer first hit with image. + static Result + parseSearchResponse(const std::string& body); + static Result> parsePrintVariants(const std::string& body, std::string_view setId, diff --git a/core/include/ccm/games/pokemon/PokemonCollectionSetSync.hpp b/core/include/ccm/games/pokemon/PokemonCollectionSetSync.hpp new file mode 100644 index 0000000..72a92a0 --- /dev/null +++ b/core/include/ccm/games/pokemon/PokemonCollectionSetSync.hpp @@ -0,0 +1,21 @@ +#pragma once + +// Sync Pokemon collection cards against freshly fetched set lists: +// - West: canonicalize legacy pokemontcg set ids, then refresh name/date +// - Asia: refresh name/date when the set id is present in the Asia list + +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/Set.hpp" + +#include +#include + +namespace ccm { + +// Mutates cards in place. Returns how many cards changed at least one set field. +[[nodiscard]] std::size_t syncPokemonCollectionSets( + std::vector& cards, + const std::vector& westSets, + const std::vector& asiaSets); + +} // namespace ccm diff --git a/core/include/ccm/games/pokemon/PokemonGameModule.hpp b/core/include/ccm/games/pokemon/PokemonGameModule.hpp index 62d6114..30d0b6e 100644 --- a/core/include/ccm/games/pokemon/PokemonGameModule.hpp +++ b/core/include/ccm/games/pokemon/PokemonGameModule.hpp @@ -1,7 +1,7 @@ #pragma once // PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source -// and card preview source, both backed by api.pokemontcg.io/v2. +// and card preview source, both backed by TCGdex EN (api.tcgdex.net/v2/en). #include "ccm/games/IGameModule.hpp" #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" diff --git a/core/include/ccm/games/pokemon/PokemonSetSource.hpp b/core/include/ccm/games/pokemon/PokemonSetSource.hpp index 1aae657..1f2c986 100644 --- a/core/include/ccm/games/pokemon/PokemonSetSource.hpp +++ b/core/include/ccm/games/pokemon/PokemonSetSource.hpp @@ -1,12 +1,8 @@ #pragma once -// PokemonSetSource: ISetSource implementation for the Pokemon TCG. -// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the -// response into our `Set` domain type, and sorts by release date ascending. -// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format, -// so no rewriting is needed (unlike Scryfall's `released_at`). -// Behavior matches `pokemon/set_services.rs::update_sets`. -// Set-completion catalog is built from a paginated /v2/cards dump. +// PokemonSetSource: ISetSource for West Pokemon via TCGdex EN +// (https://api.tcgdex.net/v2/en). List endpoint returns a slim array; release +// dates and set-completion checklists come from per-set detail GETs. #include "ccm/domain/PokemonSetCatalog.hpp" #include "ccm/domain/Set.hpp" @@ -14,15 +10,14 @@ #include "ccm/ports/IHttpClient.hpp" #include +#include #include namespace ccm { class PokemonSetSource final : public ISetSource { public: - static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets"; - static constexpr const char* kCardsEndpoint = "https://api.pokemontcg.io/v2/cards"; - static constexpr int kCardsPageSize = 250; + static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets"; struct FetchWithCatalog { std::vector sets; @@ -33,28 +28,18 @@ public: Result> fetchAll() override; - // Sets endpoint + paginated cards dump for the offline checklist. + // List + per-set detail (cards + release date) for the offline checklist. Result fetchAllWithCatalog(); - // Pure parser exposed for unit testing without a network round-trip. - static Result> parseResponse(const std::string& body); + // Pure parsers exposed for unit testing without a network round-trip. + static Result> parseListResponse(const std::string& body); + static Result parseReleaseDate(const std::string& detailBody); + static std::string rewriteReleaseDate(std::string_view isoDate); + static std::string buildSetDetailUrl(std::string_view setId); - // Build / merge checklist packs from one /v2/cards page body. Pass an - // accumulating catalog; returns page count metadata for pagination. - struct CardsPageMeta { - int page{1}; - int pageSize{kCardsPageSize}; - int count{0}; - int totalCount{0}; - }; - static Result mergeCardsPage(const std::string& body, - PokemonSetCatalog& catalog, - const std::vector& sets); - - static Result parseCatalog(const std::string& body, - const std::vector& sets); - - static std::string buildCardsPageUrl(int page, int pageSize = kCardsPageSize); + static Result parseCatalogPackFromSetDetail( + const std::string& detailBody, + const Set& set); private: IHttpClient& http_; diff --git a/core/include/ccm/games/pokemon/PokemonWestSetId.hpp b/core/include/ccm/games/pokemon/PokemonWestSetId.hpp new file mode 100644 index 0000000..c45f283 --- /dev/null +++ b/core/include/ccm/games/pokemon/PokemonWestSetId.hpp @@ -0,0 +1,16 @@ +#pragma once + +// Canonicalize legacy pokemontcg.io West set ids to TCGdex EN ids. +// Identity when the id is already TCGdex (or unknown). Asia set ids must not +// be passed through this helper. + +#include +#include + +namespace ccm { + +// Returns the TCGdex EN set id for a West Pokemon card.set.id. Unknown ids +// and ids that already match TCGdex are returned unchanged. +[[nodiscard]] std::string canonicalizeWestSetId(std::string_view setId); + +} // namespace ccm diff --git a/core/include/ccm/services/CollectionService.hpp b/core/include/ccm/services/CollectionService.hpp index 17151d5..2a4d9dc 100644 --- a/core/include/ccm/services/CollectionService.hpp +++ b/core/include/ccm/services/CollectionService.hpp @@ -80,6 +80,15 @@ public: return repo_.save(game, map); } + // Replace the entire collection map in one save (e.g. after bulk set-id sync). + Result saveAll(Game game, std::vector cards) { + Map map; + for (auto& card : cards) { + map.insert_or_assign(card.id, std::move(card)); + } + return repo_.save(game, map); + } + // Remove the card with the given id. Also deletes any associated images // via the IImageStore (best-effort - image removal failures are logged in // the error string but the card itself is still purged from the JSON). diff --git a/core/src/domain/PokemonCard.cpp b/core/src/domain/PokemonCard.cpp index 6e9cfc0..18f1955 100644 --- a/core/src/domain/PokemonCard.cpp +++ b/core/src/domain/PokemonCard.cpp @@ -1,5 +1,7 @@ #include "ccm/domain/PokemonCard.hpp" +#include "ccm/games/pokemon/PokemonWestSetId.hpp" + namespace ccm { void to_json(nlohmann::json& j, const PokemonCard& c) { @@ -37,6 +39,11 @@ void from_json(const nlohmann::json& j, PokemonCard& c) { j.at("altered").get_to(c.altered); // Missing `region` defaults to West so pre-merge West-only files still load. c.region = j.value("region", PokemonRegion::West); + // Migrate legacy pokemontcg.io West set ids to TCGdex EN on load so the + // next collection save persists canonical ids. Asia ids are untouched. + if (c.region == PokemonRegion::West && !c.set.id.empty()) { + c.set.id = canonicalizeWestSetId(c.set.id); + } } } // namespace ccm diff --git a/core/src/games/pokemon/PokemonCardPreviewSource.cpp b/core/src/games/pokemon/PokemonCardPreviewSource.cpp index e3ac69c..5812a23 100644 --- a/core/src/games/pokemon/PokemonCardPreviewSource.cpp +++ b/core/src/games/pokemon/PokemonCardPreviewSource.cpp @@ -1,5 +1,6 @@ #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" +#include "ccm/games/pokemon/PokemonWestSetId.hpp" #include "ccm/util/Rfc3986.hpp" #include @@ -26,29 +27,11 @@ std::string toLower(std::string s) { return s; } -Result imageUrlFromCardObject(const nlohmann::json& card) { - using R = Result; - using K = PreviewLookupError::Kind; - if (!card.contains("images") || !card.at("images").is_object()) { - return R::err({K::NotFound, "Card has no 'images' object."}); - } - const auto& images = card.at("images"); - if (images.contains("large") && images.at("large").is_string()) { - return R::ok(images.at("large").get()); - } - if (images.contains("small") && images.at("small").is_string()) { - return R::ok(images.at("small").get()); - } - return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."}); -} - } // namespace PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {} std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) { - // Pokemon TCG search uses an unquoted `number:` clause (e.g. number:4 or - // number:TG14). Cards are commonly stored as `4/102`; strip the suffix. std::string s(setNo); const auto slash = s.find('/'); if (slash != std::string::npos) { @@ -57,68 +40,85 @@ std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view return s; } -std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name, - std::string_view setId, - std::string_view setNo) { - // When both set id and collector number are known, omit name: — Lucene - // name∩number intersections can miss even when the print is real, and - // collector numbers are unique within a set. - const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo); - std::string query; - if (!setId.empty() && !num.empty()) { - query = "set.id:"; - query += std::string(setId); - query += " number:"; - query += num; - } else { - query = "name:\""; - query += std::string(name); - query += "\""; - if (!setId.empty()) { - query += " set.id:"; - query += std::string(setId); - } - if (!num.empty()) { - query += " number:"; - query += num; - } - } - return std::string("https://api.pokemontcg.io/v2/cards?q=") + - rfc3986PercentEncode(query); +std::string PokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) { + if (imageBase.empty()) return {}; + std::string url(imageBase); + while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back(); + return url + "/high.png"; } std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId, std::string_view setNo) { - const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo); - std::string id = std::string(setId) + "-" + num; - return std::string("https://api.pokemontcg.io/v2/cards/") + rfc3986PercentEncode(id); + const std::string idCanon = canonicalizeWestSetId(setId); + const std::string num = normalizeCollectorNumber(setNo); + std::string id = idCanon + "-" + num; + return std::string("https://api.tcgdex.net/v2/en/cards/") + rfc3986PercentEncode(id); } -std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name, - std::string_view setId) { - std::string url = buildSearchUrl(name, setId, ""); - url += "&select=name,number,rarity,set"; - url += "&pageSize=50"; +std::string PokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) { + return std::string("https://api.tcgdex.net/v2/en/sets/") + + rfc3986PercentEncode(canonicalizeWestSetId(setId)); +} + +std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) { + const std::string idCanon = canonicalizeWestSetId(setId); + const std::string num = normalizeCollectorNumber(setNo); + std::string url = "https://api.tcgdex.net/v2/en/cards?"; + bool first = true; + auto append = [&](std::string_view key, std::string_view value) { + if (value.empty()) return; + if (!first) url += '&'; + first = false; + url += std::string(key); + url += "=eq:"; + url += rfc3986PercentEncode(value); + }; + + if (!idCanon.empty() && !num.empty()) { + append("set.id", idCanon); + append("localId", num); + } else { + append("name", name); + append("set.id", idCanon); + append("localId", num); + } return url; } -Result -PokemonCardPreviewSource::parseResponse(const std::string& body) { - using R = Result; +Result, PreviewLookupError> +PokemonCardPreviewSource::parseSetCards(const std::string& body) { + using R = Result, PreviewLookupError>; using K = PreviewLookupError::Kind; try { const auto j = nlohmann::json::parse(body); - if (!j.contains("data") || !j.at("data").is_array()) { - return R::err({K::Transient, "Pokemon TCG response missing 'data' array."}); + if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) { + return R::err({K::Transient, + "TCGdex EN set detail missing 'cards' array."}); } - const auto& data = j.at("data"); - if (data.empty()) { - return R::err({K::NotFound, "Pokemon TCG returned no matching cards."}); + std::vector out; + out.reserve(j.at("cards").size()); + for (const auto& card : j.at("cards")) { + SetCardRow row; + row.localId = card.value("localId", ""); + if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) { + const std::string id = card.at("id").get(); + const auto dash = id.rfind('-'); + if (dash != std::string::npos) row.localId = id.substr(dash + 1); + } + row.name = card.value("name", ""); + row.rarity = card.value("rarity", ""); + if (card.contains("image") && card.at("image").is_string()) { + row.imageBase = card.at("image").get(); + } + if (row.localId.empty()) continue; + out.push_back(std::move(row)); } - return imageUrlFromCardObject(data.at(0)); + return R::ok(std::move(out)); } catch (const std::exception& e) { return R::err({K::Transient, - std::string("Pokemon TCG JSON parse error: ") + e.what()}); + std::string("TCGdex EN set detail JSON parse error: ") + e.what()}); } } @@ -128,13 +128,48 @@ PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) { using K = PreviewLookupError::Kind; try { const auto j = nlohmann::json::parse(body); - if (!j.contains("data") || !j.at("data").is_object()) { - return R::err({K::Transient, "Pokemon TCG card response missing 'data' object."}); + if (!j.is_object()) { + return R::err({K::Transient, "TCGdex EN card response is not a JSON object."}); } - return imageUrlFromCardObject(j.at("data")); + if (!j.contains("image") || j.at("image").is_null()) { + return R::err({K::NotFound, "TCGdex EN card has no image."}); + } + if (!j.at("image").is_string()) { + return R::err({K::Transient, "TCGdex EN card image field is not a string."}); + } + const std::string base = j.at("image").get(); + if (base.empty()) { + return R::err({K::NotFound, "TCGdex EN card has no image."}); + } + return R::ok(imageUrlFromBase(base)); } catch (const std::exception& e) { return R::err({K::Transient, - std::string("Pokemon TCG JSON parse error: ") + e.what()}); + std::string("TCGdex EN card JSON parse error: ") + e.what()}); + } +} + +Result +PokemonCardPreviewSource::parseSearchResponse(const std::string& body) { + using R = Result; + using K = PreviewLookupError::Kind; + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_array()) { + return R::err({K::Transient, "TCGdex EN cards search response is not an array."}); + } + if (j.empty()) { + return R::err({K::NotFound, "TCGdex EN returned no matching cards."}); + } + for (const auto& card : j) { + if (!card.contains("image") || !card.at("image").is_string()) continue; + const std::string base = card.at("image").get(); + if (base.empty()) continue; + return R::ok(imageUrlFromBase(base)); + } + return R::err({K::NotFound, "TCGdex EN matching cards have no image."}); + } catch (const std::exception& e) { + return R::err({K::Transient, + std::string("TCGdex EN cards search JSON parse error: ") + e.what()}); } } @@ -145,79 +180,53 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name, using R = Result; using K = PreviewLookupError::Kind; + const std::string idCanon = canonicalizeWestSetId(setId); const std::string num = normalizeCollectorNumber(setNo); - if (!setId.empty() && !num.empty()) { - auto byId = http_.get(buildCardByIdUrl(setId, num)); + if (!idCanon.empty() && !num.empty()) { + auto byId = http_.get(buildCardByIdUrl(idCanon, num)); if (byId) { auto img = parseCardByIdResponse(byId.value()); if (img) return img; - // NotFound (no images) or Transient (schema): fall through to search. + // NotFound / Transient schema: fall through to search. } - // HTTP failure (404/5xx/offline): fall through to search. } - const std::string url = buildSearchUrl(name, setId, setNo); + const std::string url = buildSearchUrl(name, idCanon, num); auto resp = http_.get(url); if (!resp) return R::err({K::Transient, resp.error()}); - return parseResponse(resp.value()); + return parseSearchResponse(resp.value()); } Result> PokemonCardPreviewSource::parsePrintVariants( const std::string& body, - std::string_view setId, + std::string_view /*setId*/, std::string_view wantedCardName) { using R = Result>; - try { - const auto j = nlohmann::json::parse(body); - if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) { - return R::err("Pokemon TCG returned no matching cards."); - } - const std::string wantedSetId = trim(std::string(setId)); - const std::string wantedNameLower = toLower(trim(std::string(wantedCardName))); - - std::vector collected; - auto pushCard = [&collected](const nlohmann::json& card) { - AutoDetectedPrint out; - out.setNo = trim(card.value("number", "")); - out.rarity = trim(card.value("rarity", "")); - if (out.setNo.empty() && out.rarity.empty()) return; - collected.push_back(std::move(out)); - }; - - for (const auto& card : j.at("data")) { - if (!wantedNameLower.empty()) { - const std::string cardName = trim(card.value("name", "")); - if (toLower(cardName) != wantedNameLower) continue; - } - if (!wantedSetId.empty()) { - std::string cardSetId; - if (card.contains("set") && card.at("set").is_object()) { - cardSetId = trim(card.at("set").value("id", "")); - } - if (cardSetId != wantedSetId) continue; - } - pushCard(card); - } - - if (collected.empty()) { - if (!wantedNameLower.empty() && !wantedSetId.empty()) { - return R::err("Could not auto-detect set print metadata."); - } - return R::err("Pokemon TCG returned no matching cards."); - } - - std::vector deduped; - deduped.reserve(collected.size()); - std::unordered_set seen; - seen.reserve(collected.size() * 2); - for (auto& p : collected) { - const std::string key = p.setNo + '\0' + p.rarity; - if (seen.insert(key).second) deduped.push_back(std::move(p)); - } - return R::ok(std::move(deduped)); - } catch (const std::exception& e) { - return R::err(std::string("Pokemon TCG JSON parse error: ") + e.what()); + auto rows = parseSetCards(body); + if (!rows) { + return R::err(rows.error().message); } + + const std::string wantedLower = toLower(trim(std::string(wantedCardName))); + std::vector out; + std::unordered_set seen; + + for (const auto& row : rows.value()) { + if (!wantedLower.empty()) { + if (toLower(trim(row.name)) != wantedLower) continue; + } + const std::string localId = normalizeCollectorNumber(row.localId); + if (localId.empty() || !seen.insert(localId + '\0' + row.rarity).second) continue; + AutoDetectedPrint print; + print.setNo = localId; + print.rarity = row.rarity; + out.push_back(std::move(print)); + } + + if (out.empty()) { + return R::err("Could not auto-detect set print metadata."); + } + return R::ok(std::move(out)); } Result PokemonCardPreviewSource::detectFirstPrint(std::string_view name, @@ -234,15 +243,60 @@ Result> PokemonCardPreviewSource::detectPrintVari std::string_view name, std::string_view setId) { using R = Result>; - const std::string url = buildDetectSearchUrl(name, setId); - auto resp = http_.get(url); - if (resp) { - return parsePrintVariants(resp.value(), setId, name); + const std::string idCanon = canonicalizeWestSetId(setId); + if (!idCanon.empty()) { + auto detail = http_.get(buildSetDetailUrl(idCanon)); + if (detail) { + auto parsed = parsePrintVariants(detail.value(), idCanon, name); + if (parsed) return parsed; + } + } + + // Fallback: filtered cards search by name (+ optional set). + const std::string url = buildSearchUrl(name, idCanon, ""); + auto resp = http_.get(url); + if (!resp) return R::err(resp.error()); + + try { + const auto j = nlohmann::json::parse(resp.value()); + if (!j.is_array() || j.empty()) { + return R::err("TCGdex EN returned no matching cards."); + } + const std::string wantedLower = toLower(trim(std::string(name))); + std::vector collected; + std::unordered_set seen; + for (const auto& card : j) { + if (!wantedLower.empty()) { + const std::string cardName = trim(card.value("name", "")); + if (toLower(cardName) != wantedLower) continue; + } + if (!idCanon.empty()) { + std::string cardSetId; + if (card.contains("set") && card.at("set").is_object()) { + cardSetId = trim(card.at("set").value("id", "")); + } else if (card.contains("id") && card.at("id").is_string()) { + // Slim search hits are "setId-localId". + const std::string id = card.at("id").get(); + const auto dash = id.rfind('-'); + if (dash != std::string::npos) cardSetId = id.substr(0, dash); + } + if (cardSetId != idCanon) continue; + } + AutoDetectedPrint print; + print.setNo = normalizeCollectorNumber(card.value("localId", "")); + print.rarity = trim(card.value("rarity", "")); + if (print.setNo.empty() && print.rarity.empty()) continue; + const std::string key = print.setNo + '\0' + print.rarity; + if (!seen.insert(key).second) continue; + collected.push_back(std::move(print)); + } + if (collected.empty()) { + return R::err("Could not auto-detect set print metadata."); + } + return R::ok(std::move(collected)); + } catch (const std::exception& e) { + return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what()); } - const std::string fallbackUrl = buildDetectSearchUrl(name, ""); - auto fallback = http_.get(fallbackUrl); - if (!fallback) return R::err(fallback.error()); - return parsePrintVariants(fallback.value(), setId, name); } } // namespace ccm diff --git a/core/src/games/pokemon/PokemonCollectionSetSync.cpp b/core/src/games/pokemon/PokemonCollectionSetSync.cpp new file mode 100644 index 0000000..5c27cda --- /dev/null +++ b/core/src/games/pokemon/PokemonCollectionSetSync.cpp @@ -0,0 +1,70 @@ +#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp" + +#include "ccm/games/pokemon/PokemonWestSetId.hpp" + +#include + +namespace ccm { + +namespace { + +std::unordered_map indexById(const std::vector& sets) { + std::unordered_map out; + out.reserve(sets.size()); + for (const auto& s : sets) { + if (s.id.empty()) continue; + out.emplace(s.id, &s); + } + return out; +} + +bool applySetMetadata(PokemonCard& card, const Set& upstream) { + bool changed = false; + if (card.set.name != upstream.name) { + card.set.name = upstream.name; + changed = true; + } + if (card.set.releaseDate != upstream.releaseDate) { + card.set.releaseDate = upstream.releaseDate; + changed = true; + } + return changed; +} + +} // namespace + +std::size_t syncPokemonCollectionSets(std::vector& cards, + const std::vector& westSets, + const std::vector& asiaSets) { + const auto westById = indexById(westSets); + const auto asiaById = indexById(asiaSets); + + std::size_t touched = 0; + for (auto& card : cards) { + bool changed = false; + if (card.region == PokemonRegion::West) { + if (!card.set.id.empty()) { + const std::string canon = canonicalizeWestSetId(card.set.id); + if (canon != card.set.id) { + card.set.id = canon; + changed = true; + } + } + if (!card.set.id.empty()) { + if (const auto it = westById.find(card.set.id); it != westById.end()) { + if (applySetMetadata(card, *it->second)) changed = true; + } + } + } else if (card.region == PokemonRegion::Asia) { + if (!card.set.id.empty()) { + if (const auto it = asiaById.find(card.set.id); it != asiaById.end()) { + if (applySetMetadata(card, *it->second)) changed = true; + } + } + } + if (changed) ++touched; + } + return touched; +} + +} // namespace ccm diff --git a/core/src/games/pokemon/PokemonSetSource.cpp b/core/src/games/pokemon/PokemonSetSource.cpp index 7e23c5a..d50bb51 100644 --- a/core/src/games/pokemon/PokemonSetSource.cpp +++ b/core/src/games/pokemon/PokemonSetSource.cpp @@ -1,193 +1,166 @@ #include "ccm/games/pokemon/PokemonSetSource.hpp" #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" +#include "ccm/util/Rfc3986.hpp" #include #include #include -#include #include #include namespace ccm { -namespace { +PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {} -void finalizeCatalog(PokemonSetCatalog& catalog) { - for (auto& pack : catalog.packs) { - std::sort(pack.cards.begin(), pack.cards.end(), - [](const PokemonCatalogCard& a, const PokemonCatalogCard& b) { - if (a.setNo != b.setNo) return a.setNo < b.setNo; - return a.name < b.name; - }); +std::string PokemonSetSource::rewriteReleaseDate(std::string_view isoDate) { + std::string out(isoDate); + for (char& ch : out) { + if (ch == '-') ch = '/'; } + return out; +} + +std::string PokemonSetSource::buildSetDetailUrl(std::string_view setId) { + return std::string("https://api.tcgdex.net/v2/en/sets/") + + rfc3986PercentEncode(setId); +} + +Result> PokemonSetSource::parseListResponse(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_array()) { + return Result>::err( + "TCGdex EN sets response is not a JSON array."); + } + std::vector out; + out.reserve(j.size()); + for (const auto& entry : j) { + Set s; + s.id = entry.value("id", ""); + if (s.id.empty()) continue; + s.name = entry.value("name", ""); + s.releaseDate = {}; // filled from set detail + out.push_back(std::move(s)); + } + return Result>::ok(std::move(out)); + } catch (const std::exception& e) { + return Result>::err( + std::string("TCGdex EN sets JSON parse error: ") + e.what()); + } +} + +Result PokemonSetSource::parseReleaseDate(const std::string& detailBody) { + try { + const auto j = nlohmann::json::parse(detailBody); + if (!j.is_object()) { + return Result::err( + "TCGdex EN set detail response is not a JSON object."); + } + const std::string raw = j.value("releaseDate", ""); + if (raw.empty()) { + return Result::ok(std::string{}); + } + return Result::ok(rewriteReleaseDate(raw)); + } catch (const std::exception& e) { + return Result::err( + std::string("TCGdex EN set detail JSON parse error: ") + e.what()); + } +} + +Result PokemonSetSource::parseCatalogPackFromSetDetail( + const std::string& detailBody, + const Set& set) { + auto rows = PokemonCardPreviewSource::parseSetCards(detailBody); + if (!rows) { + return Result::err(rows.error().message); + } + + PokemonSetCatalogPack pack; + pack.setId = set.id; + pack.setName = set.name.empty() ? set.id : set.name; + + std::unordered_set seen; + for (const auto& row : rows.value()) { + const std::string localId = + PokemonCardPreviewSource::normalizeCollectorNumber(row.localId); + if (localId.empty() || !seen.insert(localId).second) continue; + std::string name = row.name; + if (name.empty()) name = localId; + pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)}); + } + + std::sort(pack.cards.begin(), pack.cards.end(), + [](const PokemonCatalogCard& a, const PokemonCatalogCard& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); + if (pack.cards.empty()) { + return Result::err("No cards for set " + set.id); + } + return Result::ok(std::move(pack)); +} + +Result> PokemonSetSource::fetchAll() { + auto listResp = http_.get(kListEndpoint); + if (!listResp) return Result>::err(listResp.error()); + + auto parsed = parseListResponse(listResp.value()); + if (!parsed) return parsed; + + std::vector out = std::move(parsed).value(); + for (auto& s : out) { + auto detail = http_.get(buildSetDetailUrl(s.id)); + if (!detail) continue; // keep set with empty date rather than fail all + auto date = parseReleaseDate(detail.value()); + if (date && !date.value().empty()) { + s.releaseDate = std::move(date).value(); + } + } + + std::sort(out.begin(), out.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + return Result>::ok(std::move(out)); +} + +Result PokemonSetSource::fetchAllWithCatalog() { + auto listResp = http_.get(kListEndpoint); + if (!listResp) return Result::err(listResp.error()); + + auto parsed = parseListResponse(listResp.value()); + if (!parsed) return Result::err(parsed.error()); + + std::vector sets = std::move(parsed).value(); + PokemonSetCatalog catalog; + catalog.packs.reserve(sets.size()); + + for (auto& s : sets) { + auto detail = http_.get(buildSetDetailUrl(s.id)); + if (!detail) continue; + + if (s.releaseDate.empty()) { + auto date = parseReleaseDate(detail.value()); + if (date && !date.value().empty()) { + s.releaseDate = std::move(date).value(); + } + } + + auto pack = parseCatalogPackFromSetDetail(detail.value(), s); + if (pack) { + catalog.packs.push_back(std::move(pack).value()); + } + } + + std::sort(sets.begin(), sets.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); std::sort(catalog.packs.begin(), catalog.packs.end(), [](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) { return a.setName < b.setName; }); -} -} // namespace - -PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {} - -Result> PokemonSetSource::parseResponse(const std::string& body) { - try { - const auto j = nlohmann::json::parse(body); - if (!j.contains("data") || !j.at("data").is_array()) { - return Result>::err( - "Pokemon TCG API response missing 'data' array."); - } - std::vector out; - out.reserve(j.at("data").size()); - for (const auto& entry : j.at("data")) { - Set s; - s.id = entry.value("id", ""); - s.name = entry.value("name", ""); - // Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD; - // no separator rewrite needed (cf. Scryfall's "released_at"). - s.releaseDate = entry.value("releaseDate", ""); - out.push_back(std::move(s)); - } - std::sort(out.begin(), out.end(), - [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); - return Result>::ok(std::move(out)); - } catch (const std::exception& e) { - return Result>::err( - std::string("Pokemon TCG JSON parse error: ") + e.what()); - } -} - -std::string PokemonSetSource::buildCardsPageUrl(int page, int pageSize) { - return std::string(kCardsEndpoint) + "?select=name,number,set&pageSize=" + - std::to_string(pageSize) + "&page=" + std::to_string(page); -} - -Result -PokemonSetSource::mergeCardsPage(const std::string& body, - PokemonSetCatalog& catalog, - const std::vector& sets) { - try { - const auto j = nlohmann::json::parse(body); - if (!j.contains("data") || !j.at("data").is_array()) { - return Result::err( - "Pokemon TCG cards response missing 'data' array."); - } - - std::unordered_map idToName; - idToName.reserve(sets.size()); - for (const auto& set : sets) { - if (!set.id.empty()) idToName.emplace(set.id, set.name); - } - - // Index existing packs for multi-page merges. - std::unordered_map packIndex; - for (std::size_t i = 0; i < catalog.packs.size(); ++i) { - packIndex.emplace(catalog.packs[i].setId, i); - } - std::vector> seenByPack(catalog.packs.size()); - for (std::size_t i = 0; i < catalog.packs.size(); ++i) { - for (const auto& card : catalog.packs[i].cards) { - seenByPack[i].insert(card.setNo); - } - } - - for (const auto& entry : j.at("data")) { - const std::string name = entry.value("name", ""); - const std::string number = - PokemonCardPreviewSource::normalizeCollectorNumber(entry.value("number", "")); - if (name.empty() || number.empty()) continue; - - std::string setId; - std::string setName; - if (entry.contains("set") && entry.at("set").is_object()) { - setId = entry.at("set").value("id", ""); - setName = entry.at("set").value("name", ""); - } - if (setId.empty()) continue; - if (const auto it = idToName.find(setId); it != idToName.end() && !it->second.empty()) { - setName = it->second; - } - if (setName.empty()) setName = setId; - - auto pit = packIndex.find(setId); - if (pit == packIndex.end()) { - PokemonSetCatalogPack pack; - pack.setId = setId; - pack.setName = setName; - pack.cards.push_back(PokemonCatalogCard{number, name}); - packIndex.emplace(setId, catalog.packs.size()); - seenByPack.emplace_back(std::unordered_set{number}); - catalog.packs.push_back(std::move(pack)); - continue; - } - - const std::size_t idx = pit->second; - if (!seenByPack[idx].insert(number).second) continue; - if (catalog.packs[idx].setName.empty() && !setName.empty()) { - catalog.packs[idx].setName = setName; - } - catalog.packs[idx].cards.push_back(PokemonCatalogCard{number, name}); - } - - CardsPageMeta meta; - meta.page = j.value("page", 1); - meta.pageSize = j.value("pageSize", kCardsPageSize); - meta.count = j.value("count", static_cast(j.at("data").size())); - meta.totalCount = j.value("totalCount", meta.count); - return Result::ok(meta); - } catch (const std::exception& e) { - return Result::err( - std::string("Pokemon TCG cards JSON parse error: ") + e.what()); - } -} - -Result PokemonSetSource::parseCatalog(const std::string& body, - const std::vector& sets) { - PokemonSetCatalog catalog; - auto meta = mergeCardsPage(body, catalog, sets); - if (!meta) return Result::err(meta.error()); - finalizeCatalog(catalog); - return Result::ok(std::move(catalog)); -} - -Result> PokemonSetSource::fetchAll() { - auto resp = http_.get(kEndpoint); - if (!resp) return Result>::err(resp.error()); - return parseResponse(resp.value()); -} - -Result PokemonSetSource::fetchAllWithCatalog() { - auto setsResp = http_.get(kEndpoint); - if (!setsResp) return Result::err(setsResp.error()); - auto sets = parseResponse(setsResp.value()); - if (!sets) return Result::err(sets.error()); - - PokemonSetCatalog catalog; - int page = 1; - int totalCount = 0; - int fetched = 0; - for (;;) { - auto cardsResp = http_.get(buildCardsPageUrl(page)); - if (!cardsResp) return Result::err(cardsResp.error()); - auto meta = mergeCardsPage(cardsResp.value(), catalog, sets.value()); - if (!meta) return Result::err(meta.error()); - - fetched += meta.value().count; - totalCount = meta.value().totalCount; - if (meta.value().count <= 0 || fetched >= totalCount) break; - ++page; - // Safety: avoid unbounded loops if the API lies about totals. - if (page > 10000) { - return Result::err( - "Pokemon TCG cards pagination exceeded safety limit."); - } - } - - finalizeCatalog(catalog); FetchWithCatalog out; - out.sets = std::move(sets).value(); + out.sets = std::move(sets); out.catalog = std::move(catalog); return Result::ok(std::move(out)); } diff --git a/core/src/games/pokemon/PokemonWestSetId.cpp b/core/src/games/pokemon/PokemonWestSetId.cpp new file mode 100644 index 0000000..6058a00 --- /dev/null +++ b/core/src/games/pokemon/PokemonWestSetId.cpp @@ -0,0 +1,92 @@ +#include "ccm/games/pokemon/PokemonWestSetId.hpp" + +#include + +namespace ccm { + +namespace { + +// Built by name-matching PokemonTCG/pokemon-tcg-data set ids against +// api.tcgdex.net/v2/en/sets. Only divergences are listed; shared ids +// (base1, swsh1, sv10, sve, …) pass through unchanged. +const std::unordered_map& legacyAliases() { + static const std::unordered_map kMap{ + // Classic / EX / HGSS renames + {"base6", "lc"}, + {"bp", "bog"}, + {"tk1a", "tk-ex-latia"}, + {"tk1b", "tk-ex-latio"}, + {"tk2a", "tk-ex-p"}, + {"tk2b", "tk-ex-m"}, + {"hsp", "hgssp"}, + + // McDonald's Collections + {"mcd11", "2011bw"}, + {"mcd12", "2012bw"}, + {"mcd14", "2014xy"}, + {"mcd15", "2015xy"}, + {"mcd16", "2016xy"}, + {"mcd17", "2017sm"}, + {"mcd18", "2018sm"}, + {"mcd19", "2019sm"}, + {"mcd21", "2021swsh"}, + {"mcd22", "2022swsh"}, + {"mcd23", "2023sv"}, + {"mcd24", "2024sv"}, + + // SM specials + {"sm35", "sm3.5"}, + {"sm75", "sm7.5"}, + + // SWSH specials / galleries + {"swsh35", "swsh3.5"}, + {"swsh45", "swsh4.5"}, + {"swsh45sv", "swsh4.5sv"}, + {"cel25c", "cel25cc"}, + {"swsh9tg", "swsh9.5tg"}, + {"swsh10tg", "swsh10.5tg"}, + {"pgo", "swsh10.5"}, + {"swsh11tg", "swsh11.5tg"}, + {"swsh12tg", "swsh12.5tg"}, + {"swsh12pt5", "swsh12.5"}, + {"swsh12pt5gg", "swsh12.5gg"}, + + // Scarlet & Violet (pokemontcg used unpadded / pt5 forms) + {"sv1", "sv01"}, + {"sv2", "sv02"}, + {"sv3", "sv03"}, + {"sv3pt5", "sv03.5"}, + {"sv4", "sv04"}, + {"sv4pt5", "sv04.5"}, + {"sv5", "sv05"}, + {"sv6", "sv06"}, + {"sv6pt5", "sv06.5"}, + {"sv7", "sv07"}, + {"sv8", "sv08"}, + {"sv8pt5", "sv08.5"}, + {"sv9", "sv09"}, + {"zsv10pt5", "sv10.5b"}, + {"rsv10pt5", "sv10.5w"}, + + // Mega Evolution era + {"me1", "me01"}, + {"me2", "me02"}, + {"me2pt5", "me02.5"}, + {"me3", "me03"}, + {"me4", "me04"}, + {"me5", "me05"}, + }; + return kMap; +} + +} // namespace + +std::string canonicalizeWestSetId(std::string_view setId) { + if (setId.empty()) return {}; + const auto& map = legacyAliases(); + const auto it = map.find(std::string(setId)); + if (it != map.end()) return it->second; + return std::string(setId); +} + +} // namespace ccm diff --git a/core/src/services/PokemonSetCompletion.cpp b/core/src/services/PokemonSetCompletion.cpp index 959334e..6ee7589 100644 --- a/core/src/services/PokemonSetCompletion.cpp +++ b/core/src/services/PokemonSetCompletion.cpp @@ -1,6 +1,7 @@ #include "ccm/services/PokemonSetCompletion.hpp" #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" +#include "ccm/games/pokemon/PokemonWestSetId.hpp" #include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" #include @@ -29,6 +30,10 @@ std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) { return PokemonCardPreviewSource::normalizeCollectorNumber(setNo); } +std::string westSetKey(std::string_view setId) { + return canonicalizeWestSetId(setId); +} + OwnedBySet ownedSetNosBySetId(const std::vector& collection, PokemonRegion region, std::optional languageFilter) { @@ -39,7 +44,9 @@ OwnedBySet ownedSetNosBySetId(const std::vector& collection, if (card.set.id.empty()) continue; const std::string setNo = normalizeForRegion(region, card.setNo); if (setNo.empty()) continue; - out[card.set.id].insert(setNo); + const std::string setKey = + region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id; + out[setKey].insert(setNo); } return out; } @@ -157,14 +164,18 @@ pokemonChecklistForSet(const std::vector& collection, std::optional languageFilter) { const PokemonSetCatalog& catalog = region == PokemonRegion::Asia ? asiaCatalog : westCatalog; - const auto* pack = catalog.findPack(setId); + const std::string wantSetId = + region == PokemonRegion::West ? westSetKey(setId) : std::string(setId); + const auto* pack = catalog.findPack(wantSetId); if (pack == nullptr) return {}; std::unordered_set ownedNos; for (const auto& card : collection) { if (card.region != region) continue; if (!passesLanguageFilter(card, languageFilter)) continue; - if (card.set.id != setId) continue; + const std::string cardSetId = + region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id; + if (cardSetId != wantSetId) continue; const std::string setNo = normalizeForRegion(region, card.setNo); if (!setNo.empty()) ownedNos.insert(setNo); } diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index d79fe17..bcb61e8 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -20,28 +20,33 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`, Unified **Pokemon** Game menu entry. Per-card `region` (`West` / `Asia`) selects the backend below. Collection: `pokemon/collection.json`. West sets: `pokemon/sets-west.json`. Asia sets: `pokemon/sets-asia.json`. Language choices: West → English/German/French/Spanish/Italian/Russian; Asia → Japanese/S-Chinese/T-Chinese/Korean. -### West (`Game::Pokemon`, pokemontcg.io) +### West (`Game::Pokemon`, TCGdex EN) -**Info API:** `https://api.pokemontcg.io/v2/sets` -Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date. +Upstream: [TCGdex REST API](https://tcgdex.dev/) locale `en`. No API key. Canonical West set ids are TCGdex EN ids (e.g. `base1`, `sv01`, `swsh12.5tg`). Legacy pokemontcg.io ids (`sv1`, `pgo`, `swsh12tg`, …) are rewritten via `canonicalizeWestSetId` on West collection load, preview/auto-detect lookups, and set-completion matching so existing collections keep working; the next save persists TCGdex ids. + +**Info API:** `https://api.tcgdex.net/v2/en/sets` +Used by `PokemonSetSource` to fetch the slim set list (`id`, `name`). Release dates are not on the list endpoint — each set’s `GET /v2/en/sets/{id}` supplies `releaseDate` as `YYYY-MM-DD`, rewritten to `YYYY/MM/DD`, then the list is sorted ascending by release date. + +**Asset API:** `https://api.tcgdex.net/v2/en/cards/{setId}-{localId}` (by id), `https://api.tcgdex.net/v2/en/cards?…` (filtered search), and set-detail `cards[]` for auto-detect. Image CDN bases live on `assets.tcgdex.net`; the preview source appends `/high.png` (wxImage decodes PNG, not webp). -**Asset API:** `https://api.pokemontcg.io/v2/cards` (by id) and `https://api.pokemontcg.io/v2/cards?q=...` (search) Used by `PokemonCardPreviewSource` in two ways: -1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/cards/{setId}-{number}` (single-card `data` object) — same idea as Asia’s direct localId fetch — so Lucene `name:` ∩ `number:` misses cannot blank the preview after Auto-detect fills Set #. On HTTP failure or missing images, falls back to a name-less search `set.id:… number:…` (collector numbers are unique within a set). When Set # or set id is missing, keeps the older `name:"…"` search with optional `set.id` / `number`. The parser takes `images.large` first and falls back to `images.small`. +1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/en/cards/{setId}-{localId}` (card object with `image` base). On HTTP failure or missing image, falls back to a filtered search `set.id=eq:…&localId=eq:…` (collector numbers are unique within a set). When Set # or set id is missing, uses `name=eq:…` with optional `set.id` / `localId`. Legacy set ids are canonicalized before URL build. -2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the search endpoint with `name:""` and `set.id:` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the picker’s **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the card’s `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save. +2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId` → `AutoDetectedPrint::setNo` and `rarity` → `AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped. The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save. -The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query and card-id path expect only the printed-number component (unquoted `number:4` / `number:TG14`; do not wrap alphanumeric numbers in Lucene quotes when combining with other clauses — that has been observed to 500 on the live API). +The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `localId` path expects only the printed-number component. ### Set-completion catalog (West) **Sets → Update Pokemon** uses `PokemonSetSource::fetchAllWithCatalog()` so the West path writes: -1. The set list (`pokemon/sets-west.json`) from `/v2/sets` (same as before) -2. A pack checklist at `/pokemon/set-catalog-west.json` from a paginated `/v2/cards?select=name,number,set&pageSize=250` dump +1. The set list (`pokemon/sets-west.json`) from `/v2/en/sets` + per-set detail dates +2. A pack checklist at `/pokemon/set-catalog-west.json` from each set’s detail `cards[]` (`localId` → `setNo`, `name` → name) -Each catalog pack stores `id` (pokemontcg.io set id), `name` (display), and `cards[]` of `{ setNo, name }` keyed by the API `number` field (normalized by stripping anything after `/`). Duplicate collector numbers within a pack collapse to one checklist row. The Pokemon **Set Completion** tab reads this file offline; ownership for a West pack requires `PokemonRegion::West`, matching `card.set.id`, and a normalized collector number match. Amount / holo / 1st Edition are ignored for completion counts. +Each catalog pack stores `id` (TCGdex EN set id), `name` (display), and `cards[]` of `{ setNo, name }` keyed by `localId` (normalized by stripping anything after `/`). Duplicate collector numbers within a pack collapse to one checklist row. The Pokemon **Set Completion** tab reads this file offline; ownership for a West pack requires `PokemonRegion::West`, a canonicalized `card.set.id` match, and a normalized collector number match. Amount / holo / 1st Edition are ignored for completion counts. + +After a successful Update, `PokemonGameView` also runs `syncPokemonCollectionSets` against the refreshed set lists: West cards get legacy set-id migration plus `set.name` / `releaseDate` refresh when the id is present; Asia cards refresh name/date the same way. Changed cards are persisted via `CollectionService::saveAll`. If `set-catalog-west.json` is missing (and the active region filter is West or All with no Asia catalog either), the Set Completion tab prompts the user to run Update Pokemon. @@ -156,7 +161,7 @@ Empty search array / `{"error":"..."}` → `NotFound`; bad JSON / HTTP → `Tran ## Japanese Pokémon TCG APIs (TCGdex `ja`) — Asia region backend -Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemon`, same data directory as West). It is **not** a separate Game menu entry: the unified **Pokemon** UI stores both West and Asia cards in `pokemon/collection.json` with a per-card `region` (`West` / `Asia`). Set caches are split by filename under that directory (`pokemon/sets-west.json` vs `pokemon/sets-asia.json`). `JsonSetRepository` migrate-on-load promotes legacy `pokemon/sets.json` → `sets-west.json` and `pokemonjp/sets.json` → `sets-asia.json` when the new files are missing. **Sets > Update Pokemon** refreshes both lists. Upstream: [TCGdex REST API](https://tcgdex.dev/). No API key. Japanese set IDs (e.g. `PMCG1`, `SV1a`) are never merged into Western pokemontcg.io ids. +Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemon`, same data directory as West). It is **not** a separate Game menu entry: the unified **Pokemon** UI stores both West and Asia cards in `pokemon/collection.json` with a per-card `region` (`West` / `Asia`). Set caches are split by filename under that directory (`pokemon/sets-west.json` vs `pokemon/sets-asia.json`). `JsonSetRepository` migrate-on-load promotes legacy `pokemon/sets.json` → `sets-west.json` and `pokemonjp/sets.json` → `sets-asia.json` when the new files are missing. **Sets > Update Pokemon** refreshes both lists. Upstream: [TCGdex REST API](https://tcgdex.dev/). No API key. Japanese set IDs (e.g. `PMCG1`, `SV1a`) are never merged into Western TCGdex EN ids. ### Info API: TCGdex `GET /v2/ja/sets` (+ per-set detail) @@ -294,7 +299,7 @@ Three mechanisms reduce preview latency for **all** games (Magic, Pokemon West/A - **In-memory preview LRU** (`CardPreviewService`). Successful `fetchPreviewBytes` results are cached keyed by `(game, name, setId, setNo)`; successful `fetchImageBytesByUrl` results are cached keyed by URL (used for the per-game card-back fallback). Re-selecting a previously viewed row is decode-only — no HTTP at all. The cache is bounded by `CardPreviewService::kCacheCapacity` (currently 128 entries) and uses a list+map LRU under a mutex (the preview pipeline is invoked from a worker thread in `BaseSelectedCardPanel`). **Source errors are split** by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is *negative-cached* in this tier so subsequent selections short-circuit without HTTP, while `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable a card's preview. - **Persistent disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `.bin` (positive payload) or `.neg` (negative marker) plus a `.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path. -- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, and `images.digimoncard.io` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway. +- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, and `images.digimoncard.io` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway. `CardPreviewService` consults the tiers in order **memory → disk → source/HTTP**. On a disk hit (positive *or* negative) the entry is promoted into the in-memory LRU so the next click on the same row never re-touches the disk cache. On HTTP success the bytes are written through to both tiers in one shot. On a `NotFound` source error the **negative** marker is written through to both tiers; on `Transient` source errors nothing is written, so the next selection retries cleanly. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 23c6728..ec66254 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -19,8 +19,10 @@ - `card_preview_service_tests.cpp` — `CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly. - `local_preview_byte_cache_tests.cpp` — `LocalPreviewByteCache` adapter against `StdFileSystem` (real disk under a unique `temp_directory_path()/ccm_preview_cache_test_*` per case, RAII `TempDir` cleanup; see also `std_file_system_tests.cpp`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too. - `std_file_system_tests.cpp` — `StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests. -- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. `parseCatalog` / `mergeCardsPage` for set-completion checklists. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL. -- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (name-less `set.id`+`number` when both present; `name:` when Set # empty; collector-number `4/102` -> `4` normalization), `buildCardByIdUrl`, `parseResponse` / `parseCardByIdResponse`, and `fetchImageUrl` (card-by-id first, search fallback) via `FixedHttpClient`. +- `pokemon_west_set_id_tests.cpp` — `canonicalizeWestSetId` identity + legacy pokemontcg → TCGdex EN mappings (`sv1`→`sv01`, `pgo`→`swsh10.5`, …) and unknown passthrough. +- `pokemon_collection_set_sync_tests.cpp` — `syncPokemonCollectionSets` West id migration + name/date refresh; Asia metadata-only refresh. +- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseListResponse` (TCGdex EN `/v2/en/sets` top-level array) + `parseReleaseDate` / `parseCatalogPackFromSetDetail`. Drives `fetchAll` / `fetchAllWithCatalog` via routing HTTP fakes and asserts EN endpoints. +- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` / `buildCardByIdUrl` (canonicalization + `localId` filters), `parseSearchResponse` / `parseCardByIdResponse` (`image` + `/high.png`), and `fetchImageUrl` / auto-detect via `FixedHttpClient`. - `digibattle99_set_source_tests.cpp` — `DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`. - `digibattle99_set_completion_tests.cpp` — `computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`. - `yugioh_set_completion_tests.cpp` — `computeYuGiOhSetCompletion` / `yuGiOhChecklistForSet` ownership rules (printing-slot match) + `YuGiOhSetCatalogService` round-trip against `InMemoryFileSystem`. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6fd00c1..78e95f2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -19,6 +19,8 @@ add_executable(ccm_core_tests card_preview_service_tests.cpp local_preview_byte_cache_tests.cpp std_file_system_tests.cpp + pokemon_west_set_id_tests.cpp + pokemon_collection_set_sync_tests.cpp pokemon_set_source_tests.cpp pokemon_card_preview_source_tests.cpp digibattle99_set_source_tests.cpp diff --git a/tests/collection_service_tests.cpp b/tests/collection_service_tests.cpp index 0d587c0..536cbe0 100644 --- a/tests/collection_service_tests.cpp +++ b/tests/collection_service_tests.cpp @@ -236,4 +236,22 @@ TEST_SUITE("CollectionService") { CHECK(store.removed[0].second == "a.png"); CHECK(store.removed[1].second == "b.png"); } + + TEST_CASE("saveAll replaces the collection map") { + InMemoryRepo repo; + StubImageStore store; + CollectionService svc{repo, store}; + + REQUIRE(svc.add(Game::Magic, makeCard("A")).isOk()); + REQUIRE(svc.add(Game::Magic, makeCard("B")).isOk()); + + MagicCard only = makeCard("Only"); + only.id = 7; + REQUIRE(svc.saveAll(Game::Magic, {only}).isOk()); + auto listed = svc.list(Game::Magic); + REQUIRE(listed.isOk()); + REQUIRE(listed.value().size() == 1); + CHECK(listed.value()[0].id == 7); + CHECK(listed.value()[0].name == "Only"); + } } diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index 9106c94..4be6b40 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -218,6 +218,48 @@ TEST_SUITE("PokemonCard JSON") { const PokemonCard legacy = j.get(); CHECK(legacy.region == PokemonRegion::West); } + + TEST_CASE("West load migrates legacy pokemontcg set ids to TCGdex EN") { + nlohmann::json j = { + {"id", 1}, + {"amount", 1}, + {"name", "Charizard"}, + {"set", {{"id", "sv1"}, {"name", "Scarlet & Violet"}, {"releaseDate", "2023/03/31"}}}, + {"setNo", "6"}, + {"note", ""}, + {"images", nlohmann::json::array()}, + {"language", "English"}, + {"condition", "NearMint"}, + {"firstEdition", false}, + {"holo", false}, + {"signed", false}, + {"altered", false}, + {"region", "West"}, + }; + const PokemonCard back = j.get(); + CHECK(back.set.id == "sv01"); + } + + TEST_CASE("Asia load does not rewrite set ids through West aliases") { + nlohmann::json j = { + {"id", 1}, + {"amount", 1}, + {"name", "Charmander"}, + {"set", {{"id", "sv1"}, {"name", "Keep Asia id"}, {"releaseDate", "2023/01/01"}}}, + {"setNo", "001"}, + {"note", ""}, + {"images", nlohmann::json::array()}, + {"language", "Japanese"}, + {"condition", "NearMint"}, + {"firstEdition", false}, + {"holo", false}, + {"signed", false}, + {"altered", false}, + {"region", "Asia"}, + }; + const PokemonCard back = j.get(); + CHECK(back.set.id == "sv1"); + } } TEST_SUITE("DigiBattle99Card JSON") { diff --git a/tests/pokemon_card_preview_source_tests.cpp b/tests/pokemon_card_preview_source_tests.cpp index 2970fae..ec369eb 100644 --- a/tests/pokemon_card_preview_source_tests.cpp +++ b/tests/pokemon_card_preview_source_tests.cpp @@ -24,186 +24,113 @@ public: } // namespace TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") { - TEST_CASE("name and setId produce a percent-encoded query") { - const auto url = PokemonCardPreviewSource::buildSearchUrl( - "Pikachu", "base1", ""); - CHECK(url.find("https://api.pokemontcg.io/v2/cards?q=") == 0); - CHECK(url.find("%22Pikachu%22") != std::string::npos); - CHECK(url.find("set.id%3Abase1") != std::string::npos); - // No number term when setNo is empty. - CHECK(url.find("number") == std::string::npos); - } - - TEST_CASE("setId plus setNo omits name to avoid Lucene name-number misses") { + TEST_CASE("setId plus setNo uses localId and set.id filters without name") { const auto url = PokemonCardPreviewSource::buildSearchUrl( "Charizard", "base1", "4"); - CHECK(url.find("number%3A4") != std::string::npos); - CHECK(url.find("set.id%3Abase1") != std::string::npos); - CHECK(url.find("name") == std::string::npos); - CHECK(url.find("Charizard") == std::string::npos); + CHECK(url.find("https://api.tcgdex.net/v2/en/cards?") == 0); + CHECK(url.find("set.id=eq:base1") != std::string::npos); + CHECK(url.find("localId=eq:4") != std::string::npos); + CHECK(url.find("name=") == std::string::npos); + } + + TEST_CASE("legacy swsh12tg is canonicalized to swsh12.5tg") { + const auto url = PokemonCardPreviewSource::buildSearchUrl( + "Pikachu", "swsh12tg", "TG14"); + CHECK(url.find("set.id=eq:swsh12.5tg") != std::string::npos); + CHECK(url.find("localId=eq:TG14") != std::string::npos); } TEST_CASE("setNo with a slash is normalized to the printed number") { - // Pokemon collection numbers are commonly stored as "4/102" — the - // Pokemon TCG search API only accepts the printed-number portion. const auto url = PokemonCardPreviewSource::buildSearchUrl( "Charizard", "base1", "4/102"); - CHECK(url.find("number%3A4") != std::string::npos); + CHECK(url.find("localId=eq:4") != std::string::npos); CHECK(url.find("102") == std::string::npos); - CHECK(url.find("name") == std::string::npos); } - TEST_CASE("name with spaces is percent-encoded") { + TEST_CASE("name-only search percent-encodes the name") { const auto url = PokemonCardPreviewSource::buildSearchUrl( "Mr. Mime", "base1", ""); - CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos); - } - - TEST_CASE("empty setId keeps name and appends number") { - const auto url = - PokemonCardPreviewSource::buildSearchUrl("Pikachu", "", "25"); - CHECK(url.find("set.id") == std::string::npos); - CHECK(url.find("%22Pikachu%22") != std::string::npos); - CHECK(url.find("number%3A25") != std::string::npos); + CHECK(url.find("name=eq:Mr.%20Mime") != std::string::npos); + CHECK(url.find("set.id=eq:base1") != std::string::npos); } } TEST_SUITE("PokemonCardPreviewSource::buildCardByIdUrl") { TEST_CASE("joins setId and normalized number with a hyphen") { const auto url = PokemonCardPreviewSource::buildCardByIdUrl("base1", "4"); - CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4"); + CHECK(url == "https://api.tcgdex.net/v2/en/cards/base1-4"); } - TEST_CASE("percent-encodes alphanumeric collector numbers") { + TEST_CASE("canonicalizes legacy set ids") { const auto url = PokemonCardPreviewSource::buildCardByIdUrl("swsh12tg", "TG14"); - CHECK(url == "https://api.pokemontcg.io/v2/cards/swsh12tg-TG14"); + CHECK(url == "https://api.tcgdex.net/v2/en/cards/swsh12.5tg-TG14"); } TEST_CASE("strips slash form before building the id") { const auto url = PokemonCardPreviewSource::buildCardByIdUrl("base1", "4/102"); - CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4"); + CHECK(url == "https://api.tcgdex.net/v2/en/cards/base1-4"); } } -TEST_SUITE("PokemonCardPreviewSource::parseResponse") { - TEST_CASE("returns images.large when present") { - const std::string json = R"({ - "data": [ - { - "name": "Pikachu", - "images": { - "small": "https://images.pokemontcg.io/small.png", - "large": "https://images.pokemontcg.io/large.png" - } - } - ] - })"; - const auto out = PokemonCardPreviewSource::parseResponse(json); +TEST_SUITE("PokemonCardPreviewSource::parseSearchResponse") { + TEST_CASE("appends /high.png to the first card image base") { + const std::string json = R"([ + {"id":"base1-25","localId":"25","name":"Pikachu", + "image":"https://assets.tcgdex.net/en/base/base1/25"} + ])"; + const auto out = PokemonCardPreviewSource::parseSearchResponse(json); REQUIRE(out.isOk()); - CHECK(out.value() == "https://images.pokemontcg.io/large.png"); + CHECK(out.value() == + "https://assets.tcgdex.net/en/base/base1/25/high.png"); } - TEST_CASE("falls back to images.small when large is absent") { - const std::string json = R"({ - "data": [ - {"name":"Pikachu","images":{"small":"https://small.only/img.png"}} - ] - })"; - const auto out = PokemonCardPreviewSource::parseResponse(json); - REQUIRE(out.isOk()); - CHECK(out.value() == "https://small.only/img.png"); - } - - TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") { - const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":[]})"); + TEST_CASE("empty array is NotFound") { + const auto out = PokemonCardPreviewSource::parseSearchResponse("[]"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } - TEST_CASE("missing data array is classified as Transient (schema deviation)") { - const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})"); + TEST_CASE("object shape is Transient") { + const auto out = PokemonCardPreviewSource::parseSearchResponse(R"({"data":[]})"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } - TEST_CASE("'data' present but not an array is Transient") { - const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":{}})"); - REQUIRE(out.isErr()); - CHECK(out.error().kind == PreviewLookupError::Kind::Transient); - } - - TEST_CASE("'images' present but not an object is NotFound") { - const auto out = - PokemonCardPreviewSource::parseResponse(R"({"data":[{"images":[]}]})"); + TEST_CASE("cards without image are NotFound") { + const auto out = PokemonCardPreviewSource::parseSearchResponse( + R"([{"id":"base1-1","localId":"1","name":"X"}])"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } - TEST_CASE("large unusable type falls back to small string") { - const auto out = PokemonCardPreviewSource::parseResponse(R"({ - "data":[{"images":{"large":123,"small":"https://only.small/img.png"}}] - })"); - REQUIRE(out.isOk()); - CHECK(out.value() == "https://only.small/img.png"); - } - - TEST_CASE("no usable large or small string yields NotFound") { - const auto out = PokemonCardPreviewSource::parseResponse(R"({ - "data":[{"images":{"large":null,"small":false}}] - })"); - REQUIRE(out.isErr()); - CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); - } - - TEST_CASE("entry without images is classified as NotFound") { - const auto out = PokemonCardPreviewSource::parseResponse( - R"({"data":[{"name":"Pikachu"}]})"); - REQUIRE(out.isErr()); - CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); - } - - TEST_CASE("invalid JSON is classified as Transient") { - const auto out = PokemonCardPreviewSource::parseResponse("{not json"); + TEST_CASE("invalid JSON is Transient") { + const auto out = PokemonCardPreviewSource::parseSearchResponse("{not json"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } } TEST_SUITE("PokemonCardPreviewSource::parseCardByIdResponse") { - TEST_CASE("returns images.large from data object") { + TEST_CASE("returns image base with /high.png") { const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({ - "data": { - "id": "base1-4", - "images": { - "small": "https://images.pokemontcg.io/small.png", - "large": "https://images.pokemontcg.io/large.png" - } - } + "id": "base1-4", + "image": "https://assets.tcgdex.net/en/base/base1/4" })"); REQUIRE(out.isOk()); - CHECK(out.value() == "https://images.pokemontcg.io/large.png"); + CHECK(out.value() == "https://assets.tcgdex.net/en/base/base1/4/high.png"); } - TEST_CASE("falls back to images.small when large is absent") { - const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({ - "data": {"images":{"small":"https://small.only/img.png"}} - })"); - REQUIRE(out.isOk()); - CHECK(out.value() == "https://small.only/img.png"); - } - - TEST_CASE("missing images is NotFound") { + TEST_CASE("null image is NotFound") { const auto out = PokemonCardPreviewSource::parseCardByIdResponse( - R"({"data":{"id":"base1-4","name":"Charizard"}})"); + R"({"id":"base1-4","image":null})"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } - TEST_CASE("data array shape is Transient") { - const auto out = - PokemonCardPreviewSource::parseCardByIdResponse(R"({"data":[]})"); + TEST_CASE("array shape is Transient") { + const auto out = PokemonCardPreviewSource::parseCardByIdResponse("[]"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } @@ -228,15 +155,16 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") { TEST_CASE("with setNo prefers card-by-id endpoint") { FixedHttpClient http; http.ok = true; - http.body = R"({"data":{"images":{"large":"https://l/by-id.png"}}})"; + http.body = R"({"id":"base1-25","image":"https://assets.tcgdex.net/en/base/base1/25"})"; PokemonCardPreviewSource src{http}; const auto out = src.fetchImageUrl("Pikachu", "base1", "25"); REQUIRE(out.isOk()); - CHECK(out.value() == "https://l/by-id.png"); - CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/cards/base1-25"); + CHECK(out.value() == + "https://assets.tcgdex.net/en/base/base1/25/high.png"); + CHECK(http.lastUrl == "https://api.tcgdex.net/v2/en/cards/base1-25"); } - TEST_CASE("falls back to name-less search when card-by-id HTTP fails") { + TEST_CASE("falls back to search when card-by-id HTTP fails") { class RoutingHttp final : public IHttpClient { public: int calls = 0; @@ -244,250 +172,130 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") { Result get(std::string_view url) override { lastUrl = std::string(url); ++calls; - if (url.find("/v2/cards?") == std::string::npos) { + if (url.find("/v2/en/cards?") == std::string::npos) { return Result::err("HTTP 404 from card id"); } return Result::ok( - R"({"data":[{"images":{"large":"https://l/search.png"}}]})"); + R"([{"id":"base1-4","localId":"4","name":"Charizard", + "image":"https://assets.tcgdex.net/en/base/base1/4"}])"); } } http; PokemonCardPreviewSource src{http}; const auto out = src.fetchImageUrl("Charizard", "base1", "4"); REQUIRE(out.isOk()); - CHECK(out.value() == "https://l/search.png"); + CHECK(out.value() == + "https://assets.tcgdex.net/en/base/base1/4/high.png"); CHECK(http.calls == 2); - CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos); - CHECK(http.lastUrl.find("number%3A4") != std::string::npos); - CHECK(http.lastUrl.find("name") == std::string::npos); + CHECK(http.lastUrl.find("set.id=eq:base1") != std::string::npos); + CHECK(http.lastUrl.find("localId=eq:4") != std::string::npos); } TEST_CASE("empty setNo uses name search without card-by-id") { FixedHttpClient http; http.ok = true; - http.body = R"({"data":[{"images":{"large":"https://l/x.png"}}]})"; + http.body = R"([{"id":"base1-25","localId":"25","name":"Pikachu", + "image":"https://assets.tcgdex.net/en/base/base1/25"}])"; PokemonCardPreviewSource src{http}; const auto out = src.fetchImageUrl("Pikachu", "base1", ""); REQUIRE(out.isOk()); - CHECK(out.value() == "https://l/x.png"); - CHECK(http.lastUrl.find("%22Pikachu%22") != std::string::npos); - CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos); - CHECK(http.lastUrl.find("/v2/cards/base1-") == std::string::npos); + CHECK(http.lastUrl.find("name=eq:Pikachu") != std::string::npos); + CHECK(http.lastUrl.find("/v2/en/cards/base1-") == std::string::npos); } } namespace { -const char* kCharizardSwsh4 = R"({ - "data": [ +const char* kCharizardSwsh4Detail = R"({ + "id": "swsh4", + "name": "Vivid Voltage", + "cards": [ { + "id": "swsh4-25", + "localId": "25", "name": "Charizard", - "number": "25", "rarity": "Rare", - "set": { - "id": "swsh4", - "name": "Vivid Voltage", - "printedTotal": 185 - } + "image": "https://assets.tcgdex.net/en/swsh/swsh4/25" } ] })"; -const char* kMultiVariantPayload = R"({ - "data": [ - { - "name": "Pikachu", - "number": "25", - "rarity": "Common", - "set": {"id": "base1", "printedTotal": 102} - }, - { - "name": "Pikachu", - "number": "58", - "rarity": "Rare", - "set": {"id": "base1", "printedTotal": 102} - }, - { - "name": "Pikachu", - "number": "25", - "rarity": "Common", - "set": {"id": "base2", "printedTotal": 64} - } +const char* kMultiVariantDetail = R"({ + "id": "base1", + "cards": [ + {"localId":"25","name":"Pikachu","rarity":"Common"}, + {"localId":"58","name":"Pikachu","rarity":"Rare"}, + {"localId":"1","name":"Alakazam","rarity":"Rare"} ] })"; } // namespace TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") { - TEST_CASE("maps API number into setNo without printedTotal suffix") { - const auto out = - PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Charizard"); + TEST_CASE("maps localId into setNo from set detail") { + const auto out = PokemonCardPreviewSource::parsePrintVariants( + kCharizardSwsh4Detail, "swsh4", "Charizard"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value().front().setNo == "25"); CHECK(out.value().front().rarity == "Rare"); } - TEST_CASE("filters by set id and keeps multiple numbers in the same set") { - const auto out = - PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base1", "Pikachu"); + TEST_CASE("filters by card name within the set") { + const auto out = PokemonCardPreviewSource::parsePrintVariants( + kMultiVariantDetail, "base1", "Pikachu"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2); CHECK(out.value()[0].setNo == "25"); CHECK(out.value()[1].setNo == "58"); } - TEST_CASE("wrong set id yields explicit error when name and set are supplied") { - const auto out = - PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "base1", "Charizard"); - REQUIRE(out.isErr()); - CHECK(out.error() == "Could not auto-detect set print metadata."); - } - - TEST_CASE("wrong card name is filtered out") { - const auto out = - PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Blastoise"); - REQUIRE(out.isErr()); - CHECK(out.error() == "Could not auto-detect set print metadata."); - } - - TEST_CASE("empty data array yields error") { - const auto out = - PokemonCardPreviewSource::parsePrintVariants(R"({"data":[]})", "base1", "Pikachu"); - REQUIRE(out.isErr()); - CHECK(out.error() == "Pokemon TCG returned no matching cards."); - } - - TEST_CASE("name-only payload still filters to requested set id") { - const auto out = - PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base2", "Pikachu"); - REQUIRE(out.isOk()); - REQUIRE(out.value().size() == 1); - CHECK(out.value().front().setNo == "25"); - } - - TEST_CASE("keeps bare number when printedTotal is zero") { - const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ - "data": [ - { - "name": "Promo", - "number": "7", - "rarity": "Promo", - "set": {"id": "promo1", "printedTotal": 0} - } - ] - })", - "promo1", "Promo"); - REQUIRE(out.isOk()); - REQUIRE(out.value().size() == 1); - CHECK(out.value().front().setNo == "7"); - } - - TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") { - const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ - "data":[ - {"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"}, - {"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}} - ] - })", - "base1", "Pikachu"); - REQUIRE(out.isOk()); - REQUIRE(out.value().size() == 1); - CHECK(out.value().front().setNo == "26"); - } - - TEST_CASE("empty setId skips set filter and collects prints across sets") { - const char* crossSet = R"({ - "data": [ - {"name":"Pikachu","number":"1","rarity":"Common","set":{"id":"base1"}}, - {"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base2"}} - ] - })"; - const auto out = PokemonCardPreviewSource::parsePrintVariants(crossSet, "", "Pikachu"); - REQUIRE(out.isOk()); - REQUIRE(out.value().size() == 2u); - } - - TEST_CASE("empty wanted card name skips name filter within the set") { - const char* twoInSet = R"({ - "data": [ - {"name":"Electabuzz","number":"1","rarity":"Common","set":{"id":"base1"}}, - {"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base1"}} - ] - })"; - const auto out = PokemonCardPreviewSource::parsePrintVariants(twoInSet, "base1", ""); - REQUIRE(out.isOk()); - REQUIRE(out.value().size() == 2u); - } - - TEST_CASE("cards with empty number and rarity are skipped for auto-detect metadata") { - const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ - "data": [ - {"name":"Pikachu","number":"","rarity":"","set":{"id":"base1"}} - ] - })", - "base1", "Pikachu"); - REQUIRE(out.isErr()); - CHECK(out.error() == "Could not auto-detect set print metadata."); - } - - TEST_CASE("no matches with empty setId yields generic no matching cards message") { + TEST_CASE("wrong card name yields error") { const auto out = PokemonCardPreviewSource::parsePrintVariants( - R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})", - "", - "Nobody"); + kCharizardSwsh4Detail, "swsh4", "Blastoise"); REQUIRE(out.isErr()); - CHECK(out.error() == "Pokemon TCG returned no matching cards."); + CHECK(out.error() == "Could not auto-detect set print metadata."); } - TEST_CASE("invalid JSON in parsePrintVariants yields parse error") { - const auto out = - PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu"); + TEST_CASE("missing cards array is an error") { + const auto out = PokemonCardPreviewSource::parsePrintVariants( + R"({"id":"base1"})", "base1", "Pikachu"); REQUIRE(out.isErr()); - CHECK(out.error().find("Pokemon TCG JSON parse error:") == 0); + } + + TEST_CASE("empty wanted name collects all prints in the set") { + const auto out = PokemonCardPreviewSource::parsePrintVariants( + kMultiVariantDetail, "base1", ""); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 3u); } } TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") { - TEST_CASE("supports auto-detect and returns first print") { + TEST_CASE("supports auto-detect and returns first print from set detail") { FixedHttpClient http; - http.body = kCharizardSwsh4; + http.body = kCharizardSwsh4Detail; PokemonCardPreviewSource src{http}; CHECK(src.supportsAutoDetectPrint()); const auto first = src.detectFirstPrint("Charizard", "swsh4"); REQUIRE(first.isOk()); CHECK(first.value().setNo == "25"); + CHECK(http.lastUrl == "https://api.tcgdex.net/v2/en/sets/swsh4"); } - TEST_CASE("uses slim set-scoped search URL without number clause") { - FixedHttpClient http; - http.body = kCharizardSwsh4; - PokemonCardPreviewSource src{http}; - const auto out = src.detectPrintVariants("Charizard", "swsh4"); - REQUIRE(out.isOk()); - CHECK(http.lastUrl.find("number%3A") == std::string::npos); - CHECK(http.lastUrl.find("set.id%3Aswsh4") != std::string::npos); - CHECK(http.lastUrl.find("select=name,number,rarity,set") != std::string::npos); - CHECK(http.lastUrl.find("pageSize=50") != std::string::npos); - } - - TEST_CASE("buildDetectSearchUrl requests only parser fields") { - const auto url = PokemonCardPreviewSource::buildDetectSearchUrl("Charizard", "swsh4"); - CHECK(url.find("select=name,number,rarity,set") != std::string::npos); - CHECK(url.find("pageSize=50") != std::string::npos); - } - - TEST_CASE("retries name-only query when the set-scoped request fails") { + TEST_CASE("falls back to cards search when set detail fails") { class FallbackHttpClient final : public IHttpClient { public: int calls = 0; Result get(std::string_view url) override { ++calls; - if (calls == 1) return Result::err("offline"); - if (url.find("set.id") != std::string::npos) { - return Result::err("unexpected set-scoped retry"); + if (std::string(url).find("/sets/") != std::string::npos) { + return Result::err("offline"); } - return Result::ok(kMultiVariantPayload); + return Result::ok(R"([ + {"id":"base1-25","localId":"25","name":"Pikachu","rarity":"Common"}, + {"id":"base1-58","localId":"58","name":"Pikachu","rarity":"Rare"} + ])"); } } http; @@ -498,7 +306,7 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") { CHECK(http.calls == 2); } - TEST_CASE("detectPrintVariants surfaces fallback HTTP error when both requests fail") { + TEST_CASE("surfaces search HTTP error when set detail and search fail") { class AlwaysFailHttp final : public IHttpClient { public: int calls = 0; @@ -514,13 +322,4 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") { CHECK(out.error() == "offline"); CHECK(http.calls == 2); } - - TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") { - FixedHttpClient http; - http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})"; - PokemonCardPreviewSource src{http}; - const auto out = src.detectFirstPrint("Promo", "promo1"); - REQUIRE(out.isErr()); - CHECK(out.error() == "Could not auto-detect set print metadata."); - } } diff --git a/tests/pokemon_collection_set_sync_tests.cpp b/tests/pokemon_collection_set_sync_tests.cpp new file mode 100644 index 0000000..9850066 --- /dev/null +++ b/tests/pokemon_collection_set_sync_tests.cpp @@ -0,0 +1,74 @@ +#include + +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp" + +using namespace ccm; + +namespace { + +PokemonCard makeWest(std::string setId, std::string setName, std::string releaseDate) { + PokemonCard c; + c.id = 1; + c.name = "Card"; + c.region = PokemonRegion::West; + c.set.id = std::move(setId); + c.set.name = std::move(setName); + c.set.releaseDate = std::move(releaseDate); + c.setNo = "1"; + c.language = Language::English; + return c; +} + +PokemonCard makeAsia(std::string setId, std::string setName, std::string releaseDate) { + PokemonCard c = makeWest(std::move(setId), std::move(setName), std::move(releaseDate)); + c.region = PokemonRegion::Asia; + c.language = Language::Japanese; + return c; +} + +} // namespace + +TEST_SUITE("syncPokemonCollectionSets") { + TEST_CASE("migrates West legacy set id and refreshes name/date") { + std::vector cards{ + makeWest("sv1", "Old Name", "2000/01/01"), + }; + const std::vector west{Set{"sv01", "Scarlet & Violet", "2023/03/31"}}; + const std::vector asia; + + CHECK(syncPokemonCollectionSets(cards, west, asia) == 1); + CHECK(cards[0].set.id == "sv01"); + CHECK(cards[0].set.name == "Scarlet & Violet"); + CHECK(cards[0].set.releaseDate == "2023/03/31"); + } + + TEST_CASE("Asia cards refresh metadata without West id aliases") { + std::vector cards{ + makeAsia("sv1", "Old", "2000/01/01"), + }; + const std::vector west{Set{"sv01", "Scarlet & Violet", "2023/03/31"}}; + const std::vector asia{Set{"sv1", "Asia Set", "2023/01/20"}}; + + CHECK(syncPokemonCollectionSets(cards, west, asia) == 1); + CHECK(cards[0].set.id == "sv1"); + CHECK(cards[0].set.name == "Asia Set"); + CHECK(cards[0].set.releaseDate == "2023/01/20"); + } + + TEST_CASE("unchanged cards are not counted") { + std::vector cards{ + makeWest("base1", "Base Set", "1999/01/09"), + }; + const std::vector west{Set{"base1", "Base Set", "1999/01/09"}}; + CHECK(syncPokemonCollectionSets(cards, west, {}) == 0); + } + + TEST_CASE("unknown set id still migrates when aliased") { + std::vector cards{makeWest("pgo", "GO", "")}; + // No matching upstream set — id still migrates. + CHECK(syncPokemonCollectionSets(cards, {}, {}) == 1); + CHECK(cards[0].set.id == "swsh10.5"); + CHECK(cards[0].set.name == "GO"); + } +} diff --git a/tests/pokemon_set_completion_tests.cpp b/tests/pokemon_set_completion_tests.cpp index f2c6681..5b125f2 100644 --- a/tests/pokemon_set_completion_tests.cpp +++ b/tests/pokemon_set_completion_tests.cpp @@ -127,6 +127,28 @@ TEST_SUITE("computePokemonSetCompletion") { CHECK(rows[0].ownedUnique == 1); } + TEST_CASE("legacy pokemontcg West set id matches TCGdex catalog pack") { + PokemonSetCatalog west; + PokemonSetCatalogPack pack; + pack.setId = "sv01"; + pack.setName = "Scarlet & Violet"; + pack.cards = {{"6", "Charizard"}}; + west.packs.push_back(std::move(pack)); + PokemonSetCatalog emptyAsia; + std::vector collection{ + makeOwned(PokemonRegion::West, "sv1", "6"), + }; + const auto rows = computePokemonSetCompletion(collection, west, emptyAsia); + REQUIRE(rows.size() == 1); + CHECK(rows[0].setId == "sv01"); + CHECK(rows[0].ownedUnique == 1); + + const auto checklist = pokemonChecklistForSet( + collection, west, emptyAsia, PokemonRegion::West, "sv01"); + REQUIRE(checklist.size() == 1); + CHECK(checklist[0].owned); + } + TEST_CASE("amount does not inflate unique ownership") { const auto west = westCatalog(); PokemonSetCatalog emptyAsia; diff --git a/tests/pokemon_set_source_tests.cpp b/tests/pokemon_set_source_tests.cpp index 36b7855..d2e4b4c 100644 --- a/tests/pokemon_set_source_tests.cpp +++ b/tests/pokemon_set_source_tests.cpp @@ -3,6 +3,9 @@ #include "ccm/games/pokemon/PokemonSetSource.hpp" #include "ccm/ports/IHttpClient.hpp" +#include +#include + using namespace ccm; namespace { @@ -19,59 +22,93 @@ public: } }; +class RoutingHttpClient final : public IHttpClient { +public: + std::string listBody; + std::unordered_map byUrl; + std::string lastUrl; + Result get(std::string_view url) override { + lastUrl = std::string(url); + if (lastUrl == PokemonSetSource::kListEndpoint) { + return Result::ok(listBody); + } + const auto it = byUrl.find(lastUrl); + if (it == byUrl.end()) return Result::err("missing route"); + return Result::ok(it->second); + } +}; + } // namespace -TEST_SUITE("PokemonSetSource::parseResponse") { - TEST_CASE("happy path: maps id/name/releaseDate without rewriting separators") { - // The Pokemon TCG API returns releaseDate already in YYYY/MM/DD form, - // unlike Scryfall's released_at YYYY-MM-DD. - const std::string json = R"({ - "data": [ - {"id":"base1","name":"Base","releaseDate":"1999/01/09"}, - {"id":"jungle","name":"Jungle","releaseDate":"1999/06/16"} - ] - })"; +TEST_SUITE("PokemonSetSource::parseListResponse") { + TEST_CASE("happy path: maps id/name from top-level array") { + const std::string json = R"([ + {"id":"base1","name":"Base Set","cardCount":{"total":102,"official":102}}, + {"id":"base2","name":"Jungle","cardCount":{"total":64,"official":64}} + ])"; - const auto out = PokemonSetSource::parseResponse(json); + const auto out = PokemonSetSource::parseListResponse(json); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2); CHECK(out.value()[0].id == "base1"); - CHECK(out.value()[0].name == "Base"); - CHECK(out.value()[0].releaseDate == "1999/01/09"); - CHECK(out.value()[1].id == "jungle"); - CHECK(out.value()[1].releaseDate == "1999/06/16"); + CHECK(out.value()[0].name == "Base Set"); + CHECK(out.value()[0].releaseDate.empty()); + CHECK(out.value()[1].id == "base2"); } - TEST_CASE("sorts by release date ascending") { - const std::string json = R"({ - "data": [ - {"id":"newer","name":"N","releaseDate":"2024/01/01"}, - {"id":"older","name":"O","releaseDate":"2010/01/01"} - ] - })"; - const auto out = PokemonSetSource::parseResponse(json); - REQUIRE(out.isOk()); - CHECK(out.value().front().id == "older"); - CHECK(out.value().back().id == "newer"); - } - - TEST_CASE("empty data array returns an empty list (not an error)") { - const auto out = PokemonSetSource::parseResponse(R"({"data":[]})"); + TEST_CASE("empty array returns an empty list (not an error)") { + const auto out = PokemonSetSource::parseListResponse("[]"); REQUIRE(out.isOk()); CHECK(out.value().empty()); } - TEST_CASE("missing data array returns an error") { - const auto out = PokemonSetSource::parseResponse(R"({"meta":{}})"); + TEST_CASE("object shape returns an error") { + const auto out = PokemonSetSource::parseListResponse(R"({"data":[]})"); CHECK(out.isErr()); } TEST_CASE("invalid JSON returns an error") { - const auto out = PokemonSetSource::parseResponse("{not json"); + const auto out = PokemonSetSource::parseListResponse("{not json"); CHECK(out.isErr()); } } +TEST_SUITE("PokemonSetSource::parseReleaseDate") { + TEST_CASE("rewrites YYYY-MM-DD to YYYY/MM/DD") { + const auto out = PokemonSetSource::parseReleaseDate( + R"({"id":"base1","releaseDate":"1999-01-09"})"); + REQUIRE(out.isOk()); + CHECK(out.value() == "1999/01/09"); + } + + TEST_CASE("missing releaseDate yields empty string") { + const auto out = PokemonSetSource::parseReleaseDate(R"({"id":"base1"})"); + REQUIRE(out.isOk()); + CHECK(out.value().empty()); + } +} + +TEST_SUITE("PokemonSetSource::parseCatalogPackFromSetDetail") { + TEST_CASE("builds checklist from cards localId/name and dedupes") { + const Set set{"base1", "Base Set", "1999/01/09"}; + const std::string json = R"({ + "id":"base1", + "name":"Base Set", + "cards":[ + {"id":"base1-4","localId":"4","name":"Charizard"}, + {"id":"base1-4","localId":"4/102","name":"Charizard"}, + {"id":"base1-58","localId":"58","name":"Growlithe"} + ] + })"; + const auto pack = PokemonSetSource::parseCatalogPackFromSetDetail(json, set); + REQUIRE(pack.isOk()); + CHECK(pack.value().setId == "base1"); + REQUIRE(pack.value().cards.size() == 2); + CHECK(pack.value().cards[0].setNo == "4"); + CHECK(pack.value().cards[1].setNo == "58"); + } +} + TEST_SUITE("PokemonSetSource::fetchAll") { TEST_CASE("network error is surfaced as a Result error") { FixedHttpClient http; @@ -80,67 +117,42 @@ TEST_SUITE("PokemonSetSource::fetchAll") { CHECK(src.fetchAll().isErr()); } - TEST_CASE("network success is parsed end-to-end and hits the public endpoint") { - FixedHttpClient http; - http.ok = true; - http.body = R"({"data":[{"id":"x","name":"X","releaseDate":"2020/01/01"}]})"; + TEST_CASE("list plus set detail fills release dates and hits EN endpoints") { + RoutingHttpClient http; + http.listBody = R"([{"id":"base1","name":"Base Set"}])"; + http.byUrl[PokemonSetSource::buildSetDetailUrl("base1")] = + R"({"id":"base1","name":"Base Set","releaseDate":"1999-01-09","cards":[]})"; PokemonSetSource src{http}; const auto out = src.fetchAll(); REQUIRE(out.isOk()); - CHECK(out.value().front().id == "x"); - CHECK(out.value().front().releaseDate == "2020/01/01"); - CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets"); + REQUIRE(out.value().size() == 1); + CHECK(out.value().front().id == "base1"); + CHECK(out.value().front().releaseDate == "1999/01/09"); + CHECK(http.lastUrl == PokemonSetSource::buildSetDetailUrl("base1")); } } -TEST_SUITE("PokemonSetSource::parseCatalog") { - TEST_CASE("groups cards by set.id and dedupes collector numbers") { - const std::vector sets{ - Set{"base1", "Base", "1999/01/09"}, - Set{"jungle", "Jungle", "1999/06/16"}, - }; - const std::string json = R"({ - "data": [ - {"name":"Charizard","number":"4","set":{"id":"base1","name":"Base"}}, - {"name":"Charizard","number":"4/102","set":{"id":"base1","name":"Base"}}, - {"name":"Growlithe","number":"58","set":{"id":"base1","name":"Base"}}, - {"name":"Pikachu","number":"60","set":{"id":"jungle","name":"Jungle"}} - ], - "page":1,"pageSize":250,"count":4,"totalCount":4 +TEST_SUITE("PokemonSetSource::fetchAllWithCatalog") { + TEST_CASE("builds catalog packs from set detail cards") { + RoutingHttpClient http; + http.listBody = R"([{"id":"base1","name":"Base Set"}])"; + http.byUrl[PokemonSetSource::buildSetDetailUrl("base1")] = R"({ + "id":"base1", + "name":"Base Set", + "releaseDate":"1999-01-09", + "cards":[ + {"localId":"4","name":"Charizard"}, + {"localId":"58","name":"Growlithe"} + ] })"; - const auto catalog = PokemonSetSource::parseCatalog(json, sets); - REQUIRE(catalog.isOk()); - REQUIRE(catalog.value().packs.size() == 2); - const auto* base = catalog.value().findPack("base1"); - REQUIRE(base != nullptr); - REQUIRE(base->cards.size() == 2); - CHECK(base->cards[0].setNo == "4"); - CHECK(base->cards[1].setNo == "58"); - const auto* jungle = catalog.value().findPack("jungle"); - REQUIRE(jungle != nullptr); - REQUIRE(jungle->cards.size() == 1); - CHECK(jungle->cards[0].setNo == "60"); - } - - TEST_CASE("mergeCardsPage accumulates across pages") { - const std::vector sets{Set{"base1", "Base", "1999/01/09"}}; - PokemonSetCatalog catalog; - const std::string page1 = R"({ - "data":[{"name":"A","number":"1","set":{"id":"base1","name":"Base"}}], - "page":1,"pageSize":1,"count":1,"totalCount":2 - })"; - const std::string page2 = R"({ - "data":[{"name":"B","number":"2","set":{"id":"base1","name":"Base"}}], - "page":2,"pageSize":1,"count":1,"totalCount":2 - })"; - REQUIRE(PokemonSetSource::mergeCardsPage(page1, catalog, sets).isOk()); - REQUIRE(PokemonSetSource::mergeCardsPage(page2, catalog, sets).isOk()); - REQUIRE(catalog.packs.size() == 1); - REQUIRE(catalog.packs[0].cards.size() == 2); - } - - TEST_CASE("buildCardsPageUrl includes select and pagination") { - CHECK(PokemonSetSource::buildCardsPageUrl(2) == - "https://api.pokemontcg.io/v2/cards?select=name,number,set&pageSize=250&page=2"); + PokemonSetSource src{http}; + const auto out = src.fetchAllWithCatalog(); + REQUIRE(out.isOk()); + REQUIRE(out.value().sets.size() == 1); + REQUIRE(out.value().catalog.packs.size() == 1); + const auto* pack = out.value().catalog.findPack("base1"); + REQUIRE(pack != nullptr); + REQUIRE(pack->cards.size() == 2); + CHECK(pack->cards[0].setNo == "4"); } } diff --git a/tests/pokemon_west_set_id_tests.cpp b/tests/pokemon_west_set_id_tests.cpp new file mode 100644 index 0000000..d288097 --- /dev/null +++ b/tests/pokemon_west_set_id_tests.cpp @@ -0,0 +1,41 @@ +#include + +#include "ccm/games/pokemon/PokemonWestSetId.hpp" + +using namespace ccm; + +TEST_SUITE("canonicalizeWestSetId") { + TEST_CASE("identity for ids already on TCGdex") { + CHECK(canonicalizeWestSetId("base1") == "base1"); + CHECK(canonicalizeWestSetId("swsh3") == "swsh3"); + CHECK(canonicalizeWestSetId("sv01") == "sv01"); + CHECK(canonicalizeWestSetId("sv10") == "sv10"); + } + + TEST_CASE("maps Scarlet & Violet divergences") { + CHECK(canonicalizeWestSetId("sv1") == "sv01"); + CHECK(canonicalizeWestSetId("sv3") == "sv03"); + CHECK(canonicalizeWestSetId("sv3pt5") == "sv03.5"); + CHECK(canonicalizeWestSetId("sv8pt5") == "sv08.5"); + CHECK(canonicalizeWestSetId("zsv10pt5") == "sv10.5b"); + CHECK(canonicalizeWestSetId("rsv10pt5") == "sv10.5w"); + } + + TEST_CASE("maps SWSH galleries and specials") { + CHECK(canonicalizeWestSetId("pgo") == "swsh10.5"); + CHECK(canonicalizeWestSetId("swsh12tg") == "swsh12.5tg"); + CHECK(canonicalizeWestSetId("swsh12pt5") == "swsh12.5"); + CHECK(canonicalizeWestSetId("swsh45") == "swsh4.5"); + CHECK(canonicalizeWestSetId("cel25c") == "cel25cc"); + } + + TEST_CASE("maps McDonald's year codes") { + CHECK(canonicalizeWestSetId("mcd19") == "2019sm"); + CHECK(canonicalizeWestSetId("mcd22") == "2022swsh"); + } + + TEST_CASE("unknown and empty pass through") { + CHECK(canonicalizeWestSetId("fut20") == "fut20"); + CHECK(canonicalizeWestSetId("").empty()); + } +} diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index 05c814e..b4a8369 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -1,5 +1,6 @@ #include "ccm/ui/PokemonGameView.hpp" +#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp" #include "ccm/games/pokemon/PokemonSetSource.hpp" #include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp" #include "ccm/ui/CardEditModalGuard.hpp" @@ -493,6 +494,30 @@ std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) { asiaErr = asiaBoth.error(); } + std::size_t collectionSynced = 0; + std::string collectionErr; + // Sync collection against whichever set lists we successfully refreshed + // (and any still-cached lists from a prior Update). + { + auto loaded = collection_.list(Game::Pokemon); + if (!loaded) { + collectionErr = loaded.error(); + } else { + auto cards = std::move(loaded).value(); + collectionSynced = + syncPokemonCollectionSets(cards, setsCacheWest_, setsCacheAsia_); + if (collectionSynced > 0) { + auto saved = collection_.saveAll(Game::Pokemon, std::move(cards)); + if (!saved) { + collectionErr = saved.error(); + collectionSynced = 0; + } else { + refreshCollection(); + } + } + } + } + if (setCompletionPanel_ != nullptr) { setCompletionPanel_->reloadFromStore(); if (auto loaded = collection_.list(Game::Pokemon)) { @@ -524,12 +549,22 @@ std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) { return "Pokemon sets partially updated."; } - showThemedMessageDialog( - parentWindow, - "Updated " + std::to_string(westSets) + " West sets (" + - std::to_string(westPacks) + " checklists) and " + std::to_string(asiaSets) + - " Asia sets (" + std::to_string(asiaPacks) + " checklists).", - "Sets updated", wxOK | wxICON_INFORMATION); + std::string body = "Updated " + std::to_string(westSets) + " West sets (" + + std::to_string(westPacks) + " checklists) and " + + std::to_string(asiaSets) + " Asia sets (" + + std::to_string(asiaPacks) + " checklists)."; + if (collectionSynced > 0) { + body += "\nSynced set metadata on " + std::to_string(collectionSynced) + + " collection card(s)."; + } + if (!collectionErr.empty()) { + body += "\nCollection sync failed: " + collectionErr; + showThemedMessageDialog(parentWindow, body, "Sets updated", + wxOK | wxICON_WARNING); + return "Pokemon sets updated."; + } + showThemedMessageDialog(parentWindow, body, "Sets updated", + wxOK | wxICON_INFORMATION); return "Pokemon sets updated."; }