patch: Patch/misc (#10)

* yugioh adjustments

* test coverage
This commit is contained in:
Sebastian Dine
2026-05-10 11:43:25 +02:00
committed by GitHub
parent c1d42bdadd
commit 8b7d45fdac
23 changed files with 564 additions and 26 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. - 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). - 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. - 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`. - 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: - Keep `permissions` least-privilege:
- reusable build workflows: `contents: read` - reusable build workflows: `contents: read`
+1
View File
@@ -70,6 +70,7 @@ jobs:
-Dsonar.cfamily.compile-commands=build/compile_commands.json -Dsonar.cfamily.compile-commands=build/compile_commands.json
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml -Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/** -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: linux:
name: Linux build + tests name: Linux build + tests
+1
View File
@@ -70,6 +70,7 @@ jobs:
-Dsonar.cfamily.compile-commands=build/compile_commands.json -Dsonar.cfamily.compile-commands=build/compile_commands.json
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml -Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/** -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: compute-version:
name: Determine semantic version name: Determine semantic version
+12 -1
View File
@@ -52,9 +52,17 @@ Run from the **workspace root**.
- Run the app: - Run the app:
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows) `./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
- Run tests (CCM_BUILD_TESTS defaults to ON): - 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: **215 tests, all green**.
- Build tests only: - Build tests only:
`cmake --build build --target ccm_core_tests` `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. > **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.
> >
@@ -97,6 +105,9 @@ 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 `.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 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. - 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.
- 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.
- 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 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 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`. - 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`.
-1
View File
@@ -20,7 +20,6 @@ struct YuGiOhCard {
Set set; Set set;
std::string setNo; std::string setNo;
std::string rarity; std::string rarity;
std::string rarityCode;
std::string note; std::string note;
std::vector<std::string> images; std::vector<std::string> images;
Language language{Language::English}; Language language{Language::English};
+1
View File
@@ -59,6 +59,7 @@ enum class YuGiOhSortColumn {
Language, Language,
Condition, Condition,
Amount, Amount,
Rarity,
FirstEdition, FirstEdition,
Signed, Signed,
Altered, Altered,
@@ -69,4 +69,31 @@ namespace ccm {
&& std::isdigit(static_cast<unsigned char>(tail[1])) != 0; && 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 } // namespace ccm
-3
View File
@@ -15,7 +15,6 @@ void to_json(nlohmann::json& j, const YuGiOhCard& c) {
{"condition", c.condition}, {"condition", c.condition},
{"firstEdition", c.firstEdition}, {"firstEdition", c.firstEdition},
{"rarity", c.rarity}, {"rarity", c.rarity},
{"rarityCode", c.rarityCode},
{"signed", c.signed_}, {"signed", c.signed_},
{"altered", c.altered}, {"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("condition").get_to(c.condition);
j.at("firstEdition").get_to(c.firstEdition); j.at("firstEdition").get_to(c.firstEdition);
j.at("rarity").get_to(c.rarity); 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("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered); j.at("altered").get_to(c.altered);
} }
@@ -1,4 +1,5 @@
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp" #include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@@ -134,6 +135,10 @@ std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
} }
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) { 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 // Compare case-insensitively, ignoring whitespace, against a table of
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions) // CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
// plus a few extras occasionally seen in imported collections. The codes // plus a few extras occasionally seen in imported collections. The codes
@@ -171,7 +176,7 @@ std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName)
{"ultraparallelrare", "UPR"}, {"ultraparallelrare", "UPR"},
{"holographicrare", "HGR"}, {"holographicrare", "HGR"},
{"starlightrare", "StR"}, {"starlightrare", "StR"},
{"collectorsrare", "ColR"}, {"collectorsrare", "CR"},
{"prismaticcollectorsrare", "PColR"}, {"prismaticcollectorsrare", "PColR"},
{"quartercenturysecretrare", "QCScR"}, {"quartercenturysecretrare", "QCScR"},
{"prismaticultimaterare", "PUtR"}, {"prismaticultimaterare", "PUtR"},
+2
View File
@@ -1,6 +1,7 @@
#include "ccm/services/CardFilter.hpp" #include "ccm/services/CardFilter.hpp"
#include "ccm/domain/Enums.hpp" #include "ccm/domain/Enums.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <cctype> #include <cctype>
#include <string> #include <string>
@@ -72,6 +73,7 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
if (containsLower(card.set.name, needle)) return true; if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true; if (containsLower(card.setNo, needle)) return true;
if (containsLower(card.rarity, 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.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true; if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true; if (containsLower(std::to_string(card.amount), needle)) return true;
+7
View File
@@ -1,6 +1,7 @@
#include "ccm/services/CardSorter.hpp" #include "ccm/services/CardSorter.hpp"
#include "ccm/domain/Enums.hpp" #include "ccm/domain/Enums.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -203,6 +204,12 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
return a.amount < b.amount; return a.amount < b.amount;
}, ascending)); }, ascending));
break; 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: case YuGiOhSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional( std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) { [](const YuGiOhCard& a, const YuGiOhCard& b) {
+2 -2
View File
@@ -47,7 +47,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: 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`). - `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). - `edition``1E` when the user marked the card as 1st Edition, otherwise `UE` (Unlimited).
`buildCandidateFilenames(...)` then produces a priority-ordered list: `buildCandidateFilenames(...)` then produces a priority-ordered list:
@@ -114,4 +114,4 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
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`), 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.
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.
+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) ### 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 ## 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 ## 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: Current automated tests cover non-UI behavior, including:
+3 -2
View File
@@ -18,10 +18,11 @@
- `magic_card_preview_source_tests.cpp``MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`. - `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. - `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` (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.
- `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_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`. - `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
- `yugioh_set_source_tests.cpp``YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. - `yugioh_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_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_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. - `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). - `CMakeLists.txt` — explicit list of every `.cpp` (no glob).
@@ -29,7 +30,7 @@
## Conventions ## Conventions
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`. 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. 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. 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". 5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file".
+2
View File
@@ -24,6 +24,8 @@ add_executable(ccm_core_tests
yugioh_card_preview_source_tests.cpp yugioh_card_preview_source_tests.cpp
card_sorter_tests.cpp card_sorter_tests.cpp
card_filter_tests.cpp card_filter_tests.cpp
game_module_tests.cpp
std_file_system_tests.cpp
main.cpp main.cpp
) )
+1
View File
@@ -180,6 +180,7 @@ TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare"); const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "lob-005")); CHECK(matchesYuGiOhFilter(c, "lob-005"));
CHECK(matchesYuGiOhFilter(c, "ultra")); CHECK(matchesYuGiOhFilter(c, "ultra"));
CHECK(matchesYuGiOhFilter(c, "ur"));
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare")); CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
} }
} }
+160 -2
View File
@@ -46,7 +46,10 @@ PokemonCard pc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate, std::string setName, std::string releaseDate,
std::uint8_t amount = 1, std::uint8_t amount = 1,
bool holo = false, bool firstEdition = false, 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; PokemonCard c;
c.id = id; c.id = id;
c.name = std::move(name); c.name = std::move(name);
@@ -57,6 +60,9 @@ PokemonCard pc(std::uint32_t id, std::string name,
c.firstEdition = firstEdition; c.firstEdition = firstEdition;
c.signed_ = sgnd; c.signed_ = sgnd;
c.altered = altered; c.altered = altered;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
return c; return c;
} }
@@ -64,7 +70,13 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate, std::string setName, std::string releaseDate,
std::string setNo = "", std::string setNo = "",
std::string rarity = "", 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; YuGiOhCard c;
c.id = id; c.id = id;
c.name = std::move(name); c.name = std::move(name);
@@ -73,6 +85,12 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
c.setNo = std::move(setNo); c.setNo = std::move(setNo);
c.rarity = std::move(rarity); c.rarity = std::move(rarity);
c.amount = amount; 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; return c;
} }
@@ -141,6 +159,9 @@ TEST_SUITE("CardSorter - Magic columns") {
}; };
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true); sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10 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)") { TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
@@ -254,6 +275,67 @@ TEST_SUITE("CardSorter - Pokemon-specific columns") {
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true); sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2}); 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") { TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
@@ -272,6 +354,82 @@ TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
} }
TEST_SUITE("CardSorter - YuGiOh columns") { 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("Amount sorts numerically") { TEST_CASE("Amount sorts numerically") {
std::vector<YuGiOhCard> v = { std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 9), yc(1, "a", "X", "2000/01/01", "", "", 9),
+64 -2
View File
@@ -42,6 +42,50 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json bad = "Spanglish"; nlohmann::json bad = "Spanglish";
CHECK_THROWS(bad.get<Language>()); 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") { TEST_SUITE("Set JSON shape stays stable") {
@@ -124,6 +168,25 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
const auto back = j.get<Configuration>(); const auto back = j.get<Configuration>();
CHECK(back == cfg); CHECK(back == cfg);
} }
TEST_CASE("theme defaults to Light when missing from payload") {
const nlohmann::json j = {
{"dataStorage", "/storage"},
{"defaultGame", "Magic"},
};
const auto cfg = j.get<Configuration>();
CHECK(cfg.dataStorage == "/storage");
CHECK(cfg.defaultGame == Game::Magic);
CHECK(cfg.theme == Theme::Light);
}
TEST_CASE("missing required keys still throws") {
const nlohmann::json j = {
{"theme", "Dark"},
};
CHECK_THROWS(j.get<Configuration>());
}
} }
TEST_SUITE("YuGiOhCard JSON") { TEST_SUITE("YuGiOhCard JSON") {
@@ -135,7 +198,6 @@ TEST_SUITE("YuGiOhCard JSON") {
c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"}; c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"};
c.setNo = "SDK-001"; c.setNo = "SDK-001";
c.rarity = "Ultra Rare"; c.rarity = "Ultra Rare";
c.rarityCode = "(UR)";
c.note = "classic"; c.note = "classic";
c.images = {"77+starter+blue-eyes+0.png"}; c.images = {"77+starter+blue-eyes+0.png"};
c.language = Language::English; c.language = Language::English;
@@ -147,7 +209,7 @@ TEST_SUITE("YuGiOhCard JSON") {
nlohmann::json j = c; nlohmann::json j = c;
CHECK(j.at("setNo") == "SDK-001"); CHECK(j.at("setNo") == "SDK-001");
CHECK(j.at("rarity") == "Ultra Rare"); CHECK(j.at("rarity") == "Ultra Rare");
CHECK(j.at("rarityCode") == "(UR)"); CHECK_FALSE(j.contains("rarityCode"));
CHECK(j.at("signed") == false); CHECK(j.at("signed") == false);
const YuGiOhCard back = j.get<YuGiOhCard>(); const YuGiOhCard back = j.get<YuGiOhCard>();
+51
View File
@@ -0,0 +1,51 @@
#include <doctest/doctest.h>
#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);
}
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);
}
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);
}
}
+189
View File
@@ -0,0 +1,189 @@
#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("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);
}
}
@@ -78,6 +78,23 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
} }
} }
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_SUITE("YuGiOhCardPreviewSource::normalizeName") { TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
TEST_CASE("strips whitespace and policy-banned punctuation") { TEST_CASE("strips whitespace and policy-banned punctuation") {
// Yugipedia's image policy: whitespace and a fixed punctuation set // Yugipedia's image policy: whitespace and a fixed punctuation set
@@ -101,6 +118,8 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare") CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
== "QCScR"); == "QCScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Collector's Rare") == "CR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Platinum Secret Rare") == "PlScR");
} }
TEST_CASE("returns empty string for unknown rarity names") { TEST_CASE("returns empty string for unknown rarity names") {
// Unknown rarity should fall through to the rarity-less filename // Unknown rarity should fall through to the rarity-less filename
+12 -9
View File
@@ -2,6 +2,7 @@
#include "ccm/services/CardFilter.hpp" #include "ccm/services/CardFilter.hpp"
#include "ccm/ui/SvgIcons.hpp" #include "ccm/ui/SvgIcons.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <string> #include <string>
@@ -15,12 +16,13 @@ YuGiOhCardListPanel::YuGiOhCardListPanel(wxWindow* parent)
std::vector<YuGiOhCardListPanel::TextColumnSpec> std::vector<YuGiOhCardListPanel::TextColumnSpec>
YuGiOhCardListPanel::declareTextColumns() const { YuGiOhCardListPanel::declareTextColumns() const {
return { return {
{"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name}, {"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name},
{"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate}, {"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate},
{"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount}, {"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount},
{"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition}, {"Rarity", 90, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Rarity},
{"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Language}, {"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition},
{"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Note}, {"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Language},
{"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Note},
}; };
} }
@@ -40,9 +42,10 @@ std::string YuGiOhCardListPanel::renderTextCell(const YuGiOhCard& card,
case 0: return card.name; case 0: return card.name;
case 1: return card.set.name; case 1: return card.set.name;
case 2: return std::to_string(card.amount); case 2: return std::to_string(card.amount);
case 3: return std::string(to_string(card.condition)); case 3: return ygoRarityShortCode(card.rarity);
case 4: return std::string(to_string(card.language)); case 4: return std::string(to_string(card.condition));
case 5: return card.note; case 5: return std::string(to_string(card.language));
case 6: return card.note;
} }
return {}; return {};
} }