Fix: replace api.pokemontcg.io with api.tcgdex.net (#20)

This commit is contained in:
Sebastian Dine
2026-07-23 17:58:00 +02:00
committed by GitHub
parent 9917e364c1
commit ab0e3c5ae2
27 changed files with 1067 additions and 784 deletions
+1 -1
View File
@@ -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. - 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. - `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 `<exeDir>/.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. - `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `<exeDir>/.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 ## Windows UI theming guardrails
+1 -1
View File
@@ -31,7 +31,7 @@ FetchContent_MakeAvailable(nlohmann_json)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need # 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)` # 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 # rule that references `libcurl_shared`, which isn't in any export set when
+2 -2
View File
@@ -8,7 +8,7 @@
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns. - `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `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<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. - `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (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/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (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). - `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. - `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<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly. 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<std::string>` 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 (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) 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 `<setNo>||<rarity>||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface. 9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) 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 `<setNo>||<rarity>||<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 `<exeDir>/.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 `<hash>.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. 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 `<exeDir>/.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 `<hash>.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 ## Adding a new game
+2
View File
@@ -36,6 +36,8 @@ add_library(ccm_core STATIC
src/games/magic/MagicSetSource.cpp src/games/magic/MagicSetSource.cpp
src/games/magic/MagicCardPreviewSource.cpp src/games/magic/MagicCardPreviewSource.cpp
src/games/magic/MagicGameModule.cpp src/games/magic/MagicGameModule.cpp
src/games/pokemon/PokemonWestSetId.cpp
src/games/pokemon/PokemonCollectionSetSync.cpp
src/games/pokemon/PokemonSetSource.cpp src/games/pokemon/PokemonSetSource.cpp
src/games/pokemon/PokemonCardPreviewSource.cpp src/games/pokemon/PokemonCardPreviewSource.cpp
src/games/pokemon/PokemonGameModule.cpp src/games/pokemon/PokemonGameModule.cpp
@@ -1,11 +1,9 @@
#pragma once #pragma once
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon // PokemonCardPreviewSource: West Pokemon previews via TCGdex EN.
// TCG. When set id + collector number are both known, prefers // Prefers GET /v2/en/cards/{setId}-{localId}, then filtered card search, then
// GET https://api.pokemontcg.io/v2/cards/{setId}-{number} // set-detail name match for auto-detect. Image URLs append /high.png (wxImage
// then falls back to a name-less search `set.id:… number:…`. Name-based // decodes PNG, not webp).
// search is kept for lookups that lack a set number (or set id). Returns
// `images.large` (with `images.small` as a graceful fallback).
#include "ccm/ports/ICardPreviewSource.hpp" #include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp" #include "ccm/ports/IHttpClient.hpp"
@@ -31,40 +29,33 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name, Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override; std::string_view setId) override;
// Build the fully URL-encoded Pokemon TCG search URL for the given card. // Strip everything after the first '/' (e.g. "4/102" -> "4").
// When both setId and setNo are non-empty, omits the name: clause so the static std::string normalizeCollectorNumber(std::string_view setNo);
// Lucene query cannot miss on name∩number intersections.
// Exposed for unit testing and to keep encoding rules in one place. 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, static std::string buildSearchUrl(std::string_view name,
std::string_view setId, std::string_view setId,
std::string_view setNo); std::string_view setNo);
static std::string imageUrlFromBase(std::string_view imageBase);
// Direct card endpoint: /v2/cards/{setId}-{normalizedNumber}. struct SetCardRow {
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo); std::string localId;
std::string name;
std::string imageBase;
std::string rarity;
};
// Strip everything after the first '/' (e.g. "4/102" -> "4"). Used by static Result<std::vector<SetCardRow>, PreviewLookupError>
// preview lookups, auto-detect, and set-completion ownership matching. parseSetCards(const std::string& body);
static std::string normalizeCollectorNumber(std::string_view setNo);
// 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<std::string, PreviewLookupError>
parseResponse(const std::string& body);
// Parse a Pokemon TCG /v2/cards/{id} response (`data` object).
static Result<std::string, PreviewLookupError> static Result<std::string, PreviewLookupError>
parseCardByIdResponse(const std::string& body); parseCardByIdResponse(const std::string& body);
// Enumerate distinct collector numbers (and rarities) for an exact card // Parse a slim TCGdex cards-array search response; prefer first hit with image.
// name inside the chosen set. Exposed for unit testing without HTTP. static Result<std::string, PreviewLookupError>
parseSearchResponse(const std::string& body);
static Result<std::vector<AutoDetectedPrint>> static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body, parsePrintVariants(const std::string& body,
std::string_view setId, std::string_view setId,
@@ -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 <cstddef>
#include <vector>
namespace ccm {
// Mutates cards in place. Returns how many cards changed at least one set field.
[[nodiscard]] std::size_t syncPokemonCollectionSets(
std::vector<PokemonCard>& cards,
const std::vector<Set>& westSets,
const std::vector<Set>& asiaSets);
} // namespace ccm
@@ -1,7 +1,7 @@
#pragma once #pragma once
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source // 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/IGameModule.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
@@ -1,12 +1,8 @@
#pragma once #pragma once
// PokemonSetSource: ISetSource implementation for the Pokemon TCG. // PokemonSetSource: ISetSource for West Pokemon via TCGdex EN
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the // (https://api.tcgdex.net/v2/en). List endpoint returns a slim array; release
// response into our `Set` domain type, and sorts by release date ascending. // dates and set-completion checklists come from per-set detail GETs.
// 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.
#include "ccm/domain/PokemonSetCatalog.hpp" #include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp" #include "ccm/domain/Set.hpp"
@@ -14,15 +10,14 @@
#include "ccm/ports/IHttpClient.hpp" #include "ccm/ports/IHttpClient.hpp"
#include <string> #include <string>
#include <string_view>
#include <vector> #include <vector>
namespace ccm { namespace ccm {
class PokemonSetSource final : public ISetSource { class PokemonSetSource final : public ISetSource {
public: public:
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets"; static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets";
static constexpr const char* kCardsEndpoint = "https://api.pokemontcg.io/v2/cards";
static constexpr int kCardsPageSize = 250;
struct FetchWithCatalog { struct FetchWithCatalog {
std::vector<Set> sets; std::vector<Set> sets;
@@ -33,28 +28,18 @@ public:
Result<std::vector<Set>> fetchAll() override; Result<std::vector<Set>> fetchAll() override;
// Sets endpoint + paginated cards dump for the offline checklist. // List + per-set detail (cards + release date) for the offline checklist.
Result<FetchWithCatalog> fetchAllWithCatalog(); Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parser exposed for unit testing without a network round-trip. // Pure parsers exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body); static Result<std::vector<Set>> parseListResponse(const std::string& body);
static Result<std::string> 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 static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
// accumulating catalog; returns page count metadata for pagination. const std::string& detailBody,
struct CardsPageMeta { const Set& set);
int page{1};
int pageSize{kCardsPageSize};
int count{0};
int totalCount{0};
};
static Result<CardsPageMeta> mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets);
static Result<PokemonSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
static std::string buildCardsPageUrl(int page, int pageSize = kCardsPageSize);
private: private:
IHttpClient& http_; IHttpClient& http_;
@@ -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 <string>
#include <string_view>
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
@@ -80,6 +80,15 @@ public:
return repo_.save(game, map); return repo_.save(game, map);
} }
// Replace the entire collection map in one save (e.g. after bulk set-id sync).
Result<void> saveAll(Game game, std::vector<TCard> 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 // Remove the card with the given id. Also deletes any associated images
// via the IImageStore (best-effort - image removal failures are logged in // via the IImageStore (best-effort - image removal failures are logged in
// the error string but the card itself is still purged from the JSON). // the error string but the card itself is still purged from the JSON).
+7
View File
@@ -1,5 +1,7 @@
#include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
namespace ccm { namespace ccm {
void to_json(nlohmann::json& j, const PokemonCard& c) { 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); j.at("altered").get_to(c.altered);
// Missing `region` defaults to West so pre-merge West-only files still load. // Missing `region` defaults to West so pre-merge West-only files still load.
c.region = j.value("region", PokemonRegion::West); 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 } // namespace ccm
@@ -1,5 +1,6 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/util/Rfc3986.hpp" #include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@@ -26,29 +27,11 @@ std::string toLower(std::string s) {
return s; return s;
} }
Result<std::string, PreviewLookupError> imageUrlFromCardObject(const nlohmann::json& card) {
using R = Result<std::string, PreviewLookupError>;
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<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return R::ok(images.at("small").get<std::string>());
}
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
}
} // namespace } // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {} PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) { 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); std::string s(setNo);
const auto slash = s.find('/'); const auto slash = s.find('/');
if (slash != std::string::npos) { if (slash != std::string::npos) {
@@ -57,68 +40,85 @@ std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view
return s; return s;
} }
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name, std::string PokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
std::string_view setId, if (imageBase.empty()) return {};
std::string_view setNo) { std::string url(imageBase);
// When both set id and collector number are known, omit name: — Lucene while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
// name∩number intersections can miss even when the print is real, and return url + "/high.png";
// 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::buildCardByIdUrl(std::string_view setId, std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
std::string_view setNo) { std::string_view setNo) {
const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo); const std::string idCanon = canonicalizeWestSetId(setId);
std::string id = std::string(setId) + "-" + num; const std::string num = normalizeCollectorNumber(setNo);
return std::string("https://api.pokemontcg.io/v2/cards/") + rfc3986PercentEncode(id); 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 PokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
std::string_view setId) { return std::string("https://api.tcgdex.net/v2/en/sets/") +
std::string url = buildSearchUrl(name, setId, ""); rfc3986PercentEncode(canonicalizeWestSetId(setId));
url += "&select=name,number,rarity,set"; }
url += "&pageSize=50";
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; return url;
} }
Result<std::string, PreviewLookupError> Result<std::vector<PokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
PokemonCardPreviewSource::parseResponse(const std::string& body) { PokemonCardPreviewSource::parseSetCards(const std::string& body) {
using R = Result<std::string, PreviewLookupError>; using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
using K = PreviewLookupError::Kind; using K = PreviewLookupError::Kind;
try { try {
const auto j = nlohmann::json::parse(body); const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) { if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."}); return R::err({K::Transient,
"TCGdex EN set detail missing 'cards' array."});
} }
const auto& data = j.at("data"); std::vector<SetCardRow> out;
if (data.empty()) { out.reserve(j.at("cards").size());
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."}); 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<std::string>();
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<std::string>();
}
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) { } catch (const std::exception& e) {
return R::err({K::Transient, 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; using K = PreviewLookupError::Kind;
try { try {
const auto j = nlohmann::json::parse(body); const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_object()) { if (!j.is_object()) {
return R::err({K::Transient, "Pokemon TCG card response missing 'data' 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<std::string>();
if (base.empty()) {
return R::err({K::NotFound, "TCGdex EN card has no image."});
}
return R::ok(imageUrlFromBase(base));
} catch (const std::exception& e) { } catch (const std::exception& e) {
return R::err({K::Transient, 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<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseSearchResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
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<std::string>();
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<std::string, PreviewLookupError>; using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind; using K = PreviewLookupError::Kind;
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo); const std::string num = normalizeCollectorNumber(setNo);
if (!setId.empty() && !num.empty()) { if (!idCanon.empty() && !num.empty()) {
auto byId = http_.get(buildCardByIdUrl(setId, num)); auto byId = http_.get(buildCardByIdUrl(idCanon, num));
if (byId) { if (byId) {
auto img = parseCardByIdResponse(byId.value()); auto img = parseCardByIdResponse(byId.value());
if (img) return img; 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); auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()}); if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value()); return parseSearchResponse(resp.value());
} }
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants( Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
const std::string& body, const std::string& body,
std::string_view setId, std::string_view /*setId*/,
std::string_view wantedCardName) { std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>; using R = Result<std::vector<AutoDetectedPrint>>;
try { auto rows = parseSetCards(body);
const auto j = nlohmann::json::parse(body); if (!rows) {
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) { return R::err(rows.error().message);
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<AutoDetectedPrint> 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<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> 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());
} }
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> 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<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name, Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
@@ -234,15 +243,60 @@ Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVari
std::string_view name, std::string_view name,
std::string_view setId) { std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>; using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildDetectSearchUrl(name, setId); const std::string idCanon = canonicalizeWestSetId(setId);
auto resp = http_.get(url); if (!idCanon.empty()) {
if (resp) { auto detail = http_.get(buildSetDetailUrl(idCanon));
return parsePrintVariants(resp.value(), setId, name); 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<AutoDetectedPrint> collected;
std::unordered_set<std::string> 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<std::string>();
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 } // namespace ccm
@@ -0,0 +1,70 @@
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include <unordered_map>
namespace ccm {
namespace {
std::unordered_map<std::string, const Set*> indexById(const std::vector<Set>& sets) {
std::unordered_map<std::string, const Set*> 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<PokemonCard>& cards,
const std::vector<Set>& westSets,
const std::vector<Set>& 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
+142 -169
View File
@@ -1,193 +1,166 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp" #include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <algorithm> #include <algorithm>
#include <string> #include <string>
#include <unordered_map>
#include <unordered_set> #include <unordered_set>
#include <utility> #include <utility>
namespace ccm { namespace ccm {
namespace { PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
void finalizeCatalog(PokemonSetCatalog& catalog) { std::string PokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
for (auto& pack : catalog.packs) { std::string out(isoDate);
std::sort(pack.cards.begin(), pack.cards.end(), for (char& ch : out) {
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) { if (ch == '-') ch = '/';
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
} }
return out;
}
std::string PokemonSetSource::buildSetDetailUrl(std::string_view setId) {
return std::string("https://api.tcgdex.net/v2/en/sets/") +
rfc3986PercentEncode(setId);
}
Result<std::vector<Set>> PokemonSetSource::parseListResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"TCGdex EN sets response is not a JSON array.");
}
std::vector<Set> 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<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("TCGdex EN sets JSON parse error: ") + e.what());
}
}
Result<std::string> PokemonSetSource::parseReleaseDate(const std::string& detailBody) {
try {
const auto j = nlohmann::json::parse(detailBody);
if (!j.is_object()) {
return Result<std::string>::err(
"TCGdex EN set detail response is not a JSON object.");
}
const std::string raw = j.value("releaseDate", "");
if (raw.empty()) {
return Result<std::string>::ok(std::string{});
}
return Result<std::string>::ok(rewriteReleaseDate(raw));
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("TCGdex EN set detail JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalogPack> PokemonSetSource::parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set) {
auto rows = PokemonCardPreviewSource::parseSetCards(detailBody);
if (!rows) {
return Result<PokemonSetCatalogPack>::err(rows.error().message);
}
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
std::unordered_set<std::string> 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<PokemonSetCatalogPack>::err("No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return parsed;
std::vector<Set> 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<std::vector<Set>>::ok(std::move(out));
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
std::vector<Set> 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(), std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) { [](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName; return a.setName < b.setName;
}); });
}
} // namespace
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> 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<std::vector<Set>>::err(
"Pokemon TCG API response missing 'data' array.");
}
std::vector<Set> 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<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::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::CardsPageMeta>
PokemonSetSource::mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<CardsPageMeta>::err(
"Pokemon TCG cards response missing 'data' array.");
}
std::unordered_map<std::string, std::string> 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<std::string, std::size_t> packIndex;
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
packIndex.emplace(catalog.packs[i].setId, i);
}
std::vector<std::unordered_set<std::string>> 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<std::string>{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<int>(j.at("data").size()));
meta.totalCount = j.value("totalCount", meta.count);
return Result<CardsPageMeta>::ok(meta);
} catch (const std::exception& e) {
return Result<CardsPageMeta>::err(
std::string("Pokemon TCG cards JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalog> PokemonSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
PokemonSetCatalog catalog;
auto meta = mergeCardsPage(body, catalog, sets);
if (!meta) return Result<PokemonSetCatalog>::err(meta.error());
finalizeCatalog(catalog);
return Result<PokemonSetCatalog>::ok(std::move(catalog));
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
auto setsResp = http_.get(kEndpoint);
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
auto sets = parseResponse(setsResp.value());
if (!sets) return Result<FetchWithCatalog>::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<FetchWithCatalog>::err(cardsResp.error());
auto meta = mergeCardsPage(cardsResp.value(), catalog, sets.value());
if (!meta) return Result<FetchWithCatalog>::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<FetchWithCatalog>::err(
"Pokemon TCG cards pagination exceeded safety limit.");
}
}
finalizeCatalog(catalog);
FetchWithCatalog out; FetchWithCatalog out;
out.sets = std::move(sets).value(); out.sets = std::move(sets);
out.catalog = std::move(catalog); out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out)); return Result<FetchWithCatalog>::ok(std::move(out));
} }
@@ -0,0 +1,92 @@
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include <unordered_map>
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<std::string, std::string>& legacyAliases() {
static const std::unordered_map<std::string, std::string> 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
+14 -3
View File
@@ -1,6 +1,7 @@
#include "ccm/services/PokemonSetCompletion.hpp" #include "ccm/services/PokemonSetCompletion.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" #include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include <algorithm> #include <algorithm>
@@ -29,6 +30,10 @@ std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) {
return PokemonCardPreviewSource::normalizeCollectorNumber(setNo); return PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
} }
std::string westSetKey(std::string_view setId) {
return canonicalizeWestSetId(setId);
}
OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection, OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
PokemonRegion region, PokemonRegion region,
std::optional<Language> languageFilter) { std::optional<Language> languageFilter) {
@@ -39,7 +44,9 @@ OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
if (card.set.id.empty()) continue; if (card.set.id.empty()) continue;
const std::string setNo = normalizeForRegion(region, card.setNo); const std::string setNo = normalizeForRegion(region, card.setNo);
if (setNo.empty()) continue; 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; return out;
} }
@@ -157,14 +164,18 @@ pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
std::optional<Language> languageFilter) { std::optional<Language> languageFilter) {
const PokemonSetCatalog& catalog = const PokemonSetCatalog& catalog =
region == PokemonRegion::Asia ? asiaCatalog : westCatalog; 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 {}; if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos; std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) { for (const auto& card : collection) {
if (card.region != region) continue; if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) 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); const std::string setNo = normalizeForRegion(region, card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo); if (!setNo.empty()) ownedNos.insert(setNo);
} }
+17 -12
View File
@@ -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. 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` 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.
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
**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 sets `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: 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 Asias 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:"<name>"` and `set.id:<setId>` 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 pickers **`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 cards `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) ### Set-completion catalog (West)
**Sets → Update Pokemon** uses `PokemonSetSource::fetchAllWithCatalog()` so the West path writes: **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) 1. The set list (`pokemon/sets-west.json`) from `/v2/en/sets` + per-set detail dates
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-west.json` from a paginated `/v2/cards?select=name,number,set&pageSize=250` dump 2. A pack checklist at `<dataStorage>/pokemon/set-catalog-west.json` from each sets 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. 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 ## 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) ### 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. - **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 `<exeDir>/.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 `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.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 disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `<exeDir>/.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 `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.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. `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.
+4 -2
View File
@@ -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. - `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. - `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. - `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_west_set_id_tests.cpp``canonicalizeWestSetId` identity + legacy pokemontcg → TCGdex EN mappings (`sv1``sv01`, `pgo``swsh10.5`, …) and unknown passthrough.
- `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_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_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`. - `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`. - `yugioh_set_completion_tests.cpp``computeYuGiOhSetCompletion` / `yuGiOhChecklistForSet` ownership rules (printing-slot match) + `YuGiOhSetCatalogService` round-trip against `InMemoryFileSystem`.
+2
View File
@@ -19,6 +19,8 @@ add_executable(ccm_core_tests
card_preview_service_tests.cpp card_preview_service_tests.cpp
local_preview_byte_cache_tests.cpp local_preview_byte_cache_tests.cpp
std_file_system_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_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp digibattle99_set_source_tests.cpp
+18
View File
@@ -236,4 +236,22 @@ TEST_SUITE("CollectionService<MagicCard>") {
CHECK(store.removed[0].second == "a.png"); CHECK(store.removed[0].second == "a.png");
CHECK(store.removed[1].second == "b.png"); CHECK(store.removed[1].second == "b.png");
} }
TEST_CASE("saveAll replaces the collection map") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> 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");
}
} }
+42
View File
@@ -218,6 +218,48 @@ TEST_SUITE("PokemonCard JSON") {
const PokemonCard legacy = j.get<PokemonCard>(); const PokemonCard legacy = j.get<PokemonCard>();
CHECK(legacy.region == PokemonRegion::West); 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<PokemonCard>();
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<PokemonCard>();
CHECK(back.set.id == "sv1");
}
} }
TEST_SUITE("DigiBattle99Card JSON") { TEST_SUITE("DigiBattle99Card JSON") {
+105 -306
View File
@@ -24,186 +24,113 @@ public:
} // namespace } // namespace
TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") { TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
TEST_CASE("name and setId produce a percent-encoded query") { TEST_CASE("setId plus setNo uses localId and set.id filters without name") {
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") {
const auto url = PokemonCardPreviewSource::buildSearchUrl( const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Charizard", "base1", "4"); "Charizard", "base1", "4");
CHECK(url.find("number%3A4") != std::string::npos); CHECK(url.find("https://api.tcgdex.net/v2/en/cards?") == 0);
CHECK(url.find("set.id%3Abase1") != std::string::npos); CHECK(url.find("set.id=eq:base1") != std::string::npos);
CHECK(url.find("name") == std::string::npos); CHECK(url.find("localId=eq:4") != std::string::npos);
CHECK(url.find("Charizard") == 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") { 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( const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Charizard", "base1", "4/102"); "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("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( const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Mr. Mime", "base1", ""); "Mr. Mime", "base1", "");
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos); CHECK(url.find("name=eq:Mr.%20Mime") != std::string::npos);
} CHECK(url.find("set.id=eq:base1") != 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);
} }
} }
TEST_SUITE("PokemonCardPreviewSource::buildCardByIdUrl") { TEST_SUITE("PokemonCardPreviewSource::buildCardByIdUrl") {
TEST_CASE("joins setId and normalized number with a hyphen") { TEST_CASE("joins setId and normalized number with a hyphen") {
const auto url = PokemonCardPreviewSource::buildCardByIdUrl("base1", "4"); 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 = const auto url =
PokemonCardPreviewSource::buildCardByIdUrl("swsh12tg", "TG14"); 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") { TEST_CASE("strips slash form before building the id") {
const auto url = const auto url =
PokemonCardPreviewSource::buildCardByIdUrl("base1", "4/102"); 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_SUITE("PokemonCardPreviewSource::parseSearchResponse") {
TEST_CASE("returns images.large when present") { TEST_CASE("appends /high.png to the first card image base") {
const std::string json = R"({ const std::string json = R"([
"data": [ {"id":"base1-25","localId":"25","name":"Pikachu",
{ "image":"https://assets.tcgdex.net/en/base/base1/25"}
"name": "Pikachu", ])";
"images": { const auto out = PokemonCardPreviewSource::parseSearchResponse(json);
"small": "https://images.pokemontcg.io/small.png",
"large": "https://images.pokemontcg.io/large.png"
}
}
]
})";
const auto out = PokemonCardPreviewSource::parseResponse(json);
REQUIRE(out.isOk()); 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") { TEST_CASE("empty array is NotFound") {
const std::string json = R"({ const auto out = PokemonCardPreviewSource::parseSearchResponse("[]");
"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":[]})");
REQUIRE(out.isErr()); REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
} }
TEST_CASE("missing data array is classified as Transient (schema deviation)") { TEST_CASE("object shape is Transient") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})"); const auto out = PokemonCardPreviewSource::parseSearchResponse(R"({"data":[]})");
REQUIRE(out.isErr()); REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient); CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
} }
TEST_CASE("'data' present but not an array is Transient") { TEST_CASE("cards without image are NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":{}})"); const auto out = PokemonCardPreviewSource::parseSearchResponse(
REQUIRE(out.isErr()); R"([{"id":"base1-1","localId":"1","name":"X"}])");
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":[]}]})");
REQUIRE(out.isErr()); REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
} }
TEST_CASE("large unusable type falls back to small string") { TEST_CASE("invalid JSON is Transient") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({ const auto out = PokemonCardPreviewSource::parseSearchResponse("{not json");
"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");
REQUIRE(out.isErr()); REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient); CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
} }
} }
TEST_SUITE("PokemonCardPreviewSource::parseCardByIdResponse") { 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"({ const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({
"data": { "id": "base1-4",
"id": "base1-4", "image": "https://assets.tcgdex.net/en/base/base1/4"
"images": {
"small": "https://images.pokemontcg.io/small.png",
"large": "https://images.pokemontcg.io/large.png"
}
}
})"); })");
REQUIRE(out.isOk()); 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") { TEST_CASE("null image is NotFound") {
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") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse( const auto out = PokemonCardPreviewSource::parseCardByIdResponse(
R"({"data":{"id":"base1-4","name":"Charizard"}})"); R"({"id":"base1-4","image":null})");
REQUIRE(out.isErr()); REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
} }
TEST_CASE("data array shape is Transient") { TEST_CASE("array shape is Transient") {
const auto out = const auto out = PokemonCardPreviewSource::parseCardByIdResponse("[]");
PokemonCardPreviewSource::parseCardByIdResponse(R"({"data":[]})");
REQUIRE(out.isErr()); REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient); CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
} }
@@ -228,15 +155,16 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
TEST_CASE("with setNo prefers card-by-id endpoint") { TEST_CASE("with setNo prefers card-by-id endpoint") {
FixedHttpClient http; FixedHttpClient http;
http.ok = true; 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}; PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Pikachu", "base1", "25"); const auto out = src.fetchImageUrl("Pikachu", "base1", "25");
REQUIRE(out.isOk()); REQUIRE(out.isOk());
CHECK(out.value() == "https://l/by-id.png"); CHECK(out.value() ==
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/cards/base1-25"); "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 { class RoutingHttp final : public IHttpClient {
public: public:
int calls = 0; int calls = 0;
@@ -244,250 +172,130 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
Result<std::string> get(std::string_view url) override { Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url); lastUrl = std::string(url);
++calls; ++calls;
if (url.find("/v2/cards?") == std::string::npos) { if (url.find("/v2/en/cards?") == std::string::npos) {
return Result<std::string>::err("HTTP 404 from card id"); return Result<std::string>::err("HTTP 404 from card id");
} }
return Result<std::string>::ok( return Result<std::string>::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; } http;
PokemonCardPreviewSource src{http}; PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Charizard", "base1", "4"); const auto out = src.fetchImageUrl("Charizard", "base1", "4");
REQUIRE(out.isOk()); 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.calls == 2);
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos); CHECK(http.lastUrl.find("set.id=eq:base1") != std::string::npos);
CHECK(http.lastUrl.find("number%3A4") != std::string::npos); CHECK(http.lastUrl.find("localId=eq:4") != std::string::npos);
CHECK(http.lastUrl.find("name") == std::string::npos);
} }
TEST_CASE("empty setNo uses name search without card-by-id") { TEST_CASE("empty setNo uses name search without card-by-id") {
FixedHttpClient http; FixedHttpClient http;
http.ok = true; 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}; PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Pikachu", "base1", ""); const auto out = src.fetchImageUrl("Pikachu", "base1", "");
REQUIRE(out.isOk()); REQUIRE(out.isOk());
CHECK(out.value() == "https://l/x.png"); CHECK(http.lastUrl.find("name=eq:Pikachu") != std::string::npos);
CHECK(http.lastUrl.find("%22Pikachu%22") != std::string::npos); CHECK(http.lastUrl.find("/v2/en/cards/base1-") == std::string::npos);
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos);
CHECK(http.lastUrl.find("/v2/cards/base1-") == std::string::npos);
} }
} }
namespace { namespace {
const char* kCharizardSwsh4 = R"({ const char* kCharizardSwsh4Detail = R"({
"data": [ "id": "swsh4",
"name": "Vivid Voltage",
"cards": [
{ {
"id": "swsh4-25",
"localId": "25",
"name": "Charizard", "name": "Charizard",
"number": "25",
"rarity": "Rare", "rarity": "Rare",
"set": { "image": "https://assets.tcgdex.net/en/swsh/swsh4/25"
"id": "swsh4",
"name": "Vivid Voltage",
"printedTotal": 185
}
} }
] ]
})"; })";
const char* kMultiVariantPayload = R"({ const char* kMultiVariantDetail = R"({
"data": [ "id": "base1",
{ "cards": [
"name": "Pikachu", {"localId":"25","name":"Pikachu","rarity":"Common"},
"number": "25", {"localId":"58","name":"Pikachu","rarity":"Rare"},
"rarity": "Common", {"localId":"1","name":"Alakazam","rarity":"Rare"}
"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}
}
] ]
})"; })";
} // namespace } // namespace
TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") { TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") {
TEST_CASE("maps API number into setNo without printedTotal suffix") { TEST_CASE("maps localId into setNo from set detail") {
const auto out = const auto out = PokemonCardPreviewSource::parsePrintVariants(
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Charizard"); kCharizardSwsh4Detail, "swsh4", "Charizard");
REQUIRE(out.isOk()); REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1); REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25"); CHECK(out.value().front().setNo == "25");
CHECK(out.value().front().rarity == "Rare"); CHECK(out.value().front().rarity == "Rare");
} }
TEST_CASE("filters by set id and keeps multiple numbers in the same set") { TEST_CASE("filters by card name within the set") {
const auto out = const auto out = PokemonCardPreviewSource::parsePrintVariants(
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base1", "Pikachu"); kMultiVariantDetail, "base1", "Pikachu");
REQUIRE(out.isOk()); REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2); REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "25"); CHECK(out.value()[0].setNo == "25");
CHECK(out.value()[1].setNo == "58"); CHECK(out.value()[1].setNo == "58");
} }
TEST_CASE("wrong set id yields explicit error when name and set are supplied") { TEST_CASE("wrong card name yields error") {
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") {
const auto out = PokemonCardPreviewSource::parsePrintVariants( const auto out = PokemonCardPreviewSource::parsePrintVariants(
R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})", kCharizardSwsh4Detail, "swsh4", "Blastoise");
"",
"Nobody");
REQUIRE(out.isErr()); 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") { TEST_CASE("missing cards array is an error") {
const auto out = const auto out = PokemonCardPreviewSource::parsePrintVariants(
PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu"); R"({"id":"base1"})", "base1", "Pikachu");
REQUIRE(out.isErr()); 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_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; FixedHttpClient http;
http.body = kCharizardSwsh4; http.body = kCharizardSwsh4Detail;
PokemonCardPreviewSource src{http}; PokemonCardPreviewSource src{http};
CHECK(src.supportsAutoDetectPrint()); CHECK(src.supportsAutoDetectPrint());
const auto first = src.detectFirstPrint("Charizard", "swsh4"); const auto first = src.detectFirstPrint("Charizard", "swsh4");
REQUIRE(first.isOk()); REQUIRE(first.isOk());
CHECK(first.value().setNo == "25"); 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") { TEST_CASE("falls back to cards search when set detail fails") {
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") {
class FallbackHttpClient final : public IHttpClient { class FallbackHttpClient final : public IHttpClient {
public: public:
int calls = 0; int calls = 0;
Result<std::string> get(std::string_view url) override { Result<std::string> get(std::string_view url) override {
++calls; ++calls;
if (calls == 1) return Result<std::string>::err("offline"); if (std::string(url).find("/sets/") != std::string::npos) {
if (url.find("set.id") != std::string::npos) { return Result<std::string>::err("offline");
return Result<std::string>::err("unexpected set-scoped retry");
} }
return Result<std::string>::ok(kMultiVariantPayload); return Result<std::string>::ok(R"([
{"id":"base1-25","localId":"25","name":"Pikachu","rarity":"Common"},
{"id":"base1-58","localId":"58","name":"Pikachu","rarity":"Rare"}
])");
} }
} http; } http;
@@ -498,7 +306,7 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
CHECK(http.calls == 2); 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 { class AlwaysFailHttp final : public IHttpClient {
public: public:
int calls = 0; int calls = 0;
@@ -514,13 +322,4 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
CHECK(out.error() == "offline"); CHECK(out.error() == "offline");
CHECK(http.calls == 2); 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.");
}
} }
@@ -0,0 +1,74 @@
#include <doctest/doctest.h>
#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<PokemonCard> cards{
makeWest("sv1", "Old Name", "2000/01/01"),
};
const std::vector<Set> west{Set{"sv01", "Scarlet & Violet", "2023/03/31"}};
const std::vector<Set> 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<PokemonCard> cards{
makeAsia("sv1", "Old", "2000/01/01"),
};
const std::vector<Set> west{Set{"sv01", "Scarlet & Violet", "2023/03/31"}};
const std::vector<Set> 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<PokemonCard> cards{
makeWest("base1", "Base Set", "1999/01/09"),
};
const std::vector<Set> west{Set{"base1", "Base Set", "1999/01/09"}};
CHECK(syncPokemonCollectionSets(cards, west, {}) == 0);
}
TEST_CASE("unknown set id still migrates when aliased") {
std::vector<PokemonCard> 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");
}
}
+22
View File
@@ -127,6 +127,28 @@ TEST_SUITE("computePokemonSetCompletion") {
CHECK(rows[0].ownedUnique == 1); 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<PokemonCard> 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") { TEST_CASE("amount does not inflate unique ownership") {
const auto west = westCatalog(); const auto west = westCatalog();
PokemonSetCatalog emptyAsia; PokemonSetCatalog emptyAsia;
+100 -88
View File
@@ -3,6 +3,9 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp" #include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp" #include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <unordered_map>
using namespace ccm; using namespace ccm;
namespace { namespace {
@@ -19,59 +22,93 @@ public:
} }
}; };
class RoutingHttpClient final : public IHttpClient {
public:
std::string listBody;
std::unordered_map<std::string, std::string> byUrl;
std::string lastUrl;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (lastUrl == PokemonSetSource::kListEndpoint) {
return Result<std::string>::ok(listBody);
}
const auto it = byUrl.find(lastUrl);
if (it == byUrl.end()) return Result<std::string>::err("missing route");
return Result<std::string>::ok(it->second);
}
};
} // namespace } // namespace
TEST_SUITE("PokemonSetSource::parseResponse") { TEST_SUITE("PokemonSetSource::parseListResponse") {
TEST_CASE("happy path: maps id/name/releaseDate without rewriting separators") { TEST_CASE("happy path: maps id/name from top-level array") {
// The Pokemon TCG API returns releaseDate already in YYYY/MM/DD form, const std::string json = R"([
// unlike Scryfall's released_at YYYY-MM-DD. {"id":"base1","name":"Base Set","cardCount":{"total":102,"official":102}},
const std::string json = R"({ {"id":"base2","name":"Jungle","cardCount":{"total":64,"official":64}}
"data": [ ])";
{"id":"base1","name":"Base","releaseDate":"1999/01/09"},
{"id":"jungle","name":"Jungle","releaseDate":"1999/06/16"}
]
})";
const auto out = PokemonSetSource::parseResponse(json); const auto out = PokemonSetSource::parseListResponse(json);
REQUIRE(out.isOk()); REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2); REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].id == "base1"); CHECK(out.value()[0].id == "base1");
CHECK(out.value()[0].name == "Base"); CHECK(out.value()[0].name == "Base Set");
CHECK(out.value()[0].releaseDate == "1999/01/09"); CHECK(out.value()[0].releaseDate.empty());
CHECK(out.value()[1].id == "jungle"); CHECK(out.value()[1].id == "base2");
CHECK(out.value()[1].releaseDate == "1999/06/16");
} }
TEST_CASE("sorts by release date ascending") { TEST_CASE("empty array returns an empty list (not an error)") {
const std::string json = R"({ const auto out = PokemonSetSource::parseListResponse("[]");
"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":[]})");
REQUIRE(out.isOk()); REQUIRE(out.isOk());
CHECK(out.value().empty()); CHECK(out.value().empty());
} }
TEST_CASE("missing data array returns an error") { TEST_CASE("object shape returns an error") {
const auto out = PokemonSetSource::parseResponse(R"({"meta":{}})"); const auto out = PokemonSetSource::parseListResponse(R"({"data":[]})");
CHECK(out.isErr()); CHECK(out.isErr());
} }
TEST_CASE("invalid JSON returns an error") { TEST_CASE("invalid JSON returns an error") {
const auto out = PokemonSetSource::parseResponse("{not json"); const auto out = PokemonSetSource::parseListResponse("{not json");
CHECK(out.isErr()); 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_SUITE("PokemonSetSource::fetchAll") {
TEST_CASE("network error is surfaced as a Result error") { TEST_CASE("network error is surfaced as a Result error") {
FixedHttpClient http; FixedHttpClient http;
@@ -80,67 +117,42 @@ TEST_SUITE("PokemonSetSource::fetchAll") {
CHECK(src.fetchAll().isErr()); CHECK(src.fetchAll().isErr());
} }
TEST_CASE("network success is parsed end-to-end and hits the public endpoint") { TEST_CASE("list plus set detail fills release dates and hits EN endpoints") {
FixedHttpClient http; RoutingHttpClient http;
http.ok = true; http.listBody = R"([{"id":"base1","name":"Base Set"}])";
http.body = R"({"data":[{"id":"x","name":"X","releaseDate":"2020/01/01"}]})"; http.byUrl[PokemonSetSource::buildSetDetailUrl("base1")] =
R"({"id":"base1","name":"Base Set","releaseDate":"1999-01-09","cards":[]})";
PokemonSetSource src{http}; PokemonSetSource src{http};
const auto out = src.fetchAll(); const auto out = src.fetchAll();
REQUIRE(out.isOk()); REQUIRE(out.isOk());
CHECK(out.value().front().id == "x"); REQUIRE(out.value().size() == 1);
CHECK(out.value().front().releaseDate == "2020/01/01"); CHECK(out.value().front().id == "base1");
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets"); CHECK(out.value().front().releaseDate == "1999/01/09");
CHECK(http.lastUrl == PokemonSetSource::buildSetDetailUrl("base1"));
} }
} }
TEST_SUITE("PokemonSetSource::parseCatalog") { TEST_SUITE("PokemonSetSource::fetchAllWithCatalog") {
TEST_CASE("groups cards by set.id and dedupes collector numbers") { TEST_CASE("builds catalog packs from set detail cards") {
const std::vector<Set> sets{ RoutingHttpClient http;
Set{"base1", "Base", "1999/01/09"}, http.listBody = R"([{"id":"base1","name":"Base Set"}])";
Set{"jungle", "Jungle", "1999/06/16"}, http.byUrl[PokemonSetSource::buildSetDetailUrl("base1")] = R"({
}; "id":"base1",
const std::string json = R"({ "name":"Base Set",
"data": [ "releaseDate":"1999-01-09",
{"name":"Charizard","number":"4","set":{"id":"base1","name":"Base"}}, "cards":[
{"name":"Charizard","number":"4/102","set":{"id":"base1","name":"Base"}}, {"localId":"4","name":"Charizard"},
{"name":"Growlithe","number":"58","set":{"id":"base1","name":"Base"}}, {"localId":"58","name":"Growlithe"}
{"name":"Pikachu","number":"60","set":{"id":"jungle","name":"Jungle"}} ]
],
"page":1,"pageSize":250,"count":4,"totalCount":4
})"; })";
const auto catalog = PokemonSetSource::parseCatalog(json, sets); PokemonSetSource src{http};
REQUIRE(catalog.isOk()); const auto out = src.fetchAllWithCatalog();
REQUIRE(catalog.value().packs.size() == 2); REQUIRE(out.isOk());
const auto* base = catalog.value().findPack("base1"); REQUIRE(out.value().sets.size() == 1);
REQUIRE(base != nullptr); REQUIRE(out.value().catalog.packs.size() == 1);
REQUIRE(base->cards.size() == 2); const auto* pack = out.value().catalog.findPack("base1");
CHECK(base->cards[0].setNo == "4"); REQUIRE(pack != nullptr);
CHECK(base->cards[1].setNo == "58"); REQUIRE(pack->cards.size() == 2);
const auto* jungle = catalog.value().findPack("jungle"); CHECK(pack->cards[0].setNo == "4");
REQUIRE(jungle != nullptr);
REQUIRE(jungle->cards.size() == 1);
CHECK(jungle->cards[0].setNo == "60");
}
TEST_CASE("mergeCardsPage accumulates across pages") {
const std::vector<Set> 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");
} }
} }
+41
View File
@@ -0,0 +1,41 @@
#include <doctest/doctest.h>
#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());
}
}
+41 -6
View File
@@ -1,5 +1,6 @@
#include "ccm/ui/PokemonGameView.hpp" #include "ccm/ui/PokemonGameView.hpp"
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
#include "ccm/games/pokemon/PokemonSetSource.hpp" #include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp" #include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/ui/CardEditModalGuard.hpp" #include "ccm/ui/CardEditModalGuard.hpp"
@@ -493,6 +494,30 @@ std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) {
asiaErr = asiaBoth.error(); 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) { if (setCompletionPanel_ != nullptr) {
setCompletionPanel_->reloadFromStore(); setCompletionPanel_->reloadFromStore();
if (auto loaded = collection_.list(Game::Pokemon)) { if (auto loaded = collection_.list(Game::Pokemon)) {
@@ -524,12 +549,22 @@ std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) {
return "Pokemon sets partially updated."; return "Pokemon sets partially updated.";
} }
showThemedMessageDialog( std::string body = "Updated " + std::to_string(westSets) + " West sets (" +
parentWindow, std::to_string(westPacks) + " checklists) and " +
"Updated " + std::to_string(westSets) + " West sets (" + std::to_string(asiaSets) + " Asia sets (" +
std::to_string(westPacks) + " checklists) and " + std::to_string(asiaSets) + std::to_string(asiaPacks) + " checklists).";
" Asia sets (" + std::to_string(asiaPacks) + " checklists).", if (collectionSynced > 0) {
"Sets updated", wxOK | wxICON_INFORMATION); 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."; return "Pokemon sets updated.";
} }