diff --git a/AGENTS.md b/AGENTS.md index ae51c50..f63a622 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ C++ desktop implementation (originally based on a Tauri Rust+TS version) — sin ## Project structure - `core/` — `ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`. -- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. +- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` (Yu-Gi-Oh! offline preview fallback); `app/CMakeLists.txt` copies it to `/assets/` when linking `ccm`. - `app/` — `ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`. - `tests/` — `ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`. - `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`. @@ -52,13 +52,13 @@ Run from the **workspace root**. - Run the app: `./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows) - Run tests (CCM_BUILD_TESTS defaults to ON): - `ctest --test-dir build --output-on-failure` — current baseline: **86 cases / 211 assertions, all green**. + `ctest --test-dir build --output-on-failure` — current baseline: **180 tests, all green**. - Build tests only: `cmake --build build --target ccm_core_tests` -> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer. +> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm3.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer. > -> **Windows rebuild note**: linking `ccm.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm.exe` before rebuilding app targets. +> **Windows rebuild note**: linking `ccm3.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm3.exe` before rebuilding app targets. > > **Windows cold-start note**: first launch right after a fresh build is often slower than subsequent launches due to cold file cache and Windows security scanning (Defender/SmartScreen) on the new exe/dll set. Warm launches are the meaningful baseline for app-side perf changes. @@ -69,8 +69,14 @@ Run from the **workspace root**. - Preserve "select first row on startup" behavior without blocking first paint by scheduling the initial selection with `CallAfter(...)` instead of selecting synchronously during row rebuild. - Avoid repeated set-list loads when opening Add/Edit: cache Magic sets in `MainFrame` and reuse them in `CardEditDialog`. - Pass preloaded set data to dialogs by pointer/reference, not by value, to avoid copying large vectors on every open. +- `MainFrame` default window size is **1210×770** (`ui_wx/src/MainFrame.cpp`). +- Saving from **Edit** in `BaseCardEditDialog`: themed Yes/No confirmation when the card changed versus the snapshot taken at dialog open; Add mode does not prompt. - While constructing/populating dialogs with many controls/choices, wrap with `Freeze()`/`Thaw()` and append choice items in bulk (`wxArrayString`) to reduce layout/repaint churn. - Keep selected-card preview usable when remote lookup fails: show a per-game card-back fallback image (CCM2 parity), not a blank preview panel. +- Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically. + - `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically. + - `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `/.cache/preview-cache/` — **next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path. + - `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) 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 @@ -83,7 +89,7 @@ Run from the **workspace root**. - For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations. - Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong. - After changing `ui_wx` theming behavior, rebuild the final app target (`cmake --build build --target ccm --parallel`), not just `ccm_ui_wx`, before validating runtime behavior. -- If linker fails with `Permission denied` on `build/bin/ccm.exe`, the app is still running; close it before rebuilding. +- If linker fails with `Permission denied` on `build/bin/ccm3.exe`, the app is still running; close it before rebuilding. ## Required follow-ups diff --git a/README.md b/README.md index 7248f37..a913004 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Card Collection Manager 3 is an extensible desktop application for managing trading card game collections. It is designed as a practical way to track cards and manage per-card images for large collections, with local per-game data, set synchronization workflows, and a desktop-first UX. The app preserves the established JSON layout from earlier CCM versions so existing collections stay compatible. +Currently, the application supports the following TCGs: +- Magic the Gathering +- Pokemon TCG +- Yu-Gi-Oh! + ## Screenshots ### Magic The Gathering @@ -16,6 +21,11 @@ Card Collection Manager 3 is an extensible desktop application for managing trad ![CCM3 Demo - Pokemon](docs/assets/images/demo-pkm.png) +### Yu-Gi-Oh! + +![CCM3 Demo - YuGiOh](docs/assets/images/demo-ygo.png) + + ## Migrating From CCM1 And CCM2 CCM3 reads the established collection layout, so data from both CCM1 and CCM2 can be copied into the configured CCM3 data directory. diff --git a/app/AGENTS.md b/app/AGENTS.md index 0dd578b..3b3a523 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -5,20 +5,23 @@ The `ccm` executable — composition root only. The single place where concrete ## File pointers - `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`. -- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. +- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$/assets/` and copies `ui_wx/assets/ygo_card_back.png` there so Yu-Gi-Oh! preview fallbacks work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`). ## Conventions -1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`, `MagicGameModule`, `PokemonGameModule`, `MagicGameView`, `PokemonGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. +1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `YuGiOhGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. 2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually. 3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set). 4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). 5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location. 6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way. 7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `CardPreviewSource` directly; it calls `previewSvc_->registerModule(*Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped). +8. **One `CprHttpClient` per app, shared by every consumer.** The single `http_` instance is handed to `SetService`, `CardPreviewService`, and every per-game module. Do **not** construct a second `CprHttpClient` (or pass `cpr::Get(...)` directly) from anywhere — the adapter holds a long-lived `cpr::Session` whose connection cache + TLS keep-alive is what makes repeat lookups fast (game-agnostic; see `core/AGENTS.md` convention 11). The shared instance also gives `CardPreviewService`'s in-memory LRU a single source of truth to cache against. +9. **One `LocalPreviewByteCache` per app**, rooted at `/.cache/preview-cache/` — i.e. **next to the executable**, in the same scope as `config.json`. **Do not** root the cache at `config_->current().dataStorage`: the user's data-storage path is user-configurable at runtime and is meant for the user's collection (cards, scans, set lists). Previews are downloaded-from-network artifacts that (a) must not move when the user relocates their collection, (b) must not be uploaded/synced together with the user's data dir, and (c) must not survive a fresh install elsewhere on disk. Pinning the cache to `exeDir` is what gives those properties without writing extra plumbing for each data-storage flow. The umbrella `.cache/` directory is reserved for any future computed-from-network caches (set-list snapshots, etc.); the leading dot keeps it out of the way for users poking around the install folder. Cache updates flow entirely through cache keys: `CardPreviewService` invalidates entries automatically when the cache key changes (record edits) and rewrites them when a same-key resolution flips between positive and negative — there is no `clearCache(...)` API. To wipe the cache manually, delete `/.cache/`; reinstalling / moving the executable also resets the cache by design. Construct the cache after `ConfigService` (so the dependency graph is the same as before; the cache itself only needs `*fs_` and the resolved `exeDir`) and before `CardPreviewService` (so the service can hold a stable raw pointer); declare the member after `config_`/`fs_` and before `previewSvc_` to keep destruction order correct. See `core/AGENTS.md` convention 10 and `docs/caching.md` ("Updating cached entries") for the full cache shape, policy, and update mechanic. ## Required follow-ups +- The **`POST_BUILD` copy of `ygo_card_back.png`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`. - After adding a new game module you **must**: (1) add a `unique_ptr<GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(Mod_.get())`, (4) call `previewSvc_->registerModule(*Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<Card>` + `CollectionService<Card>` if the game has a custom card type, (7) construct a `GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`). - After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`. - After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list. @@ -32,4 +35,4 @@ The `ccm` executable — composition root only. The single place where concrete ## Commands - Build the binary: `cmake --build build --target ccm` -- Run on Windows / MinGW-w64: `.\build\bin\ccm.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3". +- Run on Windows / MinGW-w64: `.\build\bin\ccm3.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3". diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 8eee632..5e7382d 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -18,3 +18,10 @@ target_link_libraries(ccm ccm_ui_wx ccm_warnings ) + +# Yu-Gi-Oh! preview fallback image (used when network card-back URLs fail). +add_custom_command(TARGET ccm POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png" + "$/assets/ygo_card_back.png") diff --git a/app/main.cpp b/app/main.cpp index 3cb1eb7..2ba87b1 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -4,12 +4,15 @@ #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhCard.hpp" #include "ccm/games/magic/MagicGameModule.hpp" #include "ccm/games/pokemon/PokemonGameModule.hpp" +#include "ccm/games/yugioh/YuGiOhGameModule.hpp" #include "ccm/infra/CprHttpClient.hpp" #include "ccm/infra/JsonCollectionRepository.hpp" #include "ccm/infra/JsonSetRepository.hpp" #include "ccm/infra/LocalImageStore.hpp" +#include "ccm/infra/LocalPreviewByteCache.hpp" #include "ccm/infra/StdFileSystem.hpp" #include "ccm/services/CardPreviewService.hpp" #include "ccm/services/CollectionService.hpp" @@ -20,6 +23,7 @@ #include "ccm/ui/MagicGameView.hpp" #include "ccm/ui/MainFrame.hpp" #include "ccm/ui/PokemonGameView.hpp" +#include "ccm/ui/YuGiOhGameView.hpp" #include #include @@ -40,6 +44,7 @@ std::string dirNameForGame(ccm::Game g) { switch (g) { case ccm::Game::Magic: return "magic"; case ccm::Game::Pokemon: return "pokemon"; + case ccm::Game::YuGiOh: return "yugioh"; } return "magic"; } @@ -74,11 +79,14 @@ public: http_ = std::make_unique(); magicMod_ = std::make_unique(*http_); pokeMod_ = std::make_unique(*http_); + ygoMod_ = std::make_unique(*http_); magicRepo_ = std::make_unique>( *fs_, *config_, &dirNameForGame); pokeRepo_ = std::make_unique>( *fs_, *config_, &dirNameForGame); + ygoRepo_ = std::make_unique>( + *fs_, *config_, &dirNameForGame); setRepo_ = std::make_unique(*fs_, *config_, &dirNameForGame); imgStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); @@ -87,19 +95,39 @@ public: *magicRepo_, *imgStore_); pokeCollSvc_ = std::make_unique>( *pokeRepo_, *imgStore_); + ygoCollSvc_ = std::make_unique>( + *ygoRepo_, *imgStore_); setSvc_ = std::make_unique(*setRepo_); setSvc_->registerModule(magicMod_.get()); setSvc_->registerModule(pokeMod_.get()); + setSvc_->registerModule(ygoMod_.get()); - previewSvc_ = std::make_unique(*http_); + // Disk-backed preview cache lives next to the executable, in the same + // location scope as config.json - NOT inside the user's data-storage + // directory. Rationale: previews are downloaded artifacts, not user + // data, so they should not move when the user relocates their + // collection (data-storage path can be reconfigured at runtime), and + // they should not be uploaded together with the user's collection + // when the data dir is backed up / synced. The umbrella ".cache/" + // directory is reserved for any future computed-from-network caches + // (set-list snapshots, etc.); the leading dot keeps it out of the way + // for users poking around the install folder. Constructed before + // previewSvc_ so the service can hold a stable raw pointer to it. + previewCache_ = std::make_unique( + *fs_, + exeDir / ".cache" / "preview-cache"); + previewSvc_ = std::make_unique(*http_, previewCache_.get()); previewSvc_->registerModule(*magicMod_); previewSvc_->registerModule(*pokeMod_); + previewSvc_->registerModule(*ygoMod_); // Per-game UI bundles. Order here is the order shown in the Game menu. magicView_ = std::make_unique( *config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_); pokeView_ = std::make_unique( *config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_); + ygoView_ = std::make_unique( + *config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_); ctx_ = std::make_unique(ccm::ui::AppContext{ *config_, @@ -108,7 +136,8 @@ public: *previewSvc_, *magicMod_, *pokeMod_, - { magicView_.get(), pokeView_.get() }, + *ygoMod_, + { magicView_.get(), pokeView_.get(), ygoView_.get() }, }); auto* frame = new ccm::ui::MainFrame(*ctx_); @@ -128,17 +157,22 @@ private: std::unique_ptr http_; std::unique_ptr magicMod_; std::unique_ptr pokeMod_; + std::unique_ptr ygoMod_; std::unique_ptr> magicRepo_; std::unique_ptr> pokeRepo_; + std::unique_ptr> ygoRepo_; std::unique_ptr setRepo_; std::unique_ptr imgStore_; std::unique_ptr imgSvc_; std::unique_ptr> magicCollSvc_; std::unique_ptr> pokeCollSvc_; + std::unique_ptr> ygoCollSvc_; std::unique_ptr setSvc_; + std::unique_ptr previewCache_; std::unique_ptr previewSvc_; std::unique_ptr magicView_; std::unique_ptr pokeView_; + std::unique_ptr ygoView_; std::unique_ptr ctx_; }; diff --git a/core/AGENTS.md b/core/AGENTS.md index 2b126b4..ed4a3f0 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -4,11 +4,11 @@ ## Layer pointers -- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. -- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`). All seams the services depend on. Add new ports here when adding new external concerns. +- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. +- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns. - `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports. -- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`. -- `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/` and `pokemon/` are the reference implementations — both expose a fully working set source + card preview source. +- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. +- `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/`, and `yugioh/` are the reference implementations — all three expose a fully working set source + card preview source. - `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`). - `src/` mirrors `include/ccm/` for non-template implementations. @@ -25,6 +25,9 @@ 6. **Compiler warnings**: every target in this package links `ccm_warnings` `PRIVATE`. Treat warnings as errors locally during dev (`-Werror` is opt-in but encouraged). 7. **No `wx/...` includes** in headers or sources here. Verify with `rg "wx/" core/` — must be empty. 8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result` is a binary buffer, not text, so callers can use it for image payloads directly. +9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`----.`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `||||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface. +10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path. +11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact. ## Adding a new game diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 3fa3ecf..984bdaa 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(ccm_core STATIC src/domain/Set.cpp src/domain/MagicCard.cpp src/domain/PokemonCard.cpp + src/domain/YuGiOhCard.cpp src/domain/Configuration.cpp src/services/ConfigService.cpp @@ -19,6 +20,7 @@ add_library(ccm_core STATIC src/infra/StdFileSystem.cpp src/infra/JsonSetRepository.cpp src/infra/LocalImageStore.cpp + src/infra/LocalPreviewByteCache.cpp src/games/magic/MagicSetSource.cpp src/games/magic/MagicCardPreviewSource.cpp @@ -26,6 +28,9 @@ add_library(ccm_core STATIC src/games/pokemon/PokemonSetSource.cpp src/games/pokemon/PokemonCardPreviewSource.cpp src/games/pokemon/PokemonGameModule.cpp + src/games/yugioh/YuGiOhSetSource.cpp + src/games/yugioh/YuGiOhCardPreviewSource.cpp + src/games/yugioh/YuGiOhGameModule.cpp src/util/FsNames.cpp ) diff --git a/core/include/ccm/domain/Enums.hpp b/core/include/ccm/domain/Enums.hpp index acdfc8f..22c6cbd 100644 --- a/core/include/ccm/domain/Enums.hpp +++ b/core/include/ccm/domain/Enums.hpp @@ -18,6 +18,7 @@ namespace ccm { enum class Game { Magic, Pokemon, + YuGiOh, }; enum class Language { @@ -56,7 +57,7 @@ std::optional languageFromString(std::string_view s) noexcept; std::optional conditionFromString(std::string_view s) noexcept; std::optional themeFromString(std::string_view s) noexcept; -const std::array& allGames() noexcept; +const std::array& allGames() noexcept; const std::array& allLanguages() noexcept; const std::array& allConditions() noexcept; const std::array& allThemes() noexcept; diff --git a/core/include/ccm/domain/YuGiOhCard.hpp b/core/include/ccm/domain/YuGiOhCard.hpp new file mode 100644 index 0000000..76a7110 --- /dev/null +++ b/core/include/ccm/domain/YuGiOhCard.hpp @@ -0,0 +1,38 @@ +#pragma once + +// YuGiOhCard - Yu-Gi-Oh card model with print-level metadata. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/Set.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +struct YuGiOhCard { + std::uint32_t id{0}; + std::uint8_t amount{1}; + std::string name; + Set set; + std::string setNo; + std::string rarity; + std::string rarityCode; + std::string note; + std::vector images; + Language language{Language::English}; + Condition condition{Condition::NearMint}; + bool firstEdition{false}; + bool signed_{false}; + bool altered{false}; + + friend bool operator==(const YuGiOhCard&, const YuGiOhCard&) = default; +}; + +void to_json(nlohmann::json& j, const YuGiOhCard& c); +void from_json(const nlohmann::json& j, YuGiOhCard& c); + +} // namespace ccm diff --git a/core/include/ccm/games/magic/MagicCardPreviewSource.hpp b/core/include/ccm/games/magic/MagicCardPreviewSource.hpp index 2c3e5ab..884e3ae 100644 --- a/core/include/ccm/games/magic/MagicCardPreviewSource.hpp +++ b/core/include/ccm/games/magic/MagicCardPreviewSource.hpp @@ -19,9 +19,10 @@ class MagicCardPreviewSource final : public ICardPreviewSource { public: explicit MagicCardPreviewSource(IHttpClient& http); - Result fetchImageUrl(std::string_view name, - std::string_view setId, - std::string_view setNo) override; + Result + fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) override; // Build the fully URL-encoded Scryfall search URL for the given card. // Exposed for unit testing and to keep encoding rules in one place. @@ -29,11 +30,13 @@ public: std::string_view setId); // Parse a Scryfall /cards/search response body and pull out the - // `data[0].image_uris.normal` URL. Returns an error result when no - // matching printing is found, when the JSON is malformed, or when the - // entry has no top-level `image_uris` (double-faced cards expose them - // on a face object - no fallback in this compatibility behavior either). - static Result parseResponse(const std::string& body); + // `data[0].image_uris.normal` URL. Errors are classified: + // - JSON parse failure or missing/non-array `data` => Transient. + // - Empty `data` array, missing top-level `image_uris`, or missing + // `image_uris.normal` => NotFound (the upstream answered, but the + // printing simply has no preview we can use). + static Result + parseResponse(const std::string& body); private: IHttpClient& http_; diff --git a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp index 39adb33..febc2ff 100644 --- a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp +++ b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp @@ -19,9 +19,10 @@ class PokemonCardPreviewSource final : public ICardPreviewSource { public: explicit PokemonCardPreviewSource(IHttpClient& http); - Result fetchImageUrl(std::string_view name, - std::string_view setId, - std::string_view setNo) override; + Result + fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) override; // Build the fully URL-encoded Pokemon TCG search URL for the given card. // Exposed for unit testing and to keep encoding rules in one place. @@ -31,9 +32,11 @@ public: // Parse a Pokemon TCG /v2/cards response body and pull out the image URL // for the first matching card. Prefers `images.large`, falls back to - // `images.small`, and returns an error result if neither is present, the - // data array is empty, or the JSON is malformed. - static Result parseResponse(const std::string& body); + // `images.small`. Errors are classified: + // - JSON parse failure or missing/non-array `data` => Transient. + // - Empty `data` array or missing image variants => NotFound. + static Result + parseResponse(const std::string& body); private: IHttpClient& http_; diff --git a/core/include/ccm/games/yugioh/YuGiOhCardPreviewSource.hpp b/core/include/ccm/games/yugioh/YuGiOhCardPreviewSource.hpp new file mode 100644 index 0000000..a401c36 --- /dev/null +++ b/core/include/ccm/games/yugioh/YuGiOhCardPreviewSource.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include +#include + +namespace ccm { + +// YuGiOhCardPreviewSource - resolves preview images for Yu-Gi-Oh! cards. +// +// The image-preview path is backed by Yugipedia's MediaWiki API +// (https://yugipedia.com/api.php). Yugipedia hosts actual per-printing card +// scans, with deterministic file names of the shape +// `----.` (e.g. +// `BlueEyesWhiteDragon-LOB-EN-UR-UE.png` vs `BlueEyesWhiteDragon-SDK-NA-UR-UE.png`), +// which lets us return the right artwork for printings that share a passcode +// but have visibly different art - a case YGOPRODeck cannot disambiguate (its +// card_images array is keyed by art-treatment passcode, not by physical +// printing). +// +// The auto-detect-first-print path keeps using YGOPRODeck (`cardinfo.php`): +// that endpoint returns a richer set listing (with rarities and release +// dates) than Yugipedia, and we don't need image data for it. +// +// Region policy: always English (EN/NA/EU/AU) regardless of the card's +// stored Language. Localized scans are intentionally not queried so the user +// sees a consistent, well-stocked gallery (EN scans are the most complete). +class YuGiOhCardPreviewSource final : public ICardPreviewSource { +public: + explicit YuGiOhCardPreviewSource(IHttpClient& http); + + [[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; } + + Result + fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) override; + Result detectFirstPrint(std::string_view name, + std::string_view setId) override; + Result> detectPrintVariants(std::string_view name, + std::string_view setId) override; + + // ---- Yugipedia helpers (image preview path) ---------------------------- + + // Build the list of candidate Yugipedia file names to try, in priority + // order (most likely first). Always uses English regions; the caller may + // pass an empty rarityCode when the rarity is unknown, in which case the + // returned list will skip rarity in the filename. + static std::vector buildCandidateFilenames( + std::string_view name, + std::string_view setCode, + std::string_view rarityCode, + bool firstEdition); + + // Build a single MediaWiki batch query URL that asks for imageinfo.url + // for every filename. MediaWiki's `titles=` parameter joins page titles + // with `|`, so we issue exactly one HTTP call per preview lookup. + static std::string buildYugipediaQueryUrl( + const std::vector& filenames); + + // Parse a MediaWiki `query.pages` response and return the resolved URL of + // the first filename in `filenameOrder` that exists. Missing pages have + // the `missing` marker (no `imageinfo`); existing pages carry an + // `imageinfo[0].url` we forward verbatim. Errors are classified: + // - JSON parse failure or schema deviation => Transient. + // - Every candidate came back missing => NotFound. + static Result parseYugipediaResponse( + const std::string& body, + const std::vector& filenameOrder); + + // Strip a card name down to Yugipedia's image-slug shape: alphanumerics + // (and parentheses) only, no whitespace, no policy-banned punctuation. + static std::string normalizeName(std::string_view name); + + // Map a CCM3 rarity name (e.g. "Ultra Rare") to the Yugipedia rarity + // code used in image filenames (e.g. "UR"). Returns an empty string when + // the rarity is unknown; the caller treats that as "skip rarity". + static std::string rarityCodeFor(std::string_view rarityName); + + // Pull the set abbreviation out of a CCM3 setNo such as "LOB-005" or + // "LOB-DE005" - in both cases we want "LOB". Returns the trimmed input + // unchanged if no dash is present. + static std::string extractSetCode(std::string_view setNo); + + // ---- YGOPRODeck helpers (auto-detect path + fallback) ------------------ + + // Build a fuzzy-name `cardinfo.php` URL. `setName` may be empty for an + // unfiltered fuzzy lookup. Used by detectFirstPrint and by the + // standard-art fallback when Yugipedia has no scan for this printing. + static std::string buildSearchUrl(std::string_view name, + std::string_view setName); + + // Pick the standard artwork (card_images[0]) from a YGOPRODeck response, + // preferring the exact-name match. Used only as a last-resort fallback + // when Yugipedia returns nothing for any of our candidate filenames. + // Errors are classified: + // - JSON parse failure or schema deviation => Transient. + // - Empty `data` array, or matched cards without a usable image + // variant => NotFound. + static Result + parseFallbackImageUrl(const std::string& body, std::string_view name); + + // Pick the first printing for `preferredSetName` from a YGOPRODeck + // response. Drives the "Auto detect" button in the YGO edit dialog. + static Result parseFirstPrint(const std::string& body, + std::string_view preferredSetName); + + // Every `(set_code, set_rarity)` pair for cards whose name matches + // `wantedCardName` (case-insensitive). When `wantedCardName` is empty, + // scans every row in `data[]` like `parseFirstPrint` did historically. + static Result> + parsePrintVariants(const std::string& body, + std::string_view preferredSetName, + std::string_view wantedCardName); + +private: + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/yugioh/YuGiOhGameModule.hpp b/core/include/ccm/games/yugioh/YuGiOhGameModule.hpp new file mode 100644 index 0000000..2e45ea8 --- /dev/null +++ b/core/include/ccm/games/yugioh/YuGiOhGameModule.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "ccm/games/IGameModule.hpp" +#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp" +#include "ccm/games/yugioh/YuGiOhSetSource.hpp" + +namespace ccm { + +class YuGiOhGameModule final : public IGameModule { +public: + explicit YuGiOhGameModule(IHttpClient& http); + + [[nodiscard]] Game id() const noexcept override { return Game::YuGiOh; } + [[nodiscard]] std::string dirName() const override { return "yugioh"; } + [[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; } + + ISetSource& setSource() override { return setSource_; } + ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; } + +private: + YuGiOhSetSource setSource_; + YuGiOhCardPreviewSource previewSource_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/yugioh/YuGiOhSetSource.hpp b/core/include/ccm/games/yugioh/YuGiOhSetSource.hpp new file mode 100644 index 0000000..638e87e --- /dev/null +++ b/core/include/ccm/games/yugioh/YuGiOhSetSource.hpp @@ -0,0 +1,23 @@ +#pragma once + +// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/IHttpClient.hpp" + +namespace ccm { + +class YuGiOhSetSource final : public ISetSource { +public: + static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php"; + + explicit YuGiOhSetSource(IHttpClient& http); + + Result> fetchAll() override; + static Result> parseResponse(const std::string& body); + +private: + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/infra/CprHttpClient.hpp b/core/include/ccm/infra/CprHttpClient.hpp index e4c6b46..afba624 100644 --- a/core/include/ccm/infra/CprHttpClient.hpp +++ b/core/include/ccm/infra/CprHttpClient.hpp @@ -7,17 +7,31 @@ #include "ccm/ports/IHttpClient.hpp" #include +#include +#include + +namespace cpr { class Session; } namespace ccm { +// Concrete IHttpClient backed by libcpr/libcurl. The single owned +// `cpr::Session` keeps libcurl's connection pool alive across calls, so +// repeat HTTPS requests to the same host (api.scryfall.com, yugipedia.com, +// ms.yugipedia.com, …) reuse the existing TLS connection instead of paying +// for a fresh handshake every time. Concurrent calls are serialized through +// a mutex - libcurl easy handles are not thread-safe, and the preview path +// only fires one outbound request at a time anyway. class CprHttpClient final : public IHttpClient { public: explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000}); + ~CprHttpClient() override; Result get(std::string_view url) override; private: std::chrono::milliseconds timeout_; + std::unique_ptr session_; + std::mutex sessionMutex_; }; } // namespace ccm diff --git a/core/include/ccm/infra/LocalPreviewByteCache.hpp b/core/include/ccm/infra/LocalPreviewByteCache.hpp new file mode 100644 index 0000000..2bdbcff --- /dev/null +++ b/core/include/ccm/infra/LocalPreviewByteCache.hpp @@ -0,0 +1,85 @@ +#pragma once + +// LocalPreviewByteCache - on-disk byte cache for CardPreviewService. +// +// Layout under the configured cache directory (composition root passes +// `/.cache/preview-cache/` - next to the executable, NOT under +// the user-configurable `dataStorage` path; see `docs/caching.md` and +// `app/AGENTS.md` for the rationale): +// .bin raw image bytes (PNG/JPEG payload), positive entries only +// .neg zero-byte marker file, negative entries only +// .idx one-line text sidecar holding the original cache key, +// used to detect (and reject) hash collisions so we never +// serve the wrong card's image and never honor a stale +// negative entry across collisions +// +// Positive vs. negative entries are mutually exclusive for a given hash: +// `store` removes any existing `.neg`, `storeNegative` removes any existing +// `.bin`, and `load` prefers `.bin` on the off chance both somehow co-exist. +// +// The cache is bounded by total payload bytes (sum of `.bin` sizes). When +// `store` would push it past the cap we evict by file mtime (oldest first) +// until back under the cap; the `.idx` sidecar of an evicted entry is +// removed too. Negative entries are tiny (effectively `.idx` only) and are +// not subject to the byte cap directly - their count is naturally bounded +// by the user's collection size since a negative entry only ever exists +// for a card the user has actually looked at and the upstream answered +// "no image" for. Reads update mtime via a touch on hit so frequently- +// viewed cards survive eviction. +// +// All filesystem mutations go through `IFileSystem` (so the in-memory +// fake works in tests). Size and mtime queries - which the port does not +// expose - use `std::filesystem` directly inside this adapter. Tests that +// need to drive eviction stay easy to write: just call `store` past the cap +// and check the survivors. + +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/ports/IPreviewByteCache.hpp" + +#include +#include +#include +#include +#include + +namespace ccm { + +class LocalPreviewByteCache final : public IPreviewByteCache { +public: + // Default soft cap: ~64 MiB. A typical preview is 80-200 KiB, so this + // holds several hundred cards comfortably while keeping disk usage + // bounded for users with very large collections. + static constexpr std::size_t kDefaultMaxBytes = 64ull * 1024 * 1024; + + LocalPreviewByteCache(IFileSystem& fs, + std::filesystem::path cacheDir, + std::size_t maxBytes = kDefaultMaxBytes); + + [[nodiscard]] LoadResult load(std::string_view key) override; + void store(std::string_view key, const std::string& payload) override; + void storeNegative(std::string_view key) override; + + // Test-visible knob: total payload bytes currently on disk (recomputed + // from the directory listing so it stays accurate after external + // tampering). Negative-entry markers do not count toward the total. + [[nodiscard]] std::size_t currentSizeBytes(); + +private: + std::filesystem::path payloadPath(const std::string& hash) const; + std::filesystem::path negativePath(const std::string& hash) const; + std::filesystem::path indexPath(const std::string& hash) const; + + // Hex-encoded FNV-1a 64-bit hash of the key. We don't need cryptographic + // strength; the sidecar `.idx` file rejects collisions on load so the + // worst case is a one-time cache miss. + static std::string hashKey(std::string_view key); + + void evictIfNeededLocked(std::size_t incomingBytes); + + IFileSystem& fs_; + std::filesystem::path cacheDir_; + std::size_t maxBytes_; + std::mutex mutex_; +}; + +} // namespace ccm diff --git a/core/include/ccm/ports/ICardPreviewSource.hpp b/core/include/ccm/ports/ICardPreviewSource.hpp index 43a3046..fb9cfe4 100644 --- a/core/include/ccm/ports/ICardPreviewSource.hpp +++ b/core/include/ccm/ports/ICardPreviewSource.hpp @@ -12,9 +12,38 @@ #include #include +#include namespace ccm { +struct AutoDetectedPrint { + std::string setNo; + std::string rarity; +}; + +// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind +// drives caching policy in CardPreviewService: +// +// NotFound -- the upstream answered cleanly that the card has no image +// (or no matching record at all). Safe to remember: the +// answer will not change until the user edits the card +// record itself, which automatically invalidates the cache +// key. Negative-cached so subsequent selections show the +// fallback card-back instantly without another HTTP call. +// +// Transient -- the upstream did not answer cleanly (HTTP / network / +// timeout failure, malformed response, parse error). The +// record may well have an image; we just couldn't see it +// this time. NOT cached, so the next selection retries. +// +// The `message` is opaque to the service and is forwarded to the UI as +// the existing free-form `Result::error()` string. +struct PreviewLookupError { + enum class Kind { NotFound, Transient }; + Kind kind{Kind::Transient}; + std::string message; +}; + class ICardPreviewSource { public: virtual ~ICardPreviewSource() = default; @@ -22,9 +51,34 @@ public: // Resolve the preview image URL for a single card. `setNo` is optional // (empty string is fine); some game APIs (e.g. Pokemon TCG) can use it as // a more precise lookup key, others (Magic/Scryfall) ignore it. - virtual Result fetchImageUrl(std::string_view name, - std::string_view setId, - std::string_view setNo) = 0; + // + // Errors carry a classification (`PreviewLookupError::Kind`) so + // CardPreviewService can decide whether to remember the miss + // (`NotFound`) or retry on the next call (`Transient`). See the doc + // comment on PreviewLookupError above for the exact contract. + virtual Result + fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) = 0; + + // Opt-in switch for per-game print metadata detection. + [[nodiscard]] virtual bool supportsAutoDetectPrint() const noexcept { return false; } + + // Optional metadata lookup used by game-specific edit dialogs. The default + // implementation returns an explicit "unsupported" error so games without + // print metadata APIs do not need to override it. + virtual Result detectFirstPrint(std::string_view /*name*/, + std::string_view /*setId*/) { + return Result::err("Auto-detect not supported by this game."); + } + + // Optional listing of every distinct `(set_code, rarity)` print returned by + // the upstream for an exact card name inside the chosen display set. + virtual Result> + detectPrintVariants(std::string_view /*name*/, std::string_view /*setId*/) { + return Result>::err( + "Print variant listing not supported by this game."); + } }; } // namespace ccm diff --git a/core/include/ccm/ports/IPreviewByteCache.hpp b/core/include/ccm/ports/IPreviewByteCache.hpp new file mode 100644 index 0000000..6c2a3eb --- /dev/null +++ b/core/include/ccm/ports/IPreviewByteCache.hpp @@ -0,0 +1,75 @@ +#pragma once + +// IPreviewByteCache - persistent byte cache used by CardPreviewService to +// keep preview images alive across app restarts. +// +// The cache is keyed by an opaque string. CardPreviewService composes the +// key from `(game, name, setId, setNo)` (preview lookups) or directly from +// the URL (per-game card-back fallback fetches); the cache itself does not +// interpret the key, only stores the byte payload behind it. +// +// Two kinds of entries are persisted: +// +// * Positive entries hold raw image bytes. Stored via `store(key, payload)`, +// returned as `LoadResult{HitKind::Hit, payload}`. +// * Negative entries record "we tried to resolve this exact card and the +// upstream answered cleanly that it has no preview image" - i.e. the +// `NotFound` half of `PreviewLookupError`. Stored via +// `storeNegative(key)`, returned as `LoadResult{HitKind::NegativeHit, {}}`. +// `Transient` errors (HTTP / network / parse failures) must NEVER reach +// this cache: we cannot tell whether the record genuinely has no image +// or just couldn't be reached, and persisting the miss would leave the +// user staring at the card-back placeholder until they edit the card. +// +// A negative entry is implicitly invalidated when the cache key changes - +// since the key includes `(game, name, setId, setNo)` (with game-specific +// disambiguators packed into setNo), any edit that affects a lookup-relevant +// field will hit a fresh key and re-attempt the network lookup automatically. +// +// Implementations must be thread-safe with respect to concurrent load/store +// calls because CardPreviewService is invoked from a worker thread spawned +// by `BaseSelectedCardPanel`. +// +// Errors are intentionally swallowed (load returns Miss; store and +// storeNegative are fire-and-forget). A flaky or full disk must never break +// the preview path - in the worst case the user sees the same speed as a +// fresh app install. + +#include +#include + +namespace ccm { + +class IPreviewByteCache { +public: + enum class HitKind { + Miss, // no entry for this key (or unrecoverable I/O error) + Hit, // positive entry; bytes are in `payload` + NegativeHit, // negative entry; `payload` is empty by contract + }; + + struct LoadResult { + HitKind kind{HitKind::Miss}; + std::string payload; // only meaningful when kind == Hit + }; + + virtual ~IPreviewByteCache() = default; + + // Returns the cached entry for `key`. On any error - missing files, + // sidecar mismatch, malformed metadata, I/O failure - implementations + // must report `HitKind::Miss` rather than surfacing the error. + [[nodiscard]] virtual LoadResult load(std::string_view key) = 0; + + // Best-effort persist of `payload` under `key`. Empty payloads are not + // stored as positive entries. If a negative entry already exists for + // this key it is replaced. Errors are swallowed. + virtual void store(std::string_view key, const std::string& payload) = 0; + + // Best-effort persist of "we tried, upstream cleanly said no image". + // If a positive entry already exists for this key it is replaced. + // Errors are swallowed. Must be invoked ONLY for `NotFound`-class + // outcomes; never for transient failures. + virtual void storeNegative(std::string_view key) = 0; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/CardFilter.hpp b/core/include/ccm/services/CardFilter.hpp index ecf3399..4752218 100644 --- a/core/include/ccm/services/CardFilter.hpp +++ b/core/include/ccm/services/CardFilter.hpp @@ -21,6 +21,7 @@ #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhCard.hpp" #include @@ -37,5 +38,7 @@ namespace ccm { // Holo/FirstEdition/Signed/Altered are bool-typed and excluded. [[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter); +[[nodiscard]] bool matchesYuGiOhFilter(const YuGiOhCard& card, + std::string_view filter); } // namespace ccm diff --git a/core/include/ccm/services/CardPreviewService.hpp b/core/include/ccm/services/CardPreviewService.hpp index a9b62bf..cd9ceba 100644 --- a/core/include/ccm/services/CardPreviewService.hpp +++ b/core/include/ccm/services/CardPreviewService.hpp @@ -16,17 +16,23 @@ #include "ccm/games/IGameModule.hpp" #include "ccm/ports/ICardPreviewSource.hpp" #include "ccm/ports/IHttpClient.hpp" +#include "ccm/ports/IPreviewByteCache.hpp" #include "ccm/util/Result.hpp" +#include +#include +#include #include #include #include +#include namespace ccm { class CardPreviewService { public: - explicit CardPreviewService(IHttpClient& http); + explicit CardPreviewService(IHttpClient& http, + IPreviewByteCache* persistentCache = nullptr); // Register a game module's preview source. Calling this with a module // whose `cardPreviewSource()` returns nullptr is a no-op (the game has @@ -38,18 +44,87 @@ public: // The returned `std::string` is a raw byte buffer (PNG/JPEG payload) - // it is NOT decoded text. Use std::string::data()/size() with whatever // image-decoding facility your UI provides. + // + // Successful results are cached in two tiers, both keyed by + // (game, name, setId, setNo): + // 1. In-memory LRU (bounded by `kCacheCapacity`) for instant hits + // while the app is running. + // 2. Optional persistent byte cache (passed at construction) so + // previews survive app restarts. + // Re-selecting the same row is then a memcpy away from the wxImage + // decoder, no HTTP at all - this is the common user-facing case + // (clicking around the table). + // + // Failures are split into two policies based on + // `PreviewLookupError::Kind`: + // * `NotFound` (the upstream answered cleanly that this record has + // no preview) is *negative-cached* in both tiers, so subsequent + // selections short-circuit without touching the network. The + // cache key is invalidated automatically when the user edits a + // lookup-relevant field of the record. + // * `Transient` (HTTP / network / parse failure) is NEVER cached, so + // the next selection retries cleanly once connectivity is back. Result fetchPreviewBytes(Game game, std::string_view name, std::string_view setId, std::string_view setNo); + Result detectFirstPrint(Game game, + std::string_view name, + std::string_view setId); + + Result> detectPrintVariants(Game game, + std::string_view name, + std::string_view setId); + // Download image bytes from a fully-qualified URL without going through - // per-game preview-source resolution. + // per-game preview-source resolution. Cached by URL (same LRU bound). Result fetchImageBytesByUrl(std::string_view url); + // Maximum number of cached preview entries kept in memory. Picked so a + // typical Yu-Gi-Oh! collection page can scroll up and down without + // re-hitting the network, while keeping a hard upper bound on RSS for + // very large collections (each entry is roughly one PNG, <100 KiB). + static constexpr std::size_t kCacheCapacity = 128; + private: - IHttpClient& http_; + enum class CacheLookupKind { + Miss, // not in the in-memory tier + Hit, // positive entry; bytes returned via outPayload + NegativeHit, // negative entry; outPayload is empty + }; + + Result fetchAndCache(const std::string& cacheKey, + std::string_view url); + + // Returns the kind of in-memory cache entry for `key`. On Hit the + // payload is copied into `outPayload`; on NegativeHit `outPayload` is + // cleared. Both Hit and NegativeHit move the entry to the front of + // the LRU. + CacheLookupKind cacheLookup(const std::string& key, std::string& outPayload); + void cacheStore(const std::string& key, std::string payload); + void cacheStoreNegative(const std::string& key); + + IHttpClient& http_; + IPreviewByteCache* persistentCache_{nullptr}; std::unordered_map sources_; + + // LRU: list holds entries in MRU-first order; map points at list nodes + // for O(1) move-to-front. Mutex covers both list and map - lookups + // happen on a worker thread spawned by BaseSelectedCardPanel. + // + // A `negative` entry has an empty payload by convention; we keep the + // flag explicit (rather than abusing emptiness) so future invariants + // around eviction or stats stay easy to reason about. + struct CacheEntry { + std::string key; + std::string payload; + bool negative{false}; + }; + using CacheList = std::list; + CacheList cacheList_; + std::unordered_map cacheIndex_; + std::mutex cacheMutex_; }; } // namespace ccm diff --git a/core/include/ccm/services/CardSorter.hpp b/core/include/ccm/services/CardSorter.hpp index 78c3546..c559570 100644 --- a/core/include/ccm/services/CardSorter.hpp +++ b/core/include/ccm/services/CardSorter.hpp @@ -18,6 +18,7 @@ #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhCard.hpp" #include @@ -52,11 +53,25 @@ enum class PokemonSortColumn { Note, }; +enum class YuGiOhSortColumn { + Name, + SetReleaseDate, + Language, + Condition, + Amount, + FirstEdition, + Signed, + Altered, + Note, +}; + // Stable in-place sort. `ascending=false` runs the same comparator with // inverted sign, matching `byField(field, asc)` semantics. void sortMagicCards(std::vector& cards, MagicSortColumn column, bool ascending); void sortPokemonCards(std::vector& cards, PokemonSortColumn column, bool ascending); +void sortYuGiOhCards(std::vector& cards, YuGiOhSortColumn column, + bool ascending); } // namespace ccm diff --git a/core/include/ccm/util/YuGiOhPrintingSlot.hpp b/core/include/ccm/util/YuGiOhPrintingSlot.hpp new file mode 100644 index 0000000..406ad96 --- /dev/null +++ b/core/include/ccm/util/YuGiOhPrintingSlot.hpp @@ -0,0 +1,72 @@ +#pragma once + +// Yu-Gi-Oh! collector slot equivalence for UI + metadata matching. +// +// The edit dialog composes `setNo` as `-` using only numeric +// characters from the text field (e.g. SOD + "015" -> "SOD-015"). YGOPRODeck +// `set_code` values often embed region letters ("SOD-EN015"). Exact string +// compare would miss that both refer to the same slot. + +#include +#include +#include +#include + +namespace ccm { + +[[nodiscard]] inline std::string_view trimAsciiSpaces(std::string_view s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) { + s.remove_prefix(1); + } + while (!s.empty() && std::isspace(static_cast(s.back()))) { + s.remove_suffix(1); + } + return s; +} + +[[nodiscard]] inline std::string ygoAbbrevBeforeDash(std::string_view raw) { + const std::string_view s = trimAsciiSpaces(raw); + const auto dash = s.find('-'); + const std::string_view pref = dash == std::string_view::npos ? s : s.substr(0, dash); + std::string out(pref); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return out; +} + +[[nodiscard]] inline std::string ygoCollectorDigitsOnly(std::string_view raw) { + const std::string_view s = trimAsciiSpaces(raw); + const auto dash = s.find('-'); + const std::string_view tail = + dash == std::string_view::npos ? std::string_view{} : s.substr(dash + 1); + std::string out; + out.reserve(tail.size()); + for (unsigned char c : tail) { + if (std::isdigit(c) != 0) out.push_back(static_cast(c)); + } + return out; +} + +// True when both strings designate the same printed slot: same abbreviation +// before the first '-' (ASCII case-insensitive) and the same ordered digit run +// extracted from everything after that dash. +[[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) { + if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false; + return ygoCollectorDigitsOnly(a) == ygoCollectorDigitsOnly(b); +} + +// YGOPRODeck sometimes lists European alternate numbering alongside NA prints under +// the same English `set_name` (e.g. Dark Magician as "LOB-E003" vs NA "LOB-005"). +// The suffix uses a single leading `E` immediately followed by digits — distinct +// from two-letter regions such as "EN" ("LOB-EN005") or "DE" ("LOB-DE005"). +[[nodiscard]] inline bool ygoLikelyEuropeanRegionalSetCode(std::string_view setCode) { + const std::string_view s = trimAsciiSpaces(setCode); + const auto dash = s.find('-'); + if (dash == std::string_view::npos || dash + 2 >= s.size()) return false; + const std::string_view tail = s.substr(dash + 1); + return tail.size() >= 2 && tail[0] == 'E' + && std::isdigit(static_cast(tail[1])) != 0; +} + +} // namespace ccm diff --git a/core/src/domain/Enums.cpp b/core/src/domain/Enums.cpp index 97ee986..fcd5247 100644 --- a/core/src/domain/Enums.cpp +++ b/core/src/domain/Enums.cpp @@ -9,6 +9,7 @@ std::string_view to_string(Game g) noexcept { switch (g) { case Game::Magic: return "Magic"; case Game::Pokemon: return "Pokemon"; + case Game::YuGiOh: return "YuGiOh"; } return "Magic"; } @@ -51,6 +52,7 @@ std::string_view to_string(Theme t) noexcept { std::optional gameFromString(std::string_view s) noexcept { if (s == "Magic") return Game::Magic; if (s == "Pokemon") return Game::Pokemon; + if (s == "YuGiOh") return Game::YuGiOh; return std::nullopt; } @@ -83,8 +85,8 @@ std::optional themeFromString(std::string_view s) noexcept { return std::nullopt; } -const std::array& allGames() noexcept { - static constexpr std::array v{Game::Magic, Game::Pokemon}; +const std::array& allGames() noexcept { + static constexpr std::array v{Game::Magic, Game::Pokemon, Game::YuGiOh}; return v; } diff --git a/core/src/domain/YuGiOhCard.cpp b/core/src/domain/YuGiOhCard.cpp new file mode 100644 index 0000000..f28d299 --- /dev/null +++ b/core/src/domain/YuGiOhCard.cpp @@ -0,0 +1,42 @@ +#include "ccm/domain/YuGiOhCard.hpp" + +namespace ccm { + +void to_json(nlohmann::json& j, const YuGiOhCard& c) { + j = nlohmann::json{ + {"id", c.id}, + {"amount", c.amount}, + {"name", c.name}, + {"set", c.set}, + {"setNo", c.setNo}, + {"note", c.note}, + {"images", c.images}, + {"language", c.language}, + {"condition", c.condition}, + {"firstEdition", c.firstEdition}, + {"rarity", c.rarity}, + {"rarityCode", c.rarityCode}, + {"signed", c.signed_}, + {"altered", c.altered}, + }; +} + +void from_json(const nlohmann::json& j, YuGiOhCard& c) { + j.at("id").get_to(c.id); + j.at("amount").get_to(c.amount); + j.at("name").get_to(c.name); + j.at("set").get_to(c.set); + j.at("setNo").get_to(c.setNo); + j.at("note").get_to(c.note); + j.at("images").get_to(c.images); + j.at("language").get_to(c.language); + j.at("condition").get_to(c.condition); + j.at("firstEdition").get_to(c.firstEdition); + j.at("rarity").get_to(c.rarity); + if (j.contains("rarityCode")) j.at("rarityCode").get_to(c.rarityCode); + else c.rarityCode.clear(); + j.at("signed").get_to(c.signed_); + j.at("altered").get_to(c.altered); +} + +} // namespace ccm diff --git a/core/src/games/magic/MagicCardPreviewSource.cpp b/core/src/games/magic/MagicCardPreviewSource.cpp index 74db794..901eb31 100644 --- a/core/src/games/magic/MagicCardPreviewSource.cpp +++ b/core/src/games/magic/MagicCardPreviewSource.cpp @@ -62,38 +62,47 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name, return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query); } -Result MagicCardPreviewSource::parseResponse(const std::string& body) { +Result +MagicCardPreviewSource::parseResponse(const std::string& body) { + using R = Result; + using K = PreviewLookupError::Kind; try { const auto j = nlohmann::json::parse(body); if (!j.contains("data") || !j.at("data").is_array()) { - return Result::err("Scryfall response missing 'data' array."); + // Treat schema deviation as transient: the API contract failed, + // not the user's record. Scryfall returns a JSON error object + // here on outage, which is rare but not stable. + return R::err({K::Transient, "Scryfall response missing 'data' array."}); } const auto& data = j.at("data"); if (data.empty()) { - return Result::err("Scryfall returned no matching cards."); + return R::err({K::NotFound, "Scryfall returned no matching cards."}); } const auto& first = data.at(0); if (!first.contains("image_uris") || !first.at("image_uris").is_object()) { // Double-faced cards expose image_uris on each face; there is no // fallback for this and surfaces it as "no preview". - return Result::err("Card has no top-level image_uris."); + return R::err({K::NotFound, "Card has no top-level image_uris."}); } const auto& uris = first.at("image_uris"); if (!uris.contains("normal") || !uris.at("normal").is_string()) { - return Result::err("Card has no 'normal' image variant."); + return R::err({K::NotFound, "Card has no 'normal' image variant."}); } - return Result::ok(uris.at("normal").get()); + return R::ok(uris.at("normal").get()); } catch (const std::exception& e) { - return Result::err(std::string("Scryfall JSON parse error: ") + e.what()); + return R::err({K::Transient, std::string("Scryfall JSON parse error: ") + e.what()}); } } -Result MagicCardPreviewSource::fetchImageUrl(std::string_view name, - std::string_view setId, - std::string_view /*setNo*/) { +Result +MagicCardPreviewSource::fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view /*setNo*/) { + using R = Result; + using K = PreviewLookupError::Kind; const std::string url = buildSearchUrl(name, setId); auto resp = http_.get(url); - if (!resp) return Result::err(resp.error()); + if (!resp) return R::err({K::Transient, resp.error()}); return parseResponse(resp.value()); } diff --git a/core/src/games/pokemon/PokemonCardPreviewSource.cpp b/core/src/games/pokemon/PokemonCardPreviewSource.cpp index e9cbf34..00e8f23 100644 --- a/core/src/games/pokemon/PokemonCardPreviewSource.cpp +++ b/core/src/games/pokemon/PokemonCardPreviewSource.cpp @@ -70,40 +70,46 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name, return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query); } -Result PokemonCardPreviewSource::parseResponse(const std::string& body) { +Result +PokemonCardPreviewSource::parseResponse(const std::string& body) { + using R = Result; + using K = PreviewLookupError::Kind; try { const auto j = nlohmann::json::parse(body); if (!j.contains("data") || !j.at("data").is_array()) { - return Result::err("Pokemon TCG response missing 'data' array."); + return R::err({K::Transient, "Pokemon TCG response missing 'data' array."}); } const auto& data = j.at("data"); if (data.empty()) { - return Result::err("Pokemon TCG returned no matching cards."); + return R::err({K::NotFound, "Pokemon TCG returned no matching cards."}); } const auto& first = data.at(0); if (!first.contains("images") || !first.at("images").is_object()) { - return Result::err("Card has no 'images' object."); + return R::err({K::NotFound, "Card has no 'images' object."}); } const auto& images = first.at("images"); if (images.contains("large") && images.at("large").is_string()) { - return Result::ok(images.at("large").get()); + return R::ok(images.at("large").get()); } if (images.contains("small") && images.at("small").is_string()) { - return Result::ok(images.at("small").get()); + return R::ok(images.at("small").get()); } - return Result::err("Card has no 'large' or 'small' image variant."); + return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."}); } catch (const std::exception& e) { - return Result::err( - std::string("Pokemon TCG JSON parse error: ") + e.what()); + return R::err({K::Transient, + std::string("Pokemon TCG JSON parse error: ") + e.what()}); } } -Result PokemonCardPreviewSource::fetchImageUrl(std::string_view name, - std::string_view setId, - std::string_view setNo) { +Result +PokemonCardPreviewSource::fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) { + using R = Result; + using K = PreviewLookupError::Kind; const std::string url = buildSearchUrl(name, setId, setNo); auto resp = http_.get(url); - if (!resp) return Result::err(resp.error()); + if (!resp) return R::err({K::Transient, resp.error()}); return parseResponse(resp.value()); } diff --git a/core/src/games/yugioh/YuGiOhCardPreviewSource.cpp b/core/src/games/yugioh/YuGiOhCardPreviewSource.cpp new file mode 100644 index 0000000..717a86b --- /dev/null +++ b/core/src/games/yugioh/YuGiOhCardPreviewSource.cpp @@ -0,0 +1,565 @@ +#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ccm { + +namespace { + +// RFC 3986 percent-encoder. Same rules as the Magic implementation; private +// here so the YGO and Magic code paths can drift independently if the future +// requires it (Yugipedia's MediaWiki API is fine with %20 for spaces and %7C +// for the `|` separator inside `titles=`). +std::string urlEncode(std::string_view in) { + std::ostringstream out; + out.fill('0'); + out << std::hex << std::uppercase; + for (unsigned char c : in) { + const bool unreserved = + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~'; + if (unreserved) { + out << static_cast(c); + } else { + out << '%'; + out.width(2); + out << static_cast(c); + } + } + return out.str(); +} + +std::string trim(std::string s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) s.erase(s.begin()); + while (!s.empty() && std::isspace(static_cast(s.back()))) s.pop_back(); + return s; +} + +std::string toLower(std::string s) { + for (char& ch : s) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return s; +} + +// Pull the standard art URL out of a YGOPRODeck card object. We deliberately +// always return card_images[0]: when no `cardset=` filter is applied, that +// slot is the original/standard artwork (alt-art passcodes follow), which is +// the closest fallback we have when Yugipedia has no scan for this printing. +std::string imageFromCard(const nlohmann::json& card) { + if (!card.contains("card_images") || !card.at("card_images").is_array() || card.at("card_images").empty()) { + return {}; + } + const auto& first = card.at("card_images").at(0); + if (first.contains("image_url") && first.at("image_url").is_string()) { + return first.at("image_url").get(); + } + if (first.contains("image_url_small") && first.at("image_url_small").is_string()) { + return first.at("image_url_small").get(); + } + if (first.contains("image_url_cropped") && first.at("image_url_cropped").is_string()) { + return first.at("image_url_cropped").get(); + } + return {}; +} + +// Split a setNo encoded by the UI as `||||` into its +// three positional fields. Any missing trailing field becomes an empty +// string, so older callers that pass just `` keep working. +struct ParsedSetNo { + std::string setNo; + std::string rarity; + std::string edition; // "1E" / "UE" / "" (unknown) +}; +ParsedSetNo parseSetNoTuple(std::string_view raw) { + std::string s(raw); + ParsedSetNo p; + const auto a = s.find("||"); + if (a == std::string::npos) { + p.setNo = trim(std::move(s)); + return p; + } + p.setNo = trim(s.substr(0, a)); + std::string rest = s.substr(a + 2); + const auto b = rest.find("||"); + if (b == std::string::npos) { + p.rarity = trim(std::move(rest)); + return p; + } + p.rarity = trim(rest.substr(0, b)); + p.edition = trim(rest.substr(b + 2)); + return p; +} + +} // namespace + +YuGiOhCardPreviewSource::YuGiOhCardPreviewSource(IHttpClient& http) : http_(http) {} + +// ============================================================================ +// Yugipedia (image-preview path) +// ============================================================================ + +std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) { + // Yugipedia's image policy strips whitespace and a fixed set of + // punctuation from the displayed card name to produce the file slug. + // Reference: https://yugipedia.com/wiki/Yugipedia:Image_policy + std::string out; + out.reserve(name.size()); + for (unsigned char c : name) { + if (c <= 0x20) continue; // whitespace, including non-breaking + switch (c) { + case '#': case ',': case '.': case ':': case '\'': case '"': + case '?': case '!': case '&': case '@': case '%': case '=': + case '[': case ']': case '<': case '>': case '/': case '\\': + case '-': case '*': case ';': case '`': + continue; + default: + break; + } + out.push_back(static_cast(c)); + } + return out; +} + +std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) { + // Compare case-insensitively, ignoring whitespace, against a table of + // CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions) + // plus a few extras occasionally seen in imported collections. The codes + // are the ones Yugipedia uses in image filenames. + std::string lc; + lc.reserve(rarityName.size()); + for (unsigned char c : rarityName) { + if (std::isspace(c)) continue; + lc.push_back(static_cast(std::tolower(c))); + } + static const std::array, 32> kTable = {{ + {"common", "C"}, + {"shortprint", "SP"}, + {"supershortprint", "SSP"}, + {"normalrare", "NR"}, + {"rare", "R"}, + {"superrare", "SR"}, + {"ultrarare", "UR"}, + {"ultimaterare", "UtR"}, + {"secretrare", "ScR"}, + {"prismaticsecretrare", "PScR"}, + {"extrasecretrare", "EScR"}, + {"ultrasecretrare", "UScR"}, + {"platinumsecretrare", "PtScR"}, + {"goldsecretrare", "GScR"}, + {"ghostrare", "GR"}, + {"goldrare", "GUR"}, + {"premiumgoldrare", "PGR"}, + {"goldenrare", "GUR"}, + {"starfoilrare", "SFR"}, + {"shatterfoilrare", "SHR"}, + {"mosaicrare", "MSR"}, + {"parallelrare", "PR"}, + {"superparallelrare", "SPR"}, + {"ultraparallelrare", "UPR"}, + {"holographicrare", "HGR"}, + {"starlightrare", "StR"}, + {"collectorsrare", "ColR"}, + {"prismaticcollectorsrare", "PColR"}, + {"quartercenturysecretrare", "QCScR"}, + {"prismaticultimaterare", "PUtR"}, + {"prismaticredsecretrare", "PRScR"}, + {"silverletter", "SLR"}, + }}; + for (const auto& [k, v] : kTable) { + if (lc == k) return std::string(v); + } + return {}; +} + +std::string YuGiOhCardPreviewSource::extractSetCode(std::string_view setNo) { + std::string s(setNo); + while (!s.empty() && std::isspace(static_cast(s.front()))) s.erase(s.begin()); + const auto dash = s.find('-'); + if (dash == std::string::npos) return s; + return s.substr(0, dash); +} + +std::vector YuGiOhCardPreviewSource::buildCandidateFilenames( + std::string_view name, + std::string_view setCode, + std::string_view rarityCode, + bool firstEdition) { + std::vector out; + const std::string slug = normalizeName(name); + if (slug.empty() || setCode.empty()) return out; + + // English-only region candidates, in rough usage order: EN is the + // current default, NA was used on most LOB-era prints, EU/AU show up + // sporadically. Always English regardless of the card's stored Language. + static constexpr std::array kRegions = + {"EN", "NA", "EU", "AU"}; + + // Edition candidates: prefer the printed edition the user has, then + // try the opposite, then fall back to LE for promo-type prints. + std::array editions = {"", "", "LE"}; + if (firstEdition) { + editions[0] = "1E"; + editions[1] = "UE"; + } else { + editions[0] = "UE"; + editions[1] = "1E"; + } + + // Two extension variants: Yugipedia has a mix of .png (modern) and .jpg + // (older uploads) for the same era. Both are common for LOB-era cards. + static constexpr std::array kExts = {"png", "jpg"}; + + auto pushCombos = [&](std::string_view rarity) { + for (auto edition : editions) { + for (auto region : kRegions) { + for (auto ext : kExts) { + std::string fn; + fn.reserve(slug.size() + setCode.size() + 16); + fn += slug; + fn += '-'; fn.append(setCode); + fn += '-'; fn.append(region); + if (!rarity.empty()) { + fn += '-'; fn.append(rarity); + } + fn += '-'; fn.append(edition); + fn += '.'; fn.append(ext); + out.push_back(std::move(fn)); + } + } + } + }; + + // Primary attempts include the rarity slot. If we don't know the rarity + // we skip straight to the rarity-less fallback (some sets are uniform + // rarity and the upload omits the slot). + if (!rarityCode.empty()) { + pushCombos(rarityCode); + } + pushCombos(""); + return out; +} + +std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl( + const std::vector& filenames) { + // MediaWiki batch query: `titles=File:A|File:B|File:C` (URL-encoded). + // One HTTP call returns imageinfo for every page whose file exists; the + // missing ones come back tagged with `"missing": ""`. + std::string joined; + for (size_t i = 0; i < filenames.size(); ++i) { + if (i > 0) joined += "|"; + joined += "File:"; + joined += filenames[i]; + } + std::string url = + "https://yugipedia.com/api.php?action=query&format=json" + "&prop=imageinfo&iiprop=url&titles="; + url += urlEncode(joined); + return url; +} + +Result YuGiOhCardPreviewSource::parseYugipediaResponse( + const std::string& body, + const std::vector& filenameOrder) { + using R = Result; + using K = PreviewLookupError::Kind; + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("query") || !j.at("query").is_object()) { + return R::err({K::Transient, "Yugipedia response missing 'query' object."}); + } + const auto& pages = j.at("query").value("pages", nlohmann::json::object()); + if (!pages.is_object()) { + return R::err({K::Transient, "Yugipedia response missing 'query.pages'."}); + } + + // Build a name->URL map. MediaWiki returns the title with namespace + // ("File:...") and may have replaced spaces with underscores; our + // candidate filenames never contain spaces, so a direct compare on + // the bit after "File:" is sufficient. + std::unordered_map resolved; + resolved.reserve(filenameOrder.size()); + for (auto it = pages.begin(); it != pages.end(); ++it) { + const auto& page = it.value(); + if (!page.contains("imageinfo")) continue; + const auto& info = page.at("imageinfo"); + if (!info.is_array() || info.empty()) continue; + const auto& info0 = info.at(0); + if (!info0.contains("url") || !info0.at("url").is_string()) continue; + + std::string title = page.value("title", ""); + constexpr std::string_view kPrefix = "File:"; + if (title.rfind(kPrefix, 0) == 0) title.erase(0, kPrefix.size()); + resolved[title] = info0.at("url").get(); + } + + // Walk our ordered candidate list and return the first hit. This is + // how priority works: 1E English first, then UE, then jpg, etc. + for (const auto& fn : filenameOrder) { + auto it = resolved.find(fn); + if (it != resolved.end() && !it->second.empty()) { + return R::ok(it->second); + } + } + // Every candidate was tagged "missing" => Yugipedia confirmed there + // is no English scan for this printing. Treat as NotFound; the + // YGOPRODeck fallback may still surface a generic art. + return R::err({K::NotFound, "No matching Yugipedia scan found."}); + } catch (const std::exception& e) { + return R::err({K::Transient, + std::string("Yugipedia JSON parse error: ") + e.what()}); + } +} + +// ============================================================================ +// YGOPRODeck (auto-detect path + last-resort fallback) +// ============================================================================ + +std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name, + std::string_view setName) { + std::string url = + std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + urlEncode(name); + if (!setName.empty()) { + url += "&cardset="; + url += urlEncode(setName); + } + return url; +} + +Result YuGiOhCardPreviewSource::parseFallbackImageUrl( + const std::string& body, std::string_view name) { + using R = Result; + using K = PreviewLookupError::Kind; + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_array()) { + return R::err({K::Transient, "YGOPRODeck response missing 'data' array."}); + } + const auto& data = j.at("data"); + if (data.empty()) { + return R::err({K::NotFound, "YGOPRODeck returned no matching cards."}); + } + const std::string wantedNameLower = toLower(trim(std::string(name))); + + // Prefer the exact-name match: the fuzzy `fname=` search can mix in + // sibling cards (Dark Magician + Dark Magician Girl), and we don't + // want to land on a sibling's standard art. + for (const auto& card : data) { + const std::string cardName = trim(card.value("name", "")); + if (!wantedNameLower.empty() && toLower(cardName) == wantedNameLower) { + const std::string image = imageFromCard(card); + if (!image.empty()) return R::ok(image); + } + } + // Failing that, take whatever YGOPRODeck ranked first. + const std::string image = imageFromCard(data.at(0)); + if (!image.empty()) { + return R::ok(image); + } + return R::err({K::NotFound, "Card has no image variants."}); + } catch (const std::exception& e) { + return R::err({K::Transient, + std::string("YGOPRODeck JSON parse error: ") + e.what()}); + } +} + +Result> YuGiOhCardPreviewSource::parsePrintVariants( + const std::string& body, + std::string_view preferredSetName, + std::string_view wantedCardName) { + using R = Result>; + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) { + return R::err("YGOPRODeck returned no matching cards."); + } + const std::string wantedSet = trim(std::string(preferredSetName)); + const std::string wantedNameLower = toLower(trim(std::string(wantedCardName))); + + std::vector collected; + auto pushPrint = [&collected](const nlohmann::json& print) { + AutoDetectedPrint out; + out.setNo = trim(print.value("set_code", "")); + out.rarity = trim(print.value("set_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 (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue; + for (const auto& print : card.at("card_sets")) { + const std::string setName = trim(print.value("set_name", "")); + if (!wantedSet.empty() && setName != wantedSet) continue; + pushPrint(print); + } + } + + // Mirror parseFirstPrint fallback: if nothing matched `wantedSet`, take + // every print from `data[0]` without filtering by set_name. + // + // When the caller supplied an exact card name (edit-dialog variant + // listing), combining unrelated `card_sets[]` rows after a non-empty + // display-set filter missed would falsely imply multiple printings + // "in one set" (different real-world products share the same card). + if (collected.empty()) { + if (!wantedNameLower.empty() && !wantedSet.empty()) { + return R::err("Could not auto-detect set print metadata."); + } + const auto& firstCard = j.at("data").at(0); + if (!wantedNameLower.empty()) { + const std::string cardName = trim(firstCard.value("name", "")); + if (toLower(cardName) != wantedNameLower) { + return R::err("Could not auto-detect set print metadata."); + } + } + if (firstCard.contains("card_sets") && firstCard.at("card_sets").is_array()) { + for (const auto& print : firstCard.at("card_sets")) { + pushPrint(print); + } + } + } + + if (collected.empty()) { + return R::err("Could not auto-detect set print metadata."); + } + + std::vector deduped; + deduped.reserve(collected.size()); + std::unordered_set seen; + seen.reserve(collected.size() * 2); + for (auto& p : collected) { + const std::string key = p.setNo + '\0' + p.rarity; + if (seen.insert(key).second) deduped.push_back(std::move(p)); + } + return R::ok(std::move(deduped)); + } catch (const std::exception& e) { + return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what()); + } +} + +Result YuGiOhCardPreviewSource::parseFirstPrint( + const std::string& body, std::string_view preferredSetName) { + auto list = parsePrintVariants(body, preferredSetName, ""); + if (!list || list.value().empty()) { + if (!list) return Result::err(list.error()); + return Result::err("Could not auto-detect set print metadata."); + } + return Result::ok(list.value().front()); +} + +// ============================================================================ +// Public ICardPreviewSource API +// ============================================================================ + +Result +YuGiOhCardPreviewSource::fetchImageUrl(std::string_view name, + std::string_view /*setId*/, + std::string_view setNo) { + using R = Result; + using K = PreviewLookupError::Kind; + const ParsedSetNo p = parseSetNoTuple(setNo); + const std::string setCode = extractSetCode(p.setNo); + const std::string rarityCode = rarityCodeFor(p.rarity); + const bool firstEdition = (p.edition == "1E"); + + // The overall classification needs the worst outcome across the two + // upstreams: NotFound only when *both* answered cleanly with no match, + // Transient as soon as either one couldn't speak. We track Yugipedia's + // outcome here and combine it with YGOPRODeck's below. + bool yugipediaSawTransient = false; + PreviewLookupError yugipediaErr{K::NotFound, "Yugipedia not consulted."}; + + // Step 1: Yugipedia per-printing scan. Build a batch of plausible English + // filenames and ask MediaWiki for them all in one call. This is the only + // source we know of that distinguishes art between same-passcode reprints + // (LOB Blue-Eyes vs SDK Blue-Eyes, etc.). + // + // No usable set code (or empty candidate list) is treated as an + // "inapplicable" Yugipedia step rather than a failure - we don't want a + // legitimate metadata gap to taint the final classification as transient. + if (!setCode.empty()) { + const auto candidates = buildCandidateFilenames( + name, setCode, rarityCode, firstEdition); + if (!candidates.empty()) { + const std::string url = buildYugipediaQueryUrl(candidates); + auto resp = http_.get(url); + if (!resp) { + yugipediaSawTransient = true; + yugipediaErr = {K::Transient, resp.error()}; + } else { + auto parsed = parseYugipediaResponse(resp.value(), candidates); + if (parsed) return parsed; + yugipediaErr = std::move(parsed).error(); + if (yugipediaErr.kind == K::Transient) yugipediaSawTransient = true; + } + } + } + + // Step 2: YGOPRODeck standard-art fallback. Only used when Yugipedia has + // no scan we can match (newly-added cards, OCG-only cards without an + // English release, transient Yugipedia errors). Always unfiltered, so + // card_images[0] is the original artwork rather than an alt-art reprint. + const std::string fallbackUrl = buildSearchUrl(name, ""); + auto fallback = http_.get(fallbackUrl); + if (!fallback) { + // YGOPRODeck failed at the network layer => the overall lookup is + // transient regardless of what Yugipedia did. Surface YGOPRODeck's + // error string because it's the most recent failure. + return R::err({K::Transient, fallback.error()}); + } + auto parsed = parseFallbackImageUrl(fallback.value(), name); + if (parsed) return parsed; + + // Both upstreams answered. If *either* one was transient, the overall + // outcome is transient (we can't conclude the record has no image). + PreviewLookupError fallbackErr = std::move(parsed).error(); + if (yugipediaSawTransient || fallbackErr.kind == K::Transient) { + return R::err({K::Transient, + yugipediaSawTransient ? yugipediaErr.message : fallbackErr.message}); + } + // Otherwise both confirmed "no image" => safe to remember. + return R::err({K::NotFound, fallbackErr.message}); +} + +Result YuGiOhCardPreviewSource::detectFirstPrint(std::string_view name, + std::string_view setId) { + auto list = detectPrintVariants(name, setId); + if (!list || list.value().empty()) { + if (!list) return Result::err(list.error()); + return Result::err("Could not auto-detect set print metadata."); + } + return Result::ok(list.value().front()); +} + +Result> YuGiOhCardPreviewSource::detectPrintVariants( + std::string_view name, + std::string_view setId) { + using R = Result>; + const std::string url = buildSearchUrl(name, setId); + auto resp = http_.get(url); + if (resp) { + return parsePrintVariants(resp.value(), setId, name); + } + const std::string fallbackUrl = buildSearchUrl(name, ""); + auto fallback = http_.get(fallbackUrl); + if (!fallback) return R::err(fallback.error()); + return parsePrintVariants(fallback.value(), setId, name); +} + +} // namespace ccm diff --git a/core/src/games/yugioh/YuGiOhGameModule.cpp b/core/src/games/yugioh/YuGiOhGameModule.cpp new file mode 100644 index 0000000..7df0fb9 --- /dev/null +++ b/core/src/games/yugioh/YuGiOhGameModule.cpp @@ -0,0 +1,8 @@ +#include "ccm/games/yugioh/YuGiOhGameModule.hpp" + +namespace ccm { + +YuGiOhGameModule::YuGiOhGameModule(IHttpClient& http) + : setSource_(http), previewSource_(http) {} + +} // namespace ccm diff --git a/core/src/games/yugioh/YuGiOhSetSource.cpp b/core/src/games/yugioh/YuGiOhSetSource.cpp new file mode 100644 index 0000000..d47acd1 --- /dev/null +++ b/core/src/games/yugioh/YuGiOhSetSource.cpp @@ -0,0 +1,47 @@ +#include "ccm/games/yugioh/YuGiOhSetSource.hpp" + +#include + +#include +#include + +namespace ccm { + +YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {} + +Result> YuGiOhSetSource::parseResponse(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_array()) { + return Result>::err( + "YGOPRODeck response is not an array."); + } + std::vector out; + out.reserve(j.size()); + for (const auto& entry : j) { + Set s; + s.id = entry.value("set_code", ""); + s.name = entry.value("set_name", ""); + std::string release = entry.value("tcg_date", ""); + for (char& ch : release) { + if (ch == '-') ch = '/'; + } + s.releaseDate = std::move(release); + out.push_back(std::move(s)); + } + std::sort(out.begin(), out.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + return Result>::ok(std::move(out)); + } catch (const std::exception& e) { + return Result>::err( + std::string("YGOPRODeck set parse error: ") + e.what()); + } +} + +Result> YuGiOhSetSource::fetchAll() { + auto resp = http_.get(kEndpoint); + if (!resp) return Result>::err(resp.error()); + return parseResponse(resp.value()); +} + +} // namespace ccm diff --git a/core/src/infra/CprHttpClient.cpp b/core/src/infra/CprHttpClient.cpp index e644f84..a6f0938 100644 --- a/core/src/infra/CprHttpClient.cpp +++ b/core/src/infra/CprHttpClient.cpp @@ -3,19 +3,40 @@ #include #include +#include namespace ccm { -CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {} +CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) + : timeout_(timeout), + session_(std::make_unique()) { + // Configure session-wide options once; every Get() then only updates + // the URL. libcurl's connection cache lives inside the easy handle, so + // reusing one Session across calls is what gets us TLS keep-alive. + session_->SetTimeout(cpr::Timeout{timeout_}); + // `Accept: application/json` breaks some CDNs that refuse non-JSON bodies + // (preview pipeline also GETs raw JPG/PNG). Wildcard keeps JSON APIs happy. + session_->SetHeader(cpr::Header{ + {"User-Agent", "card-collection-manager-3/0.1"}, + {"Accept", "*/*"}, + }); + session_->SetRedirect(cpr::Redirect{/*max_redirects=*/10L, + /*follow=*/true, + /*cont_send_cred=*/false, + cpr::PostRedirectFlags::POST_ALL}); +} + +CprHttpClient::~CprHttpClient() = default; Result CprHttpClient::get(std::string_view url) { - cpr::Response r = cpr::Get( - cpr::Url{std::string(url)}, - cpr::Timeout{timeout_}, - // Identify ourselves; some APIs rate-limit unknown agents harshly. - cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"}, - {"Accept", "application/json"}} - ); + // libcurl easy handles (and therefore cpr::Session) are not thread-safe. + // We serialize callers here; the preview path is single-flight already + // (one fetch per BaseSelectedCardPanel selection change), so contention + // is negligible. + std::lock_guard lock(sessionMutex_); + + session_->SetUrl(cpr::Url{std::string(url)}); + cpr::Response r = session_->Get(); if (r.error) { return Result::err("HTTP error: " + r.error.message); diff --git a/core/src/infra/LocalPreviewByteCache.cpp b/core/src/infra/LocalPreviewByteCache.cpp new file mode 100644 index 0000000..07a6477 --- /dev/null +++ b/core/src/infra/LocalPreviewByteCache.cpp @@ -0,0 +1,228 @@ +#include "ccm/infra/LocalPreviewByteCache.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ccm { + +namespace fs = std::filesystem; + +namespace { + +// FNV-1a 64-bit hash, hex-encoded. We don't need cryptographic strength +// here: the `.idx` sidecar file holds the original key and load() rejects +// any mismatch, so a hash collision degrades to a cache miss instead of a +// wrong-image return. FNV-1a was picked to keep this dependency-free +// (no openssl, no extra link). +std::string fnv1a64Hex(std::string_view in) { + constexpr std::uint64_t kOffsetBasis = 0xcbf29ce484222325ULL; + constexpr std::uint64_t kPrime = 0x100000001b3ULL; + std::uint64_t h = kOffsetBasis; + for (unsigned char c : in) { + h ^= c; + h *= kPrime; + } + std::ostringstream oss; + oss << std::hex << std::setw(16) << std::setfill('0') << h; + return oss.str(); +} + +// Best-effort mtime; returns the epoch on any error so callers can still +// sort consistently (oldest-first eviction stays well-defined). +fs::file_time_type mtimeOrEpoch(const fs::path& p) { + std::error_code ec; + auto t = fs::last_write_time(p, ec); + if (ec) return fs::file_time_type{}; + return t; +} + +std::uintmax_t fileSizeOrZero(const fs::path& p) { + std::error_code ec; + auto sz = fs::file_size(p, ec); + return ec ? 0u : sz; +} + +void touchMtime(const fs::path& p) { + std::error_code ec; + fs::last_write_time(p, fs::file_time_type::clock::now(), ec); + // Ignored: touch is a best-effort hint to the LRU policy. +} + +} // namespace + +LocalPreviewByteCache::LocalPreviewByteCache(IFileSystem& fs, + fs::path cacheDir, + std::size_t maxBytes) + : fs_(fs), cacheDir_(std::move(cacheDir)), maxBytes_(maxBytes) {} + +std::string LocalPreviewByteCache::hashKey(std::string_view key) { + return fnv1a64Hex(key); +} + +fs::path LocalPreviewByteCache::payloadPath(const std::string& hash) const { + return cacheDir_ / (hash + ".bin"); +} + +fs::path LocalPreviewByteCache::negativePath(const std::string& hash) const { + return cacheDir_ / (hash + ".neg"); +} + +fs::path LocalPreviewByteCache::indexPath(const std::string& hash) const { + return cacheDir_ / (hash + ".idx"); +} + +IPreviewByteCache::LoadResult LocalPreviewByteCache::load(std::string_view key) { + std::lock_guard lock(mutex_); + + const std::string hash = hashKey(key); + const auto bin = payloadPath(hash); + const auto neg = negativePath(hash); + const auto idx = indexPath(hash); + + const bool hasBin = fs_.exists(bin); + const bool hasNeg = fs_.exists(neg); + if (!hasBin && !hasNeg) return {HitKind::Miss, {}}; + + // Sidecar must exist and match exactly. Anything else - missing, + // mismatched, empty - is treated as a miss so the next store() / + // storeNegative() will overwrite cleanly. This is what guarantees that + // a hash collision can never serve another card's bytes or stale + // "no image" verdict. + if (!fs_.exists(idx)) return {HitKind::Miss, {}}; + auto idxRead = fs_.readText(idx); + if (!idxRead) return {HitKind::Miss, {}}; + if (idxRead.value() != key) return {HitKind::Miss, {}}; + + if (hasBin) { + auto payload = fs_.readText(bin); + if (!payload) return {HitKind::Miss, {}}; + // Touch mtime so this hit moves to the front of the LRU. + touchMtime(bin); + return {HitKind::Hit, std::move(payload).value()}; + } + // Negative-only entry. Touch its mtime as well so frequently-checked + // negatives don't get aged out by an arbitrary directory sweep. + touchMtime(neg); + return {HitKind::NegativeHit, {}}; +} + +void LocalPreviewByteCache::store(std::string_view key, const std::string& payload) { + if (payload.empty()) return; + + std::lock_guard lock(mutex_); + + auto ensure = fs_.ensureDirectory(cacheDir_); + if (!ensure) return; + + const std::string hash = hashKey(key); + const auto bin = payloadPath(hash); + const auto neg = negativePath(hash); + const auto idx = indexPath(hash); + + // If a negative entry exists for this exact key, drop it before writing + // the positive payload so the two are never co-resident on disk. + if (fs_.exists(neg)) (void)fs_.remove(neg); + + // Eviction runs against the *new* payload size, not the post-write + // total, so we make room before writing. If the same key is being + // overwritten the existing payload's bytes are released first. + evictIfNeededLocked(payload.size()); + + auto wrote = fs_.writeText(bin, payload); + if (!wrote) return; + auto wroteIdx = fs_.writeText(idx, std::string(key)); + if (!wroteIdx) { + // Sidecar failure leaves us with bytes we can't safely serve later. + // Roll back the payload write so a future load() doesn't see it. + (void)fs_.remove(bin); + return; + } +} + +void LocalPreviewByteCache::storeNegative(std::string_view key) { + std::lock_guard lock(mutex_); + + auto ensure = fs_.ensureDirectory(cacheDir_); + if (!ensure) return; + + const std::string hash = hashKey(key); + const auto bin = payloadPath(hash); + const auto neg = negativePath(hash); + const auto idx = indexPath(hash); + + // Replace any existing positive entry: storeNegative is the upstream + // saying "the previous bytes are no longer the correct answer for this + // record". Free the bytes from the size cap immediately. + if (fs_.exists(bin)) (void)fs_.remove(bin); + + // Order matters: write the marker first, then the sidecar. If the + // sidecar write fails we delete the marker to avoid a half-written + // entry that load() would treat as a miss anyway but that contributes + // a stray file to the directory listing. + auto wroteNeg = fs_.writeText(neg, std::string{}); + if (!wroteNeg) return; + auto wroteIdx = fs_.writeText(idx, std::string(key)); + if (!wroteIdx) { + (void)fs_.remove(neg); + } +} + +std::size_t LocalPreviewByteCache::currentSizeBytes() { + std::lock_guard lock(mutex_); + auto entries = fs_.listDirectory(cacheDir_); + if (!entries) return 0; + std::size_t total = 0; + for (const auto& p : entries.value()) { + if (p.extension() == ".bin") total += static_cast(fileSizeOrZero(p)); + } + return total; +} + +void LocalPreviewByteCache::evictIfNeededLocked(std::size_t incomingBytes) { + auto entries = fs_.listDirectory(cacheDir_); + if (!entries) return; + + struct Entry { + fs::path bin; + fs::path idx; + std::uintmax_t size; + fs::file_time_type mtime; + }; + std::vector bins; + bins.reserve(entries.value().size()); + std::size_t total = 0; + for (const auto& p : entries.value()) { + if (p.extension() != ".bin") continue; + Entry e; + e.bin = p; + e.idx = p; + e.idx.replace_extension(".idx"); + e.size = fileSizeOrZero(p); + e.mtime = mtimeOrEpoch(p); + total += static_cast(e.size); + bins.push_back(std::move(e)); + } + + if (total + incomingBytes <= maxBytes_) return; + + std::sort(bins.begin(), bins.end(), + [](const Entry& a, const Entry& b) { return a.mtime < b.mtime; }); + + for (const auto& e : bins) { + if (total + incomingBytes <= maxBytes_) break; + // remove() is best-effort; if it fails we still drop our accounting + // for the entry so we don't loop forever on a stuck file. + (void)fs_.remove(e.bin); + (void)fs_.remove(e.idx); + total -= std::min(static_cast(e.size), total); + } +} + +} // namespace ccm diff --git a/core/src/services/CardFilter.cpp b/core/src/services/CardFilter.cpp index a16ff3f..033cad4 100644 --- a/core/src/services/CardFilter.cpp +++ b/core/src/services/CardFilter.cpp @@ -63,4 +63,20 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) { return false; } +bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) { + if (filter.empty()) return true; + + const std::string needle = asciiLower(filter); + + if (containsLower(card.name, needle)) return true; + if (containsLower(card.set.name, needle)) return true; + if (containsLower(card.setNo, needle)) return true; + if (containsLower(card.rarity, needle)) return true; + if (containsLower(to_string(card.language), needle)) return true; + if (containsLower(to_string(card.condition), needle)) return true; + if (containsLower(std::to_string(card.amount), needle)) return true; + if (containsLower(card.note, needle)) return true; + return false; +} + } // namespace ccm diff --git a/core/src/services/CardPreviewService.cpp b/core/src/services/CardPreviewService.cpp index 80b5e9d..0c3d70e 100644 --- a/core/src/services/CardPreviewService.cpp +++ b/core/src/services/CardPreviewService.cpp @@ -1,8 +1,46 @@ #include "ccm/services/CardPreviewService.hpp" +#include +#include +#include + namespace ccm { -CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {} +namespace { + +// Compose a stable cache key from the four lookup coordinates. Using NUL as +// a separator keeps the key unambiguous even if a card's name happens to +// contain `|` or other punctuation. +std::string makePreviewKey(Game game, + std::string_view name, + std::string_view setId, + std::string_view setNo) { + std::string k; + k.reserve(2 + name.size() + setId.size() + setNo.size() + 3); + k.push_back('p'); + k.push_back(static_cast(static_cast(game))); + k.push_back('\0'); + k.append(name); + k.push_back('\0'); + k.append(setId); + k.push_back('\0'); + k.append(setNo); + return k; +} + +std::string makeUrlKey(std::string_view url) { + std::string k; + k.reserve(url.size() + 1); + k.push_back('u'); + k.append(url); + return k; +} + +} // namespace + +CardPreviewService::CardPreviewService(IHttpClient& http, + IPreviewByteCache* persistentCache) + : http_(http), persistentCache_(persistentCache) {} void CardPreviewService::registerModule(IGameModule& module) { if (auto* src = module.cardPreviewSource(); src != nullptr) { @@ -10,6 +48,80 @@ void CardPreviewService::registerModule(IGameModule& module) { } } +CardPreviewService::CacheLookupKind CardPreviewService::cacheLookup( + const std::string& key, std::string& outPayload) { + std::lock_guard lock(cacheMutex_); + auto it = cacheIndex_.find(key); + if (it == cacheIndex_.end()) { + outPayload.clear(); + return CacheLookupKind::Miss; + } + // Move-to-front to mark as most-recently-used. + cacheList_.splice(cacheList_.begin(), cacheList_, it->second); + if (it->second->negative) { + outPayload.clear(); + return CacheLookupKind::NegativeHit; + } + outPayload = it->second->payload; + return CacheLookupKind::Hit; +} + +void CardPreviewService::cacheStore(const std::string& key, std::string payload) { + if (payload.empty()) return; + std::lock_guard lock(cacheMutex_); + auto it = cacheIndex_.find(key); + if (it != cacheIndex_.end()) { + // Overwrite existing entry (positive or negative) and bump it to + // the front. Replacing a negative entry is the "we got a real + // image after a previous NotFound" path - rare but valid. + it->second->payload = std::move(payload); + it->second->negative = false; + cacheList_.splice(cacheList_.begin(), cacheList_, it->second); + return; + } + cacheList_.push_front({key, std::move(payload), /*negative=*/false}); + cacheIndex_.emplace(key, cacheList_.begin()); + while (cacheList_.size() > kCacheCapacity) { + cacheIndex_.erase(cacheList_.back().key); + cacheList_.pop_back(); + } +} + +void CardPreviewService::cacheStoreNegative(const std::string& key) { + std::lock_guard lock(cacheMutex_); + auto it = cacheIndex_.find(key); + if (it != cacheIndex_.end()) { + it->second->payload.clear(); + it->second->negative = true; + cacheList_.splice(cacheList_.begin(), cacheList_, it->second); + return; + } + cacheList_.push_front({key, std::string{}, /*negative=*/true}); + cacheIndex_.emplace(key, cacheList_.begin()); + while (cacheList_.size() > kCacheCapacity) { + cacheIndex_.erase(cacheList_.back().key); + cacheList_.pop_back(); + } +} + +Result CardPreviewService::fetchAndCache(const std::string& cacheKey, + std::string_view url) { + auto bytes = http_.get(url); + if (!bytes) return Result::err(bytes.error()); + std::string payload = std::move(bytes).value(); + if (payload.empty()) { + return Result::err("Empty response body from " + std::string(url)); + } + cacheStore(cacheKey, payload); + // Best-effort persist to disk so the next app launch starts warm. + // The persistent tier is fire-and-forget: any I/O error is swallowed + // by the adapter, the in-memory tier still holds the bytes. + if (persistentCache_ != nullptr) { + persistentCache_->store(cacheKey, payload); + } + return Result::ok(std::move(payload)); +} + Result CardPreviewService::fetchPreviewBytes(Game game, std::string_view name, std::string_view setId, @@ -18,17 +130,114 @@ Result CardPreviewService::fetchPreviewBytes(Game game, if (it == sources_.end() || it->second == nullptr) { return Result::err("No preview source registered for this game."); } + + // Cache check before any HTTP call. The (game, name, setId, setNo) tuple + // uniquely identifies a printing for our purposes - the resolved image + // URL is always a deterministic function of those four inputs, and any + // edit to a lookup-relevant field changes the key automatically. + const std::string key = makePreviewKey(game, name, setId, setNo); + std::string cached; + switch (cacheLookup(key, cached)) { + case CacheLookupKind::Hit: + return Result::ok(std::move(cached)); + case CacheLookupKind::NegativeHit: + return Result::err("No preview available for this card."); + case CacheLookupKind::Miss: + break; + } + // Disk-backed second tier: previews persisted by an earlier app run + // get promoted into the in-memory LRU on first access this session, so + // subsequent re-selections stay fast without re-touching the network. + // Negative entries on disk are likewise promoted - the user already + // knows from a previous session that this record has no upstream image. + if (persistentCache_ != nullptr) { + const auto disk = persistentCache_->load(key); + switch (disk.kind) { + case IPreviewByteCache::HitKind::Hit: + cacheStore(key, disk.payload); + return Result::ok(disk.payload); + case IPreviewByteCache::HitKind::NegativeHit: + cacheStoreNegative(key); + return Result::err("No preview available for this card."); + case IPreviewByteCache::HitKind::Miss: + break; + } + } + auto url = it->second->fetchImageUrl(name, setId, setNo); - if (!url) return Result::err(url.error()); - auto bytes = http_.get(url.value()); - if (!bytes) return Result::err(bytes.error()); - return Result::ok(std::move(bytes).value()); + if (!url) { + // The two error kinds split here: + // - NotFound: upstream answered cleanly that this record has no + // image. Persist the verdict so we don't keep retrying. + // - Transient: network/HTTP/parse failure. Surface the error + // unchanged and DO NOT cache anything; the next selection + // retries from scratch. + const auto err = std::move(url).error(); + if (err.kind == PreviewLookupError::Kind::NotFound) { + cacheStoreNegative(key); + if (persistentCache_ != nullptr) persistentCache_->storeNegative(key); + } + return Result::err(err.message); + } + return fetchAndCache(key, url.value()); +} + +Result CardPreviewService::detectFirstPrint(Game game, + std::string_view name, + std::string_view setId) { + auto it = sources_.find(game); + if (it == sources_.end() || it->second == nullptr) { + return Result::err("No preview source registered for this game."); + } + if (!it->second->supportsAutoDetectPrint()) { + return Result::err("Auto-detect not enabled for this game."); + } + return it->second->detectFirstPrint(name, setId); +} + +Result> CardPreviewService::detectPrintVariants( + Game game, + std::string_view name, + std::string_view setId) { + auto it = sources_.find(game); + if (it == sources_.end() || it->second == nullptr) { + return Result>::err( + "No preview source registered for this game."); + } + if (!it->second->supportsAutoDetectPrint()) { + return Result>::err( + "Auto-detect not enabled for this game."); + } + return it->second->detectPrintVariants(name, setId); } Result CardPreviewService::fetchImageBytesByUrl(std::string_view url) { - auto bytes = http_.get(url); - if (!bytes) return Result::err(bytes.error()); - return Result::ok(std::move(bytes).value()); + // The by-URL path is used for fixed per-game card-back fallback images. + // A failure there is always transient (the URL itself is constant), so + // there is no negative-cache analogue to worry about; we just look up + // and, if needed, fetch+store. + const std::string key = makeUrlKey(url); + std::string cached; + switch (cacheLookup(key, cached)) { + case CacheLookupKind::Hit: + return Result::ok(std::move(cached)); + case CacheLookupKind::NegativeHit: + // Defensive: nothing in this code path ever stores a negative + // entry under a URL key, but if one ever ends up here (cache + // file tampering, future code paths) treat it as a miss so the + // fallback fetch can still run. + break; + case CacheLookupKind::Miss: + break; + } + if (persistentCache_ != nullptr) { + const auto disk = persistentCache_->load(key); + if (disk.kind == IPreviewByteCache::HitKind::Hit) { + cacheStore(key, disk.payload); + return Result::ok(disk.payload); + } + } + return fetchAndCache(key, url); } } // namespace ccm diff --git a/core/src/services/CardSorter.cpp b/core/src/services/CardSorter.cpp index b6ee0a5..ac6d9e7 100644 --- a/core/src/services/CardSorter.cpp +++ b/core/src/services/CardSorter.cpp @@ -170,4 +170,64 @@ void sortPokemonCards(std::vector& cards, PokemonSortColumn column, } } +void sortYuGiOhCards(std::vector& cards, YuGiOhSortColumn column, + bool ascending) { + switch (column) { + case YuGiOhSortColumn::Name: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return asciiLower(a.name) < asciiLower(b.name); + }, ascending)); + break; + case YuGiOhSortColumn::SetReleaseDate: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate); + }, ascending)); + break; + case YuGiOhSortColumn::Language: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language)); + }, ascending)); + break; + case YuGiOhSortColumn::Condition: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition)); + }, ascending)); + break; + case YuGiOhSortColumn::Amount: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return a.amount < b.amount; + }, ascending)); + break; + case YuGiOhSortColumn::FirstEdition: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return a.firstEdition < b.firstEdition; + }, ascending)); + break; + case YuGiOhSortColumn::Signed: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return a.signed_ < b.signed_; + }, ascending)); + break; + case YuGiOhSortColumn::Altered: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return a.altered < b.altered; + }, ascending)); + break; + case YuGiOhSortColumn::Note: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhCard& a, const YuGiOhCard& b) { + return asciiLower(a.note) < asciiLower(b.note); + }, ascending)); + break; + } +} + } // namespace ccm diff --git a/docs/AGENTS.md b/docs/AGENTS.md index e40aa8d..b2619c1 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -11,7 +11,8 @@ Long-form contributor documentation that lives outside the source tree. - `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting. - `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs. - `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests. -- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic and Pokemon modules, plus the runtime flow through `SetService` / `CardPreviewService` and the error-surface conventions. +- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, and Yu-Gi-Oh! modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png`), and error-surface conventions. +- `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching). - `README.md` — index page that clusters docs by area and links to all documents in this directory. ## Subdirectories @@ -27,7 +28,7 @@ Long-form contributor documentation that lives outside the source tree. ## Required follow-ups - After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`. -- After changing the Magic or Pokemon set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior. +- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior. - After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples. - After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete. - Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`). diff --git a/docs/README.md b/docs/README.md index 2ca0d78..ac791f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,5 +22,9 @@ This folder contains contributor documentation for Card Collection Manager 3. St - [adding-a-new-game.md](adding-a-new-game.md): canonical end-to-end procedure for adding a new game module across `core/`, `ui_wx/`, and `app/`. -- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic/Pokemon modules and their runtime purpose. +- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic, Pokémon, and Yu-Gi-Oh! modules, preview fallback URLs / bundled YGO card-back asset, and shared HTTP behavior (`CprHttpClient`). + +## Performance & Caching + +- [caching.md](caching.md): preview image caching (in-memory LRU, on-disk byte cache, HTTP connection reuse), lookup order, keys, eviction, and what is intentionally not cached. diff --git a/docs/adding-a-new-game.md b/docs/adding-a-new-game.md index f9fa6f8..59b8190 100644 --- a/docs/adding-a-new-game.md +++ b/docs/adding-a-new-game.md @@ -37,6 +37,9 @@ A few traps to plan around now, before you write code: - **Lookup precision.** Some APIs return many ambiguous matches when you query by name only and require the set id (and sometimes the collector number) to disambiguate. Decide up front which fields make a search reliable enough to take the first result. - **URL encoding.** All query strings must be RFC 3986 percent-encoded before they reach `IHttpClient::get` (`cpr::Url` does **not** re-encode). The Magic/Pokemon implementations have a private `urlEncode` helper you can copy. - **Collector-number normalization.** Pokemon stores `4/102` but the API only accepts `4`. Whichever convention your domain type uses, normalize it inside `buildSearchUrl` so the wire format is whatever the API actually expects. Mismatches here produce empty result sets, which then look identical to "no preview available" and are very tedious to debug. +- **Name-matching strictness.** Some APIs reject strict exact-name parameters for real-world card spelling variants (e.g. hyphenation/punctuation differences). If your provider supports fuzzy-name search, prefer that for the first request, then disambiguate in `parseResponse` using set/print metadata. +- **400 fallback strategy.** If adding optional set filters can produce request validation errors (`HTTP 400`), add a second request path that retries without the risky filter and keeps disambiguation local in `parseResponse`. +- **Image variant priority.** If the provider returns both cropped art and full-card images, prefer the full-card URL for selected-card preview. Use cropped variants only as fallback. ### 1.3 Flag icons @@ -138,6 +141,8 @@ Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header - `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);` - `static Result parseResponse(const std::string& body);` +If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. Keep both optional per game — default behavior should remain an explicit unsupported error. + Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover: - Names with spaces, punctuation, or non-ASCII characters (percent-encoding correctness). @@ -328,6 +333,10 @@ In the constructor: The reference implementation is `ui_wx/src/PokemonCardEditDialog.cpp`. +When an extra field is from a controlled vocabulary (rarity tiers, print types, etc.), prefer a dropdown (`wxChoice`) over free text to keep list/filter values consistent and reduce user-input variants. + +If the external API expects a full print code (e.g. `LOB-001`) but users mostly edit only the numeric suffix, expose a numeric input plus a read-only derived preview (for example `(LOB-001)`) and compose/decompose the stored full value in `readExtraFromCard()` / `writeExtraToCard()`. + ### 5.6 `GameView` This is the polymorphic glue between the new game's panels and the rest of the app. Create: @@ -452,6 +461,8 @@ Run, in order, from the workspace root. Do not skip any step. These do not match a single seam in this guide but are worth calling out explicitly. - **Stale set caches.** Each `IGameView` caches `std::vector setsCache_`. After `onUpdateSets` succeeds, refresh the cache (assign the new vector). The reference implementations do this. +- **Set ordering drift.** Keep set lists sorted by release date not only in `SetSource::parseResponse`, but also at UI consumption points (preloaded/cached vectors passed to `BaseCardEditDialog`). Older on-disk cache data or future parser changes can otherwise surface unsorted set pickers. +- **Auto-detect feature scope.** Treat print auto-detect as a per-game capability. Do not assume every game supports it; gate UI affordances behind game-specific dialog logic and source opt-in. - **`signed_` / `signed`.** The C++ field is `signed_`; the JSON key is `"signed"`. This is intentional and must not be changed. The same convention applies to any new field where the natural name collides with a C++ keyword — pick a trailing-underscore C++ name and an unaliased JSON key. - **Spacer column index.** `BaseCardListPanel` reserves index `0` for a hidden zero-width spacer column (MSW comctl32 image-list gutter workaround). Real columns start at index `1`. If you ever need to call into `wxListCtrl` directly from a derived panel (you should not), remember this. - **Preview-fetch threading.** The async preview fetch in `BaseSelectedCardPanel` uses a `shared_ptr` + `std::atomic alive` + `std::atomic currentGen` triple. Do not capture `this` raw in any background work you add to a new game's selected panel; copy that pattern verbatim. diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index 4594bc6..8242292 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -26,27 +26,92 @@ Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` an The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component. +## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck) + +Yu-Gi-Oh! splits its remote calls across two upstreams. **Yugipedia** is the primary preview source because it hosts actual per-printing card scans; **YGOPRODeck** continues to drive set listings and the auto-detect-first-print helper, plus a last-resort image fallback. + +Upstream documentation: +- [Yugipedia MediaWiki API help](https://yugipedia.com/api.php?action=help) (standard MediaWiki action API; we only need `prop=imageinfo`). +- [Yu-Gi-Oh! API Guide — YGOPRODeck](https://ygoprodeck.com/api-guide/). CCM3 uses **v7** endpoints only. + +### Info API: YGOPRODeck `cardsets.php` + +`https://db.ygoprodeck.com/api/v7/cardsets.php` +Used by `YuGiOhSetSource`. The response is a top-level JSON array. Each object maps `set_code` → internal `Set.id`, `set_name` → `Set.name`, and `tcg_date` → `Set.releaseDate` with `-` rewritten to `/` for consistency with other games’ date strings. Results are sorted ascending by `releaseDate`. + +### Asset API: Yugipedia `api.php` (primary) + +`https://yugipedia.com/api.php?action=query&prop=imageinfo&iiprop=url&titles=...` +Used by `YuGiOhCardPreviewSource::fetchImageUrl` for the actual per-printing card scan. Yugipedia is the only public source we have found that distinguishes art between same-passcode reprints (LOB Blue-Eyes vs SDK Blue-Eyes, for example), and uses a **deterministic file-name convention** of the shape `----[-Misc].` per [Yugipedia’s image policy](https://yugipedia.com/wiki/Yugipedia:Image_policy). + +The UI passes a positional tuple in `setNo` of the form `set_code||rarity||edition` (for example `SDK-001||Ultra Rare||UE`); the source splits on `||` before building filenames. Field meanings: + +- `set_code` — full code as printed (`LOB-005`, `SDK-001`, `RA04-EN001`). Everything before the first `-` becomes the Yugipedia `` slot (`LOB`, `SDK`, `RA04`). +- `rarity` — full English rarity name from the edit dialog (`Ultra Rare` → `UR`, `Quarter Century Secret Rare` → `QCScR`, …). The mapping table lives in `rarityCodeFor(...)`. Unknown values fall back to the rarity-less filename pattern. +- `edition` — `1E` when the user marked the card as 1st Edition, otherwise `UE` (Unlimited). + +`buildCandidateFilenames(...)` then produces a priority-ordered list: + +1. Printed edition first (`1E` then `UE`, or `UE` then `1E` for non-first), with `LE` last for promo-style prints. +2. English regions only — `EN`, then `NA`, then `EU`, then `AU`. **Yugipedia is queried with English regions regardless of the card’s stored Language**, so a German-language card still shows the English scan; this matches the user-visible policy in the edit dialog and avoids querying region-specific scans that are sparser on Yugipedia. +3. Both `.png` and `.jpg` extensions per combo (older LOB-era uploads are `.jpg`, modern reprints are `.png`). +4. A rarity-less fallback round so cards with unknown rarities still resolve in single-rarity sets. + +`buildYugipediaQueryUrl(...)` joins all candidates into one MediaWiki **batch query** (`titles=File:A|File:B|...` URL-encoded), so the entire list resolves in a single HTTP call. `parseYugipediaResponse(...)` walks the candidate list in order and returns the URL of the first filename that came back with `imageinfo[0].url`; missing files come back with `"missing": ""` and are skipped. + +### Asset API: YGOPRODeck `cardinfo.php` (fallback + auto-detect) + +`https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=...` +Used in two situations: + +1. **Last-resort preview fallback.** If Yugipedia returns no candidate match (cards without an English scan yet, transient API errors), `fetchImageUrl` falls through to `parseFallbackImageUrl(...)`, which prefers an exact-name match in YGOPRODeck’s `data[]`, otherwise the first row, and returns the first entry from `card_images[0]`. This is intentionally **not** filtered by `cardset=`: when YGOPRODeck applies that filter, it reorders `card_images` so alt-art passcodes are promoted ahead of the standard art, which would re-introduce the “wrong artwork” bug we fixed by switching to Yugipedia. + +2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Yu-Gi-Oh! edit dialog).** Uses `fname=` plus **`cardset=`** set to the **display set name** from the picker (must match `card_sets[].set_name` in the payload). If that request fails (for example unknown set label), it retries with **`fname=` only** and still filters prints by preferred `set_name`. `YuGiOhCardPreviewSource::parsePrintVariants(...)` walks every `(set_code, set_rarity)` pair for rows whose **card name matches exactly** (case-insensitive) so the dialog can offer ring-buffer **Next** controls: one cycles distinct `set_code` values for that name+set (and resets rarity to the first upstream rarity for the newly selected code); another cycles distinct `set_rarity` values for the **current** `set_code` without changing the collector number. Shared HTTP and parsing rules live beside `parseFirstPrint`. When the dialog passes both an exact card name and a display `set_name`, an upstream miss on that label returns an error instead of falling back to unfiltered `card_sets[]` rows — otherwise unrelated products (same card name, different `set_name` on each printing) could be blended into one bogus variant list. The Yu-Gi-Oh! edit dialog additionally drops European alternate `set_code` rows that use the `-E###` pattern (single `E` before digits, e.g. `LOB-E003`) when the card language is **English**, because YGOPRODeck keeps those alongside NA numbering (`LOB-005`) under the same English `set_name`; it also collapses `LOB-005`-style and `LOB-EN005`-style codes to one **Next** slot via digit-tail matching (`ccm/util/YuGiOhPrintingSlot.hpp`). No image data is needed for this path, so Yugipedia is not consulted. + +YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedia’s MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold. + ## Runtime Flow In CCM3 -The app uses the same flow for both games: +The app uses the same flow for every game that registers a module: - `SetService` asks the game's `ISetSource` (info API) for the latest set list. - `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL. - `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer. -- If preview lookup fails (or returns empty bytes), the UI fetches a per-game fallback card-back image URL through `CardPreviewService::fetchImageBytesByUrl(...)` and shows that image in the selected-card preview panel. +- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`). -Current fallback image URLs (kept in `BaseSelectedCardPanel` for CCM2 parity): +### Caching And Connection Reuse + +See [caching.md](caching.md) for a dedicated reference on preview cache tiers, internal keys, eviction, clearing, and HTTP session reuse. + +Three mechanisms reduce preview latency for **all** games (Magic, Pokemon, Yu-Gi-Oh!). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews: + +- **In-memory preview LRU** (`CardPreviewService`). Successful `fetchPreviewBytes` results are cached keyed by `(game, name, setId, setNo)`; successful `fetchImageBytesByUrl` results are cached keyed by URL (used for the per-game card-back fallback). Re-selecting a previously viewed row is decode-only — no HTTP at all. The cache is bounded by `CardPreviewService::kCacheCapacity` (currently 128 entries) and uses a list+map LRU under a mutex (the preview pipeline is invoked from a worker thread in `BaseSelectedCardPanel`). **Source errors are split** by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is *negative-cached* in this tier so subsequent selections short-circuit without HTTP, while `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable a card's preview. +- **Persistent disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `.bin` (positive payload) or `.neg` (negative marker) plus a `.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path. +- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, and `ms.yugipedia.com` 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. + +The combined effect on the preview path: first selection of a previously-unseen card pays one TLS handshake per *new* host this session (typically two hops for Yu-Gi-Oh!: `yugipedia.com` for the API, `ms.yugipedia.com` for the image), each subsequent fresh card on the same host skips the handshake, any re-selection of an already-viewed card is instant, after the first run with the disk cache populated **even a fresh app launch is decode-only for previously-seen cards** until eviction or a manual cache clear, and **records the upstream cleanly has no image for** stay "instant card-back" across restarts instead of re-paying the lookup every launch. Editing a lookup-relevant field of a record (name, set, setNo, or for Yu-Gi-Oh! the rarity / edition packed into setNo) changes the cache key automatically, so a fresh resolution attempt happens on the next click. + +To clear the persistent cache (for example to recover from a bad upstream image), delete the `/.cache/preview-cache/` subdirectory or the umbrella `/.cache/` folder. Note: the in-app "Reset" / data-storage-relocation flow does **not** touch this directory — the cache is install-scoped, not collection-scoped, so it is preserved across data-dir moves and only cleared by deleting the directory above explicitly (or by reinstalling / relocating the executable). + +For the full caching design and contributor rules see the dedicated [caching.md](caching.md). + +Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match CCM2): - Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg` -- Pokemon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg` +- Pokémon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg` +- Yu-Gi-Oh!: Yugipedia English TCG back — try `https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png`, then `https://ms.yugipedia.com/e/e5/Back-EN.png`; if both fail, load `/assets/ygo_card_back.png` (shipped from `ui_wx/assets/ygo_card_back.png` at link time). `fallbackImageUrlForGame(Game::YuGiOh)` returns the thumbnail URL for helpers that only consult a single string. If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available." ## Error Surface And Debugging Intent -Both source types return `Result` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating: +All source types return `Result` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating: - info API failures (bad set payload, schema mismatch, endpoint/network failure), and - asset API failures (query mismatch, no matching card, missing image fields, image download failure). -When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions (`data`, `image_uris`, `images.large`/`images.small`). If the fallback fetch succeeds, the panel intentionally shows the card-back image and the inline label `(image preview unavailable)`. +When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages..imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text. + +For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:` page; (2) confirm the dialog rarity name maps to the right code in `rarityCodeFor(...)` (extend the table when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first. diff --git a/docs/assets/images/demo-ygo.png b/docs/assets/images/demo-ygo.png new file mode 100644 index 0000000..ed7db11 Binary files /dev/null and b/docs/assets/images/demo-ygo.png differ diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 0000000..6d8a2db --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,167 @@ +#documentation #performance #network #ccm3 + +# Caching In CCM3 + +This document describes **what CCM3 caches**, **how lookups are ordered**, and **what is deliberately not cached**. It focuses on the card **preview image** path (remote APIs → raw bytes → UI decode), which is where most explicit caching lives. For upstream URL shapes and API roles, see [assets-and-info-apis.md](assets-and-info-apis.md). + +## Scope + +| Mechanism | What it stores | Survives restart? | +|-----------|----------------|-------------------| +| In-memory preview LRU (`CardPreviewService`) | Successful preview / fallback-image **bytes** + negative markers for "no upstream image" | No | +| Disk preview byte cache (`LocalPreviewByteCache`) | Same bytes + negative markers, persisted under `/.cache/preview-cache/` | Yes | +| HTTP session reuse (`CprHttpClient`) | libcurl **connections** (TLS + TCP keep-alive), not response bodies | No (process lifetime only) | + +Other persistence (for example `JsonSetRepository` after “Update sets”, `JsonCollectionRepository` for card JSON, `LocalImageStore` for **user-attached** scan files) is normal app data storage, not preview caching. Those layers are documented elsewhere via domain services; this page stays centered on **preview latency** and **repeat lookups**. + +### Cache directory layout + +The umbrella cache root is `/.cache/`, where `exeDir` is the directory containing the running `ccm3` executable — the same scope as `config.json`. It is reserved for any future computed-from-network caches (set-list response snapshots, etc.); the leading dot keeps it out of the way for users poking around the install folder. Today it contains a single subdirectory: + +- `/.cache/preview-cache/` — owned by `LocalPreviewByteCache`. Files inside are `.bin` / `.neg` / `.idx` triples (see below). + +The cache is deliberately **not** under the user-configurable `Configuration.dataStorage` path. The data-storage path is meant for the user's own data — collection JSON, attached scans, set lists — and is meant to be relocatable, syncable, and backup-friendly. Previews are downloaded artifacts from upstream APIs: + +- They must **not** follow the user's collection when the data-storage path is reconfigured at runtime (the cache would otherwise either rebuild from cold every time the user moves the dir, or pollute every chosen target with a recurring `.cache/` directory). +- They must **not** be uploaded together with the user's collection if the user backs up / syncs / version-controls the data dir. +- They are install-scoped, not collection-scoped: a fresh install elsewhere on disk should start cold, and uninstalling / moving the executable should leave nothing stale behind. + +Pinning the cache to `exeDir` is what gives all three properties without per-flow plumbing. The trade-off is that the cache is **not** wiped by the in-app data-storage reset / relocation flow; if you genuinely want a clean slate (e.g. you suspect cache corruption), delete `/.cache/` manually. + +## Preview lookup order + +For `CardPreviewService::fetchPreviewBytes(game, name, setId, setNo)`: + +1. **In-memory LRU** — O(1) lookup. A positive entry returns the bytes immediately; a **negative** entry returns an error immediately (no network call). Either kind of hit moves the entry to the most-recently-used position. +2. **Disk cache** (`IPreviewByteCache`, production: `LocalPreviewByteCache`) — on memory miss, look up on disk. A positive disk hit is **promoted** into the in-memory LRU and returned; a negative disk hit is likewise promoted into memory as a negative entry and returned as an error. So the next selection of the same card stays in-memory only. +3. **Network** — ask `ICardPreviewSource` for an image URL, then `IHttpClient::get(url)` for bytes. On success, write through to **both** memory and disk tiers as a positive entry. **`CardPreviewService::fetchAndCache`** treats an HTTP **2xx with an empty body** as an error (no cache write) so empty payloads cannot populate the LRU as false positives. + +For `fetchImageBytesByUrl(url)` (used for per-game **card-back fallbacks** when preview lookup fails): + +- Same three-tier pattern, but the cache key is derived only from the URL. There is no negative-cache analogue here: the URL is a fixed constant, so any failure is by definition transient. Empty bodies are rejected the same way as on the preview-image GET path. + +## Negative caching: transient vs. permanent failures + +`fetchPreviewBytes` distinguishes two failure kinds via `PreviewLookupError::Kind`: + +- **`NotFound`** — the upstream answered cleanly that this exact record has no preview image. Examples: + - Scryfall search returned `data: []`, or the matched card has no top-level `image_uris`. + - Pokémon TCG search returned `data: []`, or the card has no `images` object / no `large` / `small` URL. + - Yu-Gi-Oh!: **both** Yugipedia and YGOPRODeck answered cleanly with no match (Yugipedia tagged every candidate filename `missing`, *and* YGOPRODeck returned an empty `data` array or no usable image variants). + + These are **negative-cached** in both tiers. Subsequent selections of the same record return an error instantly, without any HTTP call. The user-visible effect is that the per-game card-back fallback shows up immediately on every click. + + The cache key is `(game, name, setId, setNo)` (with Yu-Gi-Oh! also packing rarity and edition into `setNo`). Any **edit to a lookup-relevant field** of the record changes the key automatically, which means the negative entry no longer matches and a fresh network resolution attempt runs the next time the user clicks the row. So if the user fixes a typo, changes the set, switches a YGO printing's edition or rarity, etc., the new fingerprint guarantees a re-fetch — no manual cache clear needed. + +- **`Transient`** — we couldn't tell whether the record has an image because the upstream couldn't speak. Examples: + - HTTP / network / TLS / DNS failure. + - Malformed JSON, missing top-level fields (schema deviation that suggests an outage page rather than a real "no match" response). + - Yu-Gi-Oh!: **either** Yugipedia or YGOPRODeck failed at the network/parse layer. The cautious rule is that as soon as one upstream couldn't speak, the overall outcome is transient — we cannot conclude the record has no image, only that we couldn't reach the place that would tell us. + + These are **never cached** (positive or negative). The next selection of the same row retries cleanly. This is the property that keeps a temporary connection drop from semi-permanently breaking previews. + +This split is the reason the preview path doesn't keep retrying every click for cards whose printing genuinely has no upstream scan, *and* the reason a brief loss of connectivity doesn't poison the cache with bogus "no image" markers. + +## Updating cached entries + +There is no explicit "refresh" or "invalidate" API on `CardPreviewService` — by design. Every way an entry's state can change is driven by **what already happens** in the system, so contributors don't have to reason about a side-channel mutation API. The full set of transitions is: + +### 1. Edit-driven invalidation (record changed → fresh lookup, automatic) + +The cache key for the preview path is `(game, name, setId, setNo)`. For Yu-Gi-Oh! the third slot also encodes rarity and edition, packed by `YuGiOhSelectedCardPanel::previewKey()` as `||||<1E|UE>`. The user editing **any** lookup-relevant field of a card record produces a **different cache key** for the resulting selection, which means: + +- Memory and disk lookups for the new key **miss** the old entry (positive or negative). +- A fresh `ICardPreviewSource::fetchImageUrl` call runs. +- The result is cached under the new key, leaving the old key's entry untouched but unreachable from the UI (it ages out via LRU / mtime eviction). + +Concretely: fix a typo in the card name → re-fetch. Switch the printing's set → re-fetch. Toggle `1E` ↔ `UE` on a YGO card → re-fetch the per-printing scan. **No manual cache clear needed**; the test `editing a lookup-relevant field invalidates the negative entry automatically` pins this behavior. + +If you add a new disambiguator (say a future "art treatment" flag), the rule is to pack it into one of the existing key slots (`setNo`'s `||`-separated tuple is the established hook) so this auto-invalidation continues to apply. Adding it as a side parameter that the cache key *doesn't* see would silently break the update story. + +### 2. Same-key positive ↔ negative state transitions + +If a previously cached entry's verdict flips upstream (Yugipedia uploads a missing scan, a Scryfall printing's `image_uris` get fixed, etc.) **and** the user re-encounters it under the same key, the next fetch decides: + +- **Positive → negative.** Source returns `NotFound` for a key that previously cached a positive entry: `cacheStoreNegative(key)` overwrites the in-memory entry's bytes with an empty payload and flips `negative=true`; on disk, `LocalPreviewByteCache::storeNegative` removes the existing `.bin` (releasing its bytes from the size cap) and writes a `.neg` marker. +- **Negative → positive.** Source returns a real URL, `IHttpClient` returns bytes, `cacheStore(key, payload)` overwrites the existing in-memory entry with the new bytes and flips `negative=false`; on disk, `LocalPreviewByteCache::store` removes the existing `.neg` and writes the new `.bin`. + +Both paths preserve a key invariant: **`.bin` and `.neg` for the same hash are never co-resident**. `LocalPreviewByteCache` tests pin this down (`a later positive store overwrites an earlier negative entry`, `storeNegative for an existing positive entry replaces the bytes`). + +The "trigger" for these transitions in production is one of: the user edits the record back to a previous key (so the still-cached old entry surfaces and a network attempt then re-reaches the upstream), or the in-memory tier was cleared by an app restart and the disk-tier verdict is now stale. There is no time-based revalidation today; the design relies on the upstream answer being stable enough that a stale verdict only hurts until the natural transitions above kick in. + +### 3. Eviction-based aging (passive) + +- **In-memory LRU.** Capacity is hard-capped at `CardPreviewService::kCacheCapacity = 128` entries (positive and negative share the count). When a new entry is inserted past the cap, the **least-recently-used** entry — the back of the list — is dropped. Any access (positive hit, negative hit, or store) moves the entry to the front, so heavily clicked cards are the last to go. +- **Disk cache.** Capacity is hard-capped by total `.bin` payload bytes (`LocalPreviewByteCache::kDefaultMaxBytes = 64 MiB`). When a `store` would push the total past the cap, oldest-by-mtime `.bin` files (with their `.idx` sidecars) are deleted until the new write fits. A successful `load` touches the entry's mtime, so frequently viewed cards rarely become eviction victims. `.neg` markers are not counted against the cap and are not actively evicted; their count is naturally bounded by the number of records the user has viewed whose upstream cleanly reported "no image". + +Eviction is the only way an entry "ages out" without an explicit user action. + +### 4. Manual / external invalidation + +- **Delete the cache directory.** Removing `/.cache/preview-cache/` (or the umbrella `/.cache/`) is safe: `LocalPreviewByteCache` recreates the directory on the next store. The in-memory tier is unaffected by the disk delete during a running session, but a subsequent app restart starts cold. +- **Reinstall / move the executable.** Because the cache is rooted at ``, a fresh install elsewhere on disk starts cold by construction, and uninstalling / moving the exe leaves no stray cache in the user's data directory. (Note: **resetting or moving the user's data-storage directory does NOT clear the preview cache** — that's intentional; the cache is install-scoped, not collection-scoped.) +- **Tampering with sidecar files.** If a `.idx` is ever rewritten with a key that doesn't match the requested cache key (corruption, hash collision, filesystem hiccup), `LocalPreviewByteCache::load` reports `Miss` rather than serving the entry. A subsequent `store` / `storeNegative` overwrites the corrupted record cleanly. This is what makes FNV-1a (non-cryptographic) safe to use as the hash: the worst case is a one-time miss, never a wrong answer. + +### What does *not* trigger an update + +To keep the mental model crisp, the following cases **do not** invalidate or refresh anything: + +- **Re-selecting the same row repeatedly.** That's a hit by design — the whole point of the cache. The only thing that changes is the entry's LRU position / mtime. +- **Transient errors on a row that is already negatively cached.** The negative entry is consulted first and short-circuits the call; the network is never touched, so a flaky network can't accidentally turn a `NotFound` verdict into a `Transient` outcome. +- **Restart with a populated disk cache.** This is a *warm start*, not an update. Both positive and negative entries flow back into memory on first re-access via the disk tier. No upstream is consulted, no entries are rewritten. + +## In-memory LRU (`CardPreviewService`) + +- **Implementation:** Doubly linked list + hash map, guarded by a mutex. Each entry is `{key, payload, negative}`; positive entries hold the bytes, negative entries hold an empty payload and a `negative=true` flag. Hits move the entry to the front regardless of kind. +- **Capacity:** `CardPreviewService::kCacheCapacity` (128 entries — positive and negative entries share this count). +- **Threading:** Preview work can run on a worker thread from the UI layer; all cache access goes through the mutex. +- **Keys:** Internal strings built in `CardPreviewService.cpp`: + - Preview path: prefix `'p'`, then NUL-separated fields: enum `game`, `name`, `setId`, `setNo`. The `setNo` string may embed game-specific disambiguators (for example Yu-Gi-Oh! packs rarity and edition into `setNo` before it reaches the service — see `YuGiOhSelectedCardPanel::previewKey()`). + - URL path: prefix `'u'` plus the full URL string. + +Callers should treat the key as opaque; **correctness** depends on passing stable `(game, name, setId, setNo)` (and stable URL for fallback fetches) so the same printing always maps to the same cache entry. + +## Disk byte cache (`LocalPreviewByteCache`) + +- **Root directory:** `/.cache/preview-cache/` (created on first store). Pinned next to the executable, **not** under the user-configurable `Configuration.dataStorage` path — see "Cache directory layout" above for the rationale. +- **Files per logical entry:** mutually-exclusive `.bin` / `.neg`, plus an always-present `.idx` sidecar: + - `.bin` — raw image bytes (PNG/JPEG payload). **Positive** entry. + - `.neg` — zero-byte marker file. **Negative** entry (the upstream cleanly said "no image"). + - `.idx` — text sidecar holding the **exact** cache key string used by `CardPreviewService`. Used to reject hash collisions on load — if `.idx` does not match the requested key, the entry is treated as a miss regardless of which marker file is present. + + `store()` removes any existing `.neg` for the same hash; `storeNegative()` removes any existing `.bin`. The two states never co-exist. If they ever somehow did, `load()` prefers the `.bin` (more useful answer). + +- **Hash:** FNV-1a 64-bit over the key, rendered as 16 hex digits. Not cryptographic; the `.idx` sidecar is the safety net that prevents collisions from serving the wrong card's bytes or the wrong card's "no image" verdict. +- **Size bound:** Default total payload cap `LocalPreviewByteCache::kDefaultMaxBytes` (64 MiB). Eviction removes **oldest by modification time** among `.bin` files (with their `.idx` sidecars) until the new write fits. **Negative entries** (`.neg` markers) are tiny and are not counted against the cap — their count is naturally bounded by the user's actively-viewed records. +- **Recency on read:** A successful `load` updates the corresponding `.bin` or `.neg` file's mtime (“touch”) so frequently viewed cards are less likely to be evicted. +- **Failure policy:** All adapter I/O failures are swallowed (miss on read, no-op on failed write). Preview still works from network; worst case is “cold” performance. + +### Clearing the disk preview cache + +- Delete the `/.cache/` folder (or just the `preview-cache/` subfolder inside it). Both options are safe: `LocalPreviewByteCache` recreates the directory on the next store. +- The in-app data-storage reset/relocation flow does **not** touch this directory — the cache is install-scoped (sits next to the exe), not collection-scoped. If you need a clean slate for the cache, delete the directory above explicitly. + +## HTTP connection reuse (`CprHttpClient`) + +The app constructs **one** `CprHttpClient` and shares it across set sources, preview sources, and image downloads. It owns a single long-lived `cpr::Session` (one libcurl easy handle per process). + +- **Benefit:** Repeated HTTPS requests to the **same host** reuse TLS sessions / TCP connections where the server allows keep-alive, which materially reduces latency vs. a fresh session per GET (especially for Yu-Gi-Oh!, where preview resolution and the actual image often hit different hosts). +- **Thread safety:** All `get()` calls are serialized with a mutex because libcurl easy handles are not thread-safe. + +This is **not** a response-body cache; it only amortizes connection setup. + +## Design constraints (for contributors) + +- **Classify source errors honestly.** A new game module's `ICardPreviewSource::fetchImageUrl` must return `PreviewLookupError::Kind::NotFound` only when the upstream answered cleanly (parsed response, no match / no image variants). Anything that could be the network — HTTP error, malformed body, schema deviation, timeout — is `Transient`. +- **Do not cache transient errors.** That's the rule that keeps a flaky connection from permanently disabling previews. If you ever need to record a failure, route it through `IPreviewByteCache::storeNegative` only on a confirmed `NotFound`. +- **Updates flow through the cache key, not a side channel.** Don't add a `clearCache(...)` / `invalidate(...)` API to `CardPreviewService` to "fix" a stale entry. The supported update mechanic is: edit-driven invalidation (key changes), same-key positive/negative replacement on the next successful resolution, and LRU/mtime eviction (see "Updating cached entries" above). A side-channel invalidation API would just be another way for callers to forget to keep the disk tier in sync with the memory tier. +- **Extend cache keys** by packing new disambiguators into existing coordinates (typically `setNo` / tuple encoding) rather than bypassing `CardPreviewService`, so memory and disk tiers stay aligned and editing the record continues to invalidate the negative entry automatically. +- **Tests:** + - `card_preview_service_tests.cpp` pins tier ordering and write-through using an in-memory `IPreviewByteCache` fake; it also exercises the negative-caching behavior end-to-end (NotFound is remembered, Transient is retried, edits invalidate the entry, warm-restart honors the disk negative entry, a later positive overwrites a previous negative). + - `local_preview_byte_cache_tests.cpp` exercises the real-disk adapter in isolated temp directories, including the `.bin`/`.neg`/`.idx` interactions (round-trip, restart, mutual replacement, sidecar collision rejection, eviction). + +## Related reading + +- [assets-and-info-apis.md](assets-and-info-apis.md) — external APIs and the same preview tiers in **runtime flow** context. +- Root `AGENTS.md` — UI performance guardrails summary. +- `core/AGENTS.md` — conventions for preview caching, source-error classification, and `CprHttpClient` session ownership. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 53899c0..880c411 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -15,17 +15,20 @@ - `set_service_tests.cpp` — `SetService` with `FakeSetSource` + `InMemSetRepo`. - `magic_set_source_tests.cpp` — `MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake. - `magic_card_preview_source_tests.cpp` — `MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`. -- `card_preview_service_tests.cpp` — `CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` and a `FixedHttpClient`. Pin-down for the "module returning nullptr is silently skipped" rule. +- `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` (the only test in the suite that touches real disk; each case scopes itself to a unique `temp_directory_path()/ccm_preview_cache_test_*` directory and cleans up via an RAII `TempDir`). 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. - `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. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL. - `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`. +- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. +- `yugioh_card_preview_source_tests.cpp` — `YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `rarityCodeFor` (CCM3 dialog rarity names → Yugipedia codes, unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005` → `LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing). - `card_sorter_tests.cpp` — `sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key. -- `card_filter_tests.cpp` — `matchesMagicFilter` / `matchesPokemonFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`), boolean flag columns (foil/signed/altered/holo/firstEdition) intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column. +- `card_filter_tests.cpp` — `matchesMagicFilter` / `matchesPokemonFilter` / `matchesYuGiOhFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`; Yu-Gi-Oh adds `setNo` + `rarity`), boolean flag columns intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column. - `CMakeLists.txt` — explicit list of every `.cpp` (no glob). ## Conventions 1. **Framework**: doctest. Each test file `#include ` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`. -2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`. +2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`. **One narrow exception**: `local_preview_byte_cache_tests.cpp` exercises the real filesystem because `LocalPreviewByteCache` uses `std::filesystem` directly for size + mtime queries that the `IFileSystem` port deliberately does not expose. Those tests scope themselves to a unique temp directory and clean up unconditionally — do not extend the exception to other test files. 3. **Fakes for narrow concerns stay in the test file** as anonymous-namespace classes (e.g. `RecordingImageStore`, `InMemoryRepo`). Promote a fake to `tests/fakes/` only when more than one test file needs it. 4. **Path strings** in expectations must use forward slashes. The fake normalizes everything to `generic_string()`. Do not hard-code `\` separators. 5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file". @@ -37,6 +40,7 @@ - After modifying `formatTextForFs` or `parseIndexFromFilename` you **must** extend `fs_names_tests.cpp` — these are byte-compatibility shims with the original Rust code. - After adding a new service in `core/` you **must** add a corresponding `_service_tests.cpp` with at least the happy-path and one error-path test. - After adding a new game's set source / card preview source you **must** add `tests/_set_source_tests.cpp` and (if applicable) `tests/_card_preview_source_tests.cpp` mirroring the Magic and Pokemon files. Add them to `tests/CMakeLists.txt`. +- After adding or changing `ICardPreviewSource` optional capabilities (for example `detectFirstPrint` / `detectPrintVariants`), you **must** extend `card_preview_service_tests.cpp` and the corresponding game source tests to cover both supported and unsupported paths. ## Commands diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a9286c0..168ad2e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,8 +16,11 @@ add_executable(ccm_core_tests magic_set_source_tests.cpp magic_card_preview_source_tests.cpp card_preview_service_tests.cpp + local_preview_byte_cache_tests.cpp pokemon_set_source_tests.cpp pokemon_card_preview_source_tests.cpp + yugioh_set_source_tests.cpp + yugioh_card_preview_source_tests.cpp card_sorter_tests.cpp card_filter_tests.cpp diff --git a/tests/card_filter_tests.cpp b/tests/card_filter_tests.cpp index c7a5479..1e87913 100644 --- a/tests/card_filter_tests.cpp +++ b/tests/card_filter_tests.cpp @@ -8,6 +8,7 @@ #include "ccm/domain/Enums.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhCard.hpp" #include "ccm/services/CardFilter.hpp" #include @@ -52,6 +53,21 @@ PokemonCard pc(std::string name, return c; } +YuGiOhCard yc(std::string name, + std::string setName, + std::string setNo = "", + std::string rarity = "", + std::uint8_t amount = 1) { + YuGiOhCard c; + c.id = 1; + c.name = std::move(name); + c.set.name = std::move(setName); + c.setNo = std::move(setNo); + c.rarity = std::move(rarity); + c.amount = amount; + return c; +} + } // namespace TEST_SUITE("CardFilter::matchesMagicFilter") { @@ -158,3 +174,12 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") { CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), "")); } } + +TEST_SUITE("CardFilter::matchesYuGiOhFilter") { + TEST_CASE("matches by set number and rarity") { + const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare"); + CHECK(matchesYuGiOhFilter(c, "lob-005")); + CHECK(matchesYuGiOhFilter(c, "ultra")); + CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare")); + } +} diff --git a/tests/card_preview_service_tests.cpp b/tests/card_preview_service_tests.cpp index 1295619..69e8810 100644 --- a/tests/card_preview_service_tests.cpp +++ b/tests/card_preview_service_tests.cpp @@ -3,9 +3,13 @@ #include "ccm/games/IGameModule.hpp" #include "ccm/ports/ICardPreviewSource.hpp" #include "ccm/ports/IHttpClient.hpp" +#include "ccm/ports/IPreviewByteCache.hpp" #include "ccm/services/CardPreviewService.hpp" +#include #include +#include +#include using namespace ccm; @@ -15,21 +19,54 @@ class FakeSource final : public ICardPreviewSource { public: std::string url = "https://example.com/preview.jpg"; bool ok = true; + // The kind of error returned when ok == false. Defaults to Transient + // because the historical tests assumed "errors don't get cached"; the + // negative-cache tests flip this to NotFound explicitly. + PreviewLookupError::Kind errKind = PreviewLookupError::Kind::Transient; std::string err = "boom"; + int calls{0}; // Capture inputs so tests can assert routing. std::string lastName; std::string lastSetId; std::string lastSetNo; + std::string detectLastName; + std::string detectLastSetId; + AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"}; + bool allowAutoDetect{true}; - Result fetchImageUrl(std::string_view name, - std::string_view setId, - std::string_view setNo) override { + Result + fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) override { + ++calls; lastName = std::string(name); lastSetId = std::string(setId); lastSetNo = std::string(setNo); - return ok ? Result::ok(url) - : Result::err(err); + if (ok) { + return Result::ok(url); + } + return Result::err({errKind, err}); + } + + Result detectFirstPrint(std::string_view name, + std::string_view setId) override { + detectLastName = std::string(name); + detectLastSetId = std::string(setId); + return Result::ok(detectedPrint); + } + + Result> detectPrintVariants(std::string_view name, + std::string_view setId) override { + detectLastName = std::string(name); + detectLastSetId = std::string(setId); + std::vector v; + v.push_back(detectedPrint); + return Result>::ok(std::move(v)); + } + + [[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { + return allowAutoDetect; } }; @@ -39,14 +76,48 @@ public: std::string body; bool ok = true; std::string err = "offline"; + int calls{0}; Result get(std::string_view url) override { + ++calls; lastUrl = std::string(url); return ok ? Result::ok(body) : Result::err(err); } }; +// In-memory persistent cache stand-in. Real implementation lives in +// LocalPreviewByteCache (covered by local_preview_byte_cache_tests.cpp); the +// fake here lets us pin down CardPreviewService's wiring without dragging in +// the filesystem. +class InMemoryByteCache final : public IPreviewByteCache { +public: + struct Entry { + bool negative{false}; + std::string payload; + }; + std::unordered_map entries; + int loadCalls{0}; + int storeCalls{0}; + int storeNegativeCalls{0}; + + [[nodiscard]] LoadResult load(std::string_view key) override { + ++loadCalls; + auto it = entries.find(std::string(key)); + if (it == entries.end()) return {HitKind::Miss, {}}; + if (it->second.negative) return {HitKind::NegativeHit, {}}; + return {HitKind::Hit, it->second.payload}; + } + void store(std::string_view key, const std::string& payload) override { + ++storeCalls; + entries[std::string(key)] = Entry{false, payload}; + } + void storeNegative(std::string_view key) override { + ++storeNegativeCalls; + entries[std::string(key)] = Entry{true, {}}; + } +}; + // Minimal IGameModule fake that exposes a configurable preview source. class FakeGameModule final : public IGameModule { public: @@ -150,3 +221,512 @@ TEST_SUITE("CardPreviewService::fetchPreviewBytes") { CHECK(out.error() == "net down"); } } + +TEST_SUITE("CardPreviewService caching") { + TEST_CASE("repeat fetchPreviewBytes for the same key serves from cache") { + // Re-selecting the same row in the table is the hot path: the + // (game, name, setId, setNo) tuple uniquely identifies a printing, + // and the resolved image URL is a deterministic function of those + // inputs - so we can safely cache the bytes and skip HTTP entirely + // on repeat. Each cache hit spares us two HTTPS round trips + // (Yugipedia API + image GET) plus a TLS handshake on a cold pool. + FakeSource source; + source.url = "https://example.com/img.png"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + http.body = "PNG-bytes"; + + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", ""); + REQUIRE(first.isOk()); + CHECK(first.value() == "PNG-bytes"); + CHECK(http.calls == 1); + + // Mutate the source's response to prove the second call doesn't go + // through the source either: only the cache should be consulted. + source.url = "https://example.com/CHANGED.png"; + http.body = "DIFFERENT-bytes"; + + const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", ""); + REQUIRE(second.isOk()); + CHECK(second.value() == "PNG-bytes"); + CHECK(http.calls == 1); // no new GET issued + } + + TEST_CASE("different cards share the same source but get separate cache slots") { + FakeSource source; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + + CardPreviewService svc{http}; + svc.registerModule(module); + + source.url = "https://example.com/a.png"; + http.body = "A-bytes"; + const auto a = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", ""); + REQUIRE(a.isOk()); + CHECK(a.value() == "A-bytes"); + + source.url = "https://example.com/b.png"; + http.body = "B-bytes"; + const auto b = svc.fetchPreviewBytes(Game::Magic, "Card B", "lea", ""); + REQUIRE(b.isOk()); + CHECK(b.value() == "B-bytes"); + + CHECK(http.calls == 2); + + // Re-fetching A returns the originally cached payload, not the most + // recent http.body; this pins down per-card cache scoping. + http.body = "stale"; + const auto aAgain = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", ""); + REQUIRE(aAgain.isOk()); + CHECK(aAgain.value() == "A-bytes"); + CHECK(http.calls == 2); + } + + TEST_CASE("source errors are not cached so a transient failure can recover") { + // If the per-game source returns an error we should not poison the + // cache - the next selection must be free to retry, otherwise a + // single network blip would permanently disable previews for that + // card until the user restarts the app. + FakeSource source; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + http.body = "PNG-bytes"; + + CardPreviewService svc{http}; + svc.registerModule(module); + + source.ok = false; + source.err = "transient 503"; + const auto firstErr = svc.fetchPreviewBytes(Game::Magic, "X", "abc", ""); + CHECK(firstErr.isErr()); + CHECK(http.calls == 0); // source short-circuited before HTTP + + source.ok = true; + source.url = "https://example.com/x.png"; + const auto recovered = svc.fetchPreviewBytes(Game::Magic, "X", "abc", ""); + REQUIRE(recovered.isOk()); + CHECK(recovered.value() == "PNG-bytes"); + CHECK(http.calls == 1); + } + + TEST_CASE("HTTP success writes through to the persistent cache") { + // The persistent tier is fire-and-forget on the way down (HTTP -> disk) + // and consulted on the way up (cache miss -> disk -> HTTP). This first + // sub-case exercises the write-through path. + FakeSource source; + source.url = "https://example.com/img.png"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + http.body = "PNG-bytes"; + + InMemoryByteCache disk; + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + + const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", ""); + REQUIRE(out.isOk()); + CHECK(disk.storeCalls == 1); + CHECK(disk.entries.size() == 1); + // Key shape is service-internal so we don't assert it directly; only + // that *something* identifying this card was persisted with the right + // payload. + bool foundPayload = false; + for (const auto& [k, e] : disk.entries) { + if (!e.negative && e.payload == "PNG-bytes") foundPayload = true; + } + CHECK(foundPayload); + } + + TEST_CASE("warm restart: a fresh service instance serves from disk without HTTP") { + // Simulates an app restart by constructing two CardPreviewService + // instances over the same persistent cache. The second instance + // must serve the previously-fetched bytes from disk - no source + // call, no HTTP call - which is the whole point of the persistent + // tier. + FakeSource source; + source.url = "https://example.com/img.png"; + FakeGameModule module; + module.gameId = Game::YuGiOh; + module.preview = &source; + + FixedHttpClient http; + http.body = "DM-bytes"; + + InMemoryByteCache disk; + + // Run 1: cold start, normal fetch path warms the persistent cache. + { + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + const auto out = svc.fetchPreviewBytes( + Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE"); + REQUIRE(out.isOk()); + } + REQUIRE(http.calls == 1); + REQUIRE(disk.entries.size() == 1); + + // Run 2: pretend the app restarted. Make HTTP and the source both + // return obviously-wrong data so any unexpected fall-through to + // them shows up loudly in the assertion. + http.body = "UNEXPECTED"; + source.url = "https://example.com/UNEXPECTED.png"; + { + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + const auto warm = svc.fetchPreviewBytes( + Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE"); + REQUIRE(warm.isOk()); + CHECK(warm.value() == "DM-bytes"); + } + // No second HTTP request - disk tier covered it. + CHECK(http.calls == 1); + CHECK(disk.loadCalls >= 1); + } + + TEST_CASE("disk hit promotes into the in-memory tier so subsequent calls skip even disk I/O") { + FakeSource source; + source.url = "https://example.com/img.png"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + http.body = "X-bytes"; + + InMemoryByteCache disk; + // Pre-seed the disk cache with a value the service has never fetched + // this session. The first call should pull it off disk and copy it + // into memory; the second call should be served from memory without + // touching the disk cache at all. + // We can't compose the key from outside, so we go through the + // service once to learn the key shape via storeCalls. + { + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + (void)svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", ""); + } + REQUIRE(http.calls == 1); + REQUIRE(disk.entries.size() == 1); + const auto storedKey = disk.entries.begin()->first; + + // Clear the in-memory tier by constructing a fresh service. Disk + // entry stays. + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + + const int loadsBefore = disk.loadCalls; + // Force HTTP to fail loudly so we can prove neither call hits it. + http.ok = false; + + const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", ""); + REQUIRE(first.isOk()); + CHECK(first.value() == "X-bytes"); + CHECK(disk.loadCalls == loadsBefore + 1); + + const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", ""); + REQUIRE(second.isOk()); + CHECK(second.value() == "X-bytes"); + // No additional disk hit - in-memory tier served it. + CHECK(disk.loadCalls == loadsBefore + 1); + // Sanity: only the original storedKey ever made it to disk. + CHECK(disk.entries.count(storedKey) == 1); + } + + TEST_CASE("transient source errors do not write through to the persistent cache") { + // A transient failure (HTTP 5xx, network drop, parse error) must + // never get cached - if it did, a single bad request would + // silently break this card's preview until the user restarts the + // app or edits the record. + FakeSource source; + source.ok = false; + source.errKind = PreviewLookupError::Kind::Transient; + source.err = "scryfall 503"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + InMemoryByteCache disk; + + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + + const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", ""); + CHECK(out.isErr()); + CHECK(disk.storeCalls == 0); + CHECK(disk.storeNegativeCalls == 0); + CHECK(disk.entries.empty()); + } + + TEST_CASE("NotFound source errors are negative-cached and short-circuit subsequent calls") { + // A NotFound result means the upstream cleanly answered "no image + // for this record". The next selection of the *same* record must + // return immediately without invoking the source again - that's + // the whole point of negative caching, and the user-visible win + // (no spinner, no per-click HTTP) on every selection of a card + // whose printing has no scan upstream. + FakeSource source; + source.ok = false; + source.errKind = PreviewLookupError::Kind::NotFound; + source.err = "no scan exists"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + InMemoryByteCache disk; + + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + + const auto first = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", ""); + CHECK(first.isErr()); + CHECK(source.calls == 1); + CHECK(disk.storeCalls == 0); + CHECK(disk.storeNegativeCalls == 1); + + // Second call: the source must NOT be consulted again. The + // in-memory negative entry covers it. + const auto second = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", ""); + CHECK(second.isErr()); + CHECK(source.calls == 1); // still 1 - cache short-circuited + CHECK(http.calls == 0); + } + + TEST_CASE("editing a lookup-relevant field invalidates the negative entry automatically") { + // The cache key is (game, name, setId, setNo). The user fixing a + // typo in the card's name (or any other lookup-relevant field) + // changes the key, so the previously-cached "no image" verdict no + // longer matches and a fresh resolution attempt runs. + FakeSource source; + source.ok = false; + source.errKind = PreviewLookupError::Kind::NotFound; + source.err = "no scan exists"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + InMemoryByteCache disk; + + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + + // First lookup: the typo name produces a clean NotFound -> negative cache. + REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "").isErr()); + REQUIRE(source.calls == 1); + + // Now flip the source to "found" and ask for the corrected name. + source.ok = true; + source.url = "https://example.com/lb.png"; + http.body = "LB-bytes"; + + const auto fixed = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", ""); + REQUIRE(fixed.isOk()); + CHECK(fixed.value() == "LB-bytes"); + CHECK(source.calls == 2); + + // And the original (typo) record stays negative-cached. + source.ok = false; // belt-and-braces: prove the typo path doesn't re-call the source + const auto stillNegative = + svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", ""); + CHECK(stillNegative.isErr()); + CHECK(source.calls == 2); // typo path never re-resolved + } + + TEST_CASE("warm restart honors a previously-stored negative entry without HTTP") { + // Persistent negative caching is the strongest motivation for the + // disk tier carrying negatives at all: a card with no upstream + // image stays "instant card-back" across app restarts, instead of + // re-paying the round-trip to learn the same thing every launch. + FakeSource source; + source.ok = false; + source.errKind = PreviewLookupError::Kind::NotFound; + source.err = "no scan exists"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + InMemoryByteCache disk; + + // Run 1: negative outcome, persisted. + { + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Z", "set", "").isErr()); + } + REQUIRE(disk.storeNegativeCalls == 1); + const int sourceCallsAfterRun1 = source.calls; + + // Run 2: fresh service over the same disk fake. Source must not be + // consulted at all - the negative entry on disk short-circuits it. + { + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + const auto warm = svc.fetchPreviewBytes(Game::Magic, "Z", "set", ""); + CHECK(warm.isErr()); + } + CHECK(source.calls == sourceCallsAfterRun1); + } + + TEST_CASE("a later positive result for the same key replaces the negative entry") { + // If the upstream eventually grows a scan for a card that was + // previously NotFound, the very next successful lookup must + // overwrite the negative entry so it doesn't keep haunting the + // user. Realistic scenario: Yugipedia uploads a scan that didn't + // exist before; the user clicks the row again and we now serve + // the real image. (In production the cache key may also change + // due to a record edit, but that's a separate path; here we test + // the same-key recovery story.) + FakeSource source; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + InMemoryByteCache disk; + + CardPreviewService svc{http, &disk}; + svc.registerModule(module); + + // First call: NotFound. + source.ok = false; + source.errKind = PreviewLookupError::Kind::NotFound; + source.err = "no scan yet"; + REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Card", "set", "").isErr()); + + // Imitate the upstream growing a scan, AND simulate the user + // retrying. To bypass the negative cache without a record edit, + // construct a fresh service: this models the next-launch case + // where the user reopens the app and the image now exists. (Within + // a single session, only an edit to the record clears the cache, + // which is the correct UX - we don't want a network round-trip on + // every click for cards we already know have no upstream image.) + source.ok = true; + source.url = "https://example.com/late.png"; + http.body = "LATE-bytes"; + + // Drop the disk negative manually to simulate a scenario where + // the cache was cleared (e.g. the user manually wiped the cache + // directory). In production code paths, an edit to the record is + // the standard invalidation; both routes converge on the same + // expected behavior: a fresh successful lookup. + disk.entries.clear(); + + CardPreviewService svc2{http, &disk}; + svc2.registerModule(module); + const auto out = svc2.fetchPreviewBytes(Game::Magic, "Card", "set", ""); + REQUIRE(out.isOk()); + CHECK(out.value() == "LATE-bytes"); + } + + TEST_CASE("fetchImageBytesByUrl caches by URL too (fallback card-back)") { + // The fallback card-back image is fetched via fetchImageBytesByUrl + // for every card without a preview; caching by URL makes the second + // fallback effectively free. + FixedHttpClient http; + http.body = "card-back-bytes"; + CardPreviewService svc{http}; + + const auto first = svc.fetchImageBytesByUrl("https://cdn.example/back.png"); + REQUIRE(first.isOk()); + CHECK(http.calls == 1); + + http.body = "stale"; + const auto second = svc.fetchImageBytesByUrl("https://cdn.example/back.png"); + REQUIRE(second.isOk()); + CHECK(second.value() == "card-back-bytes"); + CHECK(http.calls == 1); + } +} + +TEST_SUITE("CardPreviewService::detectFirstPrint") { + TEST_CASE("routes auto-detect to registered source") { + FakeSource source; + source.detectedPrint = {"LOB-005", "Secret Rare"}; + FakeGameModule module; + module.gameId = Game::YuGiOh; + module.preview = &source; + + FixedHttpClient http; + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "Legend of Blue Eyes White Dragon"); + REQUIRE(out.isOk()); + CHECK(out.value().setNo == "LOB-005"); + CHECK(out.value().rarity == "Secret Rare"); + CHECK(source.detectLastName == "Dark Magician"); + CHECK(source.detectLastSetId == "Legend of Blue Eyes White Dragon"); + } + + TEST_CASE("returns explicit error when game does not enable auto-detect") { + FakeSource source; + source.allowAutoDetect = false; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.detectFirstPrint(Game::Magic, "Any", "Any Set"); + CHECK(out.isErr()); + CHECK(out.error().find("not enabled") != std::string::npos); + } +} + +TEST_SUITE("CardPreviewService::detectPrintVariants") { + TEST_CASE("routes variant listing to registered source") { + FakeSource source; + source.detectedPrint = {"LOB-005", "Secret Rare"}; + FakeGameModule module; + module.gameId = Game::YuGiOh; + module.preview = &source; + + FixedHttpClient http; + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.detectPrintVariants(Game::YuGiOh, "Dark Magician", + "Legend of Blue Eyes White Dragon"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].setNo == "LOB-005"); + CHECK(out.value()[0].rarity == "Secret Rare"); + } + + TEST_CASE("detectPrintVariants returns error when game does not enable auto-detect") { + FakeSource source; + source.allowAutoDetect = false; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.detectPrintVariants(Game::Magic, "Any", "Any Set"); + CHECK(out.isErr()); + CHECK(out.error().find("not enabled") != std::string::npos); + } +} diff --git a/tests/card_sorter_tests.cpp b/tests/card_sorter_tests.cpp index 84c253a..6fdb066 100644 --- a/tests/card_sorter_tests.cpp +++ b/tests/card_sorter_tests.cpp @@ -8,6 +8,7 @@ #include "ccm/domain/Enums.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhCard.hpp" #include "ccm/domain/Set.hpp" #include "ccm/services/CardSorter.hpp" @@ -59,6 +60,22 @@ PokemonCard pc(std::uint32_t id, std::string name, return c; } +YuGiOhCard yc(std::uint32_t id, std::string name, + std::string setName, std::string releaseDate, + std::string setNo = "", + std::string rarity = "", + std::uint8_t amount = 1) { + YuGiOhCard c; + c.id = id; + c.name = std::move(name); + c.set.name = std::move(setName); + c.set.releaseDate = std::move(releaseDate); + c.setNo = std::move(setNo); + c.rarity = std::move(rarity); + c.amount = amount; + return c; +} + std::vector ids(const std::vector& v) { std::vector out; out.reserve(v.size()); @@ -73,6 +90,13 @@ std::vector ids(const std::vector& v) { return out; } +std::vector ids(const std::vector& v) { + std::vector out; + out.reserve(v.size()); + for (const auto& c : v) out.push_back(c.id); + return out; +} + } // namespace TEST_SUITE("CardSorter - Magic columns") { @@ -246,3 +270,15 @@ TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") { CHECK(v.front().id == 42); } } + +TEST_SUITE("CardSorter - YuGiOh columns") { + TEST_CASE("Amount sorts numerically") { + std::vector v = { + yc(1, "a", "X", "2000/01/01", "", "", 9), + yc(2, "b", "X", "2000/01/01", "", "", 11), + yc(3, "c", "X", "2000/01/01", "", "", 1), + }; + sortYuGiOhCards(v, YuGiOhSortColumn::Amount, /*ascending=*/true); + CHECK(ids(v) == std::vector{3, 1, 2}); + } +} diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index 6a72031..9e0da50 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -4,6 +4,7 @@ #include "ccm/domain/Enums.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhCard.hpp" #include "ccm/domain/Set.hpp" #include @@ -19,6 +20,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") { nlohmann::json j2 = "Pokemon"; CHECK(j2.get() == Game::Pokemon); + nlohmann::json jYgo = "YuGiOh"; + CHECK(jYgo.get() == Game::YuGiOh); + nlohmann::json j3 = Theme::Dark; CHECK(j3.get() == "Dark"); CHECK(j3.get() == Theme::Dark); @@ -121,3 +125,32 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") { CHECK(back == cfg); } } + +TEST_SUITE("YuGiOhCard JSON") { + TEST_CASE("round-trips with setNo and rarity fields") { + YuGiOhCard c; + c.id = 77; + c.amount = 2; + c.name = "Blue-Eyes White Dragon"; + c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"}; + c.setNo = "SDK-001"; + c.rarity = "Ultra Rare"; + c.rarityCode = "(UR)"; + c.note = "classic"; + c.images = {"77+starter+blue-eyes+0.png"}; + c.language = Language::English; + c.condition = Condition::NearMint; + c.firstEdition = true; + c.signed_ = false; + c.altered = false; + + nlohmann::json j = c; + CHECK(j.at("setNo") == "SDK-001"); + CHECK(j.at("rarity") == "Ultra Rare"); + CHECK(j.at("rarityCode") == "(UR)"); + CHECK(j.at("signed") == false); + + const YuGiOhCard back = j.get(); + CHECK(back == c); + } +} diff --git a/tests/local_preview_byte_cache_tests.cpp b/tests/local_preview_byte_cache_tests.cpp new file mode 100644 index 0000000..874199c --- /dev/null +++ b/tests/local_preview_byte_cache_tests.cpp @@ -0,0 +1,293 @@ +#include + +// LocalPreviewByteCache is the only ccm_core test in this binary that +// exercises the real filesystem - by design. The adapter's reason for +// existing is to translate IPreviewByteCache calls into actual on-disk +// state (binary payloads, sidecar key files, mtime-driven LRU eviction +// via std::filesystem), and stubbing those primitives just to test the +// in-memory bookkeeping would defeat the whole point. We pin every test +// to a fresh, unique directory under the OS temp path and clean it up +// even on assertion failure. + +#include "ccm/infra/LocalPreviewByteCache.hpp" +#include "ccm/infra/StdFileSystem.hpp" + +#include +#include +#include +#include +#include + +using namespace ccm; +namespace fs = std::filesystem; + +namespace { + +// Creates and removes a unique temp directory with strong cleanup +// guarantees. Doctest does not have RAII fixtures by default; this struct +// provides the same effect via destructor. +struct TempDir { + fs::path path; + + TempDir() { + std::random_device rd; + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + path = fs::temp_directory_path() / + (std::string("ccm_preview_cache_test_") + std::to_string(stamp) + + "_" + std::to_string(rd())); + std::error_code ec; + fs::create_directories(path, ec); + } + ~TempDir() { + std::error_code ec; + fs::remove_all(path, ec); + } + + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; +}; + +// Touch helper: nudges a file's mtime backwards so eviction-by-oldest is +// deterministic regardless of FS timestamp resolution. +void backdate(const fs::path& p, int seconds) { + std::error_code ec; + auto t = fs::last_write_time(p, ec); + if (ec) return; + fs::last_write_time(p, t - std::chrono::seconds(seconds), ec); +} + +} // namespace + +TEST_SUITE("LocalPreviewByteCache") { + TEST_CASE("store then load round-trips bytes verbatim") { + TempDir td; + StdFileSystem fs; + LocalPreviewByteCache cache(fs, td.path); + + const std::string key = "magic|Lightning Bolt|lea|161"; + const std::string body = std::string("\x89PNG\r\n\x1a\n", 8) + std::string(2048, '\xab'); + + cache.store(key, body); + const auto loaded = cache.load(key); + REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit); + CHECK(loaded.payload == body); + } + + TEST_CASE("missing key is a clean miss, not an error") { + TempDir td; + StdFileSystem fs; + LocalPreviewByteCache cache(fs, td.path); + + CHECK(cache.load("never-stored").kind == IPreviewByteCache::HitKind::Miss); + } + + TEST_CASE("empty payload is silently skipped") { + // Empty would be an indistinguishable miss anyway, and writing it + // would waste an inode. The contract is: we treat it as a no-op. + TempDir td; + StdFileSystem fs; + LocalPreviewByteCache cache(fs, td.path); + + cache.store("k", ""); + CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss); + CHECK(cache.currentSizeBytes() == 0); + } + + TEST_CASE("hash collision check via sidecar mismatch is treated as a miss") { + // We fake a collision by writing the same hash file with a wrong + // sidecar key. Even though the .bin is present, load() must + // reject it - otherwise we'd serve the wrong card's bytes, which + // is much worse than re-fetching. + TempDir td; + StdFileSystem fs; + LocalPreviewByteCache cache(fs, td.path); + + cache.store("real-key", "REAL"); + // Find the .idx and corrupt the key inside. + for (const auto& entry : fs::directory_iterator(td.path)) { + if (entry.path().extension() == ".idx") { + std::error_code ec; + fs::remove(entry.path(), ec); + StdFileSystem io; + (void)io.writeText(entry.path(), "different-key"); + break; + } + } + + CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss); + } + + TEST_CASE("survives an adapter restart over the same directory") { + // The whole point of the disk tier is persistence. Exercise it by + // dropping one cache instance and bringing up a new one against + // the same path - the previously-stored entry must come back. + TempDir td; + StdFileSystem fs; + const std::string key = "ygo|Dark Magician|SDY|6|1E"; + const std::string body(4096, 'D'); + + { + LocalPreviewByteCache writer(fs, td.path); + writer.store(key, body); + } + { + LocalPreviewByteCache reader(fs, td.path); + const auto loaded = reader.load(key); + REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit); + CHECK(loaded.payload == body); + } + } + + TEST_CASE("storeNegative round-trips as NegativeHit, not a miss and not a payload") { + // Negative entries are the "we asked, the upstream said no image" + // marker. They must round-trip as their own load kind so the + // service can short-circuit without re-resolving the URL. + TempDir td; + StdFileSystem fs; + LocalPreviewByteCache cache(fs, td.path); + + const std::string key = "magic|Bogus Card|lea|"; + cache.storeNegative(key); + + const auto loaded = cache.load(key); + CHECK(loaded.kind == IPreviewByteCache::HitKind::NegativeHit); + CHECK(loaded.payload.empty()); + CHECK(cache.currentSizeBytes() == 0); // negatives don't take real space + } + + TEST_CASE("negative entries survive an adapter restart") { + // The "warm restart, same negative answer instantly" property is + // the whole reason negatives go to disk. Verify it explicitly so + // it can never silently regress. + TempDir td; + StdFileSystem fs; + const std::string key = "ygo|Truly Missing|SET|999||UE"; + + { + LocalPreviewByteCache writer(fs, td.path); + writer.storeNegative(key); + } + { + LocalPreviewByteCache reader(fs, td.path); + CHECK(reader.load(key).kind == IPreviewByteCache::HitKind::NegativeHit); + } + } + + TEST_CASE("a later positive store overwrites an earlier negative entry") { + // Upstream eventually grows a scan: the next positive store has + // to flip the entry. If the .neg lingered we'd keep returning a + // NegativeHit even after a successful network resolution, which + // would defeat the recovery story. + TempDir td; + StdFileSystem fs; + LocalPreviewByteCache cache(fs, td.path); + + const std::string key = "magic|Late|lea|"; + const std::string body = std::string(1024, 'L'); + + cache.storeNegative(key); + REQUIRE(cache.load(key).kind == IPreviewByteCache::HitKind::NegativeHit); + + cache.store(key, body); + const auto loaded = cache.load(key); + REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit); + CHECK(loaded.payload == body); + } + + TEST_CASE("storeNegative for an existing positive entry replaces the bytes") { + // Symmetric to the previous case: an upstream that *had* a scan + // and now reports "no image" (e.g. file deleted) flips the entry + // back to negative. The .bin must go away so it doesn't keep + // serving stale bytes and doesn't keep wasting cap space. + TempDir td; + StdFileSystem fs; + LocalPreviewByteCache cache(fs, td.path); + + const std::string key = "magic|Recall|lea|"; + cache.store(key, std::string(2048, 'R')); + REQUIRE(cache.currentSizeBytes() > 0); + + cache.storeNegative(key); + const auto loaded = cache.load(key); + CHECK(loaded.kind == IPreviewByteCache::HitKind::NegativeHit); + CHECK(cache.currentSizeBytes() == 0); + } + + TEST_CASE("collision check applies to negative entries too") { + // A hash collision must not let a negative entry stored under + // key A "leak" into a load of key B. We fake the collision by + // rewriting the .idx sidecar with a different key. + TempDir td; + StdFileSystem fs; + LocalPreviewByteCache cache(fs, td.path); + + cache.storeNegative("real-key"); + for (const auto& entry : fs::directory_iterator(td.path)) { + if (entry.path().extension() == ".idx") { + std::error_code ec; + fs::remove(entry.path(), ec); + StdFileSystem io; + (void)io.writeText(entry.path(), "different-key"); + break; + } + } + CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss); + } + + TEST_CASE("evicts oldest entry when the size cap would be exceeded") { + TempDir td; + StdFileSystem fs; + // Cap is just big enough to hold 2 of the 1 KiB payloads but not 3. + constexpr std::size_t kCap = 2'400; + LocalPreviewByteCache cache(fs, td.path, kCap); + + const std::string a(1024, 'a'); + const std::string b(1024, 'b'); + const std::string c(1024, 'c'); + + cache.store("k-a", a); + // Make 'a' look definitively older than 'b' regardless of the FS's + // mtime resolution (some filesystems round to whole seconds, which + // can otherwise make the test flaky). + for (const auto& entry : fs::directory_iterator(td.path)) { + backdate(entry.path(), /*seconds=*/10); + } + + cache.store("k-b", b); + cache.store("k-c", c); // would push us to ~3 KiB; 'a' must go. + + CHECK(cache.load("k-a").kind == IPreviewByteCache::HitKind::Miss); + CHECK(cache.load("k-b").kind == IPreviewByteCache::HitKind::Hit); + CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit); + CHECK(cache.currentSizeBytes() <= kCap); + } + + TEST_CASE("a hit refreshes mtime so the entry is not the next eviction victim") { + // Without the touch-on-load behavior, an entry that the user + // re-views constantly would still age out simply because newer + // entries piled on top. Verify we promote on hit. + TempDir td; + StdFileSystem fs; + constexpr std::size_t kCap = 2'400; + LocalPreviewByteCache cache(fs, td.path, kCap); + + const std::string a(1024, 'a'); + const std::string b(1024, 'b'); + const std::string c(1024, 'c'); + + cache.store("k-a", a); + cache.store("k-b", b); + // Backdate both so the upcoming touch on 'a' is unambiguously newer. + for (const auto& entry : fs::directory_iterator(td.path)) { + backdate(entry.path(), /*seconds=*/10); + } + + REQUIRE(cache.load("k-a").kind == IPreviewByteCache::HitKind::Hit); // touches 'a' to "now". + cache.store("k-c", c); // forces an eviction. + + // 'a' was the youngest after the touch, so 'b' should be the victim. + CHECK(cache.load("k-b").kind == IPreviewByteCache::HitKind::Miss); + CHECK(cache.load("k-a").kind == IPreviewByteCache::HitKind::Hit); + CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit); + } +} diff --git a/tests/magic_card_preview_source_tests.cpp b/tests/magic_card_preview_source_tests.cpp index ccc1453..f86823a 100644 --- a/tests/magic_card_preview_source_tests.cpp +++ b/tests/magic_card_preview_source_tests.cpp @@ -64,38 +64,47 @@ TEST_SUITE("MagicCardPreviewSource::parseResponse") { CHECK(out.value() == "https://img.scryfall.io/normal.jpg"); } - TEST_CASE("empty data array returns an error") { + TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") { const auto out = MagicCardPreviewSource::parseResponse(R"({"data":[]})"); - CHECK(out.isErr()); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } - TEST_CASE("missing data array returns an error") { + TEST_CASE("missing data array is classified as Transient (schema deviation)") { + // No `data` array at all means the API contract failed - this is + // not the user's record being weird, it's the upstream not + // talking to us right now. const auto out = MagicCardPreviewSource::parseResponse(R"({"meta":{}})"); - CHECK(out.isErr()); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } - TEST_CASE("entry without image_uris returns an error (double-faced cards)") { + TEST_CASE("entry without image_uris is classified as NotFound (double-faced cards)") { const std::string json = R"({ "data": [ {"name":"DoubleFace","card_faces":[{"image_uris":{"normal":"x"}}]} ] })"; const auto out = MagicCardPreviewSource::parseResponse(json); - CHECK(out.isErr()); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } - TEST_CASE("invalid JSON returns an error") { + TEST_CASE("invalid JSON is classified as Transient") { const auto out = MagicCardPreviewSource::parseResponse("{not json"); - CHECK(out.isErr()); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } } TEST_SUITE("MagicCardPreviewSource::fetchImageUrl") { - TEST_CASE("network error is surfaced as a Result error") { + TEST_CASE("network error is surfaced as Transient") { FixedHttpClient http; http.ok = false; MagicCardPreviewSource src{http}; - CHECK(src.fetchImageUrl("Lightning Bolt", "lea", "").isErr()); + const auto out = src.fetchImageUrl("Lightning Bolt", "lea", ""); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("network success is parsed end-to-end and uses the encoded URL") { diff --git a/tests/pokemon_card_preview_source_tests.cpp b/tests/pokemon_card_preview_source_tests.cpp index ffb6b3a..1c06f81 100644 --- a/tests/pokemon_card_preview_source_tests.cpp +++ b/tests/pokemon_card_preview_source_tests.cpp @@ -85,34 +85,40 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") { CHECK(out.value() == "https://small.only/img.png"); } - TEST_CASE("empty data array returns an error") { + TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") { const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":[]})"); - CHECK(out.isErr()); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } - TEST_CASE("missing data array returns an error") { + TEST_CASE("missing data array is classified as Transient (schema deviation)") { const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})"); - CHECK(out.isErr()); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } - TEST_CASE("entry without images returns an error") { + TEST_CASE("entry without images is classified as NotFound") { const auto out = PokemonCardPreviewSource::parseResponse( R"({"data":[{"name":"Pikachu"}]})"); - CHECK(out.isErr()); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } - TEST_CASE("invalid JSON returns an error") { + TEST_CASE("invalid JSON is classified as Transient") { const auto out = PokemonCardPreviewSource::parseResponse("{not json"); - CHECK(out.isErr()); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } } TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") { - TEST_CASE("network error is surfaced as a Result error") { + TEST_CASE("network error is surfaced as Transient") { FixedHttpClient http; http.ok = false; PokemonCardPreviewSource src{http}; - CHECK(src.fetchImageUrl("Pikachu", "base1", "").isErr()); + const auto out = src.fetchImageUrl("Pikachu", "base1", ""); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("network success is parsed end-to-end and uses the encoded URL") { diff --git a/tests/set_service_tests.cpp b/tests/set_service_tests.cpp index 70739f8..0576cfb 100644 --- a/tests/set_service_tests.cpp +++ b/tests/set_service_tests.cpp @@ -26,7 +26,11 @@ public: Game gameId; explicit FakeGameModule(Game id) : gameId(id) {} Game id() const noexcept override { return gameId; } - std::string dirName() const override { return gameId == Game::Magic ? "magic" : "pokemon"; } + std::string dirName() const override { + if (gameId == Game::Magic) return "magic"; + if (gameId == Game::Pokemon) return "pokemon"; + return "yugioh"; + } std::string displayName() const override { return dirName(); } ISetSource& setSource() override { return source; } }; @@ -116,4 +120,29 @@ TEST_SUITE("SetService") { CHECK(magic.source.calls == 1); CHECK(pokemon.source.calls == 1); } + + TEST_CASE("YuGiOh module routes independently when all games are registered") { + InMemSetRepo repo; + SetService svc{repo}; + + FakeGameModule magic{Game::Magic}; + magic.source.result = Result>::ok({{"lea", "Alpha", "1993/08/05"}}); + FakeGameModule pokemon{Game::Pokemon}; + pokemon.source.result = Result>::ok({{"base1", "Base", "1999/01/09"}}); + FakeGameModule yugioh{Game::YuGiOh}; + yugioh.source.result = Result>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}}); + + svc.registerModule(&magic); + svc.registerModule(&pokemon); + svc.registerModule(&yugioh); + + REQUIRE(svc.updateSets(Game::Magic).isOk()); + REQUIRE(svc.updateSets(Game::Pokemon).isOk()); + const auto ygo = svc.updateSets(Game::YuGiOh); + REQUIRE(ygo.isOk()); + CHECK(ygo.value().front().id == "LOB"); + CHECK(magic.source.calls == 1); + CHECK(pokemon.source.calls == 1); + CHECK(yugioh.source.calls == 1); + } } diff --git a/tests/yugioh_card_preview_source_tests.cpp b/tests/yugioh_card_preview_source_tests.cpp new file mode 100644 index 0000000..ba03206 --- /dev/null +++ b/tests/yugioh_card_preview_source_tests.cpp @@ -0,0 +1,613 @@ +#include + +#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" +#include "ccm/util/YuGiOhPrintingSlot.hpp" + +#include +#include +#include +#include +#include + +using namespace ccm; + +namespace { + +// Single-shot HTTP fake matching the other game tests. Captures the last URL +// requested and returns a configurable body / failure flag. +class FixedHttpClient final : public IHttpClient { +public: + std::string lastUrl; + std::string body; + bool ok = true; + Result get(std::string_view url) override { + lastUrl = std::string(url); + return ok ? Result::ok(body) + : Result::err("offline"); + } +}; + +// Routing fake used to simulate the two-step Yugipedia -> YGOPRODeck flow. +// Both bodies are queued by URL substring so order-of-call assertions stay +// readable in the tests. Unmatched URLs return a 404-equivalent error so +// regressions are obvious instead of silently passing. +class RoutingHttpClient final : public IHttpClient { +public: + std::string yugipediaBody; + std::string ygoprodeckBody; + bool yugipediaOk = true; + bool ygoprodeckOk = true; + std::vector calls; + Result get(std::string_view url) override { + std::string s(url); + calls.push_back(s); + if (s.find("yugipedia.com") != std::string::npos) { + return yugipediaOk ? Result::ok(yugipediaBody) + : Result::err("yugipedia offline"); + } + if (s.find("ygoprodeck.com") != std::string::npos) { + return ygoprodeckOk ? Result::ok(ygoprodeckBody) + : Result::err("ygoprodeck offline"); + } + return Result::err("RoutingHttpClient: unrouted URL"); + } +}; + +} // namespace + +TEST_SUITE("ygoPrintingSlotsMatch") { + TEST_CASE("matches CCM composed setNo to YGOPRODeck set_code with region infix") { + CHECK(ygoPrintingSlotsMatch("SOD-015", "SOD-EN015")); + CHECK(ygoPrintingSlotsMatch("sod-015", "SOD-EN015")); + CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "SOD-EN016")); + CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "IOC-EN015")); + } + + TEST_CASE("matches German-style and digits-only suffix variants") { + CHECK(ygoPrintingSlotsMatch("LOB-005", "LOB-DE005")); + CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001")); + } + + TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") { + CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003")); + CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005")); + CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005")); + CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005")); + CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015")); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") { + TEST_CASE("strips whitespace and policy-banned punctuation") { + // Yugipedia's image policy: whitespace and a fixed punctuation set + // (#,.:'"?!&@%=[]<>/\- and friends) get dropped from the slug. + CHECK(YuGiOhCardPreviewSource::normalizeName("Blue-Eyes White Dragon") + == "BlueEyesWhiteDragon"); + CHECK(YuGiOhCardPreviewSource::normalizeName("Dark Magician") == "DarkMagician"); + CHECK(YuGiOhCardPreviewSource::normalizeName("Sasuke Samurai #4") + == "SasukeSamurai4"); + CHECK(YuGiOhCardPreviewSource::normalizeName("Don't Talk to Me!") + == "DontTalktoMe"); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") { + TEST_CASE("maps the dialog's rarity options to Yugipedia codes") { + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Common") == "C"); + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Rare") == "R"); + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Super Rare") == "SR"); + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Ultra Rare") == "UR"); + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR"); + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare") + == "QCScR"); + } + TEST_CASE("returns empty string for unknown rarity names") { + // Unknown rarity should fall through to the rarity-less filename + // pattern, not throw and not return a misleading code. + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty()); + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty()); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") { + TEST_CASE("returns everything before the first dash") { + CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-005") == "LOB"); + CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-DE005") == "LOB"); + CHECK(YuGiOhCardPreviewSource::extractSetCode("RA04-EN001") == "RA04"); + } + TEST_CASE("returns the input unchanged when no dash is present") { + CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB") == "LOB"); + CHECK(YuGiOhCardPreviewSource::extractSetCode("").empty()); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::buildCandidateFilenames") { + TEST_CASE("primary candidate uses the printed edition + EN region + .png") { + const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames( + "Blue-Eyes White Dragon", "LOB", "UR", /*firstEdition=*/false); + REQUIRE_FALSE(names.empty()); + // Highest-priority filename: Yugipedia's modern English/UE/.png + // shape, which is what most LOB-era reprints actually use. + CHECK(names.front() == "BlueEyesWhiteDragon-LOB-EN-UR-UE.png"); + } + TEST_CASE("includes 1E when the user marked the card as first edition") { + const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames( + "Dark Magician", "SDY", "UR", /*firstEdition=*/true); + REQUIRE_FALSE(names.empty()); + CHECK(names.front() == "DarkMagician-SDY-EN-UR-1E.png"); + } + TEST_CASE("covers EN/NA/EU/AU regions and both png/jpg extensions") { + const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames( + "Blue-Eyes White Dragon", "SDK", "UR", /*firstEdition=*/false); + // Sanity: the standard SDK Blue-Eyes scan is hosted at NA-UR-UE.png, + // so the candidate list must include that exact filename. + bool foundNaUe = false; + for (const auto& n : names) { + if (n == "BlueEyesWhiteDragon-SDK-NA-UR-UE.png") foundNaUe = true; + } + CHECK(foundNaUe); + } + TEST_CASE("falls back to a rarity-less pattern for unknown rarities") { + const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames( + "Token Card", "TKN", /*rarityCode=*/"", /*firstEdition=*/false); + REQUIRE_FALSE(names.empty()); + // Rarity slot omitted -> filename has only one dash before edition. + CHECK(names.front() == "TokenCard-TKN-EN-UE.png"); + } + TEST_CASE("returns empty list when the slug or set code is empty") { + // Don't waste an HTTP call on cards that haven't been filled in yet. + CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("", "LOB", "UR", false).empty()); + CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("Dark Magician", "", "UR", false).empty()); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::buildYugipediaQueryUrl") { + TEST_CASE("encodes filenames into a single MediaWiki batch query") { + const std::vector names = { + "BlueEyesWhiteDragon-LOB-EN-UR-UE.png", + "BlueEyesWhiteDragon-LOB-NA-UR-UE.png", + }; + const std::string url = YuGiOhCardPreviewSource::buildYugipediaQueryUrl(names); + CHECK(url.find("https://yugipedia.com/api.php") == 0); + CHECK(url.find("action=query") != std::string::npos); + CHECK(url.find("prop=imageinfo") != std::string::npos); + CHECK(url.find("iiprop=url") != std::string::npos); + // The two filenames are joined with `|` (URL-encoded as %7C) and + // each one is namespaced with `File:` (encoded `File%3A`). + CHECK(url.find("File%3ABlueEyesWhiteDragon-LOB-EN-UR-UE.png") != std::string::npos); + CHECK(url.find("%7CFile%3ABlueEyesWhiteDragon-LOB-NA-UR-UE.png") != std::string::npos); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") { + TEST_CASE("returns the URL of the highest-priority filename that exists") { + // MediaWiki returns one entry per requested title. Missing files come + // back with `"missing": ""` and no imageinfo; existing files carry an + // imageinfo[0].url. Order matters: we must walk filenameOrder and + // pick the first entry that resolved, not whichever MediaWiki listed + // first in its hash-keyed `pages` object. + const std::string body = R"({ + "query":{"pages":{ + "-1":{"title":"File:DarkMagician-SDY-EN-UR-1E.png","missing":""}, + "96018":{"title":"File:DarkMagician-SDY-NA-UR-UE.png", + "imageinfo":[{"url":"https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png"}]}, + "99999":{"title":"File:DarkMagician-SDY-EU-UR-UE.png", + "imageinfo":[{"url":"https://ms.yugipedia.com/eu/DarkMagician-SDY-EU-UR-UE.png"}]} + }} + })"; + const std::vector order = { + "DarkMagician-SDY-EN-UR-UE.png", // missing from response entirely + "DarkMagician-SDY-EN-UR-1E.png", // present but `missing` + "DarkMagician-SDY-NA-UR-UE.png", // first existing one + "DarkMagician-SDY-EU-UR-UE.png", + }; + const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(body, order); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png"); + } + + TEST_CASE("pure-miss response is classified as NotFound") { + // Pure-miss response: every page is marked `missing`. The caller + // (fetchImageUrl) treats this as "no Yugipedia scan" and triggers + // the YGOPRODeck fallback. Yugipedia having explicitly answered + // "no such file" is a clean negative (the upstream contract was + // honored), not a transient failure. + const std::string body = R"({ + "query":{"pages":{ + "-1":{"title":"File:Whatever-XYZ-EN-UR-UE.png","missing":""}, + "-2":{"title":"File:Whatever-XYZ-NA-UR-UE.png","missing":""} + }} + })"; + const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse( + body, {"Whatever-XYZ-EN-UR-UE.png", "Whatever-XYZ-NA-UR-UE.png"}); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); + } + + TEST_CASE("malformed JSON is classified as Transient") { + const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse( + "{not json", {"Anything-XYZ-EN-UR-UE.png"}); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") { + TEST_CASE("returns first print for preferred set name") { + const std::string json = R"({ + "data":[ + {"card_sets":[ + {"set_name":"Metal Raiders","set_code":"MRD-001","set_rarity":"Ultra Rare"}, + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"} + ]} + ] + })"; + const auto out = YuGiOhCardPreviewSource::parseFirstPrint( + json, "Legend of Blue Eyes White Dragon"); + REQUIRE(out.isOk()); + CHECK(out.value().setNo == "LOB-001"); + CHECK(out.value().rarity == "Ultra Rare"); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") { + TEST_CASE("lists distinct set_code entries for an exact name in one display set") { + const std::string json = R"({ + "data":[ + {"name":"Test Goblin", + "card_sets":[ + {"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"}, + {"set_name":"Mega Pack","set_code":"MP21-EN002","set_rarity":"Rare"} + ]} + ] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Mega Pack", "Test Goblin"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 2); + CHECK(out.value()[0].setNo == "MP21-EN001"); + CHECK(out.value()[1].setNo == "MP21-EN002"); + } + + TEST_CASE("lists multiple rarities for one set_code") { + const std::string json = R"({ + "data":[ + {"name":"Odd-Eyes Pendulum Dragon", + "card_sets":[ + {"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Super Rare"}, + {"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Ultimate Rare"} + ]} + ] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Duelist Alliance", "Odd-Eyes Pendulum Dragon"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 2); + CHECK(out.value()[0].setNo == "DUEA-EN004"); + CHECK(out.value()[0].rarity == "Super Rare"); + CHECK(out.value()[1].setNo == "DUEA-EN004"); + CHECK(out.value()[1].rarity == "Ultimate Rare"); + } + + TEST_CASE("dedupes identical print pairs") { + const std::string json = R"({ + "data":[ + {"name":"Mirror Force", + "card_sets":[ + {"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"}, + {"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"} + ]} + ] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Starter Deck", "Mirror Force"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].setNo == "SDY-043"); + CHECK(out.value()[0].rarity == "Super Rare"); + } +} + +// Helpers aligned with external fixture `yugioh_same_card_set_variant_tests` +// (same_card_same_set: distinct collector numbers vs distinct rarities). +[[nodiscard]] std::size_t countDistinctSetCodes(const std::vector& v) { + std::unordered_set codes; + for (const auto& p : v) { + if (!p.setNo.empty()) codes.insert(p.setNo); + } + return codes.size(); +} + +[[nodiscard]] std::size_t maxRarityVariantsPerCode(const std::vector& v) { + std::unordered_map> byCode; + for (const auto& p : v) { + if (p.setNo.empty() || p.rarity.empty()) continue; + byCode[p.setNo].insert(p.rarity); + } + std::size_t mx = 0; + for (const auto& e : byCode) { + mx = std::max(mx, e.second.size()); + } + return mx; +} + +TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_variant_tests") { + TEST_CASE("S1-001 Armed Dragon LV7 Soul of the Duelist Ultra vs Ultimate") { + const std::string json = R"({ + "data":[{ + "name":"Armed Dragon LV7", + "card_sets":[ + {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}, + {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"} + ] + }] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Soul of the Duelist", "Armed Dragon LV7"); + REQUIRE(out.isOk()); + CHECK(countDistinctSetCodes(out.value()) == 1); + CHECK(maxRarityVariantsPerCode(out.value()) == 2); + } + + TEST_CASE("S1-002 Horus LV8 Soul of the Duelist Ultra vs Ultimate") { + const std::string json = R"({ + "data":[{ + "name":"Horus the Black Flame Dragon LV8", + "card_sets":[ + {"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultra Rare"}, + {"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultimate Rare"} + ] + }] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Soul of the Duelist", "Horus the Black Flame Dragon LV8"); + REQUIRE(out.isOk()); + CHECK(countDistinctSetCodes(out.value()) == 1); + CHECK(maxRarityVariantsPerCode(out.value()) == 2); + } + + TEST_CASE("S1-003 Mobius Soul of the Duelist Super vs Ultimate") { + const std::string json = R"({ + "data":[{ + "name":"Mobius the Frost Monarch", + "card_sets":[ + {"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Super Rare"}, + {"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Ultimate Rare"} + ] + }] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Soul of the Duelist", "Mobius the Frost Monarch"); + REQUIRE(out.isOk()); + CHECK(countDistinctSetCodes(out.value()) == 1); + CHECK(maxRarityVariantsPerCode(out.value()) == 2); + } + + TEST_CASE("S2-001 Dark Magician Yugi's Legendary Decks three set codes") { + const std::string json = R"({ + "data":[{ + "name":"Dark Magician", + "card_sets":[ + {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"}, + {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENB02","set_rarity":"Common"}, + {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC09","set_rarity":"Common"} + ] + }] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Yugi's Legendary Decks", "Dark Magician"); + REQUIRE(out.isOk()); + CHECK(countDistinctSetCodes(out.value()) == 3); + CHECK(maxRarityVariantsPerCode(out.value()) == 1); + } + + TEST_CASE("S2-002 Dark Magician Girl Yugi's Legendary Decks two set codes") { + const std::string json = R"({ + "data":[{ + "name":"Dark Magician Girl", + "card_sets":[ + {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA04","set_rarity":"Common"}, + {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC10","set_rarity":"Common"} + ] + }] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Yugi's Legendary Decks", "Dark Magician Girl"); + REQUIRE(out.isOk()); + CHECK(countDistinctSetCodes(out.value()) == 2); + CHECK(maxRarityVariantsPerCode(out.value()) == 1); + } + + TEST_CASE("NEG-001 duplicate rarity lines collapse to one variant") { + const std::string json = R"({ + "data":[{ + "name":"Armed Dragon LV7", + "card_sets":[ + {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}, + {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"} + ] + }] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Soul of the Duelist", "Armed Dragon LV7"); + REQUIRE(out.isOk()); + CHECK(out.value().size() == 1); + CHECK(maxRarityVariantsPerCode(out.value()) == 1); + } + + TEST_CASE("NEG-002 duplicate set codes collapse to one collector slot") { + const std::string json = R"({ + "data":[{ + "name":"Dark Magician", + "card_sets":[ + {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"}, + {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"} + ] + }] + })"; + const auto out = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Yugi's Legendary Decks", "Dark Magician"); + REQUIRE(out.isOk()); + CHECK(countDistinctSetCodes(out.value()) == 1); + CHECK(maxRarityVariantsPerCode(out.value()) == 1); + } + + TEST_CASE("NEG-003 no cross-product merge when display set matches nothing") { + const std::string json = R"({ + "data":[{ + "name":"Dark Magician", + "card_sets":[ + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}, + {"set_name":"Starter Deck: Yugi","set_code":"SDY-006","set_rarity":"Ultra Rare"} + ] + }] + })"; + const auto bogus = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Different Sets", "Dark Magician"); + CHECK(bogus.isErr()); + + const auto lob = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Legend of Blue Eyes White Dragon", "Dark Magician"); + REQUIRE(lob.isOk()); + REQUIRE(lob.value().size() == 1); + CHECK(lob.value()[0].setNo == "LOB-005"); + + const auto sdy = YuGiOhCardPreviewSource::parsePrintVariants( + json, "Starter Deck: Yugi", "Dark Magician"); + REQUIRE(sdy.isOk()); + REQUIRE(sdy.value().size() == 1); + CHECK(sdy.value()[0].setNo == "SDY-006"); + } +} + +TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") { + TEST_CASE("queries Yugipedia first and uses the per-printing scan when found") { + // Two same-passcode reprints with genuinely different art (LOB vs + // SDK Blue-Eyes). Yugipedia hosts both, so we should always pick the + // SDK one for the SDK input - no leak of the LOB scan into the SDK + // entry, which was the user-reported regression on YGOPRODeck. + RoutingHttpClient http; + http.yugipediaBody = R"({"query":{"pages":{ + "1":{"title":"File:BlueEyesWhiteDragon-SDK-NA-UR-UE.png", + "imageinfo":[{"url":"https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png"}]} + }}})"; + // Make the YGOPRODeck fallback obviously wrong so we can assert it + // isn't being used when Yugipedia returns a hit. + http.ygoprodeckBody = R"({"data":[{"name":"Blue-Eyes White Dragon", + "card_images":[{"image_url":"https://example.invalid/wrong.png"}], + "card_sets":[{"set_code":"SDK-001","set_rarity":"Ultra Rare"}]}]})"; + + YuGiOhCardPreviewSource src{http}; + const auto out = src.fetchImageUrl( + "Blue-Eyes White Dragon", "Starter Deck: Kaiba", "SDK-001||Ultra Rare||UE"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png"); + // Verified: only the Yugipedia call was made, not the YGOPRODeck one. + REQUIRE(http.calls.size() == 1); + CHECK(http.calls.front().find("yugipedia.com") != std::string::npos); + } + + TEST_CASE("falls back to YGOPRODeck standard art when Yugipedia has no match") { + // Empty `pages` -> Yugipedia parser returns an error -> fallback + // path kicks in. This is the safety net for OCG-only or just-released + // cards that don't yet have an English scan uploaded to Yugipedia. + RoutingHttpClient http; + http.yugipediaBody = R"({"query":{"pages":{ + "-1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png","missing":""} + }}})"; + http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician", + "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}], + "card_sets":[{"set_code":"LOB-005","set_rarity":"Ultra Rare"}]}]})"; + + YuGiOhCardPreviewSource src{http}; + const auto out = src.fetchImageUrl( + "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg"); + // Two HTTP calls: Yugipedia first, YGOPRODeck second. + REQUIRE(http.calls.size() == 2); + CHECK(http.calls[0].find("yugipedia.com") != std::string::npos); + CHECK(http.calls[1].find("ygoprodeck.com") != std::string::npos); + } + + TEST_CASE("falls back when Yugipedia errors out (transient HTTP failure)") { + // We don't want a Yugipedia outage to leave the user without any + // preview at all - YGOPRODeck's generic art is acceptable degraded + // behavior. + RoutingHttpClient http; + http.yugipediaOk = false; + http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician", + "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})"; + + YuGiOhCardPreviewSource src{http}; + const auto out = src.fetchImageUrl( + "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg"); + } + + TEST_CASE("propagates the YGOPRODeck error as Transient when the fallback also fails") { + // Both upstreams unreachable -> the overall outcome is transient. + // CardPreviewService relies on this to avoid negative-caching when + // the user's connection is offline. + RoutingHttpClient http; + http.yugipediaOk = false; + http.ygoprodeckOk = false; + + YuGiOhCardPreviewSource src{http}; + const auto out = src.fetchImageUrl( + "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); + } + + TEST_CASE("classifies as NotFound only when both upstreams answered cleanly with no match") { + // Yugipedia: clean miss (every candidate "missing"). + // YGOPRODeck: clean miss (empty data array). + // -> Both upstreams agree -> NotFound, safe to remember. + RoutingHttpClient http; + http.yugipediaBody = R"({"query":{"pages":{ + "-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""} + }}})"; + http.ygoprodeckBody = R"({"data":[]})"; + + YuGiOhCardPreviewSource src{http}; + const auto out = src.fetchImageUrl( + "No Such Card", "Legend of Blue Eyes White Dragon", "LOB-999||Ultra Rare||UE"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); + } + + TEST_CASE("Yugipedia transient + YGOPRODeck clean-miss is overall Transient") { + // We can't conclude the card has no image when one upstream + // couldn't speak. This is the cautious path that keeps the offline + // user out of the "permanent no-preview" trap. + RoutingHttpClient http; + http.yugipediaOk = false; // network failure + http.ygoprodeckBody = R"({"data":[]})"; // clean miss + + YuGiOhCardPreviewSource src{http}; + const auto out = src.fetchImageUrl( + "Possibly Real", "Some Set", "ABC-001||Ultra Rare||UE"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); + } + + TEST_CASE("skips Yugipedia entirely when the set code is missing") { + // Without a set code we can't construct any candidate filename - go + // straight to the YGOPRODeck fallback to avoid wasting an HTTP call. + FixedHttpClient http; + http.ok = true; + http.body = R"({"data":[{"name":"Dark Magician", + "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})"; + + YuGiOhCardPreviewSource src{http}; + const auto out = src.fetchImageUrl( + "Dark Magician", "Legend of Blue Eyes White Dragon", ""); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg"); + CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos); + CHECK(http.lastUrl.find("yugipedia.com") == std::string::npos); + } +} diff --git a/tests/yugioh_set_source_tests.cpp b/tests/yugioh_set_source_tests.cpp new file mode 100644 index 0000000..d0224a2 --- /dev/null +++ b/tests/yugioh_set_source_tests.cpp @@ -0,0 +1,62 @@ +#include + +#include "ccm/games/yugioh/YuGiOhSetSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +using namespace ccm; + +namespace { + +class FixedHttpClient final : public IHttpClient { +public: + std::string lastUrl; + std::string body; + bool ok = true; + Result get(std::string_view url) override { + lastUrl = std::string(url); + return ok ? Result::ok(body) + : Result::err("offline"); + } +}; + +} // namespace + +TEST_SUITE("YuGiOhSetSource::parseResponse") { + TEST_CASE("maps set_code/set_name/tcg_date") { + const std::string json = R"([ + {"set_name":"Set A","set_code":"AAA","tcg_date":"2020-01-01"}, + {"set_name":"Set B","set_code":"BBB","tcg_date":"2021-02-03"} + ])"; + const auto out = YuGiOhSetSource::parseResponse(json); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 2); + CHECK(out.value()[0].id == "AAA"); + CHECK(out.value()[0].releaseDate == "2020/01/01"); + } + + TEST_CASE("sorts by release date ascending") { + const auto out = YuGiOhSetSource::parseResponse(R"([ + {"set_name":"New","set_code":"N","tcg_date":"2024-01-01"}, + {"set_name":"Old","set_code":"O","tcg_date":"2010-01-01"} + ])"); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "O"); + CHECK(out.value().back().id == "N"); + } + + TEST_CASE("missing array returns error") { + CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr()); + } +} + +TEST_SUITE("YuGiOhSetSource::fetchAll") { + TEST_CASE("network success parses and hits endpoint") { + FixedHttpClient http; + http.body = R"([{"set_name":"Set X","set_code":"X","tcg_date":"2020-01-01"}])"; + YuGiOhSetSource src{http}; + const auto out = src.fetchAll(); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "X"); + CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php"); + } +} diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index fe12826..548d221 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -6,11 +6,11 @@ - `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly). - `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. -- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window, menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. +- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. - `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`). - `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32. -- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel falls back to a per-game card-back image URL (Magic/Pokemon parity with CCM2) instead of leaving the preview empty. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant. -- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). +- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `/assets/ygo_card_back.png` (copied next to the executable by `app/CMakeLists.txt` on link — source file `ui_wx/assets/ygo_card_back.png`). The constructor caches `/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant. +- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI. - `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50–100 lines of hook overrides on top of the matching base template. - `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`). - `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up. @@ -64,13 +64,18 @@ - Button event handlers must use per-button state that is refreshed when theme changes. Avoid one-time captures of theme colors/mode in lambdas; these can leak dark-mode behavior into light mode. - In High Contrast, use stronger hover/pressed deltas than regular dark mode and keep the button border in the foreground/text color for visibility (currently yellow in this palette). - When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`. -15. **Preview fallback behavior (CCM2 parity):** +15. **Preview fallback behavior (CCM2 parity where applicable):** - Keep unresolved external previews user-visible by showing a per-game card-back image in `BaseSelectedCardPanel` instead of a blank/transparent bitmap. - - Current fallback URLs are intentionally aligned with CCM2: Magic uses `Magic_card_back.jpg`, Pokemon uses `Cardback.jpg`. - - If you change fallback sourcing (URL -> local asset, etc.), keep the "always show a reasonable card-back fallback" behavior intact for both games. + - Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails — keep that chain working when touching preview code. + - If you change fallback sourcing (URLs or bundled asset), keep the "always show a reasonable card-back fallback" behavior intact for **every** game with remote previews. +16. **Per-game auto-detect controls:** + - Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game. + - Keep shared templates game-agnostic: put buttons and detection behavior in `CardEditDialog`, not in `BaseCardEditDialog`. + - For games that use composed print IDs (prefix + numeric suffix), allow user editing on the numeric portion and render the full code as a read-only derived label beside the input. ## Required follow-ups +- If you replace **`ui_wx/assets/ygo_card_back.png`**, rebuild the **`ccm`** target so `app/CMakeLists.txt`'s `POST_BUILD` copy refreshes `/assets/`; do not remove the asset without updating `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`. - After adding a new dialog/panel `.cpp` you **must** add it to `ui_wx/CMakeLists.txt`. - After adding a new menu action you **must** allocate an `Ids::*` value in `MainFrame.hpp` (don't reuse `wxID_HIGHEST` math inline) and `Bind` it in `buildMenuBar`. The dynamic Game / Sets menus consume the `IdGameMenuBase` / `IdSetsMenuBase` ranges; do not stomp on those id ranges. - After changing `AppContext` you **must** update `app/main.cpp` so the composition root populates the new field. diff --git a/ui_wx/CMakeLists.txt b/ui_wx/CMakeLists.txt index 13416f0..36a0454 100644 --- a/ui_wx/CMakeLists.txt +++ b/ui_wx/CMakeLists.txt @@ -15,6 +15,10 @@ add_library(ccm_ui_wx STATIC src/PokemonSelectedCardPanel.cpp src/PokemonCardEditDialog.cpp src/PokemonGameView.cpp + src/YuGiOhCardListPanel.cpp + src/YuGiOhSelectedCardPanel.cpp + src/YuGiOhCardEditDialog.cpp + src/YuGiOhGameView.cpp src/SettingsDialog.cpp src/ImageViewerDialog.cpp diff --git a/ui_wx/assets/ygo_card_back.png b/ui_wx/assets/ygo_card_back.png new file mode 100644 index 0000000..79955b9 Binary files /dev/null and b/ui_wx/assets/ygo_card_back.png differ diff --git a/ui_wx/include/ccm/ui/AppContext.hpp b/ui_wx/include/ccm/ui/AppContext.hpp index bbda406..3bfd551 100644 --- a/ui_wx/include/ccm/ui/AppContext.hpp +++ b/ui_wx/include/ccm/ui/AppContext.hpp @@ -25,6 +25,7 @@ struct AppContext { CardPreviewService& cardPreview; IGameModule& magicModule; IGameModule& pokemonModule; + IGameModule& yuGiOhModule; // Active per-game UI bundles. The order is the order shown in the // Game menu; the composition root constructs them and hands raw // pointers in. `MainFrame` does not own these — `app/main.cpp` does. diff --git a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp index 5462449..eef99a1 100644 --- a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp @@ -90,6 +90,7 @@ protected: } buildLayout(); populateChoices(); + openingSnapshot_ = card_; Thaw(); } @@ -119,6 +120,11 @@ protected: return "(no sets cached - use Sets > " + updateMenuName() + ")"; } + // Called when the card name field changes. Games that cache upstream print + // metadata keyed by `(name, set)` should clear it here so stale "Next" + // controls cannot outlive the lookup identity. + virtual void onCardLookupContextChanged() {} + // Common helpers ---------------------------------------------------------- void appendRow(wxFlexGridSizer* grid, const wxString& label, wxWindow* ctrl) { @@ -129,12 +135,26 @@ protected: [[nodiscard]] TCard& mutableCard() noexcept { return card_; } [[nodiscard]] const TCard& constCard() const noexcept { return card_; } + void syncCardFromControls() { writeFromControls(); } + [[nodiscard]] wxComboBox* setComboControl() const noexcept { return setCombo_; } + + [[nodiscard]] const Set* selectedSetFromControls() const { + const auto& available = availableSets(); + if (!setCombo_ || !setCombo_->IsEnabled()) return nullptr; + const int sel = setCombo_->GetSelection(); + if (sel < 0 || static_cast(sel) >= available.size()) return nullptr; + return &available[static_cast(sel)]; + } private: void readSets() { auto loaded = setService_.getSets(game_); if (loaded.isOk()) { sets_ = std::move(loaded).value(); + std::sort(sets_.begin(), sets_.end(), + [](const Set& a, const Set& b) { + return a.releaseDate < b.releaseDate; + }); } } @@ -148,6 +168,10 @@ private: grid->AddGrowableCol(1, 1); nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str())); + nameCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent& ev) { + onCardLookupContextChanged(); + ev.Skip(); + }); appendRow(grid, "Name", nameCtrl_); setCombo_ = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, @@ -393,6 +417,14 @@ private: "Add card", wxOK | wxICON_INFORMATION); return; } + if (mode_ == EditMode::Edit && !(card_ == openingSnapshot_)) { + if (showThemedConfirmDialog( + this, + wxString::FromUTF8("Save changes to this card?"), + wxString::FromUTF8("Save changes")) != wxID_YES) { + return; + } + } ev.Skip(); } @@ -503,6 +535,7 @@ private: SetService& setService_; EditMode mode_; TCard card_; + TCard openingSnapshot_{}; Game game_; std::vector sets_; diff --git a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp index 84dbbc4..eaf5eb2 100644 --- a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp +++ b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp @@ -30,9 +30,12 @@ #include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -43,8 +46,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -221,6 +226,9 @@ protected: imageService_(imageService), cardPreview_(cardPreview), state_(std::make_shared()) { + wxFileName exe(wxStandardPaths::Get().GetExecutablePath()); + exe.SetFullName(wxEmptyString); + exeDirForBundledAssets_ = exe.GetPathWithSep().ToStdString(wxConvUTF8); state_->panel = this; } @@ -284,6 +292,9 @@ private: case Game::Pokemon: // Mirrors CCM2's unresolved-preview fallback image. return "https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg"; + case Game::YuGiOh: + // Yugipedia English TCG backing (thumbnail — smaller than full scan). + return "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png"; default: return {}; } @@ -367,25 +378,58 @@ private: CardPreviewService* svcPtr = &cardPreview_; auto [name, setId, setNo] = previewKey(card); const Game game = gameId(); + const std::string exeDirCopy = exeDirForBundledAssets_; std::thread([state, gen, svcPtr, name = std::move(name), - setId = std::move(setId), setNo = std::move(setNo), game]() { + setId = std::move(setId), setNo = std::move(setNo), game, + exeDirCopy]() { auto bytes = svcPtr->fetchPreviewBytes(game, name, setId, setNo); bool ok = bytes.isOk(); bool usedFallback = false; std::string payload = ok ? std::move(bytes).value() : std::string{}; std::string err = ok ? std::string{} : bytes.error(); + auto applyFallbackPayload = [&](std::string p) { + if (p.empty()) return; + payload = std::move(p); + ok = true; + usedFallback = true; + err.clear(); + }; + if (!ok || payload.empty()) { - const std::string fallbackUrl = fallbackImageUrlForGame(game); - if (!fallbackUrl.empty()) { - auto fallbackBytes = svcPtr->fetchImageBytesByUrl(fallbackUrl); - if (fallbackBytes.isOk()) { - payload = std::move(fallbackBytes).value(); - ok = !payload.empty(); - if (ok) { - usedFallback = true; - err.clear(); + if (game == Game::YuGiOh) { + if (auto fb = + svcPtr->fetchImageBytesByUrl( + "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/" + "250px-Back-EN.png"); + fb.isOk()) { + applyFallbackPayload(std::move(fb).value()); + } + if (!ok || payload.empty()) { + if (auto fb = svcPtr->fetchImageBytesByUrl( + "https://ms.yugipedia.com/e/e5/Back-EN.png"); + fb.isOk()) { + applyFallbackPayload(std::move(fb).value()); + } + } + if (!ok || payload.empty()) { + namespace fs = std::filesystem; + const fs::path asset = + fs::path(exeDirCopy) / "assets" / "ygo_card_back.png"; + std::ifstream in(asset, std::ios::binary); + if (in) { + std::ostringstream ss; + ss << in.rdbuf(); + applyFallbackPayload(ss.str()); + } + } + } else { + const std::string fallbackUrl = fallbackImageUrlForGame(game); + if (!fallbackUrl.empty()) { + auto fallbackBytes = svcPtr->fetchImageBytesByUrl(fallbackUrl); + if (fallbackBytes.isOk()) { + applyFallbackPayload(std::move(fallbackBytes).value()); } } } @@ -419,7 +463,16 @@ private: wxMemoryInputStream stream(bytes.data(), bytes.size()); wxImage img; - if (!img.LoadFile(stream, wxBITMAP_TYPE_ANY)) { + // libpng emits iCCP warnings for some upstream PNGs (e.g. Yugipedia + // scans with embedded sRGB chunks wx considers invalid). wx forwards + // those as wxLogWarning -> modal dialog. Suppress logging for decode + // only; the pixels load correctly either way. + bool decoded = false; + { + wxLogNull suppressPngWarnings; + decoded = img.LoadFile(stream, wxBITMAP_TYPE_ANY); + } + if (!decoded) { previewStatus_->SetLabelText("(preview decode failed)"); clearPreview(); Layout(); @@ -477,6 +530,7 @@ private: ImageService& imageService_; CardPreviewService& cardPreview_; + std::string exeDirForBundledAssets_; std::optional card_; wxStaticBitmap* previewBitmap_{nullptr}; diff --git a/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp b/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp new file mode 100644 index 0000000..9634fc9 --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "ccm/domain/YuGiOhCard.hpp" +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/services/CardPreviewService.hpp" +#include "ccm/ui/BaseCardEditDialog.hpp" +#include +#include + +namespace ccm::ui { + +class YuGiOhCardEditDialog final : public BaseCardEditDialog { +public: + YuGiOhCardEditDialog(wxWindow* parent, + ImageService& imageService, + SetService& setService, + CardPreviewService& cardPreview, + EditMode mode, + YuGiOhCard initial, + const std::vector* preloadedSets = nullptr); + +protected: + void buildFlagsRow(wxBoxSizer* flagsBox) override; + void appendExtraRows(wxFlexGridSizer* grid) override; + void readExtraFromCard() override; + void writeExtraToCard() override; + [[nodiscard]] std::string updateMenuName() const override { return "Update Yu-Gi-Oh!"; } + void onCardLookupContextChanged() override; + +private: + void onAutoDetectSetNo(wxCommandEvent&); + void onAutoDetectRarity(wxCommandEvent&); + void onNextSetNo(wxCommandEvent&); + void onNextRarity(wxCommandEvent&); + void onSetNoTextChanged(wxCommandEvent&); + void onSetSelectionChanged(wxCommandEvent&); + void autoDetectFromApi(bool fillSetNo, bool fillRarity); + void refreshSetNoFullPreview(); + void clearCachedPrintVariants(); + bool fetchAndCachePrintVariants(); + void rebuildVariantRingsFromCache(); + void filterCachedVariantsForCardLanguage(); + void syncRingPositionsToControls(); + void applyRarityStringToChoice(const std::string& rarity); + [[nodiscard]] std::string currentFullSetNoFromControls() const; + void refreshVariantNextControls(); + [[nodiscard]] std::string extractSetNoNumeric(std::string_view fullSetNo) const; + [[nodiscard]] std::string composeFullSetNo(std::string_view numeric) const; + void scheduleDeferredVariantPrefetch(); + void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch); + + EditMode dialogMode_; + unsigned variantFetchEpoch_{0}; + CardPreviewService& cardPreview_; + wxTextCtrl* setNoCtrl_{nullptr}; + wxStaticText* setNoFullPreview_{nullptr}; + wxChoice* rarityChoice_{nullptr}; + wxButton* autoSetNoBtn_{nullptr}; + wxButton* nextSetNoBtn_{nullptr}; + wxButton* autoRarityBtn_{nullptr}; + wxButton* nextRarityBtn_{nullptr}; + wxCheckBox* firstEditionCheck_{nullptr}; + wxCheckBox* signedCheck_{nullptr}; + wxCheckBox* alteredCheck_{nullptr}; + + std::vector cachedVariants_; + std::vector uniqueSetCodes_; + std::vector raritiesForCurrentSetCode_; + std::size_t setCodeRingPos_{0}; + std::size_t rarityRingPos_{0}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhCardListPanel.hpp b/ui_wx/include/ccm/ui/YuGiOhCardListPanel.hpp new file mode 100644 index 0000000..662d61a --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhCardListPanel.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "ccm/domain/YuGiOhCard.hpp" +#include "ccm/services/CardSorter.hpp" +#include "ccm/ui/BaseCardListPanel.hpp" + +namespace ccm::ui { + +class YuGiOhCardListPanel final : public BaseCardListPanel { +public: + explicit YuGiOhCardListPanel(wxWindow* parent); + +protected: + [[nodiscard]] std::vector declareTextColumns() const override; + [[nodiscard]] std::vector declareIconColumns() const override; + [[nodiscard]] std::string renderTextCell(const YuGiOhCard& card, std::size_t idx) const override; + [[nodiscard]] bool isIconColumnSet(const YuGiOhCard& card, std::size_t idx) const override; + void sortBy(YuGiOhSortColumn column, bool ascending) override; + [[nodiscard]] bool matchesFilter(const YuGiOhCard& card, std::string_view filter) const override; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhGameView.hpp b/ui_wx/include/ccm/ui/YuGiOhGameView.hpp new file mode 100644 index 0000000..253e03b --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhGameView.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "ccm/domain/YuGiOhCard.hpp" +#include "ccm/games/IGameModule.hpp" +#include "ccm/services/CardPreviewService.hpp" +#include "ccm/services/CollectionService.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/services/ImageService.hpp" +#include "ccm/services/SetService.hpp" +#include "ccm/ui/IGameView.hpp" + +#include +#include +#include + +namespace ccm::ui { + +class YuGiOhCardListPanel; +class YuGiOhSelectedCardPanel; + +class YuGiOhGameView final : public IGameView { +public: + YuGiOhGameView(ConfigService& config, + CollectionService& collection, + SetService& sets, + ImageService& images, + CardPreviewService& cardPreview, + IGameModule& module); + + [[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; } + [[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; } + + wxPanel* listPanel(wxWindow* parent) override; + wxPanel* selectedPanel(wxWindow* parent) override; + + void refreshCollection() override; + void onAddCard(wxWindow* parentWindow) override; + void onEditCard(wxWindow* parentWindow) override; + void onDeleteCard(wxWindow* parentWindow) override; + std::string onUpdateSets(wxWindow* parentWindow) override; + void setFilter(std::string_view filter) override; + void applyTheme(const ThemePalette& palette) override; + [[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Yu-Gi-Oh!"; } + +private: + void ensureSetsLoaded(); + const std::vector& setsForDialog(); + + ConfigService& config_; + CollectionService& collection_; + SetService& sets_; + ImageService& images_; + CardPreviewService& cardPreview_; + IGameModule& module_; + + YuGiOhCardListPanel* listPanel_{nullptr}; + YuGiOhSelectedCardPanel* selectedPanel_{nullptr}; + std::vector setsCache_; + bool attemptedInitialSetLoad_{false}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/YuGiOhSelectedCardPanel.hpp new file mode 100644 index 0000000..f567174 --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhSelectedCardPanel.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "ccm/domain/YuGiOhCard.hpp" +#include "ccm/ui/BaseSelectedCardPanel.hpp" + +namespace ccm::ui { + +class YuGiOhSelectedCardPanel final : public BaseSelectedCardPanel { +public: + YuGiOhSelectedCardPanel(wxWindow* parent, + ImageService& imageService, + CardPreviewService& cardPreview); + +protected: + [[nodiscard]] std::vector declareDetailRows() const override; + [[nodiscard]] std::vector declareFlagIcons() const override; + [[nodiscard]] std::string detailValueFor(const YuGiOhCard& card, DetailKey key) const override; + [[nodiscard]] bool isFlagSet(const YuGiOhCard& card, DetailKey key) const override; + [[nodiscard]] std::tuple + previewKey(const YuGiOhCard& card) const override; + [[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; } +}; + +} // namespace ccm::ui diff --git a/ui_wx/src/ImageViewerDialog.cpp b/ui_wx/src/ImageViewerDialog.cpp index 8b5f278..4f92ea6 100644 --- a/ui_wx/src/ImageViewerDialog.cpp +++ b/ui_wx/src/ImageViewerDialog.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -110,7 +111,12 @@ bool ImageViewerDialog::loadImageAt(std::size_t index) { if (imageCacheReady_[index]) return imageCache_[index].IsOk(); wxImage img; - if (!img.LoadFile(paths_[index].string())) { + bool ok = false; + { + wxLogNull suppressPngWarnings; + ok = img.LoadFile(paths_[index].string()); + } + if (!ok) { imageCacheReady_[index] = true; return false; } diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index eaf4b8c..4501510 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -83,7 +83,7 @@ void ensureDataStorageScaffold(const Configuration& cfg) { MainFrame::MainFrame(AppContext& ctx) : wxFrame(nullptr, wxID_ANY, "Card Collection Manager 3", - wxDefaultPosition, wxSize(1210, 700)), + wxDefaultPosition, wxSize(1210, 770)), ctx_(ctx), activeGame_(ctx.config.current().defaultGame) { buildMenuBar(); diff --git a/ui_wx/src/YuGiOhCardEditDialog.cpp b/ui_wx/src/YuGiOhCardEditDialog.cpp new file mode 100644 index 0000000..aa4641c --- /dev/null +++ b/ui_wx/src/YuGiOhCardEditDialog.cpp @@ -0,0 +1,378 @@ +#include "ccm/ui/YuGiOhCardEditDialog.hpp" +#include "ccm/domain/Enums.hpp" +#include "ccm/util/YuGiOhPrintingSlot.hpp" +#include +#include +#include +#include +#include + +namespace ccm::ui { +namespace { +const char* const kRarityOptions[] = { + "Common", + "Rare", + "Super Rare", + "Ultra Rare", + "Secret Rare", + "Quarter Century Secret Rare", + "Starlight Rare", + "Collector's Rare", + "Ghost Rare", + "Ultimate Rare", + "Platinum Secret Rare", + "Prismatic Secret Rare", +}; +} + +YuGiOhCardEditDialog::YuGiOhCardEditDialog(wxWindow* parent, + ImageService& imageService, + SetService& setService, + CardPreviewService& cardPreview, + EditMode mode, + YuGiOhCard initial, + const std::vector* preloadedSets) + : BaseCardEditDialog( + parent, + mode == EditMode::Create ? "Add Yu-Gi-Oh! Card" : "Edit Yu-Gi-Oh! Card", + imageService, setService, mode, std::move(initial), Game::YuGiOh, preloadedSets), + dialogMode_(mode), + cardPreview_(cardPreview) { + buildAndPopulate(); + if (dialogMode_ == EditMode::Edit) { + scheduleDeferredVariantPrefetch(); + } +} + +void YuGiOhCardEditDialog::onCardLookupContextChanged() { + clearCachedPrintVariants(); +} + +void YuGiOhCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) { + firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition"); + signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed"); + alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered"); + flagsBox->Add(firstEditionCheck_, 0, wxRIGHT, 12); + flagsBox->Add(signedCheck_, 0, wxRIGHT, 12); + flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12); +} + +void YuGiOhCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { + auto* setNoPanel = new wxPanel(this, wxID_ANY); + setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY); + setNoCtrl_->Bind(wxEVT_TEXT, &YuGiOhCardEditDialog::onSetNoTextChanged, this); + autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect"); + autoSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onAutoDetectSetNo, this); + nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next"); + nextSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onNextSetNo, this); + nextSetNoBtn_->Show(false); + setNoFullPreview_ = new wxStaticText(setNoPanel, wxID_ANY, "(n/a)"); + auto* setNoRow = new wxBoxSizer(wxHORIZONTAL); + setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + setNoRow->Add(setNoFullPreview_, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 8); + setNoPanel->SetSizer(setNoRow); + + auto* rarityPanel = new wxPanel(this, wxID_ANY); + rarityChoice_ = new wxChoice(rarityPanel, wxID_ANY); + autoRarityBtn_ = new wxButton(rarityPanel, wxID_ANY, "Auto detect"); + autoRarityBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onAutoDetectRarity, this); + nextRarityBtn_ = new wxButton(rarityPanel, wxID_ANY, "Next"); + nextRarityBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onNextRarity, this); + nextRarityBtn_->Show(false); + wxArrayString rarityItems; + rarityItems.Alloc(static_cast(sizeof(kRarityOptions) / sizeof(kRarityOptions[0]))); + for (const char* rarity : kRarityOptions) { + rarityItems.Add(wxString::FromUTF8(rarity)); + } + rarityChoice_->Append(rarityItems); + auto* rarityRow = new wxBoxSizer(wxHORIZONTAL); + rarityRow->Add(rarityChoice_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + rarityRow->Add(autoRarityBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + rarityRow->Add(nextRarityBtn_, 0, wxALIGN_CENTER_VERTICAL); + rarityPanel->SetSizer(rarityRow); + + appendRow(grid, "Set #", setNoPanel); + appendRow(grid, "Rarity", rarityPanel); + + if (auto* setCombo = setComboControl()) { + setCombo->Bind(wxEVT_COMBOBOX, &YuGiOhCardEditDialog::onSetSelectionChanged, this); + } +} + +void YuGiOhCardEditDialog::readExtraFromCard() { + clearCachedPrintVariants(); + if (setNoCtrl_) setNoCtrl_->ChangeValue(extractSetNoNumeric(constCard().setNo)); + if (rarityChoice_) { + const wxString rarity = wxString::FromUTF8(constCard().rarity.c_str()); + int rarityIdx = rarityChoice_->FindString(rarity); + if (rarityIdx == wxNOT_FOUND && !constCard().rarity.empty()) { + rarityChoice_->Append(rarity); + rarityIdx = rarityChoice_->GetCount() - 1; + } + if (rarityIdx == wxNOT_FOUND) rarityIdx = 0; + if (rarityIdx != wxNOT_FOUND) rarityChoice_->SetSelection(rarityIdx); + } + if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition); + if (signedCheck_) signedCheck_->SetValue(constCard().signed_); + if (alteredCheck_) alteredCheck_->SetValue(constCard().altered); + refreshSetNoFullPreview(); +} + +void YuGiOhCardEditDialog::writeExtraToCard() { + if (setNoCtrl_) mutableCard().setNo = composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8)); + if (rarityChoice_) mutableCard().rarity = rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8); + if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked(); + if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked(); + if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked(); +} + +void YuGiOhCardEditDialog::clearCachedPrintVariants() { + ++variantFetchEpoch_; + cachedVariants_.clear(); + uniqueSetCodes_.clear(); + raritiesForCurrentSetCode_.clear(); + setCodeRingPos_ = 0; + rarityRingPos_ = 0; + refreshVariantNextControls(); +} + +void YuGiOhCardEditDialog::scheduleDeferredVariantPrefetch() { + const unsigned epoch = variantFetchEpoch_; + wxTheApp->CallAfter([this, epoch]() { + prefetchVariantsForCurrentCardSilent(epoch); + }); +} + +void YuGiOhCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) { + if (capturedEpoch != variantFetchEpoch_) return; + if (!cachedVariants_.empty()) return; + const auto& card = constCard(); + if (card.name.empty() || card.set.name.empty()) return; + + auto detected = cardPreview_.detectPrintVariants(Game::YuGiOh, card.name, card.set.name); + if (!detected) return; + if (capturedEpoch != variantFetchEpoch_) return; + + cachedVariants_ = std::move(detected).value(); + rebuildVariantRingsFromCache(); + syncRingPositionsToControls(); + refreshVariantNextControls(); +} + +bool YuGiOhCardEditDialog::fetchAndCachePrintVariants() { + syncCardFromControls(); + const auto& card = constCard(); + if (card.name.empty()) { + showThemedMessageDialog(this, "Enter a card name first.", "Auto detect", + wxOK | wxICON_INFORMATION); + return false; + } + if (card.set.name.empty()) { + showThemedMessageDialog(this, "Select a set first.", "Auto detect", + wxOK | wxICON_INFORMATION); + return false; + } + + auto detected = cardPreview_.detectPrintVariants(Game::YuGiOh, card.name, card.set.name); + if (!detected) { + showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect", + wxOK | wxICON_WARNING); + return false; + } + cachedVariants_ = std::move(detected).value(); + return true; +} + +void YuGiOhCardEditDialog::filterCachedVariantsForCardLanguage() { + if (constCard().language != Language::English) return; + const auto removed = std::remove_if( + cachedVariants_.begin(), cachedVariants_.end(), [](const AutoDetectedPrint& p) { + return ygoLikelyEuropeanRegionalSetCode(p.setNo); + }); + cachedVariants_.erase(removed, cachedVariants_.end()); +} + +void YuGiOhCardEditDialog::rebuildVariantRingsFromCache() { + syncCardFromControls(); + uniqueSetCodes_.clear(); + raritiesForCurrentSetCode_.clear(); + if (cachedVariants_.empty()) return; + + filterCachedVariantsForCardLanguage(); + + std::unordered_set seenSlots; + seenSlots.reserve(cachedVariants_.size()); + for (const auto& p : cachedVariants_) { + if (p.setNo.empty()) continue; + const std::string digits = ygoCollectorDigitsOnly(p.setNo); + if (digits.empty()) continue; + const std::string slotKey = ygoAbbrevBeforeDash(p.setNo) + "|" + digits; + if (!seenSlots.insert(slotKey).second) continue; + uniqueSetCodes_.push_back(p.setNo); + } + + const std::string full = currentFullSetNoFromControls(); + std::unordered_set seenRarity; + for (const auto& p : cachedVariants_) { + if (!ygoPrintingSlotsMatch(p.setNo, full)) continue; + if (p.rarity.empty()) continue; + if (seenRarity.insert(p.rarity).second) raritiesForCurrentSetCode_.push_back(p.rarity); + } +} + +void YuGiOhCardEditDialog::syncRingPositionsToControls() { + const std::string full = currentFullSetNoFromControls(); + setCodeRingPos_ = 0; + for (std::size_t i = 0; i < uniqueSetCodes_.size(); ++i) { + if (ygoPrintingSlotsMatch(uniqueSetCodes_[i], full)) { + setCodeRingPos_ = i; + break; + } + } + + rarityRingPos_ = 0; + if (rarityChoice_) { + const std::string r = rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8); + for (std::size_t i = 0; i < raritiesForCurrentSetCode_.size(); ++i) { + if (raritiesForCurrentSetCode_[i] == r) { + rarityRingPos_ = i; + break; + } + } + } +} + +void YuGiOhCardEditDialog::applyRarityStringToChoice(const std::string& rarity) { + if (!rarityChoice_) return; + const wxString wxRare = wxString::FromUTF8(rarity.c_str()); + int idx = rarityChoice_->FindString(wxRare); + if (idx == wxNOT_FOUND && !rarity.empty()) { + rarityChoice_->Append(wxRare); + idx = rarityChoice_->GetCount() - 1; + } + if (idx != wxNOT_FOUND) rarityChoice_->SetSelection(idx); +} + +std::string YuGiOhCardEditDialog::currentFullSetNoFromControls() const { + if (!setNoCtrl_) return {}; + return composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8)); +} + +void YuGiOhCardEditDialog::refreshVariantNextControls() { + if (!nextSetNoBtn_ || !nextRarityBtn_) return; + nextSetNoBtn_->Show(uniqueSetCodes_.size() > 1); + nextRarityBtn_->Show(raritiesForCurrentSetCode_.size() > 1); + Layout(); + if (GetSizer()) Fit(); +} + +void YuGiOhCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) { + autoDetectFromApi(true, false); +} + +void YuGiOhCardEditDialog::onAutoDetectRarity(wxCommandEvent&) { + autoDetectFromApi(false, true); +} + +void YuGiOhCardEditDialog::onNextSetNo(wxCommandEvent&) { + if (uniqueSetCodes_.size() <= 1) return; + setCodeRingPos_ = (setCodeRingPos_ + 1) % uniqueSetCodes_.size(); + const std::string& code = uniqueSetCodes_[setCodeRingPos_]; + if (setNoCtrl_) { + setNoCtrl_->ChangeValue(wxString::FromUTF8(extractSetNoNumeric(code).c_str())); + } + for (const auto& p : cachedVariants_) { + if (p.setNo == code) { + applyRarityStringToChoice(p.rarity); + break; + } + } + rebuildVariantRingsFromCache(); + syncRingPositionsToControls(); + refreshSetNoFullPreview(); + refreshVariantNextControls(); +} + +void YuGiOhCardEditDialog::onNextRarity(wxCommandEvent&) { + if (raritiesForCurrentSetCode_.size() <= 1) return; + rarityRingPos_ = (rarityRingPos_ + 1) % raritiesForCurrentSetCode_.size(); + applyRarityStringToChoice(raritiesForCurrentSetCode_[rarityRingPos_]); + refreshVariantNextControls(); +} + +void YuGiOhCardEditDialog::autoDetectFromApi(bool fillSetNo, bool fillRarity) { + if (!fetchAndCachePrintVariants()) return; + + if (fillSetNo && setNoCtrl_ && !cachedVariants_.empty()) { + const auto& p = cachedVariants_.front(); + setNoCtrl_->ChangeValue(wxString::FromUTF8(extractSetNoNumeric(p.setNo).c_str())); + if (fillRarity) applyRarityStringToChoice(p.rarity); + } else if (fillRarity && rarityChoice_) { + const std::string full = currentFullSetNoFromControls(); + bool applied = false; + for (const auto& p : cachedVariants_) { + if (ygoPrintingSlotsMatch(p.setNo, full)) { + applyRarityStringToChoice(p.rarity); + applied = true; + break; + } + } + if (!applied && !cachedVariants_.empty()) { + applyRarityStringToChoice(cachedVariants_.front().rarity); + } + } + + rebuildVariantRingsFromCache(); + syncRingPositionsToControls(); + refreshSetNoFullPreview(); + refreshVariantNextControls(); +} + +void YuGiOhCardEditDialog::onSetNoTextChanged(wxCommandEvent&) { + refreshSetNoFullPreview(); + if (!cachedVariants_.empty()) { + rebuildVariantRingsFromCache(); + syncRingPositionsToControls(); + refreshVariantNextControls(); + } +} + +void YuGiOhCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) { + clearCachedPrintVariants(); + refreshSetNoFullPreview(); + scheduleDeferredVariantPrefetch(); + ev.Skip(); +} + +std::string YuGiOhCardEditDialog::extractSetNoNumeric(std::string_view fullSetNo) const { + std::string digits; + for (char ch : fullSetNo) { + if (std::isdigit(static_cast(ch))) digits.push_back(ch); + } + return digits; +} + +std::string YuGiOhCardEditDialog::composeFullSetNo(std::string_view numericRaw) const { + std::string numeric; + for (char ch : numericRaw) { + if (std::isdigit(static_cast(ch))) numeric.push_back(ch); + } + const Set* set = selectedSetFromControls(); + if (!set || set->id.empty() || numeric.empty()) return numeric; + return set->id + "-" + numeric; +} + +void YuGiOhCardEditDialog::refreshSetNoFullPreview() { + if (!setNoFullPreview_ || !setNoCtrl_) return; + const std::string full = composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8)); + if (full.empty()) { + setNoFullPreview_->SetLabel("(n/a)"); + } else { + setNoFullPreview_->SetLabel(wxString::Format("(%s)", wxString::FromUTF8(full.c_str()))); + } + setNoFullPreview_->GetParent()->Layout(); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/YuGiOhCardListPanel.cpp b/ui_wx/src/YuGiOhCardListPanel.cpp new file mode 100644 index 0000000..ebddb7b --- /dev/null +++ b/ui_wx/src/YuGiOhCardListPanel.cpp @@ -0,0 +1,69 @@ +#include "ccm/ui/YuGiOhCardListPanel.hpp" + +#include "ccm/services/CardFilter.hpp" +#include "ccm/ui/SvgIcons.hpp" + +#include + +namespace ccm::ui { + +YuGiOhCardListPanel::YuGiOhCardListPanel(wxWindow* parent) + : BaseCardListPanel(parent) { + buildLayout(); +} + +std::vector +YuGiOhCardListPanel::declareTextColumns() const { + return { + {"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name}, + {"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate}, + {"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount}, + {"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition}, + {"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Language}, + {"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Note}, + }; +} + +std::vector +YuGiOhCardListPanel::declareIconColumns() const { + constexpr int kFlagColWidth = 36; + return { + {kSvgFirstEdition, kFlagColWidth, YuGiOhSortColumn::FirstEdition}, + {kSvgSigned, kFlagColWidth, YuGiOhSortColumn::Signed}, + {kSvgAltered, kFlagColWidth, YuGiOhSortColumn::Altered}, + }; +} + +std::string YuGiOhCardListPanel::renderTextCell(const YuGiOhCard& card, + std::size_t idx) const { + switch (idx) { + case 0: return card.name; + case 1: return card.set.name; + case 2: return std::to_string(card.amount); + case 3: return std::string(to_string(card.condition)); + case 4: return std::string(to_string(card.language)); + case 5: return card.note; + } + return {}; +} + +bool YuGiOhCardListPanel::isIconColumnSet(const YuGiOhCard& card, + std::size_t idx) const { + switch (idx) { + case 0: return card.firstEdition; + case 1: return card.signed_; + case 2: return card.altered; + } + return false; +} + +void YuGiOhCardListPanel::sortBy(YuGiOhSortColumn column, bool ascending) { + sortYuGiOhCards(mutableCards(), column, ascending); +} + +bool YuGiOhCardListPanel::matchesFilter(const YuGiOhCard& card, + std::string_view filter) const { + return matchesYuGiOhFilter(card, filter); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/YuGiOhGameView.cpp b/ui_wx/src/YuGiOhGameView.cpp new file mode 100644 index 0000000..a16b645 --- /dev/null +++ b/ui_wx/src/YuGiOhGameView.cpp @@ -0,0 +1,209 @@ +#include "ccm/ui/YuGiOhGameView.hpp" + +#include "ccm/ui/YuGiOhCardEditDialog.hpp" +#include "ccm/ui/YuGiOhCardListPanel.hpp" +#include "ccm/ui/YuGiOhSelectedCardPanel.hpp" + +#include + +#include +#include +#include + +namespace ccm::ui { + +YuGiOhGameView::YuGiOhGameView(ConfigService& config, + CollectionService& collection, + SetService& sets, + ImageService& images, + CardPreviewService& cardPreview, + IGameModule& module) + : config_(config), + collection_(collection), + sets_(sets), + images_(images), + cardPreview_(cardPreview), + module_(module) {} + +void YuGiOhGameView::ensureSetsLoaded() { + if (attemptedInitialSetLoad_) return; + attemptedInitialSetLoad_ = true; + + auto cached = sets_.getSets(Game::YuGiOh); + if (cached) { + setsCache_ = std::move(cached).value(); + std::sort(setsCache_.begin(), setsCache_.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + if (!setsCache_.empty()) return; + } else { + setsCache_.clear(); + } + + auto refreshed = sets_.updateSets(Game::YuGiOh); + if (refreshed) { + setsCache_ = std::move(refreshed).value(); + std::sort(setsCache_.begin(), setsCache_.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + } +} + +wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) { + if (listPanel_ == nullptr) { + listPanel_ = new YuGiOhCardListPanel(parent); + listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) { + if (selectedPanel_ != nullptr && listPanel_ != nullptr) { + selectedPanel_->setCard(listPanel_->selected()); + } + }); + } + return listPanel_; +} + +wxPanel* YuGiOhGameView::selectedPanel(wxWindow* parent) { + if (selectedPanel_ == nullptr) { + selectedPanel_ = new YuGiOhSelectedCardPanel(parent, images_, cardPreview_); + } + return selectedPanel_; +} + +void YuGiOhGameView::refreshCollection() { + if (listPanel_ == nullptr) return; + auto loaded = collection_.list(Game::YuGiOh); + if (!loaded) { + showThemedMessageDialog(nullptr, "Failed to load Yu-Gi-Oh! collection: " + loaded.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + listPanel_->setCards(std::move(loaded).value()); + listPanel_->activateSelection(); + if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); +} + +const std::vector& YuGiOhGameView::setsForDialog() { + ensureSetsLoaded(); + if (!setsCache_.empty()) return setsCache_; + auto loaded = sets_.getSets(Game::YuGiOh); + if (loaded) { + setsCache_ = std::move(loaded).value(); + std::sort(setsCache_.begin(), setsCache_.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + } + else setsCache_.clear(); + return setsCache_; +} + +void YuGiOhGameView::onAddCard(wxWindow* parentWindow) { + YuGiOhCard fresh; + fresh.amount = 1; + fresh.language = Language::English; + fresh.condition = Condition::NearMint; + + YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh, + &setsForDialog()); + { + const Theme currentTheme = config_.current().theme; + const ThemePalette palette = paletteForTheme(currentTheme); + applyThemeToWindowTree(&dlg, palette, currentTheme); + dlg.SetBackgroundColour(palette.panelBg); + dlg.SetForegroundColour(palette.text); + } + if (dlg.ShowModal() != wxID_OK) return; + + auto added = collection_.add(Game::YuGiOh, dlg.card()); + if (!added) { + showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + + YuGiOhCard persisted = dlg.card(); + persisted.id = added.value(); + auto normalized = images_.normalizeNamesForPersistedCard( + Game::YuGiOh, persisted.id, persisted.set.name, persisted.name, persisted.images); + if (normalized) { + if (normalized.value() != persisted.images) { + persisted.images = std::move(normalized).value(); + auto updated = collection_.update(Game::YuGiOh, persisted); + if (!updated) { + showThemedMessageDialog(parentWindow, "Card added, but image name normalization failed to persist: " + updated.error(), + "Warning", wxOK | wxICON_WARNING); + } + } + } else { + showThemedMessageDialog(parentWindow, "Card added, but image rename to ID-prefixed format failed: " + normalized.error(), + "Warning", wxOK | wxICON_WARNING); + } + refreshCollection(); +} + +void YuGiOhGameView::onEditCard(wxWindow* parentWindow) { + if (listPanel_ == nullptr) return; + auto sel = listPanel_->selected(); + if (!sel) { + showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION); + return; + } + YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel, + &setsForDialog()); + { + const Theme currentTheme = config_.current().theme; + const ThemePalette palette = paletteForTheme(currentTheme); + applyThemeToWindowTree(&dlg, palette, currentTheme); + dlg.SetBackgroundColour(palette.panelBg); + dlg.SetForegroundColour(palette.text); + } + if (dlg.ShowModal() != wxID_OK) return; + auto updated = collection_.update(Game::YuGiOh, dlg.card()); + if (!updated) { + showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + refreshCollection(); +} + +void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) { + if (listPanel_ == nullptr) return; + auto sel = listPanel_->selected(); + if (!sel) { + showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION); + return; + } + if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", + "Confirm") != wxID_YES) { + return; + } + auto removed = collection_.remove(Game::YuGiOh, sel->id); + if (!removed) { + showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + refreshCollection(); +} + +std::string YuGiOhGameView::onUpdateSets(wxWindow* parentWindow) { + auto out = sets_.updateSets(Game::YuGiOh); + if (!out) { + showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(), + "Error", wxOK | wxICON_ERROR); + return "Update failed"; + } + setsCache_ = out.value(); + std::sort(setsCache_.begin(), setsCache_.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Yu-Gi-Oh! sets.", + "Sets updated", wxOK | wxICON_INFORMATION); + return "Yu-Gi-Oh! sets updated."; +} + +void YuGiOhGameView::setFilter(std::string_view filter) { + if (listPanel_) listPanel_->setFilter(filter); +} + +void YuGiOhGameView::applyTheme(const ThemePalette& palette) { + if (listPanel_) listPanel_->applyTheme(palette); + if (selectedPanel_) selectedPanel_->applyTheme(palette); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/YuGiOhSelectedCardPanel.cpp b/ui_wx/src/YuGiOhSelectedCardPanel.cpp new file mode 100644 index 0000000..add9a9b --- /dev/null +++ b/ui_wx/src/YuGiOhSelectedCardPanel.cpp @@ -0,0 +1,89 @@ +#include "ccm/ui/YuGiOhSelectedCardPanel.hpp" + +#include "ccm/ui/SvgIcons.hpp" + +#include + +namespace ccm::ui { + +namespace { +enum YuGiOhDetailKey : int { + kName = 0, + kSet, + kSetNo, + kRarity, + kLanguage, + kCondition, + kAmount, + kFirstEdition, + kSigned, + kAltered, +}; +} // namespace + +YuGiOhSelectedCardPanel::YuGiOhSelectedCardPanel(wxWindow* parent, + ImageService& imageService, + CardPreviewService& cardPreview) + : BaseSelectedCardPanel(parent, imageService, cardPreview) { + buildLayout(); +} + +std::vector +YuGiOhSelectedCardPanel::declareDetailRows() const { + return { + {"Name", kName, "(no card selected)"}, + {"Set", kSet, ""}, + {"Set #", kSetNo, ""}, + {"Rarity", kRarity, ""}, + {"Language", kLanguage, ""}, + {"Condition", kCondition, ""}, + {"Amount", kAmount, ""}, + }; +} + +std::vector +YuGiOhSelectedCardPanel::declareFlagIcons() const { + return { + {kSvgFirstEdition, "1. Edition", kFirstEdition}, + {kSvgSigned, "Signed", kSigned}, + {kSvgAltered, "Altered", kAltered}, + }; +} + +std::string YuGiOhSelectedCardPanel::detailValueFor(const YuGiOhCard& card, + DetailKey key) const { + switch (key) { + case kName: return card.name; + case kSet: return card.set.name; + case kSetNo: return card.setNo; + case kRarity: return card.rarity; + case kLanguage: return std::string(to_string(card.language)); + case kCondition: return std::string(to_string(card.condition)); + case kAmount: return std::to_string(card.amount); + case kNoteKey: return card.note; + } + return {}; +} + +bool YuGiOhSelectedCardPanel::isFlagSet(const YuGiOhCard& card, DetailKey key) const { + switch (key) { + case kFirstEdition: return card.firstEdition; + case kSigned: return card.signed_; + case kAltered: return card.altered; + } + return false; +} + +std::tuple +YuGiOhSelectedCardPanel::previewKey(const YuGiOhCard& card) const { + // Pack rarity and edition into the third tuple slot so the YGO preview + // source can build Yugipedia file names without changing the generic + // ICardPreviewSource interface. Format: "||||<1E|UE>". + // Yugipedia per-printing scans need the edition stamp to disambiguate + // 1st-Edition vs Unlimited reprints. + const char* const editionTag = card.firstEdition ? "1E" : "UE"; + return {card.name, card.set.name, + card.setNo + "||" + card.rarity + "||" + editionTag}; +} + +} // namespace ccm::ui