Compare commits

...

8 Commits

Author SHA1 Message Date
Sebastian Dine e5c830e945 minor: New Game Digimon Digi-Battle (#17)
* digimon digi battle added to supported games

* sonarqube update

* readme update

---------

Co-authored-by: sdine <sdine@sdine.com>
2026-07-19 12:06:57 +02:00
Sebastian Dine 42926f2fb5 patch: Feature/ygo set selection (#16) 2026-05-13 21:16:41 +02:00
Sebastian Dine 8a50e8daba patch: Feature/pkm autodetect (#15) 2026-05-12 15:49:37 +02:00
Sebastian Dine 98f2575b5a patch: Patch/ygo 25th set 2 (#14) 2026-05-11 11:27:32 +02:00
Sebastian Dine d6c7f60aee patch: Patch/ygo set 25th (#13) 2026-05-11 09:51:19 +02:00
Sebastian Dine 7935f2b18e fix: Fix/ci cd issues (#12) 2026-05-11 08:42:07 +02:00
Sebastian Dine 5805101d24 fix: unittests 2026-05-10 12:18:45 +02:00
Sebastian Dine 8b7d45fdac patch: Patch/misc (#10)
* yugioh adjustments

* test coverage
2026-05-10 11:43:25 +02:00
104 changed files with 6251 additions and 260 deletions
+2 -1
View File
@@ -38,7 +38,8 @@ GitHub Actions workflows for CI, release automation, and policy checks.
- Prefer minimal, surgical edits; avoid large workflow rewrites unless requested.
- Reusable workflows should declare explicit `workflow_call` inputs for required context (e.g., version, merge SHA).
- Sonar coverage steps that use `gcovr` must exclude third-party build trees at discovery time with `--exclude-directories` (for example `build/_deps`) so gcov does not process dependency `.gcda` files.
- The Sonar scan step passes `-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**` so the coverage quality gate reflects **`ccm_core_tests`** only (wx UI and the composition root are not executed under test). `sonar.sources` stays `core,ui_wx,app`; bugs/security/duplications still analyze those trees.
- The Sonar scan step passes `-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**` so the coverage quality gate reflects **`ccm_core_tests`** only (wx UI and the composition root are not executed under test). `sonar.sources` stays `core,ui_wx,app`.
- The Sonar scan also sets `-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp` so intentionally parallel wx per-game UI scaffolding does not dominate the duplication quality gate.
- For Linux Sonar coverage jobs, keep compiler and gcov toolchain aligned; because `cmake/Toolchain.cmake` prefers Clang by default, set `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++` explicitly in the coverage configure step when using gcovr default `gcov`.
- Keep `permissions` least-privilege:
- reusable build workflows: `contents: read`
+3 -1
View File
@@ -55,9 +55,10 @@ jobs:
--sonarqube build/sonarqube-coverage.xml
--exclude "build/_deps/"
--exclude-directories "build/_deps"
--exclude "^tests/"
- name: SonarQube Cloud scan
uses: SonarSource/sonarqube-scan-action@v5
uses: SonarSource/sonarqube-scan-action@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@@ -70,6 +71,7 @@ jobs:
-Dsonar.cfamily.compile-commands=build/compile_commands.json
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
linux:
name: Linux build + tests
+3 -1
View File
@@ -55,9 +55,10 @@ jobs:
--sonarqube build/sonarqube-coverage.xml
--exclude "build/_deps/"
--exclude-directories "build/_deps"
--exclude "^tests/"
- name: SonarQube Cloud scan
uses: SonarSource/sonarqube-scan-action@v5
uses: SonarSource/sonarqube-scan-action@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@@ -70,6 +71,7 @@ jobs:
-Dsonar.cfamily.compile-commands=build/compile_commands.json
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
compute-version:
name: Determine semantic version
+19 -3
View File
@@ -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`. Ships `ui_wx/assets/ygo_card_back.png` (Yu-Gi-Oh! offline preview fallback); `app/CMakeLists.txt` copies it to `<exeDir>/assets/` when linking `ccm`.
- `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` and `ui_wx/assets/digibattle99_card_back.png` (offline preview fallbacks); `app/CMakeLists.txt` copies them to `<exeDir>/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,9 +52,17 @@ 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: **185 tests, all green**.
`ctest --test-dir build --output-on-failure` — current baseline: **226 tests, all green**.
- Build tests only:
`cmake --build build --target ccm_core_tests`
- Local coverage env setup (one-time, Windows/MSYS2):
`python -m venv .venv_cov`
`& "P:/msys2/msys64/usr/bin/pacman.exe" -S --noconfirm mingw-w64-ucrt-x86_64-python-lxml mingw-w64-ucrt-x86_64-python-gcovr`
- Coverage check (core-focused):
`cmake -S . -B build-cov -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Debug -DCCM_BUILD_TESTS=ON -DCMAKE_C_FLAGS=--coverage -DCMAKE_CXX_FLAGS=--coverage -DCMAKE_EXPORT_COMPILE_COMMANDS=ON`
`cmake --build build-cov --target ccm_core_tests --parallel`
`ctest --test-dir build-cov --output-on-failure`
`& "P:/msys2/msys64/ucrt64/bin/gcovr.exe" -r . --object-directory build-cov --filter "core/" --exclude "build/_deps/" --exclude "build-cov/_deps/" --exclude-directories "build/_deps" --exclude-directories "build-cov/_deps" --print-summary`
> **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.
>
@@ -76,7 +84,7 @@ Run from the **workspace root**.
- Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically.
- `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically.
- `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `<exeDir>/.cache/preview-cache/`**next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path.
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `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.
- `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`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview.
## Windows UI theming guardrails
@@ -97,10 +105,18 @@ Run from the **workspace root**.
- After adding a new `.cpp` to `core/` or `ui_wx/` you **must** add it to that package's `CMakeLists.txt`. There is no glob.
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high. Exercise both outcomes of meaningful conditionals (success vs error, empty vs non-empty, cache hit vs miss, `NotFound` vs `Transient`, early return vs fall-through), not only the happy path — Sonar condition coverage on `core/` is a separate signal from line coverage.
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR. When checking coverage locally, also review branch/condition metrics (for example `gcovr ... --txt-metric branch` or Sonar's condition coverage on the same `core/` surface); there is no repo-wide condition threshold in CI yet — use Sonar's per-file condition list to prioritize gaps.
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
## Agent collaboration (Cursor / AI)
- **Never** `git commit` or `git push` unless the user **explicitly** asked you to commit and/or push (e.g. “commit this”, “push to origin”). Preparing diffs and suggesting commands is fine; performing those Git writes without explicit instruction is not.
- **Never** check out another branch **to change it** unless the user **explicitly** asked you to work on that branch. Temporarily checking out another branch **read-only** (inspect history, compare files, run `git show`) is fine without asking; switch back to the working branch before making edits unless instructed otherwise.
## Anti-patterns
- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build).
+20 -4
View File
@@ -1,6 +1,5 @@
# Card Collection Manager 3
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=alert_status&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=bugs&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=security_rating&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
@@ -10,21 +9,38 @@ Currently, the application supports the following TCGs:
- Magic the Gathering
- Pokemon TCG
- Yu-Gi-Oh!
- Digimon (Digi-Battle)
## Screenshots
### Magic The Gathering
<details open>
<summary>Magic The Gathering</summary>
![CCM3 Demo - Magic: The Gathering](docs/assets/images/demo-mtg.png)
### Pokemon TCG
</details>
<details>
<summary>Pokemon TCG</summary>
![CCM3 Demo - Pokemon](docs/assets/images/demo-pkm.png)
### Yu-Gi-Oh!
</details>
<details>
<summary>Yu-Gi-Oh!</summary>
![CCM3 Demo - YuGiOh](docs/assets/images/demo-ygo.png)
</details>
<details>
<summary>Digimon (Digi-Battle)</summary>
![CCM3 Demo - Digimon Digi-Battle](docs/assets/images/demo-digibattle99.png)
</details>
## Migrating From CCM1 And CCM2
+3 -3
View File
@@ -5,11 +5,11 @@ 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`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/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`).
- `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 `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png` and `ui_wx/assets/digibattle99_card_back.png` there so Yu-Gi-Oh! / Digi-Battle 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<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `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.
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, 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`).
@@ -21,7 +21,7 @@ The `ccm` executable — composition root only. The single place where concrete
## 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`.
- The **`POST_BUILD` copy of `ygo_card_back.png` / `digibattle99_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<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>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.
+6 -2
View File
@@ -19,9 +19,13 @@ target_link_libraries(ccm
ccm_warnings
)
# Yu-Gi-Oh! preview fallback image (used when network card-back URLs fail).
# Yu-Gi-Oh! / Digi-Battle preview fallback images (used when network card-back
# URLs fail or no public URL exists).
add_custom_command(TARGET ccm POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ccm>/assets"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png"
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png")
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/ui_wx/assets/digibattle99_card_back.png"
"$<TARGET_FILE_DIR:ccm>/assets/digibattle99_card_back.png")
+25 -4
View File
@@ -2,9 +2,11 @@
// it to the wxWidgets UI layer. This is the only place where concrete adapter
// types are mentioned - everything downstream depends on interfaces.
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
@@ -20,6 +22,7 @@
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/AppContext.hpp"
#include "ccm/ui/DigiBattle99GameView.hpp"
#include "ccm/ui/MagicGameView.hpp"
#include "ccm/ui/MainFrame.hpp"
#include "ccm/ui/PokemonGameView.hpp"
@@ -42,9 +45,10 @@ namespace {
// need for the repositories to know about concrete game module classes.
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";
case ccm::Game::Magic: return "magic";
case ccm::Game::Pokemon: return "pokemon";
case ccm::Game::YuGiOh: return "yugioh";
case ccm::Game::DigiBattle99: return "digibattle99";
}
return "magic";
}
@@ -80,6 +84,7 @@ public:
magicMod_ = std::make_unique<ccm::MagicGameModule>(*http_);
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*http_);
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
*fs_, *config_, &dirNameForGame);
@@ -87,6 +92,9 @@ public:
*fs_, *config_, &dirNameForGame);
ygoRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhCard>>(
*fs_, *config_, &dirNameForGame);
digiBattle99Repo_ =
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
*fs_, *config_, &dirNameForGame);
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
@@ -97,10 +105,14 @@ public:
*pokeRepo_, *imgStore_);
ygoCollSvc_ = std::make_unique<ccm::CollectionService<ccm::YuGiOhCard>>(
*ygoRepo_, *imgStore_);
digiBattle99CollSvc_ =
std::make_unique<ccm::CollectionService<ccm::DigiBattle99Card>>(
*digiBattle99Repo_, *imgStore_);
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
setSvc_->registerModule(magicMod_.get());
setSvc_->registerModule(pokeMod_.get());
setSvc_->registerModule(ygoMod_.get());
setSvc_->registerModule(digiBattle99Mod_.get());
// Disk-backed preview cache lives next to the executable, in the same
// location scope as config.json - NOT inside the user's data-storage
@@ -120,6 +132,7 @@ public:
previewSvc_->registerModule(*magicMod_);
previewSvc_->registerModule(*pokeMod_);
previewSvc_->registerModule(*ygoMod_);
previewSvc_->registerModule(*digiBattle99Mod_);
// Per-game UI bundles. Order here is the order shown in the Game menu.
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
@@ -128,6 +141,9 @@ public:
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_);
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*digiBattle99Mod_);
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
*config_,
@@ -137,7 +153,8 @@ public:
*magicMod_,
*pokeMod_,
*ygoMod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get() },
*digiBattle99Mod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() },
});
auto* frame = new ccm::ui::MainFrame(*ctx_);
@@ -158,21 +175,25 @@ private:
std::unique_ptr<ccm::MagicGameModule> magicMod_;
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_;
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::DigiBattle99Card>> digiBattle99CollSvc_;
std::unique_ptr<ccm::SetService> setSvc_;
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
std::unique_ptr<ccm::ui::YuGiOhGameView> ygoView_;
std::unique_ptr<ccm::ui::DigiBattle99GameView> digiBattle99View_;
std::unique_ptr<ccm::ui::AppContext> ctx_;
};
+4 -4
View File
@@ -4,12 +4,12 @@
## Layer pointers
- `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/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `DigiBattle99Card`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (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`).
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, and `digibattle99/` are the reference implementations — all four 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`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
- `src/` mirrors `include/ccm/` for non-template implementations.
## Conventions
@@ -27,7 +27,7 @@
8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly.
9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `<setNo>||<rarity>||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface.
10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `<exeDir>/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `<hash>.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path.
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.
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`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
## Adding a new game
+4
View File
@@ -7,6 +7,7 @@ add_library(ccm_core STATIC
src/domain/MagicCard.cpp
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.cpp
src/domain/DigiBattle99Card.cpp
src/domain/Configuration.cpp
src/services/ConfigService.cpp
@@ -31,6 +32,9 @@ add_library(ccm_core STATIC
src/games/yugioh/YuGiOhSetSource.cpp
src/games/yugioh/YuGiOhCardPreviewSource.cpp
src/games/yugioh/YuGiOhGameModule.cpp
src/games/digibattle99/DigiBattle99SetSource.cpp
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
src/games/digibattle99/DigiBattle99GameModule.cpp
src/util/FsNames.cpp
)
@@ -0,0 +1,38 @@
#pragma once
// DigiBattle99Card - Digimon Digi-Battle (1999 English) card model.
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct DigiBattle99Card {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
Condition condition{Condition::NearMint};
bool firstEdition{false};
bool holo{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const DigiBattle99Card&, const DigiBattle99Card&) = default;
};
void to_json(nlohmann::json& j, const DigiBattle99Card& c);
void from_json(const nlohmann::json& j, DigiBattle99Card& c);
} // namespace ccm
+2 -1
View File
@@ -19,6 +19,7 @@ enum class Game {
Magic,
Pokemon,
YuGiOh,
DigiBattle99,
};
enum class Language {
@@ -57,7 +58,7 @@ std::optional<Language> languageFromString(std::string_view s) noexcept;
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
std::optional<Theme> themeFromString(std::string_view s) noexcept;
const std::array<Game, 3>& allGames() noexcept;
const std::array<Game, 4>& allGames() noexcept;
const std::array<Language, 8>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
-1
View File
@@ -20,7 +20,6 @@ struct YuGiOhCard {
Set set;
std::string setNo;
std::string rarity;
std::string rarityCode;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
@@ -0,0 +1,67 @@
#pragma once
// DigiBattle99CardPreviewSource: digimoncard.io search + CDN card images for
// Digimon Digi-Battle (1999 English).
//
// Preview key middle slot is Set.name (pack display name) so search.php?pack=
// works without a reverse slug map. When setNo is present, the CDN URL is
// built directly — no search round-trip.
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class DigiBattle99CardPreviewSource final : public ICardPreviewSource {
public:
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
static constexpr const char* kImageBase =
"https://images.digimoncard.io/images/cards/";
explicit DigiBattle99CardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setName,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setName) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setName) override;
// Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88).
// Does not invent zero-padding — CDN keys match API ids literally.
static std::string normalizeCardNumber(std::string_view setNo);
// CDN preview URL for a normalized card id (.jpg — wxImage registers
// JPEG/PNG only; digimoncard.io also serves .webp but we cannot decode it).
static std::string buildImageUrl(std::string_view setNo);
// digimoncard.io search URL: n= / pack= / series= / optional card=.
// setName is the pack display name (Set.name), not the slug id.
static std::string buildSearchUrl(std::string_view name,
std::string_view setName,
std::string_view setNo);
// Parse a digimoncard.io search.php body into a CDN image URL for the
// first exact name match (optional pack filter applied by the request).
static Result<std::string, PreviewLookupError>
parseImageUrlFromSearch(const std::string& body,
std::string_view wantedCardName);
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setName,
std::string_view wantedCardName);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,27 @@
#pragma once
// DigiBattle99GameModule: Digimon Digi-Battle (1999 English) via digimoncard.io.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
namespace ccm {
class DigiBattle99GameModule final : public IGameModule {
public:
explicit DigiBattle99GameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::DigiBattle99; }
[[nodiscard]] std::string dirName() const override { return "digibattle99"; }
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
DigiBattle99SetSource setSource_;
DigiBattle99CardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,37 @@
#pragma once
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
namespace ccm {
class DigiBattle99SetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint =
"https://digimoncard.io/api-public/search.php?"
"series=Digimon%20Digi-Battle%20Card%20Game&limit=1000&sort=name&sortdirection=asc";
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
explicit DigiBattle99SetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
static std::string slugifyPackName(std::string_view packName);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -12,6 +12,7 @@
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
@@ -19,10 +20,16 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
public:
explicit PokemonCardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) 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.
@@ -30,6 +37,11 @@ public:
std::string_view setId,
std::string_view setNo);
// Slimmer search URL for auto-detect: omits the number clause and asks the
// API for only the fields the print-variant parser needs.
static std::string buildDetectSearchUrl(std::string_view name,
std::string_view setId);
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
// for the first matching card. Prefers `images.large`, falls back to
// `images.small`. Errors are classified:
@@ -38,6 +50,13 @@ public:
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
// Enumerate distinct collector numbers (and rarities) for an exact card
// name inside the chosen set. Exposed for unit testing without HTTP.
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setId,
std::string_view wantedCardName);
private:
IHttpClient& http_;
};
+17
View File
@@ -7,6 +7,7 @@
#include "ccm/ports/IHttpClient.hpp"
#include <chrono>
#include <functional>
#include <memory>
#include <mutex>
@@ -23,7 +24,21 @@ namespace ccm {
// only fires one outbound request at a time anyway.
class CprHttpClient final : public IHttpClient {
public:
struct RawResponse {
bool transportError{false};
std::string transportMessage;
int statusCode{0};
std::string body;
};
using GetExecutor = std::function<Result<std::string>(std::string_view)>;
using RawGetExecutor = std::function<RawResponse(std::string_view)>;
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
CprHttpClient(GetExecutor executor,
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
CprHttpClient(RawGetExecutor rawExecutor,
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
~CprHttpClient() override;
Result<std::string> get(std::string_view url) override;
@@ -31,6 +46,8 @@ public:
private:
std::chrono::milliseconds timeout_;
std::unique_ptr<cpr::Session> session_;
GetExecutor executor_;
RawGetExecutor rawExecutor_;
std::mutex sessionMutex_;
};
+5
View File
@@ -19,6 +19,7 @@
// * An empty filter matches every row, exactly as in JS where every string
// `.includes("")` returns true.
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -41,4 +42,8 @@ namespace ccm {
[[nodiscard]] bool matchesYuGiOhFilter(const YuGiOhCard& card,
std::string_view filter);
// Digi-Battle mirrors Pokemon searchable columns (includes setNo).
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
std::string_view filter);
} // namespace ccm
+19
View File
@@ -16,6 +16,7 @@
// UI relies on it so successive clicks on different columns compose predictably
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -59,6 +60,21 @@ enum class YuGiOhSortColumn {
Language,
Condition,
Amount,
Rarity,
FirstEdition,
Signed,
Altered,
Note,
};
// Digi-Battle mirrors Pokemon columns (setNo is filter-only, not a sort column).
enum class DigiBattle99SortColumn {
Name,
SetReleaseDate,
Language,
Condition,
Amount,
Holo,
FirstEdition,
Signed,
Altered,
@@ -73,5 +89,8 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
bool ascending);
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
bool ascending);
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
DigiBattle99SortColumn column,
bool ascending);
} // namespace ccm
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
// ASCII-only tolower for sort/filter parity with the legacy TS path:
// String.prototype.toLowerCase() on English/German/etc. card metadata behaves
// identically for this byte range.
[[nodiscard]] inline std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
} // namespace ccm
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "ccm/util/Result.hpp"
#include <string>
#include <string_view>
namespace ccm {
// Shared classification for raw HTTP GET outcomes (transport vs status vs OK).
// `CprHttpClient::get` delegates here so doctest can exercise the branches
// without touching libcpr or the network stack.
[[nodiscard]] inline Result<std::string> mapHttpGetResponse(bool curlTransportError,
std::string_view curlErrorMessage,
long httpStatusCode,
std::string responseBody,
std::string_view requestUrl) {
if (curlTransportError) {
return Result<std::string>::err(std::string("HTTP error: ") +
std::string(curlErrorMessage));
}
if (httpStatusCode < 200 || httpStatusCode >= 300) {
return Result<std::string>::err(
"HTTP " + std::to_string(httpStatusCode) + " from " +
std::string(requestUrl));
}
return Result<std::string>::ok(std::move(responseBody));
}
} // namespace ccm
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <sstream>
#include <string>
#include <string_view>
namespace ccm {
// Percent-encode all bytes that are not unreserved per RFC 3986
// (A-Z / a-z / 0-9 / - . _ ~). Needed because cpr does not encode the URL
// string passed to IHttpClient::get.
[[nodiscard]] inline std::string rfc3986PercentEncode(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<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
} // namespace ccm
@@ -69,4 +69,31 @@ namespace ccm {
&& std::isdigit(static_cast<unsigned char>(tail[1])) != 0;
}
// Canonical short-form for Yu-Gi-Oh rarities used by the overview table.
// Returns empty when rarity is unknown.
[[nodiscard]] inline std::string ygoRarityShortCode(std::string_view rarity) {
std::string normalized;
normalized.reserve(rarity.size());
for (unsigned char c : rarity) {
if (std::isspace(c) != 0) continue;
if (c == '\'' || c == '`' || c == '-') continue;
normalized.push_back(static_cast<char>(std::tolower(c)));
}
if (normalized == "common") return "C";
if (normalized == "rare") return "R";
if (normalized == "superrare") return "SR";
if (normalized == "ultrarare") return "UR";
if (normalized == "secretrare") return "ScR";
if (normalized == "quartercenturysecretrare") return "QCScR";
if (normalized == "qcsr") return "QCScR";
if (normalized == "starlightrare") return "StR";
if (normalized == "collectorsrare") return "CR";
if (normalized == "ghostrare") return "GR";
if (normalized == "ultimaterare") return "UtR";
if (normalized == "platinumsecretrare") return "PlScR";
if (normalized == "prismaticsecretrare") return "PScR";
return {};
}
} // namespace ccm
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// Resolves a Yu-Gi-Oh! product code (YGOPRODeck `set_code`, stored as `Set.id`)
// against a cached set list. Used by the Yu-Gi-Oh! edit dialog "set code" mode.
#include "ccm/domain/Set.hpp"
#include <cctype>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhSetShorthandLookup {
enum class Kind { Unique, NotFound, Ambiguous };
Kind kind{Kind::NotFound};
std::size_t index{0};
};
[[nodiscard]] inline std::string normalizeYuGiOhSetIdForLookup(std::string_view id) {
std::string out;
out.reserve(id.size());
for (unsigned char uch : id) {
out.push_back(static_cast<char>(std::tolower(uch)));
}
return out;
}
[[nodiscard]] inline std::string_view trimAsciiWhitespace(std::string_view s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
s.remove_prefix(1);
}
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
s.remove_suffix(1);
}
return s;
}
[[nodiscard]] inline YuGiOhSetShorthandLookup lookupYuGiOhSetByShorthand(
std::string_view query, const std::vector<Set>& sets) {
const std::string_view trimmed = trimAsciiWhitespace(query);
if (trimmed.empty()) {
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
}
const std::string qNorm = normalizeYuGiOhSetIdForLookup(trimmed);
std::size_t firstIdx = 0;
int matchCount = 0;
for (std::size_t i = 0; i < sets.size(); ++i) {
if (normalizeYuGiOhSetIdForLookup(sets[i].id) == qNorm) {
if (matchCount == 0) firstIdx = i;
++matchCount;
if (matchCount > 1) {
return {YuGiOhSetShorthandLookup::Kind::Ambiguous, 0};
}
}
}
if (matchCount == 1) {
return {YuGiOhSetShorthandLookup::Kind::Unique, firstIdx};
}
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/DigiBattle99Card.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const DigiBattle99Card& 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},
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, DigiBattle99Card& 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("holo").get_to(c.holo);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+21 -12
View File
@@ -3,15 +3,22 @@
#include <stdexcept>
#include <string>
#if defined(__GNUC__) || defined(__clang__)
#define CCM_UNREACHABLE() __builtin_unreachable()
#else
#define CCM_UNREACHABLE() ((void)0)
#endif
namespace ccm {
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";
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "YuGiOh";
case Game::DigiBattle99: return "DigiBattle99";
}
return "Magic";
CCM_UNREACHABLE();
}
std::string_view to_string(Language l) noexcept {
@@ -25,7 +32,7 @@ std::string_view to_string(Language l) noexcept {
case Language::Japanese: return "Japanese";
case Language::Russian: return "Russian";
}
return "English";
CCM_UNREACHABLE();
}
std::string_view to_string(Condition c) noexcept {
@@ -38,7 +45,7 @@ std::string_view to_string(Condition c) noexcept {
case Condition::Played: return "Played";
case Condition::Poor: return "Poor";
}
return "Mint";
CCM_UNREACHABLE();
}
std::string_view to_string(Theme t) noexcept {
@@ -46,13 +53,14 @@ std::string_view to_string(Theme t) noexcept {
case Theme::Light: return "Light";
case Theme::Dark: return "Dark";
}
return "Light";
CCM_UNREACHABLE();
}
std::optional<Game> gameFromString(std::string_view s) noexcept {
if (s == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
if (s == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
if (s == "DigiBattle99") return Game::DigiBattle99;
return std::nullopt;
}
@@ -85,8 +93,9 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
return std::nullopt;
}
const std::array<Game, 3>& allGames() noexcept {
static constexpr std::array<Game, 3> v{Game::Magic, Game::Pokemon, Game::YuGiOh};
const std::array<Game, 4>& allGames() noexcept {
static constexpr std::array<Game, 4> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99};
return v;
}
-3
View File
@@ -15,7 +15,6 @@ void to_json(nlohmann::json& j, const YuGiOhCard& c) {
{"condition", c.condition},
{"firstEdition", c.firstEdition},
{"rarity", c.rarity},
{"rarityCode", c.rarityCode},
{"signed", c.signed_},
{"altered", c.altered},
};
@@ -33,8 +32,6 @@ void from_json(const nlohmann::json& j, YuGiOhCard& c) {
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);
}
@@ -0,0 +1,222 @@
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <string>
#include <unordered_set>
#include <vector>
namespace ccm {
namespace {
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
std::string toLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
bool cardInPack(const nlohmann::json& card, std::string_view packName) {
if (packName.empty()) return true;
if (!card.contains("set_name") || !card.at("set_name").is_array()) return false;
for (const auto& pack : card.at("set_name")) {
if (pack.is_string() && pack.get<std::string>() == packName) return true;
}
return false;
}
} // namespace
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
: http_(http) {}
std::string DigiBattle99CardPreviewSource::normalizeCardNumber(std::string_view setNo) {
std::string s = trim(std::string(setNo));
if (s.empty()) return s;
// Uppercase leading alphabetic prefix (ST / BO / MO / Fx-style).
std::size_t i = 0;
while (i < s.size() && std::isalpha(static_cast<unsigned char>(s[i]))) {
s[i] = static_cast<char>(std::toupper(static_cast<unsigned char>(s[i])));
++i;
}
return s;
}
std::string DigiBattle99CardPreviewSource::buildImageUrl(std::string_view setNo) {
const std::string id = normalizeCardNumber(setNo);
return std::string(kImageBase) + id + ".jpg";
}
std::string DigiBattle99CardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setName,
std::string_view setNo) {
std::string url = "https://digimoncard.io/api-public/search.php?series=";
url += rfc3986PercentEncode(kSeries);
if (!name.empty()) {
url += "&n=";
url += rfc3986PercentEncode(name);
}
if (!setName.empty()) {
url += "&pack=";
url += rfc3986PercentEncode(setName);
}
const std::string num = normalizeCardNumber(setNo);
if (!num.empty()) {
url += "&card=";
url += rfc3986PercentEncode(num);
}
url += "&sort=name&sortdirection=asc";
return url;
}
Result<std::string, PreviewLookupError>
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(const std::string& body,
std::string_view wantedCardName) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return R::err({K::NotFound, j.value("error", std::string{"No cards found."})});
}
if (!j.is_array()) {
return R::err({K::Transient, "digimoncard.io Digi-Battle response is not a JSON array."});
}
if (j.empty()) {
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
}
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
const nlohmann::json* chosen = nullptr;
for (const auto& card : j) {
if (!wantedLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedLower) continue;
}
chosen = &card;
break;
}
if (chosen == nullptr) {
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
}
const std::string id = normalizeCardNumber(chosen->value("id", ""));
if (id.empty()) {
return R::err({K::NotFound, "Digi-Battle card has no id / card number."});
}
return R::ok(buildImageUrl(id));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setName,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string num = normalizeCardNumber(setNo);
if (!num.empty()) {
return R::ok(buildImageUrl(num));
}
if (name.empty()) {
return R::err({K::NotFound, "Digi-Battle preview requires a card name or set number."});
}
const std::string url = buildSearchUrl(name, setName, "");
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
return parseImageUrlFromSearch(resp.value(), name);
}
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setName,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return R::err(j.value("error", std::string{"No cards found."}));
}
if (!j.is_array() || j.empty()) {
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
}
const std::string wantedPack = trim(std::string(setName));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
for (const auto& card : j) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!cardInPack(card, wantedPack)) continue;
AutoDetectedPrint out;
out.setNo = normalizeCardNumber(card.value("id", ""));
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
if (out.setNo.empty()) continue;
collected.push_back(std::move(out));
}
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedPack.empty()) {
return R::err("Could not auto-detect Digi-Battle set print metadata.");
}
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
if (seen.insert(p.setNo).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectFirstPrint(
std::string_view name,
std::string_view setName) {
auto list = detectPrintVariants(name, setName);
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect Digi-Battle set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setName) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildSearchUrl(name, setName, "");
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setName, name);
}
// Retry name-only; still filter by pack in parsePrintVariants.
const std::string fallbackUrl = buildSearchUrl(name, "", "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setName, name);
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
namespace ccm {
DigiBattle99GameModule::DigiBattle99GameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
@@ -0,0 +1,119 @@
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <string>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
// Curated EN release dates for the vintage Digi-Battle product line.
// Series 1 Starter is verified 1999-06-01; other entries use digimoncard.io /
// checklist years (day unknown -> YYYY/01/01 or mid-year anchors for ordering).
const std::unordered_map<std::string, std::string>& curatedReleaseDates() {
static const std::unordered_map<std::string, std::string> kDates{
{"Series 1 Starter Set", "1999/06/01"},
{"Series 1 Booster Pack", "1999/06/01"},
{"Series 2 Booster Pack", "1999/09/01"},
{"Series 3 Booster Pack", "2000/01/01"},
{"Series 4 Booster Pack", "2000/06/01"},
{"Series 5 Booster Pack", "2000/10/01"},
{"Series 6 Booster Pack", "2001/01/01"},
{"Street Starter Set 1", "2001/01/01"},
{"Street Starter Set 2", "2001/02/01"},
{"Street Starter Set 3", "2001/03/01"},
{"Street Starter Set 4", "2001/04/01"},
{"Digimon The Movie Promo Cards", "2000/10/01"},
};
return kDates;
}
std::string releaseDateForPack(const std::string& packName) {
const auto& dates = curatedReleaseDates();
const auto it = dates.find(packName);
if (it != dates.end()) return it->second;
return {};
}
} // namespace
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
std::string out;
out.reserve(packName.size());
bool pendingHyphen = false;
for (unsigned char ch : packName) {
if (std::isalnum(ch)) {
if (pendingHyphen && !out.empty()) out.push_back('-');
pendingHyphen = false;
out.push_back(static_cast<char>(std::tolower(ch)));
} else {
pendingHyphen = !out.empty();
}
}
return out;
}
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return Result<std::vector<Set>>::err(
j.value("error", std::string{"digimoncard.io set search error"}));
}
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"digimoncard.io Digi-Battle response is not a JSON array.");
}
// Preserve first-seen order of pack names, then sort by release date.
std::unordered_set<std::string> seen;
std::vector<std::string> packNames;
packNames.reserve(16);
for (const auto& entry : j) {
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string name = pack.get<std::string>();
if (name.empty()) continue;
if (seen.insert(name).second) packNames.push_back(name);
}
}
std::vector<Set> out;
out.reserve(packNames.size());
for (const auto& name : packNames) {
Set s;
s.id = slugifyPackName(name);
s.name = name;
s.releaseDate = releaseDateForPack(name);
if (s.id.empty()) continue;
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
return a.name < b.name;
});
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
}
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
@@ -1,40 +1,16 @@
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
namespace ccm {
namespace {
// Percent-encode all bytes that are not unreserved per RFC 3986
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
// which does not URL-encode the URL string we hand it.
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<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
// Apply the same name massaging as the legacy query path before sending.
std::string sanitizeName(std::string_view name) {
std::string s(name);
@@ -59,7 +35,8 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
query += sanitized;
query += "\" AND set:";
query += std::string(setId);
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
return std::string("https://api.scryfall.com/cards/search?q=") +
rfc3986PercentEncode(query);
}
Result<std::string, PreviewLookupError>
@@ -1,39 +1,18 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
#include <unordered_set>
#include <vector>
namespace ccm {
namespace {
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
// Magic implementation; kept private so the two can drift independently if a
// future API requires it.
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<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
// Strip everything after the first '/' in a Pokemon collector number.
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
// `4/102`. Without this, no API match is found.
@@ -46,6 +25,19 @@ std::string normalizeNumber(std::string_view setNo) {
return s;
}
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
std::string toLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
@@ -67,7 +59,16 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
query += " number:";
query += num;
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
rfc3986PercentEncode(query);
}
std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name,
std::string_view setId) {
std::string url = buildSearchUrl(name, setId, "");
url += "&select=name,number,rarity,set";
url += "&pageSize=50";
return url;
}
Result<std::string, PreviewLookupError>
@@ -113,4 +114,87 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
return parseResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setId,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("Pokemon TCG returned no matching cards.");
}
const std::string wantedSetId = trim(std::string(setId));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushCard = [&collected](const nlohmann::json& card) {
AutoDetectedPrint out;
out.setNo = trim(card.value("number", ""));
out.rarity = trim(card.value("rarity", ""));
if (out.setNo.empty() && out.rarity.empty()) return;
collected.push_back(std::move(out));
};
for (const auto& card : j.at("data")) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!wantedSetId.empty()) {
std::string cardSetId;
if (card.contains("set") && card.at("set").is_object()) {
cardSetId = trim(card.at("set").value("id", ""));
}
if (cardSetId != wantedSetId) continue;
}
pushCard(card);
}
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSetId.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::err("Pokemon TCG returned no matching cards.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
const std::string key = p.setNo + '\0' + p.rarity;
if (seen.insert(key).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("Pokemon TCG JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildDetectSearchUrl(name, setId);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
}
const std::string fallbackUrl = buildDetectSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
}
} // namespace ccm
@@ -1,10 +1,12 @@
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <array>
#include <cctype>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -16,31 +18,6 @@ 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<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
@@ -54,6 +31,17 @@ std::string toLower(std::string s) {
return s;
}
std::string canonicalizeSetNameForAutoDetect(std::string_view setName) {
std::string canonical = trim(std::string(setName));
constexpr std::string_view k25thSuffix = " (25th Anniversary Edition)";
if (canonical.size() > k25thSuffix.size()
&& canonical.ends_with(k25thSuffix)) {
canonical.erase(canonical.size() - k25thSuffix.size());
canonical = trim(std::move(canonical));
}
return canonical;
}
// 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
@@ -134,6 +122,10 @@ std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
}
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
if (const std::string canonical = ygoRarityShortCode(rarityName); !canonical.empty()) {
return canonical;
}
// 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
@@ -171,7 +163,7 @@ std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName)
{"ultraparallelrare", "UPR"},
{"holographicrare", "HGR"},
{"starlightrare", "StR"},
{"collectorsrare", "ColR"},
{"collectorsrare", "CR"},
{"prismaticcollectorsrare", "PColR"},
{"quartercenturysecretrare", "QCScR"},
{"prismaticultimaterare", "PUtR"},
@@ -266,7 +258,7 @@ std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
std::string url =
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=imageinfo&iiprop=url&titles=";
url += urlEncode(joined);
url += rfc3986PercentEncode(joined);
return url;
}
@@ -330,10 +322,10 @@ Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaR
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);
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + rfc3986PercentEncode(name);
if (!setName.empty()) {
url += "&cardset=";
url += urlEncode(setName);
url += rfc3986PercentEncode(setName);
}
return url;
}
@@ -385,7 +377,7 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVarian
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 wantedSet = canonicalizeSetNameForAutoDetect(preferredSetName);
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
@@ -551,15 +543,16 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVaria
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildSearchUrl(name, setId);
const std::string canonicalSetName = canonicalizeSetNameForAutoDetect(setId);
const std::string url = buildSearchUrl(name, canonicalSetName);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
return parsePrintVariants(resp.value(), canonicalSetName, 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);
return parsePrintVariants(fallback.value(), canonicalSetName, name);
}
} // namespace ccm
+35
View File
@@ -3,9 +3,43 @@
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <string>
namespace ccm {
namespace {
struct YuGiOhSetAlias {
const char* code;
const char* name;
const char* releaseDate;
};
constexpr std::array<YuGiOhSetAlias, 6> kMissing25thAnniversaryReprints{{
// Keep this list in sync with docs/assets-and-info-apis.md (Info API section).
{"LOB-25TH", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "2023/04/20"},
{"MRD-25TH", "Metal Raiders (25th Anniversary Edition)", "2023/04/20"},
{"SRL-25TH", "Spell Ruler (25th Anniversary Edition)", "2023/04/20"},
{"PSV-25TH", "Pharaoh's Servant (25th Anniversary Edition)", "2023/04/20"},
{"DCR-25TH", "Dark Crisis (25th Anniversary Edition)", "2023/04/20"},
{"IOC-25TH", "Invasion of Chaos (25th Anniversary Edition)", "2023/06/08"},
}};
void appendMissingSetAliases(std::vector<Set>& sets) {
for (const auto& alias : kMissing25thAnniversaryReprints) {
const bool exists = std::any_of(
sets.begin(), sets.end(), [&](const Set& s) { return s.name == alias.name; });
if (exists) continue;
Set s;
s.id = alias.code;
s.name = alias.name;
s.releaseDate = alias.releaseDate;
sets.push_back(std::move(s));
}
}
} // namespace
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
@@ -29,6 +63,7 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
s.releaseDate = std::move(release);
out.push_back(std::move(s));
}
appendMissingSetAliases(out);
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
+34 -10
View File
@@ -1,5 +1,7 @@
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/util/HttpGetMapping.hpp"
#include <cpr/cpr.h>
#include <string>
@@ -24,8 +26,30 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
/*follow=*/true,
/*cont_send_cred=*/false,
cpr::PostRedirectFlags::POST_ALL});
rawExecutor_ = [this](std::string_view url) -> RawResponse {
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
return RawResponse{
.transportError = static_cast<bool>(r.error),
.transportMessage = r.error.message,
.statusCode = static_cast<int>(r.status_code),
.body = std::move(r.text),
};
};
}
CprHttpClient::CprHttpClient(GetExecutor executor,
std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(nullptr),
executor_(std::move(executor)) {}
CprHttpClient::CprHttpClient(RawGetExecutor rawExecutor,
std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(nullptr),
rawExecutor_(std::move(rawExecutor)) {}
CprHttpClient::~CprHttpClient() = default;
Result<std::string> CprHttpClient::get(std::string_view url) {
@@ -34,18 +58,18 @@ Result<std::string> CprHttpClient::get(std::string_view url) {
// (one fetch per BaseSelectedCardPanel selection change), so contention
// is negligible.
std::lock_guard<std::mutex> lock(sessionMutex_);
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
if (executor_) {
return executor_(url);
}
if (r.status_code < 200 || r.status_code >= 300) {
return Result<std::string>::err(
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
if (rawExecutor_) {
RawResponse raw = rawExecutor_(url);
return mapHttpGetResponse(raw.transportError,
raw.transportMessage,
raw.statusCode,
std::move(raw.body),
url);
}
return Result<std::string>::ok(std::move(r.text));
return Result<std::string>::err("HTTP error: no executor configured");
}
} // namespace ccm
+18 -15
View File
@@ -1,28 +1,15 @@
#include "ccm/services/CardFilter.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/util/AsciiUtils.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used
// String.prototype.toLowerCase() which on the realistic ASCII-only data set
// (English/German set names, Scryfall-fed labels, integer amounts) behaves
// identically.
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
bool containsLower(std::string_view haystack, std::string_view needleLower) {
return asciiLower(haystack).find(needleLower) != std::string::npos;
}
@@ -72,6 +59,22 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
if (containsLower(card.rarity, needle)) return true;
if (containsLower(ygoRarityShortCode(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;
}
bool matchesDigiBattle99Filter(const DigiBattle99Card& 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(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;
+5 -11
View File
@@ -218,18 +218,12 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
// and, if needed, fetch+store.
const std::string key = makeUrlKey(url);
std::string cached;
switch (cacheLookup(key, cached)) {
case CacheLookupKind::Hit:
return Result<std::string>::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;
const auto mem = cacheLookup(key, cached);
if (mem == CacheLookupKind::Hit) {
return Result<std::string>::ok(std::move(cached));
}
// Miss, or a spurious negative under a URL key (never written by normal
// code) — both continue to disk / network.
if (persistentCache_ != nullptr) {
const auto disk = persistentCache_->load(key);
if (disk.kind == IPreviewByteCache::HitKind::Hit) {
+78 -15
View File
@@ -1,29 +1,16 @@
#include "ccm/services/CardSorter.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/util/AsciiUtils.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// The comparator lowercases strings before compare via String.toLowerCase()-style behavior.
// We use ASCII-only tolower; the original TS app processed the same fields and
// never special-cased Unicode either, so this stays byte-compatible for the
// realistic data set (English/German/etc. names already lowercase identically).
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
// Wrap a less-than predicate so that ascending=false flips its meaning,
// mirroring `byField(field, asc)` in TableTemplate.tsx.
template <typename Less>
@@ -203,6 +190,12 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
return a.amount < b.amount;
}, ascending));
break;
case YuGiOhSortColumn::Rarity:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(ygoRarityShortCode(a.rarity)) < asciiLower(ygoRarityShortCode(b.rarity));
}, ascending));
break;
case YuGiOhSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
@@ -230,4 +223,74 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
}
}
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
DigiBattle99SortColumn column,
bool ascending) {
switch (column) {
case DigiBattle99SortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case DigiBattle99SortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(a.set.releaseDate) <
asciiLower(b.set.releaseDate);
}, ascending));
break;
case DigiBattle99SortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(to_string(a.language)) <
asciiLower(to_string(b.language));
}, ascending));
break;
case DigiBattle99SortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(to_string(a.condition)) <
asciiLower(to_string(b.condition));
}, ascending));
break;
case DigiBattle99SortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.amount < b.amount;
}, ascending));
break;
case DigiBattle99SortColumn::Holo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.holo < b.holo;
}, ascending));
break;
case DigiBattle99SortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.firstEdition < b.firstEdition;
}, ascending));
break;
case DigiBattle99SortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case DigiBattle99SortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.altered < b.altered;
}, ascending));
break;
case DigiBattle99SortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
} // namespace ccm
-1
View File
@@ -78,7 +78,6 @@ std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept {
for (std::size_t i = begin; i < end; ++i) {
value = value * 10 + static_cast<unsigned int>(filename[i] - '0');
}
if (value > 255) value = 255;
return static_cast<std::uint8_t>(value);
}
+3 -3
View File
@@ -11,13 +11,13 @@ 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, 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.
- `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, Yu-Gi-Oh!, and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
- `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
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`, `demo-ygo.png`, `demo-digibattle99.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
## Conventions
@@ -28,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 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 changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`, `DigiBattle99SetSource`, `DigiBattle99CardPreviewSource`) — 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/`).
+1 -1
View File
@@ -22,7 +22,7 @@ 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, Pokémon, and Yu-Gi-Oh! modules, preview fallback URLs / bundled YGO card-back asset, and shared HTTP behavior (`CprHttpClient`).
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic, Pokémon, Yu-Gi-Oh!, and Digimon Digi-Battle modules, preview fallback URLs / bundled card-back assets, and shared HTTP behavior (`CprHttpClient`).
## Performance & Caching
+8 -2
View File
@@ -326,6 +326,12 @@ Derive from `BaseCardEditDialog<<Name>Card>`. Override:
- `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`.
- `updateMenuName()` — return `"Update <Display>"`. This is what the dialog's "no sets cached" hint shows the user.
Optional `BaseCardEditDialog` extension points (defaults keep a single read-only set combo in the **Set** row):
- `customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo)` — the base wraps the combo in a host panel and calls this so a game can add adjacent controls (Yu-Gi-Oh! adds a **Set code** toggle, a text field, and **Auto detect** beside the combo). The default implementation only does `row.Add(combo, 1, wxEXPAND)`.
- `applySetSelectionByIndex(std::size_t index)` (non-virtual helper on the base) — selects a row in the combo and assigns `card_.set` from `availableSets()[index]` when the combo is enabled.
- `onSetSelectionApplied()` — called after `applySetSelectionByIndex` completes; default no-op. Yu-Gi-Oh! overrides it to clear cached print-variant metadata and reschedule the same follow-up as a manual `wxEVT_COMBOBOX` set change.
In the constructor:
1. Pass through to the `BaseCardEditDialog` constructor with the dialog title (e.g. `"Add <Display> Card"` or `"Edit <Display> Card"` based on `EditMode`), `imageService`, `setService`, `mode`, `std::move(initial)`, `Game::<Name>`, and the optional `preloadedSets` pointer.
@@ -354,10 +360,10 @@ Implement the virtuals:
- `gameId()` returns `Game::<Name>`.
- `displayName()` returns `"<Display>"`.
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`, and `Bind(EVT_CARD_ACTIVATED, ...)` so a double-click (or Enter on the focused row) calls `onEditCard` with `wxGetTopLevelParent(listPanel_)` as the modal owner when available. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `selectedPanel(parent)` — lazily allocates the selected panel.
- `refreshCollection()` — calls `collection_.list(Game::<Name>)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(...)`. Also re-syncs the selected panel.
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success.
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. For Add/Edit, follow the built-in game views: if `cardEditModalIsActive()` from `ccm/ui/CardEditModalGuard.hpp`, show a themed info dialog and return; otherwise wrap `ShowModal()` with `CardEditModalGuard` so a second Add/Edit cannot stack while one card dialog is already open.
- `onUpdateSets(parent)` — calls `sets_.updateSets(Game::<Name>)`, refreshes `setsCache_`, returns a status string.
- `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`.
- `applyTheme(palette)` — forwards to both panels' `applyTheme`.
+54 -8
View File
@@ -22,9 +22,13 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
Used by `PokemonCardPreviewSource` in two ways:
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.
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the cards `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
@@ -39,6 +43,10 @@ Upstream documentation:
`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`.
CCM3 also applies a deterministic local patch step in `YuGiOhSetSource::appendMissingSetAliases(...)` after parsing: if upstream omits known 25th Anniversary TCG reprints, the app injects missing aliases for `LOB-25TH`, `MRD-25TH`, `SRL-25TH`, `PSV-25TH`, `DCR-25TH`, and `IOC-25TH` (with fixed release dates) so users can still select those products in the set picker.
**UI note (set code entry, no extra HTTP):** The Yu-Gi-Oh! Add/Edit dialog can resolve a typed **product code** against the **already cached** set vector (same data as the set dropdown). Matching is implemented in `core/include/ccm/util/YuGiOhSetLookup.hpp` as `lookupYuGiOhSetByShorthand(...)`: trim ASCII whitespace, ASCII case-fold, then require an **exact** match on `Set.id` (the YGOPRODeck `set_code`). Zero matches → user error; more than one row with the same normalized id → ambiguous error (defensive). On a unique hit the dialog returns to the dropdown and selects that set.
### Asset API: Yugipedia `api.php` (primary)
`https://yugipedia.com/api.php?action=query&prop=imageinfo&iiprop=url&titles=...`
@@ -47,7 +55,7 @@ Used by `YuGiOhCardPreviewSource::fetchImageUrl` for the actual per-printing car
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 `<SET>` 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.
- `rarity` — full English rarity name from the edit dialog (`Ultra Rare``UR`, `Quarter Century Secret Rare``QCScR`, …). The canonical short-form mapping lives in `ygoRarityShortCode(...)` (`core/include/ccm/util/YuGiOhPrintingSlot.hpp`) and is reused by both the Yu-Gi-Oh overview-table rarity rendering and preview filename construction (`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:
@@ -70,6 +78,43 @@ Used in two situations:
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. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
## Digimon Digi-Battle (1999) APIs (digimoncard.io)
English Digi-Battle is wired as `Game::DigiBattle99` (`dirName` `digibattle99`, UI label **Digimon (Digi-Battle)**). Upstream docs: [digimoncard.io Public API](https://digimoncard.io/api-documentation). Always scope requests with `series=Digimon Digi-Battle Card Game` so modern Digimon Card Game rows are never mixed in. Rate limit: **15 requests / 10 seconds / IP** (429 then temporary block on abuse).
### Info API: derived set list from `search.php`
There is **no** dedicated sets endpoint. `DigiBattle99SetSource` calls:
`https://digimoncard.io/api-public/search.php?series=Digimon%20Digi-Battle%20Card%20Game&limit=1000&sort=name&sortdirection=asc`
and collects unique `set_name[]` pack strings. Each pack becomes a `Set` with:
- `Set.name` — exact pack display name (used as `pack=` on search / auto-detect)
- `Set.id` — stable slug (`Series 1 Starter Set``series-1-starter-set`); never rename after ship
- `Set.releaseDate` — curated table in the set source (Series 1 Starter = `1999/06/01` verified; other packs use documented year/month anchors)
Unknown future packs get an empty release date and sort last.
### Asset API: CDN images + `search.php` lookup
Card scans live at:
`https://images.digimoncard.io/images/cards/{id}.jpg`
where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also serves `.webp`, but CCM3 uses `.jpg` because `OnInit` only registers `wxPNGHandler` / `wxJPEGHandler` (WebP bytes would surface as “image decode failed”).
`DigiBattle99CardPreviewSource::fetchImageUrl`:
1. If `setNo` is non-empty → normalize alphabetic prefix to uppercase (**no** invented zero-padding) and return the CDN URL with **no** search round-trip.
2. Otherwise search with `n=` + optional `pack=` (display set name) + `series=`, take the first exact name matchs `id`, then build the CDN URL.
**Preview key:** `(name, set.name, setNo)` — middle slot is the pack **display name** (same idea as Yu-Gi-Oh! passing `set.name` for YGOPRODeck `cardset=`), not the slug id.
**Auto-detect** (`detectPrintVariants`): same search; distinct `id` values become `AutoDetectedPrint::setNo`. Digi-Battle UI is Pokémon-like (no persisted rarity).
Empty search array / `{"error":"..."}``NotFound`; bad JSON / HTTP → `Transient`.
## Runtime Flow In CCM3
The app uses the same flow for every game that registers a module:
@@ -83,15 +128,15 @@ The app uses the same flow for every game that registers a module:
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:
Three mechanisms reduce preview latency for **all** games (Magic, Pokemon, Yu-Gi-Oh!, DigiBattle99). 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 `<exeDir>/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path.
- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, 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.
- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, and `images.digimoncard.io` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway.
`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.
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; Digi-Battle often hits `images.digimoncard.io` only when `setNo` is already known), 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 `<exeDir>/.cache/preview-cache/` subdirectory or the umbrella `<exeDir>/.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).
@@ -102,6 +147,7 @@ Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match C
- Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.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 `<exeDir>/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.
- Digimon (Digi-Battle): no stable public back URL; load `<exeDir>/assets/digibattle99_card_back.png` (shipped from `ui_wx/assets/digibattle99_card_back.png` at link time).
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."
@@ -112,6 +158,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
- 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: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.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.
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`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`). 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 Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` 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.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+1 -1
View File
@@ -68,7 +68,7 @@ There is no explicit "refresh" or "invalidate" API on `CardPreviewService` — b
### 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 `<setNo>||<rarity>||<1E|UE>`. The user editing **any** lookup-relevant field of a card record produces a **different cache key** for the resulting selection, which means:
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 `<setNo>||<rarity>||<1E|UE>`. For Digimon Digi-Battle the middle slot is the pack **display name** (`Set.name`), not the slug id, so `pack=` search and the CDN path stay aligned. 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.
+1 -1
View File
@@ -19,7 +19,7 @@ The repository uses GitHub Actions workflows split by branch intent, with one or
### SonarQube Cloud (coverage quality gate)
Both `feature-ci.yml` and `master-ci.yml` include a Linux job that configures with GCC coverage flags, builds, runs `ctest`, generates `build/sonarqube-coverage.xml` via `gcovr`, and runs the SonarCloud scan. **`sonar.coverage.exclusions`** omit `ui_wx/` and `app/` from the coverage denominator because only `ccm_core` is exercised by automated tests; Sonar still analyzes those directories for bugs, vulnerabilities, and duplications. See [Testing Guide And Test Code Of Conduct](testing-and-test-code-of-conduct.md).
Both `feature-ci.yml` and `master-ci.yml` include a Linux job that configures with GCC coverage flags, builds, runs `ctest`, generates `build/sonarqube-coverage.xml` via `gcovr`, and runs the SonarCloud scan. **`sonar.coverage.exclusions`** omit `ui_wx/` and `app/` from the coverage denominator because only `ccm_core` is exercised by automated tests. The scan also sets **`sonar.cpd.exclusions`** for the repeated per-game wx scaffolding files (`*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so intentional parallel UI implementations do not drive the duplication gate. See [Testing Guide And Test Code Of Conduct](testing-and-test-code-of-conduct.md).
## Version Flow
+1 -1
View File
@@ -30,7 +30,7 @@ Windows and Linux use the same logical flow; only generator and compiler setup d
## Coverage Surface
**SonarCloud:** The CI Sonar scan reports coverage against `core/` paths that `ccm_core_tests` can execute. `ui_wx/` and the `app/` composition root are excluded from Sonars **coverage** calculation (`sonar.coverage.exclusions`) because they are not run under the doctest suite; UI behavior is covered by manual validation below. Other Sonar metrics still include those directories.
**SonarCloud:** The CI Sonar scan reports coverage against `core/` paths that `ccm_core_tests` can execute. `ui_wx/` and the `app/` composition root are excluded from Sonars **coverage** calculation (`sonar.coverage.exclusions`) because they are not run under the doctest suite; UI behavior is covered by manual validation below. For duplication, the scan excludes intentionally parallel per-game wx scaffolding (`sonar.cpd.exclusions` on `*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so CPD focuses on shared logic rather than mirrored UI wiring.
Current automated tests cover non-UI behavior, including:
+8 -3
View File
@@ -17,11 +17,16 @@
- `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` (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.
- `local_preview_byte_cache_tests.cpp``LocalPreviewByteCache` adapter against `StdFileSystem` (real disk under a unique `temp_directory_path()/ccm_preview_cache_test_*` per case, RAII `TempDir` cleanup; see also `std_file_system_tests.cpp`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
- `std_file_system_tests.cpp``StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
- `pokemon_set_source_tests.cpp``PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. 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`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. Drives `fetchAll` via `FixedHttpClient`.
- `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` 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).
- `yugioh_set_lookup_tests.cpp``lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous).
- `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh / DigiBattle99) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`.
- `yugioh_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; 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` / `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).
@@ -29,7 +34,7 @@
## Conventions
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` 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`. **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.
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`. **Narrow exceptions** (unique temp dirs + RAII cleanup): `local_preview_byte_cache_tests.cpp` (mtime/size semantics tied to real `std::filesystem`) and `std_file_system_tests.cpp` (`StdFileSystem` integration). Do not add further real-disk suites without the same cleanup guarantees.
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".
+9
View File
@@ -18,12 +18,21 @@ add_executable(ccm_core_tests
magic_card_preview_source_tests.cpp
card_preview_service_tests.cpp
local_preview_byte_cache_tests.cpp
std_file_system_tests.cpp
pokemon_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
icard_preview_source_tests.cpp
yugioh_set_source_tests.cpp
yugioh_set_lookup_tests.cpp
yugioh_card_preview_source_tests.cpp
game_module_tests.cpp
card_sorter_tests.cpp
card_filter_tests.cpp
ascii_utils_tests.cpp
http_get_mapping_tests.cpp
cpr_http_client_tests.cpp
main.cpp
)
+25
View File
@@ -0,0 +1,25 @@
#include <doctest/doctest.h>
#include "ccm/util/AsciiUtils.hpp"
using namespace ccm;
TEST_SUITE("asciiLower") {
TEST_CASE("empty string stays empty") {
CHECK(asciiLower("").empty());
}
TEST_CASE("lowercases ASCII letters and leaves other ASCII bytes unchanged") {
CHECK(asciiLower("AbC123!@#") == "abc123!@#");
}
TEST_CASE("non-ASCII UTF-8 bytes pass through unchanged") {
const std::string input = "caf\u00e9";
CHECK(asciiLower(input) == input);
}
TEST_CASE("bytes above ASCII range are passed through tolower unchanged") {
const std::string input(1, static_cast<char>('\x80'));
CHECK(asciiLower(input) == input);
}
}
+94 -1
View File
@@ -6,6 +6,7 @@
#include <doctest/doctest.h>
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -57,7 +58,13 @@ YuGiOhCard yc(std::string name,
std::string setName,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "",
bool firstEdition = false,
bool sgnd = false,
bool altered = false) {
YuGiOhCard c;
c.id = 1;
c.name = std::move(name);
@@ -65,6 +72,12 @@ YuGiOhCard yc(std::string name,
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
return c;
}
@@ -176,10 +189,90 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
}
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
TEST_CASE("empty filter matches every row") {
CHECK(matchesYuGiOhFilter(yc("Dark Magician", "Legend of Blue Eyes"), ""));
}
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(matchesYuGiOhFilter(c, "ur"));
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
}
TEST_CASE("matches by name and set.name") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "dark"));
CHECK(matchesYuGiOhFilter(c, "blue eyes"));
CHECK_FALSE(matchesYuGiOhFilter(c, "spell"));
}
TEST_CASE("matches by language, condition, amount, and note") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare",
12, Language::German, Condition::Played, "binder copy");
CHECK(matchesYuGiOhFilter(c, "german"));
CHECK(matchesYuGiOhFilter(c, "played"));
CHECK(matchesYuGiOhFilter(c, "12"));
CHECK(matchesYuGiOhFilter(c, "binder"));
CHECK_FALSE(matchesYuGiOhFilter(c, "english"));
}
TEST_CASE("matches rarity shorthand when the long rarity string does not") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005",
"Quarter Century Secret Rare");
CHECK(matchesYuGiOhFilter(c, "qcscr"));
CHECK_FALSE(matchesYuGiOhFilter(c, "mythic"));
}
TEST_CASE("boolean flag columns are not matched") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare",
1, Language::English, Condition::NearMint, "",
/*firstEdition=*/true, /*sgnd=*/true, /*altered=*/true);
CHECK_FALSE(matchesYuGiOhFilter(c, "true"));
CHECK_FALSE(matchesYuGiOhFilter(c, "false"));
CHECK(matchesYuGiOhFilter(c, "dark"));
}
TEST_CASE("no column hit returns false") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK_FALSE(matchesYuGiOhFilter(c, "zzznomatch"));
}
}
TEST_SUITE("CardFilter::matchesDigiBattle99Filter") {
TEST_CASE("matches by name and set.name") {
DigiBattle99Card c;
c.name = "Agumon";
c.set.name = "Series 1 Starter Set";
CHECK(matchesDigiBattle99Filter(c, "agu"));
CHECK(matchesDigiBattle99Filter(c, "STARTER"));
CHECK_FALSE(matchesDigiBattle99Filter(c, "greymon"));
}
TEST_CASE("includes setNo in searchable columns") {
DigiBattle99Card c;
c.name = "Agumon";
c.set.name = "Series 1 Starter Set";
c.setNo = "ST-01";
CHECK(matchesDigiBattle99Filter(c, "st-01"));
CHECK(matchesDigiBattle99Filter(c, "ST-"));
}
TEST_CASE("empty filter matches everything") {
DigiBattle99Card c;
c.name = "Agumon";
CHECK(matchesDigiBattle99Filter(c, ""));
}
TEST_CASE("boolean flag columns are not matched") {
DigiBattle99Card c;
c.name = "Agumon";
c.holo = true;
c.firstEdition = true;
c.signed_ = true;
c.altered = true;
CHECK_FALSE(matchesDigiBattle99Filter(c, "true"));
CHECK(matchesDigiBattle99Filter(c, "agu"));
}
}
+196
View File
@@ -118,6 +118,18 @@ public:
}
};
class AlwaysNegativeUrlCache final : public IPreviewByteCache {
public:
[[nodiscard]] LoadResult load(std::string_view key) override {
if (!key.empty() && key.front() == 'u') {
return {HitKind::NegativeHit, {}};
}
return {HitKind::Miss, {}};
}
void store(std::string_view, const std::string&) override {}
void storeNegative(std::string_view) override {}
};
// Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule {
public:
@@ -322,6 +334,78 @@ TEST_SUITE("CardPreviewService caching") {
CHECK(http.calls == 1);
}
TEST_CASE("memory-only caching works when no persistent cache is configured") {
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, nullptr};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "").isOk());
http.body = "OTHER";
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(second.isOk());
CHECK(second.value() == "PNG-bytes");
CHECK(http.calls == 1);
}
TEST_CASE("registerModule replaces the preview source for the same game") {
FakeSource firstSource;
firstSource.url = "https://example.com/first.png";
FakeGameModule firstModule;
firstModule.gameId = Game::Magic;
firstModule.preview = &firstSource;
FakeSource secondSource;
secondSource.url = "https://example.com/second.png";
FakeGameModule secondModule;
secondModule.gameId = Game::Magic;
secondModule.preview = &secondSource;
FixedHttpClient http;
http.body = "SECOND";
CardPreviewService svc{http};
svc.registerModule(firstModule);
svc.registerModule(secondModule);
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(out.isOk());
CHECK(out.value() == "SECOND");
CHECK(http.lastUrl == "https://example.com/second.png");
CHECK(firstSource.calls == 0);
CHECK(secondSource.calls == 1);
}
TEST_CASE("NotFound without persistent cache still negative-caches in memory") {
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "not found";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http, nullptr};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "X", "abc", "").isErr());
CHECK(source.calls == 1);
const auto second = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
REQUIRE(second.isErr());
CHECK(second.error() == "No preview available for this card.");
CHECK(source.calls == 1);
CHECK(http.calls == 0);
}
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
@@ -655,6 +739,98 @@ TEST_SUITE("CardPreviewService caching") {
CHECK(second.value() == "card-back-bytes");
CHECK(http.calls == 1);
}
TEST_CASE("empty HTTP body is rejected and not cached") {
FakeSource source;
source.url = "https://example.com/empty.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "";
CardPreviewService svc{http};
svc.registerModule(module);
const auto first = svc.fetchPreviewBytes(Game::Magic, "Any", "set", "1");
CHECK(first.isErr());
CHECK(first.error().find("Empty response body") != std::string::npos);
CHECK(http.calls == 1);
const auto second = svc.fetchPreviewBytes(Game::Magic, "Any", "set", "1");
CHECK(second.isErr());
CHECK(http.calls == 2);
}
TEST_CASE("url negative entry on disk is treated as miss and refetched") {
FixedHttpClient http;
http.body = "card-back";
AlwaysNegativeUrlCache disk;
CardPreviewService svc{http, &disk};
const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(out.isOk());
CHECK(out.value() == "card-back");
CHECK(http.calls == 1);
}
TEST_CASE("fetchImageBytesByUrl propagates HTTP errors when uncached") {
FixedHttpClient http;
http.ok = false;
http.err = "url fetch failed";
CardPreviewService svc{http};
const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(out.isErr());
CHECK(out.error() == "url fetch failed");
}
TEST_CASE("fetchImageBytesByUrl serves from persistent cache hit without HTTP") {
FixedHttpClient http;
http.body = "warm-card-back";
InMemoryByteCache disk;
// Seed persistent cache via first service instance.
{
CardPreviewService seed{http, &disk};
const auto seeded = seed.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(seeded.isOk());
CHECK(seeded.value() == "warm-card-back");
}
REQUIRE(http.calls == 1);
// Fresh service instance: force HTTP failure and ensure disk hit is used.
CardPreviewService warm{http, &disk};
http.ok = false;
const auto warmHit = warm.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(warmHit.isOk());
CHECK(warmHit.value() == "warm-card-back");
CHECK(http.calls == 1);
}
TEST_CASE("in-memory LRU evicts oldest entry after exceeding capacity") {
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "x";
CardPreviewService svc{http};
svc.registerModule(module);
const auto cap = CardPreviewService::kCacheCapacity;
for (std::size_t i = 0; i < cap + 1; ++i) {
const std::string name = std::string("LRU-") + std::to_string(i);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, name, "lea", "").isOk());
}
REQUIRE(http.calls == cap + 1);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "LRU-0", "lea", "").isOk());
CHECK(http.calls == cap + 2);
}
}
TEST_SUITE("CardPreviewService::detectFirstPrint") {
@@ -692,6 +868,16 @@ TEST_SUITE("CardPreviewService::detectFirstPrint") {
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
TEST_CASE("unregistered game returns explicit error") {
FixedHttpClient http;
CardPreviewService svc{http};
const auto out =
svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "LOB");
CHECK(out.isErr());
CHECK(out.error().find("No preview source registered") !=
std::string::npos);
}
}
TEST_SUITE("CardPreviewService::detectPrintVariants") {
@@ -729,4 +915,14 @@ TEST_SUITE("CardPreviewService::detectPrintVariants") {
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
TEST_CASE("unregistered game returns explicit error") {
FixedHttpClient http;
CardPreviewService svc{http};
const auto out =
svc.detectPrintVariants(Game::YuGiOh, "Dark Magician", "LOB");
CHECK(out.isErr());
CHECK(out.error().find("No preview source registered") !=
std::string::npos);
}
}
+235 -2
View File
@@ -6,6 +6,7 @@
#include <doctest/doctest.h>
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -46,7 +47,10 @@ PokemonCard pc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::uint8_t amount = 1,
bool holo = false, bool firstEdition = false,
bool sgnd = false, bool altered = false) {
bool sgnd = false, bool altered = false,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "") {
PokemonCard c;
c.id = id;
c.name = std::move(name);
@@ -57,6 +61,9 @@ PokemonCard pc(std::uint32_t id, std::string name,
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
return c;
}
@@ -64,7 +71,13 @@ 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) {
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
bool firstEdition = false,
bool sgnd = false,
bool altered = false,
std::string note = "") {
YuGiOhCard c;
c.id = id;
c.name = std::move(name);
@@ -73,6 +86,36 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
c.note = std::move(note);
return c;
}
DigiBattle99Card db(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::uint8_t amount = 1,
bool holo = false, bool firstEdition = false,
bool sgnd = false, bool altered = false,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "") {
DigiBattle99Card c;
c.id = id;
c.name = std::move(name);
c.set.name = std::move(setName);
c.set.releaseDate = std::move(releaseDate);
c.amount = amount;
c.holo = holo;
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
return c;
}
@@ -97,6 +140,13 @@ std::vector<std::uint32_t> ids(const std::vector<YuGiOhCard>& v) {
return out;
}
std::vector<std::uint32_t> ids(const std::vector<DigiBattle99Card>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
for (const auto& c : v) out.push_back(c.id);
return out;
}
} // namespace
TEST_SUITE("CardSorter - Magic columns") {
@@ -141,6 +191,9 @@ TEST_SUITE("CardSorter - Magic columns") {
};
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
@@ -254,6 +307,67 @@ TEST_SUITE("CardSorter - Pokemon-specific columns") {
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
TEST_CASE("Name sorts case-insensitively") {
std::vector<PokemonCard> v = {
pc(1, "piKAchu", "X", "2000/01/01"),
pc(2, "abra", "X", "2000/01/01"),
pc(3, "CHARMANDER", "X", "2000/01/01"),
};
sortPokemonCards(v, PokemonSortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
}
TEST_CASE("Language and Condition sort by lowercased labels") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, false, false,
Language::Japanese, Condition::Mint),
pc(2, "b", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::Played),
pc(3, "c", "X", "2000/01/01", 1, false, false, false, false,
Language::German, Condition::NearMint),
};
sortPokemonCards(v, PokemonSortColumn::Language, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortPokemonCards(v, PokemonSortColumn::Condition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("Signed and Altered sort like Magic booleans") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, /*sgnd=*/false, /*alt=*/true),
pc(2, "b", "X", "2000/01/01", 1, false, false, /*sgnd=*/true, /*alt=*/false),
};
sortPokemonCards(v, PokemonSortColumn::Signed, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
sortPokemonCards(v, PokemonSortColumn::Altered, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
TEST_CASE("Note sorts case-insensitively") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "ZETA"),
pc(2, "b", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "alpha"),
pc(3, "c", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "Beta"),
};
sortPokemonCards(v, PokemonSortColumn::Note, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
}
TEST_CASE("descending SetReleaseDate reverses chronological order") {
std::vector<PokemonCard> v = {
pc(1, "x", "Late", "2020/01/01"),
pc(2, "y", "Early", "1999/01/01"),
pc(3, "z", "Mid", "2015/06/01"),
};
sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
}
TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
@@ -272,6 +386,93 @@ TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
}
TEST_SUITE("CardSorter - YuGiOh columns") {
TEST_CASE("Name sorts case-insensitively") {
std::vector<YuGiOhCard> v = {
yc(1, "BLUE-EYES", "X", "2000/01/01"),
yc(2, "dark magician", "X", "2000/01/01"),
yc(3, "CELTIC_GUARDIAN", "X", "2000/01/01"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("SetReleaseDate sorts chronologically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "Late", "2020/01/01"),
yc(2, "b", "Early", "1999/01/01"),
yc(3, "c", "Mid", "2015/06/01"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("Language and Condition sort by lowercased labels") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 1,
Language::Japanese, Condition::Mint),
yc(2, "b", "X", "2000/01/01", "", "", 1,
Language::English, Condition::Played),
yc(3, "c", "X", "2000/01/01", "", "", 1,
Language::German, Condition::NearMint),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Language, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Condition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("FirstEdition Signed Altered and Note columns sort consistently") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/true, /*sgnd=*/false, /*alt=*/true, "Z"),
yc(2, "b", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/false, /*sgnd=*/true, /*alt=*/false, "a"),
yc(3, "c", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/false, /*sgnd=*/false, /*alt=*/false, "m"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::FirstEdition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Signed, /*ascending=*/true);
// After FirstEdition sort order is {2,3,1}: signed=false wins first (stable: 3 then 1).
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
sortYuGiOhCards(v, YuGiOhSortColumn::Altered, /*ascending=*/true);
// Previous order {3,1,2}: altered=false entries are 3 and 2 before true (1).
CHECK(ids(v) == std::vector<std::uint32_t>{3, 2, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Note, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // a, m, Z (case-insensitive)
}
TEST_CASE("Rarity sorts by rarity shorthand") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "Ultra Rare", 1),
yc(2, "b", "X", "2000/01/01", "", "Common", 1),
yc(3, "c", "X", "2000/01/01", "", "Secret Rare", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // C, ScR, UR
}
TEST_CASE("Rarity treats unknown labels as equal empty shorthand") {
std::vector<YuGiOhCard> v = {
yc(1, "alpha", "X", "2000/01/01", "", "Mythic Cosmic Rare", 1),
yc(2, "beta", "X", "2000/01/01", "", "Other Unknown", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
}
TEST_CASE("Amount sorts numerically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 9),
@@ -282,3 +483,35 @@ TEST_SUITE("CardSorter - YuGiOh columns") {
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
}
TEST_SUITE("CardSorter - DigiBattle99 columns") {
TEST_CASE("Holo and FirstEdition sort false before true") {
std::vector<DigiBattle99Card> v = {
db(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*first=*/false),
db(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/true),
db(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/false),
};
sortDigiBattle99Cards(v, DigiBattle99SortColumn::Holo, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortDigiBattle99Cards(v, DigiBattle99SortColumn::FirstEdition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
TEST_CASE("Set column sorts by release date") {
std::vector<DigiBattle99Card> v = {
db(1, "x", "Late", "2001/01/01"),
db(2, "y", "Early", "1999/06/01"),
};
sortDigiBattle99Cards(v, DigiBattle99SortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
TEST_CASE("Name sorts case-insensitively") {
std::vector<DigiBattle99Card> v = {
db(1, "greymon", "X", "2000/01/01"),
db(2, "Agumon", "X", "2000/01/01"),
};
sortDigiBattle99Cards(v, DigiBattle99SortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
}
+120 -1
View File
@@ -16,9 +16,15 @@ namespace {
class InMemoryRepo final : public ICollectionRepository<MagicCard> {
public:
Map storage;
bool failLoad{false};
bool failSave{false};
Result<Map> load(Game) override { return Result<Map>::ok(storage); }
Result<Map> load(Game) override {
if (failLoad) return Result<Map>::err("load failed");
return Result<Map>::ok(storage);
}
Result<void> save(Game, const Map& m) override {
if (failSave) return Result<void>::err("save failed");
storage = m;
return Result<void>::ok();
}
@@ -27,12 +33,14 @@ public:
class StubImageStore final : public IImageStore {
public:
std::vector<std::pair<Game, std::string>> removed;
bool failRemove{false};
Result<std::string> copyIn(Game, const std::filesystem::path&, const std::string& n) override {
return Result<std::string>::ok(n);
}
Result<void> remove(Game g, const std::string& n) override {
removed.emplace_back(g, n);
if (failRemove) return Result<void>::err("remove failed for " + n);
return Result<void>::ok();
}
std::filesystem::path resolvePath(Game, const std::string& n) const override {
@@ -51,6 +59,16 @@ MagicCard makeCard(const std::string& name, std::vector<std::string> imgs = {})
} // namespace
TEST_SUITE("CollectionService<MagicCard>") {
TEST_CASE("nextId uses highest existing id plus one") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
repo.storage.emplace(2, makeCard("A"));
repo.storage.emplace(9, makeCard("B"));
CHECK(CollectionService<MagicCard>::nextId(repo.storage) == 10);
}
TEST_CASE("nextId on empty map is 0, then strictly increments") {
InMemoryRepo repo;
StubImageStore store;
@@ -117,4 +135,105 @@ TEST_SUITE("CollectionService<MagicCard>") {
CHECK(svc.remove(Game::Magic, 12345).isErr());
}
TEST_CASE("load errors are propagated by list and findById") {
InMemoryRepo repo;
repo.failLoad = true;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto listed = svc.list(Game::Magic);
REQUIRE(listed.isErr());
CHECK(listed.error() == "load failed");
const auto found = svc.findById(Game::Magic, 1);
REQUIRE(found.isErr());
CHECK(found.error() == "load failed");
}
TEST_CASE("save errors are propagated by add and update") {
InMemoryRepo repo;
repo.failSave = true;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto addRes = svc.add(Game::Magic, makeCard("A"));
REQUIRE(addRes.isErr());
CHECK(addRes.error() == "save failed");
repo.failSave = false;
const auto id = svc.add(Game::Magic, makeCard("B"));
REQUIRE(id.isOk());
repo.failSave = true;
MagicCard updated = makeCard("Renamed");
updated.id = id.value();
const auto updateRes = svc.update(Game::Magic, updated);
REQUIRE(updateRes.isErr());
CHECK(updateRes.error() == "save failed");
}
TEST_CASE("add overwrites input card id with generated id") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
MagicCard card = makeCard("Has User Id");
card.id = 777;
const auto out = svc.add(Game::Magic, card);
REQUIRE(out.isOk());
CHECK(out.value() == 0);
REQUIRE(repo.storage.count(0) == 1);
CHECK(repo.storage.at(0).name == "Has User Id");
CHECK(repo.storage.count(777) == 0);
}
TEST_CASE("findById returns nullopt for missing id") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto out = svc.findById(Game::Magic, 99);
REQUIRE(out.isOk());
CHECK_FALSE(out.value().has_value());
}
TEST_CASE("remove reports image cleanup issues but still removes card") {
InMemoryRepo repo;
StubImageStore store;
store.failRemove = true;
CollectionService<MagicCard> svc{repo, store};
const auto id = svc.add(
Game::Magic, makeCard("With Images", {"a.png", "b.png"}));
REQUIRE(id.isOk());
const auto removed = svc.remove(Game::Magic, id.value());
REQUIRE(removed.isErr());
CHECK(removed.error().find("Card removed but image cleanup had issues:") != std::string::npos);
CHECK(removed.error().find("remove failed for a.png") != std::string::npos);
CHECK(removed.error().find("remove failed for b.png") != std::string::npos);
const auto listed = svc.list(Game::Magic);
REQUIRE(listed.isOk());
CHECK(listed.value().empty());
}
TEST_CASE("remove propagates save failure after image cleanup") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto id = svc.add(
Game::Magic, makeCard("With Images", {"a.png", "b.png"}));
REQUIRE(id.isOk());
repo.failSave = true;
const auto removed = svc.remove(Game::Magic, id.value());
REQUIRE(removed.isErr());
CHECK(removed.error() == "save failed");
REQUIRE(store.removed.size() == 2);
CHECK(store.removed[0].second == "a.png");
CHECK(store.removed[1].second == "b.png");
}
}
+146
View File
@@ -0,0 +1,146 @@
#include <doctest/doctest.h>
#include "ccm/infra/CprHttpClient.hpp"
#include <string>
#include <string_view>
using namespace ccm;
TEST_SUITE("CprHttpClient injected executor") {
TEST_CASE("forwards URL to injected executor and returns payload") {
std::string seenUrl;
CprHttpClient client{
[&seenUrl](std::string_view url) -> Result<std::string> {
seenUrl = std::string(url);
return Result<std::string>::ok("body");
}
};
const auto out = client.get("https://example.com/api?q=1");
REQUIRE(out.isOk());
CHECK(out.value() == "body");
CHECK(seenUrl == "https://example.com/api?q=1");
}
TEST_CASE("propagates injected executor error as-is") {
CprHttpClient client{
[](std::string_view) -> Result<std::string> {
return Result<std::string>::err("HTTP 503 from https://example.com");
}
};
const auto out = client.get("https://example.com");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 503 from https://example.com");
}
TEST_CASE("returns an explicit error when executor is empty") {
CprHttpClient::GetExecutor empty;
CprHttpClient client{empty};
const auto out = client.get("https://example.com");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP error: no executor configured");
}
}
TEST_SUITE("CprHttpClient injected raw executor") {
TEST_CASE("maps 2xx raw response to success body") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = false,
.transportMessage = "",
.statusCode = 200,
.body = "ok-body",
};
}
};
const auto out = client.get("https://example.com/success");
REQUIRE(out.isOk());
CHECK(out.value() == "ok-body");
}
TEST_CASE("maps transport error via shared http mapping") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = true,
.transportMessage = "timeout",
.statusCode = 0,
.body = "",
};
}
};
const auto out = client.get("https://example.com/timeout");
REQUIRE(out.isErr());
CHECK(out.error().find("timeout") != std::string::npos);
}
TEST_CASE("passes URL through raw executor unchanged") {
std::string seenUrl;
CprHttpClient client{
[&seenUrl](std::string_view url) -> CprHttpClient::RawResponse {
seenUrl = std::string(url);
return CprHttpClient::RawResponse{
.transportError = false,
.transportMessage = "",
.statusCode = 200,
.body = "ok",
};
}
};
const auto out = client.get("https://example.com/raw?q=a%20b");
REQUIRE(out.isOk());
CHECK(seenUrl == "https://example.com/raw?q=a%20b");
}
TEST_CASE("maps non-2xx status to error") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = false,
.transportMessage = "",
.statusCode = 503,
.body = "service unavailable",
};
}
};
const auto out = client.get("https://example.com/fail");
REQUIRE(out.isErr());
CHECK(out.error().find("HTTP 503") != std::string::npos);
}
TEST_CASE("transport error takes precedence over status code") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = true,
.transportMessage = "socket closed",
.statusCode = 200,
.body = "ignored",
};
}
};
const auto out = client.get("https://example.com/transport");
REQUIRE(out.isErr());
CHECK(out.error().find("socket closed") != std::string::npos);
}
}
TEST_SUITE("CprHttpClient real session") {
TEST_CASE("default constructor handles malformed URL without crashing") {
// Exercise the real cpr::Session-backed constructor/lambda path
// without depending on external network availability.
CprHttpClient client{};
const auto out = client.get("://not-a-valid-url");
REQUIRE(out.isErr());
CHECK(out.error().find("HTTP") != std::string::npos);
}
}
@@ -0,0 +1,179 @@
#include <doctest/doctest.h>
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
using namespace ccm;
namespace {
class FixedHttpClient final : public IHttpClient {
public:
std::string lastUrl;
std::string body;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err("offline");
}
};
} // namespace
TEST_SUITE("DigiBattle99CardPreviewSource::normalizeCardNumber") {
TEST_CASE("uppercases alphabetic prefix without zero-padding") {
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber("bo-88") == "BO-88");
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber("st-01") == "ST-01");
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber(" MO-06 ") == "MO-06");
}
}
TEST_SUITE("DigiBattle99CardPreviewSource::buildImageUrl") {
TEST_CASE("builds CDN jpeg URL from card id") {
CHECK(DigiBattle99CardPreviewSource::buildImageUrl("ST-01") ==
"https://images.digimoncard.io/images/cards/ST-01.jpg");
CHECK(DigiBattle99CardPreviewSource::buildImageUrl("bo-115") ==
"https://images.digimoncard.io/images/cards/BO-115.jpg");
}
}
TEST_SUITE("DigiBattle99CardPreviewSource::buildSearchUrl") {
TEST_CASE("percent-encodes name pack and series") {
const auto url = DigiBattle99CardPreviewSource::buildSearchUrl(
"Agumon", "Series 1 Starter Set", "");
CHECK(url.find("https://digimoncard.io/api-public/search.php?series=") == 0);
CHECK(url.find("Digimon%20Digi-Battle%20Card%20Game") != std::string::npos);
CHECK(url.find("&n=Agumon") != std::string::npos);
CHECK(url.find("&pack=Series%201%20Starter%20Set") != std::string::npos);
CHECK(url.find("&card=") == std::string::npos);
}
TEST_CASE("includes card= when setNo is present") {
const auto url = DigiBattle99CardPreviewSource::buildSearchUrl(
"Agumon", "Series 1 Starter Set", "st-01");
CHECK(url.find("&card=ST-01") != std::string::npos);
}
TEST_CASE("empty pack omits pack clause") {
const auto url =
DigiBattle99CardPreviewSource::buildSearchUrl("Agumon", "", "ST-01");
CHECK(url.find("&pack=") == std::string::npos);
CHECK(url.find("&card=ST-01") != std::string::npos);
}
}
TEST_SUITE("DigiBattle99CardPreviewSource::parseImageUrlFromSearch") {
TEST_CASE("returns CDN URL for first exact name match") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]}
])";
const auto out =
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(json, "Agumon");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
}
TEST_CASE("empty array is NotFound") {
const auto out =
DigiBattle99CardPreviewSource::parseImageUrlFromSearch("[]", "Agumon");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("API error object is NotFound") {
const auto out = DigiBattle99CardPreviewSource::parseImageUrlFromSearch(
R"({"error":"No cards found for this search."})", "Agumon");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("non-array is Transient") {
const auto out =
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(R"({"meta":{}})", "Agumon");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("malformed JSON is Transient") {
const auto out =
DigiBattle99CardPreviewSource::parseImageUrlFromSearch("{not json", "Agumon");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("DigiBattle99CardPreviewSource::fetchImageUrl") {
TEST_CASE("setNo present skips HTTP and returns CDN URL") {
FixedHttpClient http;
DigiBattle99CardPreviewSource src{http};
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "st-01");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
CHECK(http.lastUrl.empty());
}
TEST_CASE("empty setNo searches and parses") {
FixedHttpClient http;
http.body = R"([{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}])";
DigiBattle99CardPreviewSource src{http};
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
CHECK(http.lastUrl.find("search.php") != std::string::npos);
CHECK(http.lastUrl.find("n=Agumon") != std::string::npos);
}
TEST_CASE("HTTP failure is Transient") {
FixedHttpClient http;
http.ok = false;
DigiBattle99CardPreviewSource src{http};
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("DigiBattle99CardPreviewSource::parsePrintVariants") {
TEST_CASE("collects distinct card ids for name+pack") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]},
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
])";
const auto out = DigiBattle99CardPreviewSource::parsePrintVariants(
json, "Series 1 Starter Set", "Agumon");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "ST-01");
}
TEST_CASE("pack miss with exact name returns error") {
const std::string json = R"([
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]}
])";
const auto out = DigiBattle99CardPreviewSource::parsePrintVariants(
json, "Series 1 Starter Set", "Agumon");
CHECK(out.isErr());
}
}
TEST_SUITE("DigiBattle99CardPreviewSource::detectPrintVariants") {
TEST_CASE("round-trips through FixedHttpClient") {
FixedHttpClient http;
http.body = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Agumon","id":"ST-126","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectPrintVariants("Agumon", "Series 1 Starter Set");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "ST-01");
CHECK(out.value()[1].setNo == "ST-126");
}
}
+112
View File
@@ -0,0 +1,112 @@
#include <doctest/doctest.h>
#include "ccm/games/digibattle99/DigiBattle99SetSource.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<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err("offline");
}
};
} // namespace
TEST_SUITE("DigiBattle99SetSource::slugifyPackName") {
TEST_CASE("slugifies pack display names") {
CHECK(DigiBattle99SetSource::slugifyPackName("Series 1 Starter Set") ==
"series-1-starter-set");
CHECK(DigiBattle99SetSource::slugifyPackName("Digimon The Movie Promo Cards") ==
"digimon-the-movie-promo-cards");
}
}
TEST_SUITE("DigiBattle99SetSource::parseResponse") {
TEST_CASE("derives unique packs with curated release dates") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]},
{"name":"Agumon","id":"ST-126","set_name":["Series 1 Starter Set"]},
{"name":"Promo","id":"MO-06","set_name":["Digimon The Movie Promo Cards"]}
])";
const auto out = DigiBattle99SetSource::parseResponse(json);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 3);
// Same curated date for Series 1 products → secondary sort by name:
// "Booster" before "Starter".
CHECK(out.value()[0].id == "series-1-booster-pack");
CHECK(out.value()[0].name == "Series 1 Booster Pack");
CHECK(out.value()[0].releaseDate == "1999/06/01");
CHECK(out.value()[1].id == "series-1-starter-set");
CHECK(out.value()[1].name == "Series 1 Starter Set");
CHECK(out.value()[1].releaseDate == "1999/06/01");
CHECK(out.value()[2].id == "digimon-the-movie-promo-cards");
CHECK(out.value()[2].releaseDate == "2000/10/01");
}
TEST_CASE("sorts by release date then name") {
const std::string json = R"([
{"name":"A","id":"ST-1","set_name":["Street Starter Set 2"]},
{"name":"B","id":"ST-2","set_name":["Series 1 Starter Set"]},
{"name":"C","id":"ST-3","set_name":["Street Starter Set 1"]}
])";
const auto out = DigiBattle99SetSource::parseResponse(json);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 3);
CHECK(out.value()[0].name == "Series 1 Starter Set");
CHECK(out.value()[1].name == "Street Starter Set 1");
CHECK(out.value()[2].name == "Street Starter Set 2");
}
TEST_CASE("empty array returns an empty list") {
const auto out = DigiBattle99SetSource::parseResponse("[]");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
TEST_CASE("non-array object with error is an error") {
const auto out = DigiBattle99SetSource::parseResponse(
R"({"error":"No cards found for this search."})");
CHECK(out.isErr());
}
TEST_CASE("missing top-level array returns an error") {
const auto out = DigiBattle99SetSource::parseResponse(R"({"meta":{}})");
CHECK(out.isErr());
}
TEST_CASE("invalid JSON returns an error") {
const auto out = DigiBattle99SetSource::parseResponse("{not json");
CHECK(out.isErr());
}
}
TEST_SUITE("DigiBattle99SetSource::fetchAll") {
TEST_CASE("network error is surfaced as a Result error") {
FixedHttpClient http;
http.ok = false;
DigiBattle99SetSource src{http};
CHECK(src.fetchAll().isErr());
}
TEST_CASE("network success hits the Digi-Battle search endpoint") {
FixedHttpClient http;
http.ok = true;
http.body = R"([{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}])";
DigiBattle99SetSource src{http};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
CHECK(out.value().front().id == "series-1-starter-set");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
}
+424 -2
View File
@@ -1,6 +1,7 @@
#include <doctest/doctest.h>
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
@@ -23,6 +24,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json jYgo = "YuGiOh";
CHECK(jYgo.get<Game>() == Game::YuGiOh);
nlohmann::json jDigi = "DigiBattle99";
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
nlohmann::json j3 = Theme::Dark;
CHECK(j3.get<std::string>() == "Dark");
CHECK(j3.get<Theme>() == Theme::Dark);
@@ -42,6 +46,50 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json bad = "Spanglish";
CHECK_THROWS(bad.get<Language>());
}
TEST_CASE("all enum values round-trip through string helpers") {
for (const auto game : allGames()) {
const auto name = to_string(game);
CHECK(gameFromString(name).has_value());
CHECK(*gameFromString(name) == game);
}
for (const auto language : allLanguages()) {
const auto name = to_string(language);
CHECK(languageFromString(name).has_value());
CHECK(*languageFromString(name) == language);
}
for (const auto condition : allConditions()) {
const auto name = to_string(condition);
CHECK(conditionFromString(name).has_value());
CHECK(*conditionFromString(name) == condition);
}
for (const auto theme : allThemes()) {
const auto name = to_string(theme);
CHECK(themeFromString(name).has_value());
CHECK(*themeFromString(name) == theme);
}
}
TEST_CASE("invalid enum helper inputs return nullopt") {
CHECK_FALSE(gameFromString("magic").has_value());
CHECK_FALSE(languageFromString("EN").has_value());
CHECK_FALSE(conditionFromString("Near Mint").has_value());
CHECK_FALSE(themeFromString("OLED").has_value());
}
TEST_CASE("invalid game, condition and theme JSON values throw") {
nlohmann::json badGame = "YGO";
CHECK_THROWS(badGame.get<Game>());
nlohmann::json badCondition = "Pristine";
CHECK_THROWS(badCondition.get<Condition>());
nlohmann::json badTheme = "Midnight";
CHECK_THROWS(badTheme.get<Theme>());
}
}
TEST_SUITE("Set JSON shape stays stable") {
@@ -109,6 +157,34 @@ TEST_SUITE("PokemonCard JSON") {
}
}
TEST_SUITE("DigiBattle99Card JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
DigiBattle99Card c;
c.id = 3;
c.amount = 2;
c.name = "Agumon";
c.set = Set{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"};
c.setNo = "ST-01";
c.note = "starter";
c.images = {"a.png"};
c.language = Language::English;
c.condition = Condition::NearMint;
c.firstEdition = false;
c.holo = true;
c.signed_ = true;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "ST-01");
CHECK(j.at("firstEdition") == false);
CHECK(j.at("holo") == true);
CHECK(j.at("signed") == true);
const DigiBattle99Card back = j.get<DigiBattle99Card>();
CHECK(back == c);
}
}
TEST_SUITE("Configuration JSON matches Rust serde aliases") {
TEST_CASE("dataStorage / defaultGame / theme keys are present") {
Configuration cfg;
@@ -124,6 +200,18 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
const auto back = j.get<Configuration>();
CHECK(back == cfg);
}
TEST_CASE("missing theme key defaults to Light") {
const nlohmann::json j = {
{"dataStorage", "/portable/data"},
{"defaultGame", "YuGiOh"},
};
const auto cfg = j.get<Configuration>();
CHECK(cfg.dataStorage == "/portable/data");
CHECK(cfg.defaultGame == Game::YuGiOh);
CHECK(cfg.theme == Theme::Light);
}
}
TEST_SUITE("YuGiOhCard JSON") {
@@ -135,7 +223,6 @@ TEST_SUITE("YuGiOhCard JSON") {
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;
@@ -147,10 +234,345 @@ TEST_SUITE("YuGiOhCard JSON") {
nlohmann::json j = c;
CHECK(j.at("setNo") == "SDK-001");
CHECK(j.at("rarity") == "Ultra Rare");
CHECK(j.at("rarityCode") == "(UR)");
CHECK_FALSE(j.contains("rarityCode"));
CHECK(j.at("signed") == false);
const YuGiOhCard back = j.get<YuGiOhCard>();
CHECK(back == c);
}
TEST_CASE("legacy JSON without rarityCode key parses (domain uses rarity only)") {
const nlohmann::json j = {
{"id", 1},
{"amount", 1},
{"name", "Dark Magician"},
{"set", nlohmann::json{
{"id", "SDY"},
{"name", "Starter Deck: Yugi"},
{"releaseDate", "2002/03/29"},
}},
{"setNo", "SDY-006"},
{"rarity", "Ultra Rare"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", false},
{"signed", false},
{"altered", false},
};
const auto card = j.get<YuGiOhCard>();
CHECK(card.rarity == "Ultra Rare");
CHECK(card.setNo == "SDY-006");
CHECK(card.set.id == "SDY");
}
TEST_CASE("serializes non-default flags and metadata fields") {
YuGiOhCard c;
c.id = 3;
c.amount = 4;
c.name = "Red-Eyes Black Dragon";
c.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
c.setNo = "LOB-070";
c.rarity = "Secret Rare";
c.note = "graded";
c.images = {};
c.language = Language::German;
c.condition = Condition::Played;
c.firstEdition = false;
c.signed_ = true;
c.altered = true;
const nlohmann::json j = c;
CHECK(j.at("firstEdition") == false);
CHECK(j.at("signed") == true);
CHECK(j.at("altered") == true);
CHECK(j.at("language") == "German");
CHECK(j.at("condition") == "Played");
CHECK(j.at("images") == nlohmann::json::array());
const YuGiOhCard back = j.get<YuGiOhCard>();
CHECK(back == c);
}
TEST_CASE("operator== distinguishes each field") {
YuGiOhCard base;
base.id = 10;
base.amount = 2;
base.name = "Dark Magician";
base.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
base.setNo = "LOB-005";
base.rarity = "Ultra Rare";
base.note = "note";
base.images = {"a.png"};
base.language = Language::English;
base.condition = Condition::NearMint;
base.firstEdition = true;
base.signed_ = false;
base.altered = false;
auto changed = base;
changed.id = 11;
CHECK_FALSE(changed == base);
changed = base;
changed.amount = 3;
CHECK_FALSE(changed == base);
changed = base;
changed.name = "Other";
CHECK_FALSE(changed == base);
changed = base;
changed.set.name = "Other Set";
CHECK_FALSE(changed == base);
changed = base;
changed.setNo = "LOB-006";
CHECK_FALSE(changed == base);
changed = base;
changed.rarity = "Rare";
CHECK_FALSE(changed == base);
changed = base;
changed.note = "other";
CHECK_FALSE(changed == base);
changed = base;
changed.images = {};
CHECK_FALSE(changed == base);
changed = base;
changed.language = Language::Japanese;
CHECK_FALSE(changed == base);
changed = base;
changed.condition = Condition::Played;
CHECK_FALSE(changed == base);
changed = base;
changed.firstEdition = false;
CHECK_FALSE(changed == base);
changed = base;
changed.signed_ = true;
CHECK_FALSE(changed == base);
changed = base;
changed.altered = true;
CHECK_FALSE(changed == base);
}
}
TEST_SUITE("Domain JSON required fields") {
TEST_CASE("Set missing required key throws") {
const nlohmann::json j = {
{"id", "lea"},
{"name", "Limited Edition Alpha"},
};
CHECK_THROWS(j.get<Set>());
}
TEST_CASE("MagicCard missing required key throws") {
const nlohmann::json j = {
{"id", 10},
{"amount", 1},
{"name", "Lightning Bolt"},
{"set", nlohmann::json{
{"id", "lea"},
{"name", "Limited Edition Alpha"},
{"releaseDate", "1993/08/05"},
}},
// note missing on purpose
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"foil", false},
{"signed", false},
{"altered", false},
};
CHECK_THROWS(j.get<MagicCard>());
}
TEST_CASE("PokemonCard missing required key throws") {
const nlohmann::json j = {
{"id", 7},
{"amount", 1},
{"name", "Charizard"},
{"set", nlohmann::json{
{"id", "base1"},
{"name", "Base Set"},
{"releaseDate", "1999/01/09"},
}},
{"setNo", "4/102"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "Excellent"},
{"firstEdition", true},
// holo missing on purpose
{"signed", false},
{"altered", false},
};
CHECK_THROWS(j.get<PokemonCard>());
}
TEST_CASE("YuGiOhCard missing required key throws") {
const nlohmann::json j = {
{"id", 7},
{"amount", 1},
{"name", "Blue-Eyes White Dragon"},
{"set", nlohmann::json{
{"id", "sdk"},
{"name", "Starter Deck Kaiba"},
{"releaseDate", "2002/03/29"},
}},
{"setNo", "SDK-001"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", true},
// rarity missing on purpose
{"signed", false},
{"altered", false},
};
CHECK_THROWS(j.get<YuGiOhCard>());
}
TEST_CASE("YuGiOhCard missing each required key throws") {
const nlohmann::json full = {
{"id", 7},
{"amount", 1},
{"name", "Blue-Eyes White Dragon"},
{"set", nlohmann::json{
{"id", "sdk"},
{"name", "Starter Deck Kaiba"},
{"releaseDate", "2002/03/29"},
}},
{"setNo", "SDK-001"},
{"rarity", "Ultra Rare"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", true},
{"signed", false},
{"altered", false},
};
for (const char* key : {
"id", "amount", "name", "set", "setNo", "note", "images",
"language", "condition", "firstEdition", "rarity", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<YuGiOhCard>());
}
}
TEST_CASE("MagicCard missing each required key throws") {
const nlohmann::json full = {
{"id", 10},
{"amount", 1},
{"name", "Lightning Bolt"},
{"set", nlohmann::json{
{"id", "lea"},
{"name", "Limited Edition Alpha"},
{"releaseDate", "1993/08/05"},
}},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"foil", false},
{"signed", false},
{"altered", false},
};
for (const char* key : {"id", "amount", "name", "set", "note", "images", "language",
"condition", "foil", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<MagicCard>());
}
}
TEST_CASE("PokemonCard missing each required key throws") {
const nlohmann::json full = {
{"id", 7},
{"amount", 1},
{"name", "Charizard"},
{"set", nlohmann::json{
{"id", "base1"},
{"name", "Base Set"},
{"releaseDate", "1999/01/09"},
}},
{"setNo", "4/102"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "Excellent"},
{"firstEdition", true},
{"holo", true},
{"signed", false},
{"altered", false},
};
for (const char* key :
{"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition",
"firstEdition", "holo", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<PokemonCard>());
}
}
TEST_CASE("DigiBattle99Card missing each required key throws") {
const nlohmann::json full = {
{"id", 3},
{"amount", 1},
{"name", "Agumon"},
{"set", nlohmann::json{
{"id", "series-1-starter-set"},
{"name", "Series 1 Starter Set"},
{"releaseDate", "1999/06/01"},
}},
{"setNo", "ST-01"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", false},
{"holo", true},
{"signed", false},
{"altered", false},
};
for (const char* key :
{"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition",
"firstEdition", "holo", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<DigiBattle99Card>());
}
}
TEST_CASE("Configuration missing required key throws") {
const nlohmann::json j = {
{"defaultGame", "Magic"},
{"theme", "Dark"},
};
CHECK_THROWS(j.get<Configuration>());
}
TEST_CASE("Configuration invalid theme value throws when present") {
const nlohmann::json j = {
{"dataStorage", "/portable/data"},
{"defaultGame", "Magic"},
{"theme", "Neon"},
};
CHECK_THROWS(j.get<Configuration>());
}
}
+66
View File
@@ -0,0 +1,66 @@
#include <doctest/doctest.h>
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
using namespace ccm;
namespace {
class NoopHttpClient final : public IHttpClient {
public:
Result<std::string> get(std::string_view) override {
return Result<std::string>::ok("{}");
}
};
} // namespace
TEST_SUITE("game modules expose stable identity and wiring") {
TEST_CASE("Magic module reports canonical metadata") {
NoopHttpClient http;
MagicGameModule module(http);
CHECK(module.id() == Game::Magic);
CHECK(module.dirName() == "magic");
CHECK(module.displayName() == "Magic");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("Pokemon module reports canonical metadata") {
NoopHttpClient http;
PokemonGameModule module(http);
CHECK(module.id() == Game::Pokemon);
CHECK(module.dirName() == "pokemon");
CHECK(module.displayName() == "Pokemon");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("YuGiOh module reports canonical metadata") {
NoopHttpClient http;
YuGiOhGameModule module(http);
CHECK(module.id() == Game::YuGiOh);
CHECK(module.dirName() == "yugioh");
CHECK(module.displayName() == "Yu-Gi-Oh!");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("DigiBattle99 module reports canonical metadata") {
NoopHttpClient http;
DigiBattle99GameModule module(http);
CHECK(module.id() == Game::DigiBattle99);
CHECK(module.dirName() == "digibattle99");
CHECK(module.displayName() == "Digimon (Digi-Battle)");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
}
+56
View File
@@ -0,0 +1,56 @@
#include <doctest/doctest.h>
#include "ccm/util/HttpGetMapping.hpp"
using namespace ccm;
TEST_SUITE("mapHttpGetResponse") {
TEST_CASE("curl transport error ignores HTTP status and body") {
const auto out =
mapHttpGetResponse(true, "connection refused", 0, "ignored", "http://x");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP error: connection refused");
}
TEST_CASE("HTTP status below 200 is an error") {
const auto out =
mapHttpGetResponse(false, {}, 199, "body", "http://example/a");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 199 from http://example/a");
}
TEST_CASE("HTTP status 200 returns body") {
const auto out =
mapHttpGetResponse(false, {}, 200, "payload", "http://example/a");
REQUIRE(out.isOk());
CHECK(out.value() == "payload");
}
TEST_CASE("HTTP status 299 returns body") {
const auto out =
mapHttpGetResponse(false, {}, 299, "ok", "http://example/a");
REQUIRE(out.isOk());
CHECK(out.value() == "ok");
}
TEST_CASE("HTTP status 300 and above is an error") {
const auto out =
mapHttpGetResponse(false, {}, 300, "redirect", "http://example/a");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 300 from http://example/a");
}
TEST_CASE("HTTP 404 formats URL into message") {
const auto out =
mapHttpGetResponse(false, {}, 404, "", "https://api.example/r");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 404 from https://api.example/r");
}
TEST_CASE("HTTP 200 with empty body still maps to success") {
const auto out =
mapHttpGetResponse(false, {}, 200, "", "https://api.example/empty");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
}
+60
View File
@@ -0,0 +1,60 @@
#include <doctest/doctest.h>
#include "ccm/ports/ICardPreviewSource.hpp"
using namespace ccm;
namespace {
class MinimalPreviewSource final : public ICardPreviewSource {
public:
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view,
std::string_view,
std::string_view) override {
return Result<std::string, PreviewLookupError>::err(
PreviewLookupError{PreviewLookupError::Kind::NotFound, "not found"});
}
};
class AutoDetectPreviewSource final : public ICardPreviewSource {
public:
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return true;
}
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view,
std::string_view,
std::string_view) override {
return Result<std::string, PreviewLookupError>::ok("https://example.test/card.png");
}
};
} // namespace
TEST_SUITE("ICardPreviewSource defaults") {
TEST_CASE("auto-detect is disabled by default") {
MinimalPreviewSource src;
CHECK_FALSE(src.supportsAutoDetectPrint());
}
TEST_CASE("implementations may override supportsAutoDetectPrint") {
AutoDetectPreviewSource src;
CHECK(src.supportsAutoDetectPrint());
}
TEST_CASE("default detectFirstPrint returns explicit unsupported error") {
MinimalPreviewSource src;
const auto out = src.detectFirstPrint("Card", "Set");
REQUIRE(out.isErr());
CHECK(out.error() == "Auto-detect not supported by this game.");
}
TEST_CASE("default detectPrintVariants returns explicit unsupported error") {
MinimalPreviewSource src;
const auto out = src.detectPrintVariants("Card", "Set");
REQUIRE(out.isErr());
CHECK(out.error() == "Print variant listing not supported by this game.");
}
}
+100
View File
@@ -19,16 +19,24 @@ public:
std::vector<Call> copies;
std::vector<std::pair<Game, std::string>> removes;
std::string returnedExt = ".png";
int failCopyAt = -1;
int failRemoveAt = -1;
Result<std::string> copyIn(Game game,
const std::filesystem::path& srcPath,
const std::string& targetName) override {
copies.push_back({game, srcPath, targetName});
if (failCopyAt >= 0 && static_cast<int>(copies.size()) == failCopyAt) {
return Result<std::string>::err("copy failed at " + std::to_string(failCopyAt));
}
return Result<std::string>::ok(targetName + returnedExt);
}
Result<void> remove(Game game, const std::string& imageName) override {
removes.emplace_back(game, imageName);
if (failRemoveAt >= 0 && static_cast<int>(removes.size()) == failRemoveAt) {
return Result<void>::err("remove failed at " + std::to_string(failRemoveAt));
}
return Result<void>::ok();
}
@@ -55,6 +63,16 @@ TEST_SUITE("ImageService::nextImageIndex") {
std::vector<std::string> imgs2 = {"otherIMG_BACK.png"};
CHECK(ImageService::nextImageIndex(imgs2) == 0);
}
TEST_CASE("index increments from two-digit legacy cap") {
std::vector<std::string> imgs = {"set+name+99.png"};
CHECK(ImageService::nextImageIndex(imgs) == 100);
}
TEST_CASE("three-digit filename index follows two-digit compatibility parser") {
std::vector<std::string> imgs = {"set+name+255.png"};
CHECK(ImageService::nextImageIndex(imgs) == 56);
}
}
TEST_SUITE("ImageService::buildTargetName") {
@@ -85,6 +103,40 @@ TEST_SUITE("ImageService::addImage") {
CHECK(store.copies[0].game == Game::Magic);
CHECK(store.copies[0].target == "Beta+BlackLotus+0");
}
TEST_CASE("propagates copyIn failures") {
RecordingImageStore store;
store.failCopyAt = 1;
ImageService svc{store};
std::vector<std::string> existing;
const auto out = svc.addImage(Game::Magic, "/tmp/source.png",
/*newEntry=*/true, /*cardId=*/0,
"Beta", "Black Lotus", existing);
REQUIRE(out.isErr());
CHECK(out.error().find("copy failed at 1") != std::string::npos);
}
}
TEST_SUITE("ImageService::removeImage and resolveImagePath") {
TEST_CASE("removeImage delegates to store remove") {
RecordingImageStore store;
ImageService svc{store};
const auto out = svc.removeImage(Game::Magic, "x.png");
REQUIRE(out.isOk());
REQUIRE(store.removes.size() == 1);
CHECK(store.removes[0].first == Game::Magic);
CHECK(store.removes[0].second == "x.png");
}
TEST_CASE("resolveImagePath delegates to store resolvePath") {
RecordingImageStore store;
ImageService svc{store};
const auto p = svc.resolveImagePath(Game::Pokemon, "pikachu.jpg");
CHECK(p == std::filesystem::path("/fake/pikachu.jpg"));
}
}
TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
@@ -128,4 +180,52 @@ TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
CHECK(store.copies.empty());
CHECK(store.removes.empty());
}
TEST_CASE("skips rename when computed output name equals input") {
RecordingImageStore store;
ImageService svc{store};
const std::vector<std::string> images{"42+Beta+BlackLotus+0.png"};
auto normalized = svc.normalizeNamesForPersistedCard(
Game::Magic, 42, "Beta", "Black Lotus", images);
REQUIRE(normalized.isOk());
CHECK(normalized.value() == images);
CHECK(store.copies.empty());
CHECK(store.removes.empty());
}
TEST_CASE("copy failure rolls back already-created names and returns error") {
RecordingImageStore store;
store.failCopyAt = 2;
ImageService svc{store};
const std::vector<std::string> images{
"Beta+BlackLotus+0.png",
"Beta+BlackLotus+1.jpg"
};
const auto out = svc.normalizeNamesForPersistedCard(
Game::Magic, 42, "Beta", "Black Lotus", images);
REQUIRE(out.isErr());
CHECK(out.error().find("copy failed at 2") != std::string::npos);
// Second copy failed, so first created file should be rolled back.
REQUIRE(store.removes.size() == 1);
CHECK(store.removes[0].second == "42+Beta+BlackLotus+0.png");
}
TEST_CASE("remove failure after rename returns error") {
RecordingImageStore store;
store.failRemoveAt = 1;
ImageService svc{store};
const std::vector<std::string> images{
"Beta+BlackLotus+0.png"
};
const auto out = svc.normalizeNamesForPersistedCard(
Game::Magic, 42, "Beta", "Black Lotus", images);
REQUIRE(out.isErr());
CHECK(out.error().find("remove failed at 1") != std::string::npos);
}
}
+102
View File
@@ -8,6 +8,8 @@
#include <nlohmann/json.hpp>
#include <filesystem>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
@@ -27,6 +29,37 @@ ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
std::string magicDir(Game g) { return g == Game::Magic ? "magic" : "pokemon"; }
class FailingCollectionFs final : public IFileSystem {
public:
bool existsValue{true};
bool ensureOk{true};
bool writeOk{true};
bool readOk{true};
std::string readPayload{"{}"};
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return existsValue; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
Result<void> ensureDirectory(const std::filesystem::path&) override {
if (!ensureOk) return Result<void>::err("ensure failed");
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
if (!readOk) return Result<std::string>::err("read failed");
return Result<std::string>::ok(readPayload);
}
Result<void> writeText(const std::filesystem::path&, std::string_view) override {
if (!writeOk) return Result<void>::err("write failed");
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path&) override { return Result<void>::ok(); }
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
} // namespace
TEST_SUITE("JsonCollectionRepository<MagicCard>") {
@@ -85,4 +118,73 @@ TEST_SUITE("JsonCollectionRepository<MagicCard>") {
REQUIRE(j.contains("17"));
CHECK(j.at("17").at("id") == 17);
}
TEST_CASE("load returns parse error for non-object root") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
fs.writeText("/data/magic/collection.json", R"(["not","an","object"])");
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
}
TEST_CASE("load returns parse error for non-numeric object keys") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
fs.writeText("/data/magic/collection.json", R"({"abc":{"id":1}})");
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
}
TEST_CASE("save and initialize-on-load propagate ensureDirectory/write errors") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingCollectionFs fs;
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
fs.ensureOk = false;
const auto saveEnsureFail = repo.save(Game::Magic, {});
REQUIRE(saveEnsureFail.isErr());
CHECK(saveEnsureFail.error() == "ensure failed");
fs.ensureOk = true;
fs.writeOk = false;
const auto saveWriteFail = repo.save(Game::Magic, {});
REQUIRE(saveWriteFail.isErr());
CHECK(saveWriteFail.error() == "write failed");
fs.existsValue = false;
const auto loadCreateFail = repo.load(Game::Magic);
REQUIRE(loadCreateFail.isErr());
CHECK(loadCreateFail.error() == "write failed");
}
TEST_CASE("load returns read error when collection exists but read fails") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingCollectionFs fs;
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
fs.existsValue = true;
fs.readOk = false;
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error() == "read failed");
}
TEST_CASE("load returns parse error when card object does not deserialize") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
fs.writeText("/data/magic/collection.json", R"({"0":{"id":"not-a-number"}})");
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
}
}
+103
View File
@@ -22,6 +22,43 @@ ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
cfg.initialize();
return cfg;
}
class FailingSetFs final : public IFileSystem {
public:
bool ensureOk{true};
bool writeOk{true};
bool readOk{true};
std::string readPayload{"[]"};
std::filesystem::path lastWritePath;
std::string lastWriteBody;
std::filesystem::path lastReadPath;
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
Result<void> ensureDirectory(const std::filesystem::path&) override {
if (!ensureOk) return Result<void>::err("ensure failed");
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
lastReadPath = std::filesystem::path("/tracked/read/path");
if (!readOk) return Result<std::string>::err("read failed");
return Result<std::string>::ok(readPayload);
}
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override {
if (!writeOk) return Result<void>::err("write failed");
lastWritePath = p;
lastWriteBody = std::string(contents);
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path&) override { return Result<void>::ok(); }
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
} // namespace
TEST_SUITE("JsonSetRepository") {
@@ -49,4 +86,70 @@ TEST_SUITE("JsonSetRepository") {
const auto loaded = repo.load(Game::Pokemon);
CHECK(loaded.isErr());
}
TEST_CASE("load propagates read errors from filesystem") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
fs.readOk = false;
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error() == "read failed");
}
TEST_CASE("load reports parse error for malformed sets.json") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
fs.readPayload = "{bad json";
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
}
TEST_CASE("load reports parse error for wrong JSON shape") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
fs.readPayload = R"({"not":"an array"})";
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
}
TEST_CASE("save propagates ensureDirectory and writeText failures") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
JsonSetRepository repo{fs, cfg, dirNameFn};
const std::vector<Set> sets = {{"lea", "Limited Edition Alpha", "1993/08/05"}};
fs.ensureOk = false;
const auto ensureFail = repo.save(Game::Magic, sets);
REQUIRE(ensureFail.isErr());
CHECK(ensureFail.error() == "ensure failed");
fs.ensureOk = true;
fs.writeOk = false;
const auto writeFail = repo.save(Game::Magic, sets);
REQUIRE(writeFail.isErr());
CHECK(writeFail.error() == "write failed");
}
TEST_CASE("paths are composed from dataStorage and game dir") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonSetRepository repo{fs, cfg, dirNameFn};
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
REQUIRE(repo.save(Game::Pokemon, sets).isOk());
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
}
}
+58
View File
@@ -17,10 +17,42 @@ std::string dirNameForGame(Game g) {
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
case Game::DigiBattle99: return "digibattle99";
}
return "magic";
}
class FailingImageFs final : public IFileSystem {
public:
bool ensureOk{true};
bool copyOk{true};
bool removeOk{true};
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
Result<void> ensureDirectory(const std::filesystem::path&) override {
if (!ensureOk) return Result<void>::err("ensure failed");
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
return Result<std::string>::ok({});
}
Result<void> writeText(const std::filesystem::path&, std::string_view) override {
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
if (!copyOk) return Result<void>::err("copy failed");
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path&) override {
if (!removeOk) return Result<void>::err("remove failed");
return Result<void>::ok();
}
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
} // namespace
TEST_SUITE("LocalImageStore") {
@@ -87,4 +119,30 @@ TEST_SUITE("LocalImageStore") {
const std::filesystem::path got = store.resolvePath(Game::Pokemon, "pic.jpg");
CHECK(got.generic_string() == "/coll/pokemon/images/pic.jpg");
}
TEST_CASE("copyIn propagates ensureDirectory failure") {
InMemoryFileSystem configFs;
ConfigService cfg{configFs, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
FailingImageFs fs;
fs.ensureOk = false;
LocalImageStore store(fs, cfg, dirNameForGame);
const auto out = store.copyIn(Game::Magic, "/incoming/a.png", "id001");
REQUIRE(out.isErr());
CHECK(out.error() == "ensure failed");
}
TEST_CASE("remove propagates filesystem remove failure when file exists") {
InMemoryFileSystem configFs;
ConfigService cfg{configFs, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
FailingImageFs fs;
fs.removeOk = false;
LocalImageStore store(fs, cfg, dirNameForGame);
const auto out = store.remove(Game::Magic, "a.png");
REQUIRE(out.isErr());
CHECK(out.error() == "remove failed");
}
}
+133
View File
@@ -12,6 +12,8 @@
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
@@ -56,6 +58,59 @@ void backdate(const fs::path& p, int seconds) {
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
}
class FailingEnsureDirFs final : public IFileSystem {
public:
explicit FailingEnsureDirFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override {
(void)p;
return Result<void>::err("ensure failed");
}
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
class FailingIndexWriteFs final : public IFileSystem {
public:
explicit FailingIndexWriteFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override { return inner_.ensureDirectory(p); }
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
const auto path = p.generic_string();
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".idx") == 0) {
return Result<void>::err("idx write failed");
}
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
} // namespace
TEST_SUITE("LocalPreviewByteCache") {
@@ -234,6 +289,55 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with payload but missing sidecar is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with negative marker but missing sidecar is treated as miss") {
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);
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with unreadable payload file is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".bin") {
std::error_code ec;
fs::remove(entry.path(), ec);
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;
@@ -291,3 +395,32 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
}
}
TEST_SUITE("LocalPreviewByteCache in-memory filesystem failures") {
TEST_CASE("store is a silent no-op when ensureDirectory fails") {
ccm::testing::InMemoryFileSystem inner;
FailingEnsureDirFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("store rolls back payload when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("storeNegative rolls back marker when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.storeNegative("k");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
}
+26
View File
@@ -43,6 +43,12 @@ TEST_SUITE("MagicCardPreviewSource::buildSearchUrl") {
const auto url = MagicCardPreviewSource::buildSearchUrl("X", "swsh10");
CHECK(url.find("set%3Aswsh10") != std::string::npos);
}
TEST_CASE("replaces every ampersand in the card name") {
const auto url = MagicCardPreviewSource::buildSearchUrl("A & B & C", "abc");
CHECK(url.find("A%20and%20B%20and%20C") != std::string::npos);
CHECK(url.find("%26") == std::string::npos);
}
}
TEST_SUITE("MagicCardPreviewSource::parseResponse") {
@@ -79,6 +85,26 @@ TEST_SUITE("MagicCardPreviewSource::parseResponse") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("'data' present but not an array is Transient") {
const auto out = MagicCardPreviewSource::parseResponse(R"({"data":{}})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("image_uris present but not an object is NotFound") {
const auto out = MagicCardPreviewSource::parseResponse(
R"({"data":[{"name":"X","image_uris":[]}]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("'normal' present but not a string is NotFound") {
const auto out = MagicCardPreviewSource::parseResponse(
R"({"data":[{"image_uris":{"normal":null}}]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("entry without image_uris is classified as NotFound (double-faced cards)") {
const std::string json = R"({
"data": [
+285
View File
@@ -54,6 +54,13 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
"Mr. Mime", "base1", "");
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos);
}
TEST_CASE("empty setId omits the set.id clause") {
const auto url =
PokemonCardPreviewSource::buildSearchUrl("Pikachu", "", "25");
CHECK(url.find("set.id") == std::string::npos);
CHECK(url.find("number%3A25") != std::string::npos);
}
}
TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
@@ -97,6 +104,35 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("'data' present but not an array is Transient") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":{}})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("'images' present but not an object is NotFound") {
const auto out =
PokemonCardPreviewSource::parseResponse(R"({"data":[{"images":[]}]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("large unusable type falls back to small string") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({
"data":[{"images":{"large":123,"small":"https://only.small/img.png"}}]
})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://only.small/img.png");
}
TEST_CASE("no usable large or small string yields NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({
"data":[{"images":{"large":null,"small":false}}]
})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("entry without images is classified as NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(
R"({"data":[{"name":"Pikachu"}]})");
@@ -134,3 +170,252 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
CHECK(http.lastUrl.find("number%3A25") != std::string::npos);
}
}
namespace {
const char* kCharizardSwsh4 = R"({
"data": [
{
"name": "Charizard",
"number": "25",
"rarity": "Rare",
"set": {
"id": "swsh4",
"name": "Vivid Voltage",
"printedTotal": 185
}
}
]
})";
const char* kMultiVariantPayload = R"({
"data": [
{
"name": "Pikachu",
"number": "25",
"rarity": "Common",
"set": {"id": "base1", "printedTotal": 102}
},
{
"name": "Pikachu",
"number": "58",
"rarity": "Rare",
"set": {"id": "base1", "printedTotal": 102}
},
{
"name": "Pikachu",
"number": "25",
"rarity": "Common",
"set": {"id": "base2", "printedTotal": 64}
}
]
})";
} // namespace
TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") {
TEST_CASE("maps API number into setNo without printedTotal suffix") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Charizard");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25");
CHECK(out.value().front().rarity == "Rare");
}
TEST_CASE("filters by set id and keeps multiple numbers in the same set") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base1", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "25");
CHECK(out.value()[1].setNo == "58");
}
TEST_CASE("wrong set id yields explicit error when name and set are supplied") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "base1", "Charizard");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("wrong card name is filtered out") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Blastoise");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("empty data array yields error") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(R"({"data":[]})", "base1", "Pikachu");
REQUIRE(out.isErr());
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
}
TEST_CASE("name-only payload still filters to requested set id") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base2", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25");
}
TEST_CASE("keeps bare number when printedTotal is zero") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data": [
{
"name": "Promo",
"number": "7",
"rarity": "Promo",
"set": {"id": "promo1", "printedTotal": 0}
}
]
})",
"promo1", "Promo");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "7");
}
TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data":[
{"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"},
{"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}}
]
})",
"base1", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "26");
}
TEST_CASE("empty setId skips set filter and collects prints across sets") {
const char* crossSet = R"({
"data": [
{"name":"Pikachu","number":"1","rarity":"Common","set":{"id":"base1"}},
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base2"}}
]
})";
const auto out = PokemonCardPreviewSource::parsePrintVariants(crossSet, "", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2u);
}
TEST_CASE("empty wanted card name skips name filter within the set") {
const char* twoInSet = R"({
"data": [
{"name":"Electabuzz","number":"1","rarity":"Common","set":{"id":"base1"}},
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base1"}}
]
})";
const auto out = PokemonCardPreviewSource::parsePrintVariants(twoInSet, "base1", "");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2u);
}
TEST_CASE("cards with empty number and rarity are skipped for auto-detect metadata") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data": [
{"name":"Pikachu","number":"","rarity":"","set":{"id":"base1"}}
]
})",
"base1", "Pikachu");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("no matches with empty setId yields generic no matching cards message") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(
R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})",
"",
"Nobody");
REQUIRE(out.isErr());
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
}
TEST_CASE("invalid JSON in parsePrintVariants yields parse error") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu");
REQUIRE(out.isErr());
CHECK(out.error().find("Pokemon TCG JSON parse error:") == 0);
}
}
TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
TEST_CASE("supports auto-detect and returns first print") {
FixedHttpClient http;
http.body = kCharizardSwsh4;
PokemonCardPreviewSource src{http};
CHECK(src.supportsAutoDetectPrint());
const auto first = src.detectFirstPrint("Charizard", "swsh4");
REQUIRE(first.isOk());
CHECK(first.value().setNo == "25");
}
TEST_CASE("uses slim set-scoped search URL without number clause") {
FixedHttpClient http;
http.body = kCharizardSwsh4;
PokemonCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Charizard", "swsh4");
REQUIRE(out.isOk());
CHECK(http.lastUrl.find("number%3A") == std::string::npos);
CHECK(http.lastUrl.find("set.id%3Aswsh4") != std::string::npos);
CHECK(http.lastUrl.find("select=name,number,rarity,set") != std::string::npos);
CHECK(http.lastUrl.find("pageSize=50") != std::string::npos);
}
TEST_CASE("buildDetectSearchUrl requests only parser fields") {
const auto url = PokemonCardPreviewSource::buildDetectSearchUrl("Charizard", "swsh4");
CHECK(url.find("select=name,number,rarity,set") != std::string::npos);
CHECK(url.find("pageSize=50") != std::string::npos);
}
TEST_CASE("retries name-only query when the set-scoped request fails") {
class FallbackHttpClient final : public IHttpClient {
public:
int calls = 0;
Result<std::string> get(std::string_view url) override {
++calls;
if (calls == 1) return Result<std::string>::err("offline");
if (url.find("set.id") != std::string::npos) {
return Result<std::string>::err("unexpected set-scoped retry");
}
return Result<std::string>::ok(kMultiVariantPayload);
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Pikachu", "base1");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(http.calls == 2);
}
TEST_CASE("detectPrintVariants surfaces fallback HTTP error when both requests fail") {
class AlwaysFailHttp final : public IHttpClient {
public:
int calls = 0;
Result<std::string> get(std::string_view) override {
++calls;
return Result<std::string>::err("offline");
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Pikachu", "base1");
REQUIRE(out.isErr());
CHECK(out.error() == "offline");
CHECK(http.calls == 2);
}
TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") {
FixedHttpClient http;
http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})";
PokemonCardPreviewSource src{http};
const auto out = src.detectFirstPrint("Promo", "promo1");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
}
+65
View File
@@ -39,11 +39,13 @@ class InMemSetRepo final : public ISetRepository {
public:
std::vector<Set> stored;
bool hasStored = false;
bool failSave = false;
Result<std::vector<Set>> load(Game) override {
if (!hasStored) return Result<std::vector<Set>>::err("no cache");
return Result<std::vector<Set>>::ok(stored);
}
Result<void> save(Game, const std::vector<Set>& s) override {
if (failSave) return Result<void>::err("save failed");
stored = s;
hasStored = true;
return Result<void>::ok();
@@ -145,4 +147,67 @@ TEST_SUITE("SetService") {
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
}
TEST_CASE("DigiBattle99 module routes independently when all games are registered") {
InMemSetRepo repo;
SetService svc{repo};
FakeGameModule magic{Game::Magic};
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
FakeGameModule pokemon{Game::Pokemon};
pokemon.source.result = Result<std::vector<Set>>::ok({{"base1", "Base", "1999/01/09"}});
FakeGameModule yugioh{Game::YuGiOh};
yugioh.source.result = Result<std::vector<Set>>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}});
FakeGameModule digi{Game::DigiBattle99};
digi.source.result = Result<std::vector<Set>>::ok(
{{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"}});
svc.registerModule(&magic);
svc.registerModule(&pokemon);
svc.registerModule(&yugioh);
svc.registerModule(&digi);
REQUIRE(svc.updateSets(Game::Magic).isOk());
REQUIRE(svc.updateSets(Game::Pokemon).isOk());
REQUIRE(svc.updateSets(Game::YuGiOh).isOk());
const auto out = svc.updateSets(Game::DigiBattle99);
REQUIRE(out.isOk());
CHECK(out.value().front().id == "series-1-starter-set");
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
CHECK(digi.source.calls == 1);
}
TEST_CASE("updateSets propagates repository save failures") {
InMemSetRepo repo;
repo.failSave = true;
SetService svc{repo};
FakeGameModule magic{Game::Magic};
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
svc.registerModule(&magic);
const auto out = svc.updateSets(Game::Magic);
REQUIRE(out.isErr());
CHECK(out.error() == "save failed");
}
TEST_CASE("registering a second module for same game id overwrites previous one") {
InMemSetRepo repo;
SetService svc{repo};
FakeGameModule firstMagic{Game::Magic};
firstMagic.source.result = Result<std::vector<Set>>::ok({{"a", "First", "2000/01/01"}});
FakeGameModule secondMagic{Game::Magic};
secondMagic.source.result = Result<std::vector<Set>>::ok({{"b", "Second", "2001/01/01"}});
svc.registerModule(&firstMagic);
svc.registerModule(&secondMagic);
const auto out = svc.updateSets(Game::Magic);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().id == "b");
CHECK(firstMagic.source.calls == 0);
CHECK(secondMagic.source.calls == 1);
}
}
+216
View File
@@ -0,0 +1,216 @@
#include <doctest/doctest.h>
// StdFileSystem translates IFileSystem onto std::filesystem. Like
// LocalPreviewByteCache tests, these exercise real disk under a dedicated
// temp directory so permission errors and path normalization behave like production.
#include "ccm/infra/StdFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
#include <string>
using namespace ccm;
namespace fs = std::filesystem;
namespace {
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_std_fs_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;
};
} // namespace
TEST_SUITE("StdFileSystem") {
TEST_CASE("exists and isDirectory reflect real paths") {
TempDir td;
StdFileSystem fs;
const auto nested = td.path / "a" / "b";
CHECK_FALSE(fs.exists(nested));
REQUIRE(fs.ensureDirectory(nested).isOk());
CHECK(fs.exists(nested));
CHECK(fs.isDirectory(nested));
const auto filePath = td.path / "file.bin";
REQUIRE(fs.writeText(filePath, "x").isOk());
CHECK(fs.exists(filePath));
CHECK_FALSE(fs.isDirectory(filePath));
}
TEST_CASE("ensureDirectory succeeds when directory already exists") {
TempDir td;
StdFileSystem fs;
const auto dir = td.path / "existing";
REQUIRE(fs.ensureDirectory(dir).isOk());
REQUIRE(fs.ensureDirectory(dir).isOk());
CHECK(fs.isDirectory(dir));
}
TEST_CASE("ensureDirectory errors when path is a regular file") {
TempDir td;
StdFileSystem fs;
const auto clash = td.path / "notadir";
REQUIRE(fs.writeText(clash, "block").isOk());
const auto r = fs.ensureDirectory(clash);
REQUIRE(r.isErr());
CHECK(r.error().find("not a directory") != std::string::npos);
}
TEST_CASE("readText round-trips bytes written by writeText") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "sub" / "cfg.json";
const std::string payload(std::string("{\"x\":") + std::string(4, '\0') + "}");
REQUIRE(fs.writeText(p, payload).isOk());
const auto readBack = fs.readText(p);
REQUIRE(readBack.isOk());
CHECK(readBack.value() == payload);
}
TEST_CASE("readText errors when file does not exist") {
TempDir td;
StdFileSystem fs;
const auto missing = td.path / "missing.txt";
const auto r = fs.readText(missing);
REQUIRE(r.isErr());
CHECK(r.error().find("Unable to open") != std::string::npos);
}
TEST_CASE("writeText truncates an existing file") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "t.txt";
REQUIRE(fs.writeText(p, "aaaaaaaaaa").isOk());
REQUIRE(fs.writeText(p, "hi").isOk());
const auto r = fs.readText(p);
REQUIRE(r.isOk());
CHECK(r.value() == "hi");
}
TEST_CASE("writeText/readText work for top-level relative files") {
TempDir td;
StdFileSystem fs;
const auto oldCwd = fs::current_path();
fs::current_path(td.path);
const fs::path topLevel = "top-level.txt";
REQUIRE(fs.writeText(topLevel, "hello").isOk());
const auto r = fs.readText(topLevel);
REQUIRE(r.isOk());
CHECK(r.value() == "hello");
std::error_code ec;
fs::current_path(oldCwd, ec);
}
TEST_CASE("copyFile copies bytes and respects overwrite flag") {
TempDir td;
StdFileSystem fs;
const auto src = td.path / "src.bin";
const auto dst = td.path / "nested" / "dst.bin";
REQUIRE(fs.writeText(src, "alpha").isOk());
REQUIRE(fs.copyFile(src, dst, /*overwrite=*/false).isOk());
auto rd = fs.readText(dst);
REQUIRE(rd.isOk());
CHECK(rd.value() == "alpha");
REQUIRE(fs.writeText(src, "beta").isOk());
const auto noOverwrite = fs.copyFile(src, dst, /*overwrite=*/false);
REQUIRE(noOverwrite.isErr());
REQUIRE(fs.copyFile(src, dst, /*overwrite=*/true).isOk());
rd = fs.readText(dst);
REQUIRE(rd.isOk());
CHECK(rd.value() == "beta");
}
TEST_CASE("copyFile fails cleanly when source is missing") {
TempDir td;
StdFileSystem fs;
const auto src = td.path / "ghost.dat";
const auto dst = td.path / "out.dat";
const auto r = fs.copyFile(src, dst, /*overwrite=*/false);
REQUIRE(r.isErr());
CHECK(r.error().find("copy_file failed") != std::string::npos);
}
TEST_CASE("remove deletes a file and tolerates repeated removes") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "gone.txt";
REQUIRE(fs.writeText(p, "body").isOk());
REQUIRE(fs.remove(p).isOk());
CHECK_FALSE(fs.exists(p));
REQUIRE(fs.remove(p).isOk());
}
TEST_CASE("listDirectory errors when path is not a directory") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "single.dat";
REQUIRE(fs.writeText(p, "").isOk());
const auto r = fs.listDirectory(p);
REQUIRE(r.isErr());
CHECK(r.error().find("Not a directory") != std::string::npos);
}
TEST_CASE("listDirectory returns entries for an empty and populated folder") {
TempDir td;
StdFileSystem fs;
const auto dir = td.path / "list_me";
REQUIRE(fs.ensureDirectory(dir).isOk());
auto empty = fs.listDirectory(dir);
REQUIRE(empty.isOk());
CHECK(empty.value().empty());
REQUIRE(fs.writeText(dir / "a.txt", "a").isOk());
REQUIRE(fs.writeText(dir / "b.txt", "b").isOk());
auto filled = fs.listDirectory(dir);
REQUIRE(filled.isOk());
CHECK(filled.value().size() == 2u);
}
TEST_CASE("writeText fails when the path names an existing directory") {
TempDir td;
StdFileSystem fs;
const auto dir = td.path / "is_dir";
REQUIRE(fs.ensureDirectory(dir).isOk());
const auto r = fs.writeText(dir, "cannot-write-here");
REQUIRE(r.isErr());
CHECK(r.error().find("Unable to create file") != std::string::npos);
}
}
+324
View File
@@ -54,6 +54,23 @@ public:
}
};
// First GET (filtered `cardset=` URL) fails; second GET (unfiltered) succeeds.
// Exercises `YuGiOhCardPreviewSource::detectPrintVariants` narrow-query fallback.
class FailFilteredThenOkHttpClient final : public IHttpClient {
public:
std::string unfilteredBody;
int calls{0};
Result<std::string> get(std::string_view url) override {
++calls;
std::string u(url);
if (u.find("cardset=") != std::string::npos) {
return Result<std::string>::err("filtered endpoint unavailable");
}
return Result<std::string>::ok(unfilteredBody);
}
};
} // namespace
TEST_SUITE("ygoPrintingSlotsMatch") {
@@ -75,6 +92,51 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-E"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EX005"));
}
}
TEST_SUITE("YuGiOhPrintingSlot helpers") {
TEST_CASE("trimAsciiSpaces handles empty and surrounding whitespace") {
CHECK(trimAsciiSpaces("").empty());
CHECK(trimAsciiSpaces(" ").empty());
CHECK(trimAsciiSpaces(" LOB-005 ") == "LOB-005");
}
TEST_CASE("ygoAbbrevBeforeDash and ygoCollectorDigitsOnly cover no-dash and mixed tails") {
CHECK(ygoAbbrevBeforeDash("lob") == "lob");
CHECK(ygoAbbrevBeforeDash(" SOD-015 ") == "sod");
CHECK(ygoCollectorDigitsOnly("SOD").empty());
CHECK(ygoCollectorDigitsOnly("SOD-EN015") == "015");
CHECK(ygoCollectorDigitsOnly("SOD-ABC") == "");
}
}
TEST_SUITE("ygoRarityShortCode") {
TEST_CASE("maps supported Yu-Gi-Oh rarity names to short form") {
CHECK(ygoRarityShortCode("Common") == "C");
CHECK(ygoRarityShortCode("Rare") == "R");
CHECK(ygoRarityShortCode("Super Rare") == "SR");
CHECK(ygoRarityShortCode("Ultra Rare") == "UR");
CHECK(ygoRarityShortCode("Secret Rare") == "ScR");
CHECK(ygoRarityShortCode("Quarter Century Secret Rare") == "QCScR");
CHECK(ygoRarityShortCode("Starlight Rare") == "StR");
CHECK(ygoRarityShortCode("Collector's Rare") == "CR");
CHECK(ygoRarityShortCode("Ghost Rare") == "GR");
CHECK(ygoRarityShortCode("Ultimate Rare") == "UtR");
CHECK(ygoRarityShortCode("Platinum Secret Rare") == "PlScR");
CHECK(ygoRarityShortCode("Prismatic Secret Rare") == "PScR");
}
TEST_CASE("normalizes punctuation and spacing and accepts QCSR alias") {
CHECK(ygoRarityShortCode("Ultra-Rare") == "UR");
CHECK(ygoRarityShortCode("Collector`s Rare") == "CR");
CHECK(ygoRarityShortCode("QCSR") == "QCScR");
}
TEST_CASE("unknown rarity returns empty") {
CHECK(ygoRarityShortCode("Mythic Cosmic Rare").empty());
}
}
@@ -101,6 +163,8 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
== "QCScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Collector's Rare") == "CR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Platinum Secret Rare") == "PlScR");
}
TEST_CASE("returns empty string for unknown rarity names") {
// Unknown rarity should fall through to the rarity-less filename
@@ -108,6 +172,12 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty());
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty());
}
TEST_CASE("uses dialog synonym table when ygoRarityShortCode does not match") {
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mosaic Rare") == "MSR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Parallel Rare") == "PR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Gold Rare") == "GUR");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") {
@@ -231,6 +301,117 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("missing top-level query object is Transient") {
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
R"({"not_query":{}})", {"File.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("query.pages not an object is Transient") {
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
R"({"query":{"pages":[]}})", {"X.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("page without imageinfo is treated as missing") {
const std::string body = R"({
"query":{"pages":{
"1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png"}
}}
})";
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
body, {"DarkMagician-LOB-EN-UR-UE.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::buildSearchUrl") {
TEST_CASE("percent-encodes name and optional set name filter") {
const auto url = YuGiOhCardPreviewSource::buildSearchUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon");
CHECK(url.find("https://db.ygoprodeck.com/api/v7/cardinfo.php") == 0);
CHECK(url.find("fname=Dark%20Magician") != std::string::npos);
CHECK(url.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon") != std::string::npos);
}
TEST_CASE("omits cardset when set name is empty") {
const auto url = YuGiOhCardPreviewSource::buildSearchUrl("Dark Magician", "");
CHECK(url.find("cardset=") == std::string::npos);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parseFallbackImageUrl") {
TEST_CASE("missing data array is Transient") {
const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"meta":{}})", "Dark Magician");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("data present but not an array is Transient") {
const auto out =
YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"data":{}})", "X");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("empty data array is NotFound") {
const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"data":[]})", "X");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("prefers exact-name row with image_url_small when image_url absent") {
const std::string json = R"({"data":[
{"name":"Dark Magician Girl","card_images":[{"image_url_small":"https://small.only/a.jpg"}]},
{"name":"Dark Magician","card_images":[{"image_url":"https://ignored/wrong.jpg"}]}
]})";
const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Dark Magician Girl");
REQUIRE(out.isOk());
CHECK(out.value() == "https://small.only/a.jpg");
}
TEST_CASE("exact-name match uses image_url_cropped when earlier slots absent") {
const std::string json = R"({"data":[{
"name":"Slifer",
"card_images":[{"image_url_cropped":"https://crop/z.jpg"}]
}]})";
const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Slifer");
REQUIRE(out.isOk());
CHECK(out.value() == "https://crop/z.jpg");
}
TEST_CASE("no exact name match falls back to first ranked card_images row") {
const std::string json = R"({"data":[{
"name":"Dark Magician Girl",
"card_images":[{"image_url":"https://images/std-from-ranked-first.jpg"}]
}]})";
const auto out =
YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Dark Magician");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images/std-from-ranked-first.jpg");
}
TEST_CASE("matching cards without usable images is NotFound") {
const std::string json = R"({"data":[{
"name":"Empty Card",
"card_images":[{}]
}]})";
const auto out =
YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Empty Card");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("malformed JSON is Transient") {
const auto out =
YuGiOhCardPreviewSource::parseFallbackImageUrl("{not json", "Any");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
@@ -249,6 +430,18 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
CHECK(out.value().setNo == "LOB-001");
CHECK(out.value().rarity == "Ultra Rare");
}
TEST_CASE("empty data array yields error") {
const auto out =
YuGiOhCardPreviewSource::parseFirstPrint(R"({"data":[]})", "Any Set");
CHECK(out.isErr());
}
TEST_CASE("card row without card_sets yields error") {
const auto out = YuGiOhCardPreviewSource::parseFirstPrint(
R"({"data":[{"name":"Solo"}]})", "Any Display Set");
CHECK(out.isErr());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
@@ -307,6 +500,70 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
CHECK(out.value()[0].setNo == "SDY-043");
CHECK(out.value()[0].rarity == "Super Rare");
}
TEST_CASE("malformed JSON surfaces as parse error") {
const auto out =
YuGiOhCardPreviewSource::parsePrintVariants("{bad json", "Mega Pack", "X");
REQUIRE(out.isErr());
CHECK(out.error().find("YGOPRODeck JSON parse error") != std::string::npos);
}
TEST_CASE("maps 25th Anniversary display-set alias to original set name") {
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"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "Dark Magician");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "LOB-005");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::detectPrintVariants HTTP fallback") {
TEST_CASE("retries without cardset when the filtered request fails") {
FailFilteredThenOkHttpClient http;
http.unfilteredBody = R"({
"data":[{
"name":"Test Goblin",
"card_sets":[
{"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"}
]
}]
})";
YuGiOhCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Test Goblin", "Mega Pack");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "MP21-EN001");
REQUIRE(http.calls == 2);
}
TEST_CASE("uses original set name in cardset query for 25th alias") {
FixedHttpClient http;
http.body = R"({
"data":[{
"name":"Dark Magician",
"card_sets":[
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
]
}]
})";
YuGiOhCardPreviewSource src{http};
const auto out = src.detectPrintVariants(
"Dark Magician", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)");
REQUIRE(out.isOk());
CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon")
!= std::string::npos);
CHECK(http.lastUrl.find("25th") == std::string::npos);
}
}
// Helpers aligned with external fixture `yugioh_same_card_set_variant_tests`
@@ -482,6 +739,12 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_var
}
TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
TEST_CASE("supports auto-detect print metadata") {
FixedHttpClient http;
YuGiOhCardPreviewSource src{http};
CHECK(src.supportsAutoDetectPrint());
}
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
@@ -594,6 +857,34 @@ TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("Yugipedia clean-miss + YGOPRODeck transient is overall Transient") {
RoutingHttpClient http;
http.yugipediaBody = R"({"query":{"pages":{
"-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""}
}}})";
http.ygoprodeckOk = false;
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::Transient);
}
TEST_CASE("tuple-style setNo parsing trims fields and supports empty set code") {
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", " || Ultra Rare || 1E ");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos);
}
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.
@@ -611,3 +902,36 @@ TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
CHECK(http.lastUrl.find("yugipedia.com") == std::string::npos);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") {
TEST_CASE("returns first variant from filtered request") {
FixedHttpClient http;
http.body = R"({
"data":[
{"name":"Dark Magician",
"card_sets":[
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
]}
]
})";
YuGiOhCardPreviewSource src{http};
const auto out = src.detectFirstPrint(
"Dark Magician", "Legend of Blue Eyes White Dragon");
REQUIRE(out.isOk());
CHECK(out.value().setNo == "LOB-005");
CHECK(out.value().rarity == "Ultra Rare");
CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon")
!= std::string::npos);
}
TEST_CASE("propagates unfiltered fallback errors when both requests fail") {
FixedHttpClient http;
http.ok = false;
YuGiOhCardPreviewSource src{http};
const auto out = src.detectFirstPrint("Any", "Any Set");
REQUIRE(out.isErr());
CHECK(out.error() == "offline");
}
}
+87
View File
@@ -0,0 +1,87 @@
#include <doctest/doctest.h>
#include "ccm/domain/Set.hpp"
#include "ccm/util/YuGiOhSetLookup.hpp"
using namespace ccm;
namespace {
std::vector<Set> sampleSets() {
return {
Set{.id = "LOB", .name = "Legend of Blue Eyes White Dragon", .releaseDate = "2002/03/08"},
Set{.id = "MRD", .name = "Metal Raiders", .releaseDate = "2002/06/26"},
Set{.id = "LOB-25TH", .name = "Legend of Blue Eyes White Dragon (25th Anniversary Edition)",
.releaseDate = "2023/04/20"},
};
}
} // namespace
TEST_SUITE("lookupYuGiOhSetByShorthand") {
using Kind = YuGiOhSetShorthandLookup::Kind;
TEST_CASE("empty and whitespace-only query is NotFound") {
const auto sets = sampleSets();
CHECK(lookupYuGiOhSetByShorthand("", sets).kind == Kind::NotFound);
CHECK(lookupYuGiOhSetByShorthand(" ", sets).kind == Kind::NotFound);
CHECK(lookupYuGiOhSetByShorthand("\t\n", sets).kind == Kind::NotFound);
}
TEST_CASE("case-insensitive exact id match is Unique") {
const auto sets = sampleSets();
auto r = lookupYuGiOhSetByShorthand("lob", sets);
REQUIRE(r.kind == Kind::Unique);
CHECK(r.index == 0);
CHECK(sets[r.index].id == "LOB");
r = lookupYuGiOhSetByShorthand("MRD", sets);
REQUIRE(r.kind == Kind::Unique);
CHECK(r.index == 1);
}
TEST_CASE("trim ASCII whitespace around query") {
const auto sets = sampleSets();
const auto r = lookupYuGiOhSetByShorthand(" LOB ", sets);
REQUIRE(r.kind == Kind::Unique);
CHECK(r.index == 0);
}
TEST_CASE("hyphenated set codes match") {
const auto sets = sampleSets();
const auto r = lookupYuGiOhSetByShorthand("lob-25th", sets);
REQUIRE(r.kind == Kind::Unique);
CHECK(r.index == 2);
CHECK(sets[r.index].id == "LOB-25TH");
}
TEST_CASE("unknown code is NotFound") {
const auto sets = sampleSets();
CHECK(lookupYuGiOhSetByShorthand("NOPE", sets).kind == Kind::NotFound);
}
TEST_CASE("Ambiguous when two sets share the same normalized id") {
std::vector<Set> dup = {
Set{.id = "X1", .name = "A", .releaseDate = "2000/01/01"},
Set{.id = "x1", .name = "B", .releaseDate = "2000/01/02"},
};
CHECK(lookupYuGiOhSetByShorthand("X1", dup).kind == Kind::Ambiguous);
}
TEST_CASE("first matching index is stable when Unique among similar prefixes") {
const auto sets = sampleSets();
const auto r = lookupYuGiOhSetByShorthand("LOB", sets);
REQUIRE(r.kind == Kind::Unique);
CHECK(r.index == 0);
CHECK(sets[r.index].id == "LOB");
}
TEST_CASE("normalizeYuGiOhSetIdForLookup lowercases ASCII") {
CHECK(normalizeYuGiOhSetIdForLookup("Ra04-EN001") == "ra04-en001");
}
TEST_CASE("trimAsciiWhitespace handles empty") {
CHECK(trimAsciiWhitespace("") == "");
CHECK(trimAsciiWhitespace("x") == "x");
}
}
+115 -3
View File
@@ -29,9 +29,18 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
])";
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");
bool foundA = false;
bool foundB = false;
for (const auto& set : out.value()) {
if (set.id == "AAA" && set.name == "Set A" && set.releaseDate == "2020/01/01") {
foundA = true;
}
if (set.id == "BBB" && set.name == "Set B" && set.releaseDate == "2021/02/03") {
foundB = true;
}
}
CHECK(foundA);
CHECK(foundB);
}
TEST_CASE("sorts by release date ascending") {
@@ -47,6 +56,100 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
TEST_CASE("missing array returns error") {
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
}
TEST_CASE("empty upstream array still appends missing 25th aliases") {
const auto out = YuGiOhSetSource::parseResponse("[]");
REQUIRE(out.isOk());
CHECK(out.value().size() == 6);
bool foundLob25th = false;
bool foundIoc25th = false;
for (const auto& set : out.value()) {
if (set.id == "LOB-25TH") foundLob25th = true;
if (set.id == "IOC-25TH") foundIoc25th = true;
}
CHECK(foundLob25th);
CHECK(foundIoc25th);
}
TEST_CASE("adds 25th Anniversary aliases when upstream list misses them") {
const auto out = YuGiOhSetSource::parseResponse(R"([
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}
])");
REQUIRE(out.isOk());
bool foundLob25th = false;
bool foundIoc25th = false;
for (const auto& set : out.value()) {
if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)"
&& set.id == "LOB-25TH") {
foundLob25th = true;
}
if (set.name == "Invasion of Chaos (25th Anniversary Edition)" && set.id == "IOC-25TH") {
foundIoc25th = true;
}
}
CHECK(foundLob25th);
CHECK(foundIoc25th);
}
TEST_CASE("does not duplicate aliases that already exist by name") {
const auto out = YuGiOhSetSource::parseResponse(R"json([
{"set_name":"Legend of Blue Eyes White Dragon (25th Anniversary Edition)","set_code":"LOB-25TH","tcg_date":"2023-04-20"}
])json");
REQUIRE(out.isOk());
int aliasCount = 0;
for (const auto& set : out.value()) {
if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)") {
++aliasCount;
}
}
CHECK(aliasCount == 1);
}
TEST_CASE("malformed json returns parse error") {
const auto out = YuGiOhSetSource::parseResponse("{bad json");
REQUIRE(out.isErr());
CHECK(out.error().find("YGOPRODeck set parse error:") != std::string::npos);
}
TEST_CASE("missing fields fall back to empty strings and keep parsing") {
const std::string json = R"([
{"set_name":"Set A"},
{"set_code":"BBB","tcg_date":"2021-02-03"}
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
bool foundMissingCode = false;
bool foundMissingName = false;
for (const auto& set : out.value()) {
if (set.name == "Set A" && set.id.empty() && set.releaseDate.empty()) {
foundMissingCode = true;
}
if (set.id == "BBB" && set.name.empty() && set.releaseDate == "2021/02/03") {
foundMissingName = true;
}
}
CHECK(foundMissingCode);
CHECK(foundMissingName);
}
TEST_CASE("preserves slash-formatted dates and normalizes hyphen dates") {
const std::string json = R"([
{"set_name":"Slash Date","set_code":"S","tcg_date":"2024/01/01"},
{"set_name":"Hyphen Date","set_code":"H","tcg_date":"2024-01-02"}
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
bool sawSlash = false;
bool sawHyphenNormalized = false;
for (const auto& set : out.value()) {
if (set.id == "S" && set.releaseDate == "2024/01/01") sawSlash = true;
if (set.id == "H" && set.releaseDate == "2024/01/02") sawHyphenNormalized = true;
}
CHECK(sawSlash);
CHECK(sawHyphenNormalized);
}
}
TEST_SUITE("YuGiOhSetSource::fetchAll") {
@@ -59,4 +162,13 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") {
CHECK(out.value().front().id == "X");
CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php");
}
TEST_CASE("network error is propagated") {
FixedHttpClient http;
http.ok = false;
YuGiOhSetSource src{http};
const auto out = src.fetchAll();
REQUIRE(out.isErr());
CHECK(out.error() == "offline");
}
}
+8 -7
View File
@@ -9,19 +9,20 @@
- `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<TCard, TSortColumn>` 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<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `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 `<exeDir>/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 `<exeDir>/` 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<TCard>` 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/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `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 `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` 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<TCard>` 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. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). 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` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It 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/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`.
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50100 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.
- `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types.
- `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`.
- `include/ccm/ui/ImageViewerDialog.hpp` + `src/ImageViewerDialog.cpp` — full-size viewer with prev/next.
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs.
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`).
## Conventions
1. **Only consume core through `AppContext`.** Do not include any header from `ccm/infra/` here. The set of allowed `ccm/...` includes is `domain/`, `services/`, `games/IGameModule.hpp`, `ports/ICardPreviewSource.hpp`, and `util/Result.hpp`.
1. **Only consume core through `AppContext`.** Do not include any header from `ccm/infra/` here. The set of allowed `ccm/...` includes is `domain/`, `services/`, `games/IGameModule.hpp`, `ports/ICardPreviewSource.hpp`, and `util/` headers that remain UI-agnostic (for example `util/Result.hpp`, `util/YuGiOhPrintingSlot.hpp`, `util/YuGiOhSetLookup.hpp`). Do not pull arbitrary `util/` or `games/` implementation headers beyond what a panel/dialog already needs for display or small shared helpers.
2. **Image decoding lives here, not in core.** Use `wxImage::LoadFile(path.string())` against the path returned by `IImageStore::resolvePath`. Core stays free of any image library.
3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount.
4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`.
@@ -66,16 +67,16 @@
- When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`.
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.
- 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.
- 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. Digimon Digi-Battle uses a **bundled** PNG (`assets/digibattle99_card_back.png`) — keep those chains 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 `<Name>CardEditDialog`, not in `BaseCardEditDialog`.
- Keep shared templates game-agnostic: put buttons and detection behavior in `<Name>CardEditDialog`, not in `BaseCardEditDialog`. Yu-Gi-Oh!'s **Set code** entry (`SwitchCtrl` + text + **Auto detect** against cached sets) is wired through the template hook `customizeSetPickerRow` so Magic/Pokemon keep the default single-combo row unchanged.
- 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 `<exeDir>/assets/`; do not remove the asset without updating `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`.
- If you replace **`ui_wx/assets/ygo_card_back.png`** or **`ui_wx/assets/digibattle99_card_back.png`**, rebuild the **`ccm`** target so `app/CMakeLists.txt`'s `POST_BUILD` copy refreshes `<exeDir>/assets/`; do not remove an 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.
+5
View File
@@ -19,8 +19,13 @@ add_library(ccm_ui_wx STATIC
src/YuGiOhSelectedCardPanel.cpp
src/YuGiOhCardEditDialog.cpp
src/YuGiOhGameView.cpp
src/DigiBattle99CardListPanel.cpp
src/DigiBattle99SelectedCardPanel.cpp
src/DigiBattle99CardEditDialog.cpp
src/DigiBattle99GameView.cpp
src/SettingsDialog.cpp
src/SwitchCtrl.cpp
src/ImageViewerDialog.cpp
src/IconListCtrl.cpp
src/SvgIcons.cpp
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

+1
View File
@@ -26,6 +26,7 @@ struct AppContext {
IGameModule& magicModule;
IGameModule& pokemonModule;
IGameModule& yuGiOhModule;
IGameModule& digiBattle99Module;
// 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.
+43 -9
View File
@@ -32,6 +32,7 @@
#include <wx/filedlg.h>
#include <wx/listbox.h>
#include <wx/msgdlg.h>
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/spinctrl.h>
#include <wx/stattext.h>
@@ -146,6 +147,28 @@ protected:
return &available[static_cast<std::size_t>(sel)];
}
[[nodiscard]] const std::vector<Set>& availableSets() const noexcept {
return preloadedSets_ != nullptr ? *preloadedSets_ : sets_;
}
// Default: combo only. Yu-Gi-Oh! overrides to add set-code entry + toggle.
virtual void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) {
row.Add(combo, 1, wxEXPAND);
}
// After programmatically changing the set combo + `card_.set` (see
// `applySetSelectionByIndex`). Default no-op; Yu-Gi-Oh! clears print-variant cache.
virtual void onSetSelectionApplied() {}
void applySetSelectionByIndex(std::size_t index) {
const auto& available = availableSets();
if (!setCombo_ || !setCombo_->IsEnabled()) return;
if (index >= available.size()) return;
setCombo_->SetSelection(static_cast<int>(index));
card_.set = available[index];
onSetSelectionApplied();
}
private:
void readSets() {
auto loaded = setService_.getSets(game_);
@@ -158,10 +181,6 @@ private:
}
}
[[nodiscard]] const std::vector<Set>& availableSets() const noexcept {
return preloadedSets_ != nullptr ? *preloadedSets_ : sets_;
}
void buildLayout() {
auto* root = new wxBoxSizer(wxVERTICAL);
auto* grid = new wxFlexGridSizer(2, 6, 8);
@@ -174,9 +193,16 @@ private:
});
appendRow(grid, "Name", nameCtrl_);
setCombo_ = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0,
// `setCombo_` must be parented to `setHost` so every control in the Set row
// shares the same `wxPanel`; otherwise the combo stays a direct child of the
// dialog while the sizer lives on `setHost`, which corrupts layout on MSW.
auto* setHost = new wxPanel(this, wxID_ANY);
auto* setRow = new wxBoxSizer(wxHORIZONTAL);
setHost->SetSizer(setRow);
setCombo_ = new wxComboBox(setHost, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0,
nullptr, wxCB_READONLY);
appendRow(grid, "Set", setCombo_);
customizeSetPickerRow(*setRow, setCombo_);
appendRow(grid, "Set", setHost);
// Subclass extra rows go between Set and Amount (Pokemon adds Set #).
appendExtraRows(grid);
@@ -237,8 +263,13 @@ private:
CallAfter([this]() {
if (nameCtrl_) {
nameCtrl_->SetInsertionPoint(0);
nameCtrl_->ShowPosition(0);
nameCtrl_->SetFocus();
if (mode_ == EditMode::Edit && !nameCtrl_->IsEmpty()) {
nameCtrl_->SetInsertionPointEnd();
} else {
nameCtrl_->SetInsertionPoint(0);
nameCtrl_->ShowPosition(0);
}
}
if (noteCtrl_) {
noteCtrl_->SetInsertionPoint(0);
@@ -352,11 +383,14 @@ private:
failed.reserve(static_cast<std::size_t>(paths.size()));
for (const auto& path : paths) {
const std::string setNameForImage = (game_ == Game::YuGiOh && !card_.set.id.empty())
? card_.set.id
: card_.set.name;
auto added = imageService_.addImage(game_,
std::filesystem::path(path.ToStdString()),
mode_ == EditMode::Create,
card_.id,
card_.set.name,
setNameForImage,
card_.name,
card_.images);
if (!added) {
@@ -70,6 +70,10 @@ namespace ccm::ui {
// not duplicated per template instantiation.
wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
// Raised on `wxEVT_LIST_ITEM_ACTIVATED` (double-click / Enter on a row).
// `IGameView` implementations bind this to open Edit for `selected()`.
wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
template <typename TCard, typename TSortColumn>
class BaseCardListPanel : public wxPanel {
public:
@@ -238,6 +242,7 @@ protected:
list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this);
}
// Forwarded helpers ------------------------------------------------------
@@ -642,6 +647,14 @@ private:
notifySelectionChanged();
}
void onListItemActivated(wxListEvent& event) {
(void)event;
if (inRebuild_) return;
wxCommandEvent ev(EVT_CARD_ACTIVATED, GetId());
ev.SetEventObject(this);
ProcessWindowEvent(ev);
}
// ----- members ----------------------------------------------------------
static constexpr int kFlagIconSize = 14;
+13 -1
View File
@@ -295,9 +295,11 @@ private:
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:
case Game::DigiBattle99:
// No stable public Digi-Battle back URL; UI uses bundled PNG.
return {};
}
return {};
}
void buildInfoGrid(wxBoxSizer* root) {
@@ -424,6 +426,16 @@ private:
applyFallbackPayload(ss.str());
}
}
} else if (game == Game::DigiBattle99) {
namespace fs = std::filesystem;
const fs::path asset =
fs::path(exeDirCopy) / "assets" / "digibattle99_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()) {
@@ -0,0 +1,34 @@
#pragma once
// Tracks when a modal Add/Edit card dialog is on screen so a second one
// cannot be stacked (toolbar + list activation, or rare re-entrant cases).
#include <atomic>
namespace ccm::ui {
// User-visible hint when Add/Edit is requested while a card dialog is already modal.
inline constexpr const char* kCardEditModalBlockedUtf8 =
"Close the open card dialog (save or cancel) before opening another card.";
[[nodiscard]] inline std::atomic<int>& cardEditModalDepthRef() noexcept {
static std::atomic<int> depth{0};
return depth;
}
[[nodiscard]] inline bool cardEditModalIsActive() noexcept {
return cardEditModalDepthRef().load(std::memory_order_relaxed) > 0;
}
struct CardEditModalGuard {
CardEditModalGuard() {
cardEditModalDepthRef().fetch_add(1, std::memory_order_relaxed);
}
~CardEditModalGuard() {
cardEditModalDepthRef().fetch_sub(1, std::memory_order_relaxed);
}
CardEditModalGuard(const CardEditModalGuard&) = delete;
CardEditModalGuard& operator=(const CardEditModalGuard&) = delete;
};
} // namespace ccm::ui
@@ -0,0 +1,81 @@
#pragma once
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include <wx/button.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
class DigiBattle99CardEditDialog final : public BaseCardEditDialog<DigiBattle99Card> {
public:
DigiBattle99CardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
DigiBattle99Card initial,
const std::vector<Set>* preloadedSets = nullptr);
~DigiBattle99CardEditDialog() override;
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 Digimon (Digi-Battle)";
}
void onCardLookupContextChanged() override;
private:
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void autoDetectFromApi();
void clearCachedPrintVariants();
void requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setName,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
void refreshVariantNextControls();
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
[[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl);
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetNos_;
std::size_t setNoRingPos_{0};
};
} // namespace ccm::ui
@@ -0,0 +1,26 @@
#pragma once
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/services/CardSorter.hpp"
#include "ccm/ui/BaseCardListPanel.hpp"
namespace ccm::ui {
class DigiBattle99CardListPanel final
: public BaseCardListPanel<DigiBattle99Card, DigiBattle99SortColumn> {
public:
explicit DigiBattle99CardListPanel(wxWindow* parent);
protected:
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
[[nodiscard]] std::string renderTextCell(const DigiBattle99Card& card,
std::size_t idx) const override;
[[nodiscard]] bool isIconColumnSet(const DigiBattle99Card& card,
std::size_t idx) const override;
void sortBy(DigiBattle99SortColumn column, bool ascending) override;
[[nodiscard]] bool matchesFilter(const DigiBattle99Card& card,
std::string_view filter) const override;
};
} // namespace ccm::ui
@@ -0,0 +1,64 @@
#pragma once
#include "ccm/domain/DigiBattle99Card.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 <string>
#include <string_view>
#include <vector>
namespace ccm::ui {
class DigiBattle99CardListPanel;
class DigiBattle99SelectedCardPanel;
class DigiBattle99GameView final : public IGameView {
public:
DigiBattle99GameView(ConfigService& config,
CollectionService<DigiBattle99Card>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
[[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; }
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
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 Digimon (Digi-Battle)";
}
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
ConfigService& config_;
CollectionService<DigiBattle99Card>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
DigiBattle99CardListPanel* listPanel_{nullptr};
DigiBattle99SelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,25 @@
#pragma once
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
namespace ccm::ui {
class DigiBattle99SelectedCardPanel final : public BaseSelectedCardPanel<DigiBattle99Card> {
public:
DigiBattle99SelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview);
protected:
[[nodiscard]] std::vector<DetailRowSpec> declareDetailRows() const override;
[[nodiscard]] std::vector<FlagIconSpec> declareFlagIcons() const override;
[[nodiscard]] std::string detailValueFor(const DigiBattle99Card& card,
DetailKey key) const override;
[[nodiscard]] bool isFlagSet(const DigiBattle99Card& card, DetailKey key) const override;
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const DigiBattle99Card& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; }
};
} // namespace ccm::ui
+52 -5
View File
@@ -7,7 +7,15 @@
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include <wx/button.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
@@ -16,9 +24,11 @@ public:
PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets = nullptr);
~PokemonCardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
@@ -26,13 +36,50 @@ protected:
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
void onCardLookupContextChanged() override;
private:
wxTextCtrl* setNoCtrl_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void autoDetectFromApi();
void clearCachedPrintVariants();
void requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
void refreshVariantNextControls();
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
[[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl);
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetNos_;
std::size_t setNoRingPos_{0};
};
} // namespace ccm::ui
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <wx/event.h>
#include <wx/window.h>
namespace ccm::ui {
wxDECLARE_EVENT(EVT_CCM_SWITCH, wxCommandEvent);
// Small on/off switch (pill track + thumb) for modal dialogs. Fires `EVT_CCM_SWITCH`
// when the user toggles; bind with the control pointer as the event source.
class SwitchCtrl final : public wxWindow {
public:
explicit SwitchCtrl(wxWindow* parent, wxWindowID id = wxID_ANY, bool initialOn = false);
[[nodiscard]] bool GetValue() const noexcept { return on_; }
void SetValue(bool on, bool notify = false);
bool Enable(bool enable = true) override;
private:
void onPaint(wxPaintEvent&);
void onLeftDown(wxMouseEvent&);
void onEnter(wxMouseEvent&);
void onLeave(wxMouseEvent&);
bool on_{false};
bool hovered_{false};
};
} // namespace ccm::ui
@@ -4,6 +4,7 @@
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include "ccm/ui/SwitchCtrl.hpp"
#include <wx/button.h>
#include <wx/stattext.h>
@@ -21,11 +22,13 @@ public:
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) 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;
void onSetSelectionApplied() override;
private:
void onAutoDetectSetNo(wxCommandEvent&);
@@ -34,6 +37,10 @@ private:
void onNextRarity(wxCommandEvent&);
void onSetNoTextChanged(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void handleSetSelectionChanged();
void onSetRowSwitch(wxCommandEvent&);
void onSetCodeAutoDetect(wxCommandEvent&);
void syncSetModeHint();
void autoDetectFromApi(bool fillSetNo, bool fillRarity);
void refreshSetNoFullPreview();
void clearCachedPrintVariants();
@@ -63,6 +70,12 @@ private:
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
wxPanel* setCodeRowPanel_{nullptr};
wxTextCtrl* setCodeText_{nullptr};
wxButton* setCodeAutoBtn_{nullptr};
wxStaticText* setModeHint_{nullptr};
SwitchCtrl* setPickerSwitch_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetCodes_;
std::vector<std::string> raritiesForCurrentSetCode_;
+3
View File
@@ -2,6 +2,8 @@
// declared in the corresponding base headers (BaseCardListPanel.hpp,
// BaseSelectedCardPanel.hpp) and defined exactly once here, so that template
// instantiations (Magic, Pokemon, ...) all use the same event type tag.
// EVT_CARD_ACTIVATED is declared alongside EVT_CARD_SELECTED in
// BaseCardListPanel.hpp.
#include "ccm/ui/BaseCardListPanel.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
@@ -9,6 +11,7 @@
namespace ccm::ui {
wxDEFINE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
wxDEFINE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
wxDEFINE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent);
} // namespace ccm::ui
+255
View File
@@ -0,0 +1,255 @@
#include "ccm/ui/DigiBattle99CardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <thread>
#include <unordered_set>
namespace ccm::ui {
DigiBattle99CardEditDialog::DigiBattle99CardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
DigiBattle99Card initial,
const std::vector<Set>* preloadedSets)
: BaseCardEditDialog<DigiBattle99Card>(
parent,
mode == EditMode::Create ? "Add Digimon (Digi-Battle) Card"
: "Edit Digimon (Digi-Battle) Card",
imageService, setService, mode, std::move(initial), Game::DigiBattle99,
preloadedSets),
dialogMode_(mode),
cardPreview_(cardPreview),
variantFetchState_(std::make_shared<VariantFetchState>()) {
buildAndPopulate();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
}
DigiBattle99CardEditDialog::~DigiBattle99CardEditDialog() {
if (variantFetchState_) {
variantFetchState_->alive.store(false);
}
}
void DigiBattle99CardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
void DigiBattle99CardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo");
firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition");
signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed");
alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered");
flagsBox->Add(holoCheck_, 0, wxRIGHT, 12);
flagsBox->Add(firstEditionCheck_, 0, wxRIGHT, 12);
flagsBox->Add(signedCheck_, 0, wxRIGHT, 12);
flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12);
}
void DigiBattle99CardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
autoSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onAutoDetectSetNo, this);
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
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);
setNoPanel->SetSizer(setNoRow);
appendRow(grid, "Set #", setNoPanel);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &DigiBattle99CardEditDialog::onSetSelectionChanged, this);
}
}
std::string DigiBattle99CardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
return DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
}
std::string DigiBattle99CardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
if (ctrl == nullptr) return {};
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
}
void DigiBattle99CardEditDialog::readExtraFromCard() {
clearCachedPrintVariants();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
}
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
}
void DigiBattle99CardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
}
void DigiBattle99CardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
cachedVariants_.clear();
uniqueSetNos_.clear();
setNoRingPos_ = 0;
refreshVariantNextControls();
}
void DigiBattle99CardEditDialog::scheduleDeferredVariantPrefetch() {
const unsigned epoch = variantFetchEpoch_;
wxTheApp->CallAfter([this, epoch]() {
prefetchVariantsForCurrentCardSilent(epoch);
});
}
void DigiBattle99CardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
if (capturedEpoch != variantFetchEpoch_) return;
if (!cachedVariants_.empty()) return;
const auto& card = constCard();
// digimoncard.io pack= uses the display set name, not the slug id.
if (card.name.empty() || card.set.name.empty()) return;
requestVariantsAsync(capturedEpoch, card.name, card.set.name, false, false);
}
void DigiBattle99CardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setName,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Disable();
}
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
DigiBattle99CardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setName = std::move(setName), fillSetNoOnSuccess, showFailureDialog]() {
auto detected = svc->detectPrintVariants(Game::DigiBattle99, name, setName);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
});
}).detach();
}
void DigiBattle99CardEditDialog::applyDetectedVariants(
unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
if (!detected) {
if (showFailureDialog) {
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
wxOK | wxICON_WARNING);
}
return;
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
}
rebuildVariantRingFromCache();
syncRingPositionToControls();
refreshVariantNextControls();
}
void DigiBattle99CardEditDialog::rebuildVariantRingFromCache() {
uniqueSetNos_.clear();
if (cachedVariants_.empty()) return;
std::unordered_set<std::string> seen;
seen.reserve(cachedVariants_.size());
for (const auto& p : cachedVariants_) {
if (p.setNo.empty()) continue;
if (!seen.insert(p.setNo).second) continue;
uniqueSetNos_.push_back(p.setNo);
}
}
void DigiBattle99CardEditDialog::syncRingPositionToControls() {
if (!setNoCtrl_) return;
const std::string current = storedSetNoFromControls(setNoCtrl_);
setNoRingPos_ = 0;
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
if (uniqueSetNos_[i] == current) {
setNoRingPos_ = i;
break;
}
}
}
void DigiBattle99CardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_) return;
nextSetNoBtn_->Show(uniqueSetNos_.size() > 1);
Layout();
if (GetSizer()) Fit();
}
void DigiBattle99CardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
autoDetectFromApi();
}
void DigiBattle99CardEditDialog::onNextSetNo(wxCommandEvent&) {
if (uniqueSetNos_.size() <= 1) return;
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
}
refreshVariantNextControls();
}
void DigiBattle99CardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.name.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.name, true, true);
}
void DigiBattle99CardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
} // namespace ccm::ui
+71
View File
@@ -0,0 +1,71 @@
#include "ccm/ui/DigiBattle99CardListPanel.hpp"
#include "ccm/services/CardFilter.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include <string>
namespace ccm::ui {
DigiBattle99CardListPanel::DigiBattle99CardListPanel(wxWindow* parent)
: BaseCardListPanel<DigiBattle99Card, DigiBattle99SortColumn>(parent) {
buildLayout();
}
std::vector<DigiBattle99CardListPanel::TextColumnSpec>
DigiBattle99CardListPanel::declareTextColumns() const {
return {
{"Name", 220, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Name},
{"Set", 180, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::SetReleaseDate},
{"Amount", 70, wxLIST_FORMAT_RIGHT, DigiBattle99SortColumn::Amount},
{"Condition", 100, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Condition},
{"Language", 100, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Language},
{"Note", 220, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Note},
};
}
std::vector<DigiBattle99CardListPanel::IconColumnSpec>
DigiBattle99CardListPanel::declareIconColumns() const {
constexpr int kFlagColWidth = 36;
return {
{kSvgHolo, kFlagColWidth, DigiBattle99SortColumn::Holo},
{kSvgFirstEdition, kFlagColWidth, DigiBattle99SortColumn::FirstEdition},
{kSvgSigned, kFlagColWidth, DigiBattle99SortColumn::Signed},
{kSvgAltered, kFlagColWidth, DigiBattle99SortColumn::Altered},
};
}
std::string DigiBattle99CardListPanel::renderTextCell(const DigiBattle99Card& 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 DigiBattle99CardListPanel::isIconColumnSet(const DigiBattle99Card& card,
std::size_t idx) const {
switch (idx) {
case 0: return card.holo;
case 1: return card.firstEdition;
case 2: return card.signed_;
case 3: return card.altered;
}
return false;
}
void DigiBattle99CardListPanel::sortBy(DigiBattle99SortColumn column, bool ascending) {
sortDigiBattle99Cards(mutableCards(), column, ascending);
}
bool DigiBattle99CardListPanel::matchesFilter(const DigiBattle99Card& card,
std::string_view filter) const {
return matchesDigiBattle99Filter(card, filter);
}
} // namespace ccm::ui
+216
View File
@@ -0,0 +1,216 @@
#include "ccm/ui/DigiBattle99GameView.hpp"
#include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/DigiBattle99CardEditDialog.hpp"
#include "ccm/ui/DigiBattle99CardListPanel.hpp"
#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/msgdlg.h>
#include <wx/window.h>
#include <optional>
#include <string>
namespace ccm::ui {
DigiBattle99GameView::DigiBattle99GameView(ConfigService& config,
CollectionService<DigiBattle99Card>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module)
: config_(config),
collection_(collection),
sets_(sets),
images_(images),
cardPreview_(cardPreview),
module_(module) {}
void DigiBattle99GameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return;
attemptedInitialSetLoad_ = true;
auto cached = sets_.getSets(Game::DigiBattle99);
if (cached) {
setsCache_ = std::move(cached).value();
if (!setsCache_.empty()) return;
} else {
setsCache_.clear();
}
auto refreshed = sets_.updateSets(Game::DigiBattle99);
if (refreshed) {
setsCache_ = std::move(refreshed).value();
}
}
wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) {
if (listPanel_ == nullptr) {
listPanel_ = new DigiBattle99CardListPanel(parent);
listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
onEditCard(owner != nullptr ? owner : static_cast<wxWindow*>(listPanel_));
});
}
return listPanel_;
}
wxPanel* DigiBattle99GameView::selectedPanel(wxWindow* parent) {
if (selectedPanel_ == nullptr) {
selectedPanel_ = new DigiBattle99SelectedCardPanel(parent, images_, cardPreview_);
}
return selectedPanel_;
}
void DigiBattle99GameView::refreshCollection() {
if (listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::DigiBattle99);
if (!loaded) {
showThemedMessageDialog(
nullptr,
"Failed to load Digimon (Digi-Battle) 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<Set>& DigiBattle99GameView::setsForDialog() {
ensureSetsLoaded();
if (!setsCache_.empty()) return setsCache_;
auto loaded = sets_.getSets(Game::DigiBattle99);
if (loaded) setsCache_ = std::move(loaded).value();
else setsCache_.clear();
return setsCache_;
}
void DigiBattle99GameView::onAddCard(wxWindow* parentWindow) {
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION);
return;
}
DigiBattle99Card fresh;
fresh.amount = 1;
fresh.language = Language::English;
fresh.condition = Condition::NearMint;
DigiBattle99CardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create,
fresh, &setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
auto added = collection_.add(Game::DigiBattle99, dlg.card());
if (!added) {
showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
DigiBattle99Card persisted = dlg.card();
persisted.id = added.value();
auto normalized = images_.normalizeNamesForPersistedCard(
Game::DigiBattle99, 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::DigiBattle99, 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 DigiBattle99GameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION);
return;
}
DigiBattle99CardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit,
*sel, &setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
auto updated = collection_.update(Game::DigiBattle99, dlg.card());
if (!updated) {
showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
refreshCollection();
}
void DigiBattle99GameView::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::DigiBattle99, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
refreshCollection();
}
std::string DigiBattle99GameView::onUpdateSets(wxWindow* parentWindow) {
auto out = sets_.updateSets(Game::DigiBattle99);
if (!out) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
setsCache_ = out.value();
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(out.value().size()) + " Digimon (Digi-Battle) sets.",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Digimon (Digi-Battle) sets updated.";
}
void DigiBattle99GameView::setFilter(std::string_view filter) {
if (listPanel_) listPanel_->setFilter(filter);
}
void DigiBattle99GameView::applyTheme(const ThemePalette& palette) {
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
}
} // namespace ccm::ui
@@ -0,0 +1,84 @@
#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include <string>
namespace ccm::ui {
namespace {
enum DigiBattle99DetailKey : int {
kName = 0,
kSet,
kSetNo,
kLanguage,
kCondition,
kAmount,
kHolo,
kFirstEdition,
kSigned,
kAltered,
};
} // namespace
DigiBattle99SelectedCardPanel::DigiBattle99SelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview)
: BaseSelectedCardPanel<DigiBattle99Card>(parent, imageService, cardPreview) {
buildLayout();
}
std::vector<DigiBattle99SelectedCardPanel::DetailRowSpec>
DigiBattle99SelectedCardPanel::declareDetailRows() const {
return {
{"Name", kName, "(no card selected)"},
{"Set", kSet, ""},
{"Set #", kSetNo, ""},
{"Language", kLanguage, ""},
{"Condition", kCondition, ""},
{"Amount", kAmount, ""},
};
}
std::vector<DigiBattle99SelectedCardPanel::FlagIconSpec>
DigiBattle99SelectedCardPanel::declareFlagIcons() const {
return {
{kSvgHolo, "Holo", kHolo},
{kSvgFirstEdition, "1. Edition", kFirstEdition},
{kSvgSigned, "Signed", kSigned},
{kSvgAltered, "Altered", kAltered},
};
}
std::string DigiBattle99SelectedCardPanel::detailValueFor(const DigiBattle99Card& card,
DetailKey key) const {
switch (key) {
case kName: return card.name;
case kSet: return card.set.name;
case kSetNo: return card.setNo;
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 DigiBattle99SelectedCardPanel::isFlagSet(const DigiBattle99Card& card,
DetailKey key) const {
switch (key) {
case kHolo: return card.holo;
case kFirstEdition: return card.firstEdition;
case kSigned: return card.signed_;
case kAltered: return card.altered;
}
return false;
}
std::tuple<std::string, std::string, std::string>
DigiBattle99SelectedCardPanel::previewKey(const DigiBattle99Card& card) const {
// Middle slot is Set.name (pack display name) for digimoncard.io pack=.
return {card.name, card.set.name, card.setNo};
}
} // namespace ccm::ui
+18
View File
@@ -1,11 +1,13 @@
#include "ccm/ui/MagicGameView.hpp"
#include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/MagicCardEditDialog.hpp"
#include "ccm/ui/MagicCardListPanel.hpp"
#include "ccm/ui/MagicSelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/msgdlg.h>
#include <wx/window.h>
#include <optional>
#include <string>
@@ -54,6 +56,10 @@ wxPanel* MagicGameView::listPanel(wxWindow* parent) {
selectedPanel_->setCard(listPanel_->selected());
}
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
onEditCard(owner != nullptr ? owner : static_cast<wxWindow*>(listPanel_));
});
}
return listPanel_;
}
@@ -88,6 +94,11 @@ const std::vector<Set>& MagicGameView::setsForDialog() {
}
void MagicGameView::onAddCard(wxWindow* parentWindow) {
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION);
return;
}
MagicCard fresh;
fresh.amount = 1;
fresh.language = Language::English;
@@ -96,6 +107,7 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) {
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
&setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
auto added = collection_.add(Game::Magic, dlg.card());
@@ -132,9 +144,15 @@ void MagicGameView::onEditCard(wxWindow* parentWindow) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
return;
}
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION);
return;
}
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
&setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
auto updated = collection_.update(Game::Magic, dlg.card());
if (!updated) {
+5 -3
View File
@@ -41,8 +41,10 @@ constexpr const char kFilterInputHint[] = "Filter";
std::string dirNameForGame(Game g) {
switch (g) {
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
case Game::DigiBattle99: return "digibattle99";
}
return "magic";
}
@@ -67,7 +69,7 @@ void ensureDataStorageScaffold(const Configuration& cfg) {
}
}
for (Game game : {Game::Magic, Game::Pokemon}) {
for (Game game : allGames()) {
const fs::path gameRoot = root / dirNameForGame(game);
fs::create_directories(gameRoot / "images", ec);
if (ec) continue;
+210 -5
View File
@@ -1,18 +1,41 @@
#include "ccm/ui/PokemonCardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <thread>
#include <unordered_set>
namespace ccm::ui {
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets)
: BaseCardEditDialog<PokemonCard>(
parent,
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets) {
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets),
dialogMode_(mode),
cardPreview_(cardPreview),
variantFetchState_(std::make_shared<VariantFetchState>()) {
buildAndPopulate();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
}
PokemonCardEditDialog::~PokemonCardEditDialog() {
if (variantFetchState_) {
variantFetchState_->alive.store(false);
}
}
void PokemonCardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
@@ -27,12 +50,46 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
}
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
setNoCtrl_ = new wxTextCtrl(this, wxID_ANY, constCard().setNo);
appendRow(grid, "Set #", setNoCtrl_);
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
autoSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onAutoDetectSetNo, this);
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
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);
setNoPanel->SetSizer(setNoRow);
appendRow(grid, "Set #", setNoPanel);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this);
}
}
std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
std::string out(setNo);
const auto slash = out.find('/');
if (slash != std::string::npos) {
out.resize(slash);
}
return out;
}
std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
if (ctrl == nullptr) return {};
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
}
void PokemonCardEditDialog::readExtraFromCard() {
if (setNoCtrl_) setNoCtrl_->ChangeValue(constCard().setNo);
clearCachedPrintVariants();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
}
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
@@ -40,11 +97,159 @@ void PokemonCardEditDialog::readExtraFromCard() {
}
void PokemonCardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString();
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
}
void PokemonCardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
cachedVariants_.clear();
uniqueSetNos_.clear();
setNoRingPos_ = 0;
refreshVariantNextControls();
}
void PokemonCardEditDialog::scheduleDeferredVariantPrefetch() {
const unsigned epoch = variantFetchEpoch_;
wxTheApp->CallAfter([this, epoch]() {
prefetchVariantsForCurrentCardSilent(epoch);
});
}
void PokemonCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
if (capturedEpoch != variantFetchEpoch_) return;
if (!cachedVariants_.empty()) return;
const auto& card = constCard();
if (card.name.empty() || card.set.id.empty()) return;
requestVariantsAsync(capturedEpoch, card.name, card.set.id, false, false);
}
void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Disable();
}
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
PokemonCardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog]() {
auto detected = svc->detectPrintVariants(Game::Pokemon, name, setId);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
});
}).detach();
}
void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
if (!detected) {
if (showFailureDialog) {
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
wxOK | wxICON_WARNING);
}
return;
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
}
rebuildVariantRingFromCache();
syncRingPositionToControls();
refreshVariantNextControls();
}
void PokemonCardEditDialog::rebuildVariantRingFromCache() {
uniqueSetNos_.clear();
if (cachedVariants_.empty()) return;
std::unordered_set<std::string> seen;
seen.reserve(cachedVariants_.size());
for (const auto& p : cachedVariants_) {
if (p.setNo.empty()) continue;
if (!seen.insert(p.setNo).second) continue;
uniqueSetNos_.push_back(p.setNo);
}
}
void PokemonCardEditDialog::syncRingPositionToControls() {
if (!setNoCtrl_) return;
const std::string current = storedSetNoFromControls(setNoCtrl_);
setNoRingPos_ = 0;
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
if (uniqueSetNos_[i] == current) {
setNoRingPos_ = i;
break;
}
}
}
void PokemonCardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_) return;
nextSetNoBtn_->Show(uniqueSetNos_.size() > 1);
Layout();
if (GetSizer()) Fit();
}
void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
autoDetectFromApi();
}
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
if (uniqueSetNos_.size() <= 1) return;
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
}
refreshVariantNextControls();
}
void PokemonCardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.id.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.id, true, true);
}
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
} // namespace ccm::ui
+20 -2
View File
@@ -1,11 +1,13 @@
#include "ccm/ui/PokemonGameView.hpp"
#include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/PokemonCardEditDialog.hpp"
#include "ccm/ui/PokemonCardListPanel.hpp"
#include "ccm/ui/PokemonSelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/msgdlg.h>
#include <wx/window.h>
#include <optional>
#include <string>
@@ -51,6 +53,10 @@ wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
selectedPanel_->setCard(listPanel_->selected());
}
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
onEditCard(owner != nullptr ? owner : static_cast<wxWindow*>(listPanel_));
});
}
return listPanel_;
}
@@ -85,14 +91,20 @@ const std::vector<Set>& PokemonGameView::setsForDialog() {
}
void PokemonGameView::onAddCard(wxWindow* parentWindow) {
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION);
return;
}
PokemonCard fresh;
fresh.amount = 1;
fresh.language = Language::English;
fresh.condition = Condition::NearMint;
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
&setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
auto added = collection_.add(Game::Pokemon, dlg.card());
@@ -129,9 +141,15 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
return;
}
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION);
return;
}
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
&setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
auto updated = collection_.update(Game::Pokemon, dlg.card());
if (!updated) {
+28 -4
View File
@@ -1,5 +1,6 @@
#include "ccm/ui/SettingsDialog.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/domain/Enums.hpp"
#include <wx/button.h>
#include <wx/dirdlg.h>
@@ -9,6 +10,20 @@
namespace ccm::ui {
namespace {
wxString displayLabelForGame(Game g) {
switch (g) {
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "Yu-Gi-Oh!";
case Game::DigiBattle99: return "Digimon (Digi-Battle)";
}
return wxString::FromUTF8(to_string(g).data());
}
} // namespace
SettingsDialog::SettingsDialog(wxWindow* parent, ConfigService& config)
: wxDialog(parent, wxID_ANY, "Settings",
wxDefaultPosition, wxSize(560, 200),
@@ -30,9 +45,14 @@ SettingsDialog::SettingsDialog(wxWindow* parent, ConfigService& config)
gameRow->Add(new wxStaticText(this, wxID_ANY, "Default game:"),
0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
defaultGameChoice_ = new wxChoice(this, wxID_ANY);
defaultGameChoice_->Append("Magic");
defaultGameChoice_->Append("Pokemon");
defaultGameChoice_->SetSelection(config_.current().defaultGame == Game::Magic ? 0 : 1);
int selected = 0;
int idx = 0;
for (const Game g : allGames()) {
defaultGameChoice_->Append(displayLabelForGame(g));
if (g == config_.current().defaultGame) selected = idx;
++idx;
}
defaultGameChoice_->SetSelection(selected);
gameRow->Add(defaultGameChoice_, 0);
root->Add(gameRow, 0, wxEXPAND | wxLEFT | wxRIGHT, 10);
@@ -77,7 +97,11 @@ void SettingsDialog::onBrowse(wxCommandEvent&) {
void SettingsDialog::onOk(wxCommandEvent& ev) {
Configuration next = config_.current();
next.dataStorage = dataDirCtrl_->GetValue().ToStdString();
next.defaultGame = defaultGameChoice_->GetSelection() == 0 ? Game::Magic : Game::Pokemon;
const int gameSel = defaultGameChoice_->GetSelection();
const auto& games = allGames();
if (gameSel >= 0 && static_cast<std::size_t>(gameSel) < games.size()) {
next.defaultGame = games[static_cast<std::size_t>(gameSel)];
}
switch (themeChoice_->GetSelection()) {
case 1: next.theme = Theme::Dark; break;
case 0:
+135
View File
@@ -0,0 +1,135 @@
#include "ccm/ui/SwitchCtrl.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/dcbuffer.h>
#include <wx/dcclient.h>
#include <algorithm>
namespace ccm::ui {
wxDEFINE_EVENT(EVT_CCM_SWITCH, wxCommandEvent);
namespace {
wxColour liftRgb(const wxColour& c, int delta) {
auto lift = [delta](unsigned char ch) -> unsigned char {
const int v = static_cast<int>(ch) + delta;
return static_cast<unsigned char>(v > 255 ? 255 : (v < 0 ? 0 : v));
};
return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue()));
}
} // namespace
SwitchCtrl::SwitchCtrl(wxWindow* parent, wxWindowID id, bool initialOn)
: wxWindow(parent, id, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE, wxString()),
on_(initialOn) {
SetBackgroundStyle(wxBG_STYLE_PAINT);
SetCursor(wxCURSOR_HAND);
const wxSize sz = FromDIP(wxSize(40, 20));
SetMinSize(sz);
SetMaxSize(sz);
SetInitialSize(sz);
Bind(wxEVT_PAINT, &SwitchCtrl::onPaint, this);
Bind(wxEVT_LEFT_DOWN, &SwitchCtrl::onLeftDown, this);
Bind(wxEVT_ENTER_WINDOW, &SwitchCtrl::onEnter, this);
Bind(wxEVT_LEAVE_WINDOW, &SwitchCtrl::onLeave, this);
Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
}
void SwitchCtrl::SetValue(bool on, bool notify) {
if (on_ == on) return;
on_ = on;
Refresh();
if (notify) {
wxCommandEvent e(EVT_CCM_SWITCH, GetId());
e.SetEventObject(this);
e.SetInt(on_ ? 1 : 0);
ProcessEvent(e);
}
}
bool SwitchCtrl::Enable(bool enable) {
const bool ok = wxWindow::Enable(enable);
SetCursor(enable ? wxCURSOR_HAND : wxCURSOR_ARROW);
Refresh();
return ok;
}
void SwitchCtrl::onEnter(wxMouseEvent& ev) {
hovered_ = true;
Refresh();
ev.Skip();
}
void SwitchCtrl::onLeave(wxMouseEvent& ev) {
hovered_ = false;
Refresh();
ev.Skip();
}
void SwitchCtrl::onLeftDown(wxMouseEvent& ev) {
if (!IsEnabled()) {
ev.Skip();
return;
}
on_ = !on_;
Refresh();
wxCommandEvent e(EVT_CCM_SWITCH, GetId());
e.SetEventObject(this);
e.SetInt(on_ ? 1 : 0);
ProcessEvent(e);
ev.Skip(false);
}
void SwitchCtrl::onPaint(wxPaintEvent&) {
wxAutoBufferedPaintDC dc(this);
const wxRect rect = GetClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const Theme theme = inferThemeFromWindow(this);
const ThemePalette p = paletteForTheme(theme);
const bool dark = theme == Theme::Dark;
wxColour trackOff = p.inputBg;
wxColour trackOn = p.buttonBg;
wxColour thumb = dark ? wxColour(240, 240, 240) : wxColour(252, 252, 252);
wxColour border = dark ? wxColour(72, 72, 72) : wxColour(158, 158, 158);
wxColour track = on_ ? trackOn : trackOff;
if (hovered_ && IsEnabled()) {
track = liftRgb(track, dark ? 14 : 10);
}
if (!IsEnabled()) {
track = liftRgb(track, dark ? -22 : -25);
thumb = liftRgb(thumb, dark ? -55 : -35);
border = liftRgb(border, dark ? -15 : 10);
}
// Fill the full client rect first so rounded-track corners do not show
// undrawn pixels (often black) against the parent panel.
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(wxBrush(p.panelBg));
dc.DrawRectangle(rect);
dc.SetPen(wxPen(border));
dc.SetBrush(wxBrush(track));
const int radius = rect.height / 2;
dc.DrawRoundedRectangle(rect, radius);
const int pad = FromDIP(2);
const int thumbD = std::max(4, rect.height - 2 * pad);
const int travel = std::max(0, rect.width - 2 * pad - thumbD);
const int thumbX = pad + (on_ ? travel : 0);
const int thumbY = rect.y + (rect.height - thumbD) / 2;
wxColour thumbBorder = liftRgb(border, dark ? 18 : -12);
dc.SetPen(wxPen(thumbBorder));
dc.SetBrush(wxBrush(thumb));
dc.DrawEllipse(thumbX, thumbY, thumbD, thumbD);
}
} // namespace ccm::ui

Some files were not shown because too many files have changed in this diff Show More