Compare commits

...

4 Commits

Author SHA1 Message Date
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
Sebastian Dine c1d42bdadd patch: sonarqube fixes. 2026-05-09 19:57:02 +02:00
42 changed files with 1277 additions and 191 deletions
+2
View File
@@ -38,6 +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`.
- 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`
+3
View File
@@ -55,6 +55,7 @@ jobs:
--sonarqube build/sonarqube-coverage.xml --sonarqube build/sonarqube-coverage.xml
--exclude "build/_deps/" --exclude "build/_deps/"
--exclude-directories "build/_deps" --exclude-directories "build/_deps"
--exclude "^tests/"
- name: SonarQube Cloud scan - name: SonarQube Cloud scan
uses: SonarSource/sonarqube-scan-action@v5 uses: SonarSource/sonarqube-scan-action@v5
@@ -69,6 +70,8 @@ jobs:
-Dsonar.sources=core,ui_wx,app -Dsonar.sources=core,ui_wx,app
-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.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
+3
View File
@@ -55,6 +55,7 @@ jobs:
--sonarqube build/sonarqube-coverage.xml --sonarqube build/sonarqube-coverage.xml
--exclude "build/_deps/" --exclude "build/_deps/"
--exclude-directories "build/_deps" --exclude-directories "build/_deps"
--exclude "^tests/"
- name: SonarQube Cloud scan - name: SonarQube Cloud scan
uses: SonarSource/sonarqube-scan-action@v5 uses: SonarSource/sonarqube-scan-action@v5
@@ -69,6 +70,8 @@ jobs:
-Dsonar.sources=core,ui_wx,app -Dsonar.sources=core,ui_wx,app
-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.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
+18 -2
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: **180 tests, all green**. `ctest --test-dir build --output-on-failure` — current baseline: **226 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.
> >
@@ -96,11 +104,19 @@ Run from the **workspace root**.
- After modifying a domain type's fields or JSON layout you **must** update the matching round-trip test in `tests/domain_json_tests.cpp` and re-run tests. - After modifying a domain type's fields or JSON layout you **must** update the matching round-trip test in `tests/domain_json_tests.cpp` and re-run tests.
- 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. - 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`.
## 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 ## Anti-patterns
- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build). - Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build).
-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};
+6
View File
@@ -7,6 +7,7 @@
#include "ccm/ports/IHttpClient.hpp" #include "ccm/ports/IHttpClient.hpp"
#include <chrono> #include <chrono>
#include <functional>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
@@ -23,7 +24,11 @@ namespace ccm {
// only fires one outbound request at a time anyway. // only fires one outbound request at a time anyway.
class CprHttpClient final : public IHttpClient { class CprHttpClient final : public IHttpClient {
public: public:
using GetExecutor = std::function<Result<std::string>(std::string_view)>;
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000}); explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
CprHttpClient(GetExecutor executor,
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
~CprHttpClient() override; ~CprHttpClient() override;
Result<std::string> get(std::string_view url) override; Result<std::string> get(std::string_view url) override;
@@ -31,6 +36,7 @@ public:
private: private:
std::chrono::milliseconds timeout_; std::chrono::milliseconds timeout_;
std::unique_ptr<cpr::Session> session_; std::unique_ptr<cpr::Session> session_;
GetExecutor executor_;
std::mutex sessionMutex_; std::mutex sessionMutex_;
}; };
+1
View File
@@ -59,6 +59,7 @@ enum class YuGiOhSortColumn {
Language, Language,
Condition, Condition,
Amount, Amount,
Rarity,
FirstEdition, FirstEdition,
Signed, Signed,
Altered, Altered,
+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; && 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,40 +1,16 @@
#include "ccm/games/magic/MagicCardPreviewSource.hpp" #include "ccm/games/magic/MagicCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <cctype> #include <cctype>
#include <sstream>
#include <string> #include <string>
namespace ccm { namespace ccm {
namespace { 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. // Apply the same name massaging as the legacy query path before sending.
std::string sanitizeName(std::string_view name) { std::string sanitizeName(std::string_view name) {
std::string s(name); std::string s(name);
@@ -59,7 +35,8 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
query += sanitized; query += sanitized;
query += "\" AND set:"; query += "\" AND set:";
query += std::string(setId); 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> Result<std::string, PreviewLookupError>
@@ -1,39 +1,16 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <cctype> #include <cctype>
#include <sstream>
#include <string> #include <string>
namespace ccm { namespace ccm {
namespace { 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. // Strip everything after the first '/' in a Pokemon collector number.
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as // The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
// `4/102`. Without this, no API match is found. // `4/102`. Without this, no API match is found.
@@ -67,7 +44,8 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
query += " number:"; query += " number:";
query += num; 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);
} }
Result<std::string, PreviewLookupError> Result<std::string, PreviewLookupError>
@@ -1,10 +1,12 @@
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp" #include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <array> #include <array>
#include <cctype> #include <cctype>
#include <sstream>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <unordered_map> #include <unordered_map>
@@ -16,31 +18,6 @@ namespace ccm {
namespace { 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) { 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.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back(); while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
@@ -134,6 +111,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 +152,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"},
@@ -266,7 +247,7 @@ std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
std::string url = std::string url =
"https://yugipedia.com/api.php?action=query&format=json" "https://yugipedia.com/api.php?action=query&format=json"
"&prop=imageinfo&iiprop=url&titles="; "&prop=imageinfo&iiprop=url&titles=";
url += urlEncode(joined); url += rfc3986PercentEncode(joined);
return url; return url;
} }
@@ -330,10 +311,10 @@ Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaR
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name, std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setName) { std::string_view setName) {
std::string url = 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()) { if (!setName.empty()) {
url += "&cardset="; url += "&cardset=";
url += urlEncode(setName); url += rfc3986PercentEncode(setName);
} }
return url; return url;
} }
+22 -11
View File
@@ -1,5 +1,7 @@
#include "ccm/infra/CprHttpClient.hpp" #include "ccm/infra/CprHttpClient.hpp"
#include "ccm/util/HttpGetMapping.hpp"
#include <cpr/cpr.h> #include <cpr/cpr.h>
#include <string> #include <string>
@@ -24,8 +26,25 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
/*follow=*/true, /*follow=*/true,
/*cont_send_cred=*/false, /*cont_send_cred=*/false,
cpr::PostRedirectFlags::POST_ALL}); cpr::PostRedirectFlags::POST_ALL});
executor_ = [this](std::string_view url) -> Result<std::string> {
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
if (r.error) {
return mapHttpGetResponse(true, r.error.message, r.status_code, {},
url);
}
return mapHttpGetResponse(false, {}, r.status_code, std::move(r.text),
url);
};
} }
CprHttpClient::CprHttpClient(GetExecutor executor,
std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(nullptr),
executor_(std::move(executor)) {}
CprHttpClient::~CprHttpClient() = default; CprHttpClient::~CprHttpClient() = default;
Result<std::string> CprHttpClient::get(std::string_view url) { Result<std::string> CprHttpClient::get(std::string_view url) {
@@ -34,18 +53,10 @@ Result<std::string> CprHttpClient::get(std::string_view url) {
// (one fetch per BaseSelectedCardPanel selection change), so contention // (one fetch per BaseSelectedCardPanel selection change), so contention
// is negligible. // is negligible.
std::lock_guard<std::mutex> lock(sessionMutex_); std::lock_guard<std::mutex> lock(sessionMutex_);
if (!executor_) {
session_->SetUrl(cpr::Url{std::string(url)}); return Result<std::string>::err("HTTP error: no executor configured");
cpr::Response r = session_->Get();
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
} }
if (r.status_code < 200 || r.status_code >= 300) { return executor_(url);
return Result<std::string>::err(
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
}
return Result<std::string>::ok(std::move(r.text));
} }
} // namespace ccm } // namespace ccm
+3 -15
View File
@@ -1,28 +1,15 @@
#include "ccm/services/CardFilter.hpp" #include "ccm/services/CardFilter.hpp"
#include "ccm/domain/Enums.hpp" #include "ccm/domain/Enums.hpp"
#include "ccm/util/AsciiUtils.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <cctype>
#include <string> #include <string>
#include <string_view> #include <string_view>
namespace ccm { namespace ccm {
namespace { 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) { bool containsLower(std::string_view haystack, std::string_view needleLower) {
return asciiLower(haystack).find(needleLower) != std::string::npos; return asciiLower(haystack).find(needleLower) != std::string::npos;
} }
@@ -72,6 +59,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;
+8 -15
View File
@@ -1,29 +1,16 @@
#include "ccm/services/CardSorter.hpp" #include "ccm/services/CardSorter.hpp"
#include "ccm/domain/Enums.hpp" #include "ccm/domain/Enums.hpp"
#include "ccm/util/AsciiUtils.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm> #include <algorithm>
#include <cctype>
#include <string> #include <string>
#include <string_view> #include <string_view>
namespace ccm { namespace ccm {
namespace { 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, // Wrap a less-than predicate so that ascending=false flips its meaning,
// mirroring `byField(field, asc)` in TableTemplate.tsx. // mirroring `byField(field, asc)` in TableTemplate.tsx.
template <typename Less> template <typename Less>
@@ -203,6 +190,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.
+4
View File
@@ -17,6 +17,10 @@ The repository uses GitHub Actions workflows split by branch intent, with one or
- `master-ci.yml`: single workflow run on merged PRs to `master`; computes semver, invokes Windows reusable build, then tags/publishes release assets. - `master-ci.yml`: single workflow run on merged PRs to `master`; computes semver, invokes Windows reusable build, then tags/publishes release assets.
- `master-windows.yml`: reusable Windows build/test/package workflow invoked by `master-ci.yml`. - `master-windows.yml`: reusable Windows build/test/package workflow invoked by `master-ci.yml`.
### 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. 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
Feature branches and `master` use different version modes because they solve different problems: feature builds need traceability to a commit, while `master` builds need stable semantic releases. Feature branches and `master` use different version modes because they solve different problems: feature builds need traceability to a commit, while `master` builds need stable semantic releases.
+2
View File
@@ -30,6 +30,8 @@ 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. 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:
- filesystem naming and parsing behavior - filesystem naming and parsing behavior
+6 -3
View File
@@ -12,15 +12,18 @@
- `collection_service_tests.cpp``CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`). - `collection_service_tests.cpp``CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
- `config_service_tests.cpp``ConfigService` against `InMemoryFileSystem`. - `config_service_tests.cpp``ConfigService` against `InMemoryFileSystem`.
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`. - `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`.
- `local_image_store_tests.cpp``LocalImageStore` against `InMemoryFileSystem` + `ConfigService`: `copyIn` (extension preserved, missing source errors), `remove` (existing file deleted; absent path is a no-op), `resolvePath` layout under `dataStorage/<game>/images/`.
- `set_service_tests.cpp``SetService` with `FakeSetSource` + `InMemSetRepo`. - `set_service_tests.cpp``SetService` with `FakeSetSource` + `InMemSetRepo`.
- `magic_set_source_tests.cpp``MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake. - `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`. - `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` (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_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). - `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh) 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_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).
@@ -28,7 +31,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".
+5
View File
@@ -12,17 +12,22 @@ add_executable(ccm_core_tests
config_service_tests.cpp config_service_tests.cpp
json_collection_repository_tests.cpp json_collection_repository_tests.cpp
json_set_repository_tests.cpp json_set_repository_tests.cpp
local_image_store_tests.cpp
set_service_tests.cpp set_service_tests.cpp
magic_set_source_tests.cpp magic_set_source_tests.cpp
magic_card_preview_source_tests.cpp magic_card_preview_source_tests.cpp
card_preview_service_tests.cpp card_preview_service_tests.cpp
local_preview_byte_cache_tests.cpp local_preview_byte_cache_tests.cpp
std_file_system_tests.cpp
pokemon_set_source_tests.cpp pokemon_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp pokemon_card_preview_source_tests.cpp
yugioh_set_source_tests.cpp yugioh_set_source_tests.cpp
yugioh_card_preview_source_tests.cpp yugioh_card_preview_source_tests.cpp
game_module_tests.cpp
card_sorter_tests.cpp card_sorter_tests.cpp
card_filter_tests.cpp card_filter_tests.cpp
http_get_mapping_tests.cpp
cpr_http_client_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"));
} }
} }
+90
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. // Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule { class FakeGameModule final : public IGameModule {
public: public:
@@ -655,6 +667,64 @@ TEST_SUITE("CardPreviewService caching") {
CHECK(second.value() == "card-back-bytes"); CHECK(second.value() == "card-back-bytes");
CHECK(http.calls == 1); 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("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") { TEST_SUITE("CardPreviewService::detectFirstPrint") {
@@ -692,6 +762,16 @@ TEST_SUITE("CardPreviewService::detectFirstPrint") {
CHECK(out.isErr()); CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos); 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") { TEST_SUITE("CardPreviewService::detectPrintVariants") {
@@ -729,4 +809,14 @@ TEST_SUITE("CardPreviewService::detectPrintVariants") {
CHECK(out.isErr()); CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos); 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);
}
} }
+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),
+37
View File
@@ -0,0 +1,37 @@
#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");
}
}
+146 -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,18 @@ 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("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") { TEST_SUITE("YuGiOhCard JSON") {
@@ -135,7 +191,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,10 +202,99 @@ 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>();
CHECK(back == c); 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_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("Configuration missing required key throws") {
const nlohmann::json j = {
{"defaultGame", "Magic"},
{"theme", "Dark"},
};
CHECK_THROWS(j.get<Configuration>());
}
} }
+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);
}
}
+49
View File
@@ -0,0 +1,49 @@
#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");
}
}
+90
View File
@@ -0,0 +1,90 @@
#include <doctest/doctest.h>
#include "ccm/infra/LocalImageStore.hpp"
#include "ccm/services/ConfigService.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <filesystem>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
std::string dirNameForGame(Game g) {
switch (g) {
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
}
return "magic";
}
} // namespace
TEST_SUITE("LocalImageStore") {
TEST_CASE("copyIn creates images directory and preserves source extension") {
InMemoryFileSystem mem;
ConfigService cfg{mem, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
LocalImageStore store(mem, cfg, dirNameForGame);
REQUIRE(mem.writeText("/incoming/card.PNG", "img-bytes").isOk());
auto r = store.copyIn(Game::Magic, "/incoming/card.PNG", "id001");
REQUIRE(r.isOk());
CHECK(r.value() == "id001.PNG");
const std::filesystem::path dest =
std::filesystem::path(cfg.current().dataStorage) / "magic" / "images" / "id001.PNG";
REQUIRE(mem.exists(dest));
auto body = mem.readText(dest);
REQUIRE(body.isOk());
CHECK(body.value() == "img-bytes");
}
TEST_CASE("copyIn errors when source file is missing") {
InMemoryFileSystem mem;
ConfigService cfg{mem, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
LocalImageStore store(mem, cfg, dirNameForGame);
auto r = store.copyIn(Game::Pokemon, "/nope/missing.jpg", "x");
REQUIRE(r.isErr());
}
TEST_CASE("remove deletes an existing image") {
InMemoryFileSystem mem;
ConfigService cfg{mem, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
LocalImageStore store(mem, cfg, dirNameForGame);
const std::filesystem::path imagePath =
std::filesystem::path(cfg.current().dataStorage) / "magic" / "images" / "a.png";
REQUIRE(mem.ensureDirectory(imagePath.parent_path()).isOk());
REQUIRE(mem.writeText(imagePath, "x").isOk());
REQUIRE(store.remove(Game::Magic, "a.png").isOk());
CHECK_FALSE(mem.exists(imagePath));
}
TEST_CASE("remove succeeds when file is already absent") {
InMemoryFileSystem mem;
ConfigService cfg{mem, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
LocalImageStore store(mem, cfg, dirNameForGame);
REQUIRE(store.remove(Game::YuGiOh, "ghost.bin").isOk());
}
TEST_CASE("resolvePath joins data storage game images and filename") {
InMemoryFileSystem mem;
ConfigService cfg{mem, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
LocalImageStore store(mem, cfg, dirNameForGame);
const std::filesystem::path got = store.resolvePath(Game::Pokemon, "pic.jpg");
CHECK(got.generic_string() == "/coll/pokemon/images/pic.jpg");
}
}
+20
View File
@@ -79,6 +79,26 @@ TEST_SUITE("MagicCardPreviewSource::parseResponse") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient); 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)") { TEST_CASE("entry without image_uris is classified as NotFound (double-faced cards)") {
const std::string json = R"({ const std::string json = R"({
"data": [ "data": [
@@ -54,6 +54,13 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
"Mr. Mime", "base1", ""); "Mr. Mime", "base1", "");
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos); 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") { TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
@@ -97,6 +104,35 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient); 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") { TEST_CASE("entry without images is classified as NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse( const auto out = PokemonCardPreviewSource::parseResponse(
R"({"data":[{"name":"Pikachu"}]})"); R"({"data":[{"name":"Pikachu"}]})");
+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);
}
}
+160
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 } // namespace
TEST_SUITE("ygoPrintingSlotsMatch") { TEST_SUITE("ygoPrintingSlotsMatch") {
@@ -78,6 +95,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 +135,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
@@ -231,6 +267,90 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
REQUIRE(out.isErr()); REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient); 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_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") { TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
@@ -249,6 +369,18 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
CHECK(out.value().setNo == "LOB-001"); CHECK(out.value().setNo == "LOB-001");
CHECK(out.value().rarity == "Ultra Rare"); 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") { TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
@@ -307,6 +439,34 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
CHECK(out.value()[0].setNo == "SDY-043"); CHECK(out.value()[0].setNo == "SDY-043");
CHECK(out.value()[0].rarity == "Super Rare"); 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_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);
}
} }
// Helpers aligned with external fixture `yugioh_same_card_set_variant_tests` // Helpers aligned with external fixture `yugioh_same_card_set_variant_tests`
+1 -1
View File
@@ -53,7 +53,7 @@
- Apply this rule consistently in shared templates (`BaseCardListPanel`, `BaseSelectedCardPanel`, `BaseCardEditDialog`) because a single implicit conversion in those bases affects every game view. - Apply this rule consistently in shared templates (`BaseCardListPanel`, `BaseSelectedCardPanel`, `BaseCardEditDialog`) because a single implicit conversion in those bases affects every game view.
14. **Theme consistency rules (Windows):** 14. **Theme consistency rules (Windows):**
- Treat dialog roots as `panelBg`, not a separate shade, otherwise label rows can look like mismatched darker boxes. - Treat dialog roots as `panelBg`, not a separate shade, otherwise label rows can look like mismatched darker boxes.
- Theme dialogs before `ShowModal()` with `applyThemeToWindowTree(...)`; this includes Settings, Create/Edit dialogs, image viewer, About, and custom popup dialogs. - Theme dialogs before `ShowModal()` with `applyThemeToWindowTree(...)` (and root background/foreground colors as needed); this includes Settings, image viewer, About, and custom popup dialogs. Per-game **Add/Edit** flows use `themeModalDialog(wxDialog*, Theme)` from `Theme.hpp` so `MagicGameView` / `PokemonGameView` / `YuGiOhGameView` share one path instead of duplicating palette wiring.
- Do not use native `wxMessageBox` / `wxAboutBox` for app-facing flows that must match dark mode. Use themed popup helpers (or a custom themed `wxDialog`) so body/buttons stay in sync with the app palette. - Do not use native `wxMessageBox` / `wxAboutBox` for app-facing flows that must match dark mode. Use themed popup helpers (or a custom themed `wxDialog`) so body/buttons stay in sync with the app palette.
- Center popup dialogs on the app window (`CentreOnParent()`) so confirmations/info boxes open relative to the current app window. - Center popup dialogs on the app window (`CentreOnParent()`) so confirmations/info boxes open relative to the current app window.
- Include `wxSpinCtrl` in themed input controls (Amount field) or it will keep a mismatched native background. - Include `wxSpinCtrl` in themed input controls (Amount field) or it will keep a mismatched native background.
+2
View File
@@ -4,6 +4,7 @@
#include <wx/colour.h> #include <wx/colour.h>
class wxDialog;
class wxWindow; class wxWindow;
class wxString; class wxString;
@@ -22,6 +23,7 @@ struct ThemePalette {
ThemePalette paletteForTheme(Theme theme); ThemePalette paletteForTheme(Theme theme);
Theme inferThemeFromWindow(const wxWindow* window); Theme inferThemeFromWindow(const wxWindow* window);
void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme); void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme);
void themeModalDialog(wxDialog* dlg, Theme theme);
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style); int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style);
int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption); int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption);
+3 -14
View File
@@ -3,6 +3,7 @@
#include "ccm/ui/MagicCardEditDialog.hpp" #include "ccm/ui/MagicCardEditDialog.hpp"
#include "ccm/ui/MagicCardListPanel.hpp" #include "ccm/ui/MagicCardListPanel.hpp"
#include "ccm/ui/MagicSelectedCardPanel.hpp" #include "ccm/ui/MagicSelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/msgdlg.h> #include <wx/msgdlg.h>
@@ -94,13 +95,7 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) {
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh, MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
&setsForDialog()); &setsForDialog());
{ themeModalDialog(&dlg, config_.current().theme);
const Theme currentTheme = config_.current().theme;
const ThemePalette palette = paletteForTheme(currentTheme);
applyThemeToWindowTree(&dlg, palette, currentTheme);
dlg.SetBackgroundColour(palette.panelBg);
dlg.SetForegroundColour(palette.text);
}
if (dlg.ShowModal() != wxID_OK) return; if (dlg.ShowModal() != wxID_OK) return;
auto added = collection_.add(Game::Magic, dlg.card()); auto added = collection_.add(Game::Magic, dlg.card());
@@ -139,13 +134,7 @@ void MagicGameView::onEditCard(wxWindow* parentWindow) {
} }
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel, MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
&setsForDialog()); &setsForDialog());
{ themeModalDialog(&dlg, config_.current().theme);
const Theme currentTheme = config_.current().theme;
const ThemePalette palette = paletteForTheme(currentTheme);
applyThemeToWindowTree(&dlg, palette, currentTheme);
dlg.SetBackgroundColour(palette.panelBg);
dlg.SetForegroundColour(palette.text);
}
if (dlg.ShowModal() != wxID_OK) return; if (dlg.ShowModal() != wxID_OK) return;
auto updated = collection_.update(Game::Magic, dlg.card()); auto updated = collection_.update(Game::Magic, dlg.card());
if (!updated) { if (!updated) {
+3 -14
View File
@@ -3,6 +3,7 @@
#include "ccm/ui/PokemonCardEditDialog.hpp" #include "ccm/ui/PokemonCardEditDialog.hpp"
#include "ccm/ui/PokemonCardListPanel.hpp" #include "ccm/ui/PokemonCardListPanel.hpp"
#include "ccm/ui/PokemonSelectedCardPanel.hpp" #include "ccm/ui/PokemonSelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/msgdlg.h> #include <wx/msgdlg.h>
@@ -91,13 +92,7 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh, PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
&setsForDialog()); &setsForDialog());
{ themeModalDialog(&dlg, config_.current().theme);
const Theme currentTheme = config_.current().theme;
const ThemePalette palette = paletteForTheme(currentTheme);
applyThemeToWindowTree(&dlg, palette, currentTheme);
dlg.SetBackgroundColour(palette.panelBg);
dlg.SetForegroundColour(palette.text);
}
if (dlg.ShowModal() != wxID_OK) return; if (dlg.ShowModal() != wxID_OK) return;
auto added = collection_.add(Game::Pokemon, dlg.card()); auto added = collection_.add(Game::Pokemon, dlg.card());
@@ -136,13 +131,7 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
} }
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel, PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
&setsForDialog()); &setsForDialog());
{ themeModalDialog(&dlg, config_.current().theme);
const Theme currentTheme = config_.current().theme;
const ThemePalette palette = paletteForTheme(currentTheme);
applyThemeToWindowTree(&dlg, palette, currentTheme);
dlg.SetBackgroundColour(palette.panelBg);
dlg.SetForegroundColour(palette.text);
}
if (dlg.ShowModal() != wxID_OK) return; if (dlg.ShowModal() != wxID_OK) return;
auto updated = collection_.update(Game::Pokemon, dlg.card()); auto updated = collection_.update(Game::Pokemon, dlg.card());
if (!updated) { if (!updated) {
+8
View File
@@ -609,6 +609,14 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
} }
} }
void themeModalDialog(wxDialog* dlg, Theme theme) {
if (dlg == nullptr) return;
const ThemePalette palette = paletteForTheme(theme);
applyThemeToWindowTree(dlg, palette, theme);
dlg->SetBackgroundColour(palette.panelBg);
dlg->SetForegroundColour(palette.text);
}
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style) { int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style) {
wxDialog dlg(parent, wxID_ANY, caption, wxDefaultPosition, wxDefaultSize, wxDialog dlg(parent, wxID_ANY, caption, wxDefaultPosition, wxDefaultSize,
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER); wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
+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 {};
} }
+3 -14
View File
@@ -3,6 +3,7 @@
#include "ccm/ui/YuGiOhCardEditDialog.hpp" #include "ccm/ui/YuGiOhCardEditDialog.hpp"
#include "ccm/ui/YuGiOhCardListPanel.hpp" #include "ccm/ui/YuGiOhCardListPanel.hpp"
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp" #include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/msgdlg.h> #include <wx/msgdlg.h>
@@ -100,13 +101,7 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh, YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
&setsForDialog()); &setsForDialog());
{ themeModalDialog(&dlg, config_.current().theme);
const Theme currentTheme = config_.current().theme;
const ThemePalette palette = paletteForTheme(currentTheme);
applyThemeToWindowTree(&dlg, palette, currentTheme);
dlg.SetBackgroundColour(palette.panelBg);
dlg.SetForegroundColour(palette.text);
}
if (dlg.ShowModal() != wxID_OK) return; if (dlg.ShowModal() != wxID_OK) return;
auto added = collection_.add(Game::YuGiOh, dlg.card()); auto added = collection_.add(Game::YuGiOh, dlg.card());
@@ -145,13 +140,7 @@ void YuGiOhGameView::onEditCard(wxWindow* parentWindow) {
} }
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel, YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
&setsForDialog()); &setsForDialog());
{ themeModalDialog(&dlg, config_.current().theme);
const Theme currentTheme = config_.current().theme;
const ThemePalette palette = paletteForTheme(currentTheme);
applyThemeToWindowTree(&dlg, palette, currentTheme);
dlg.SetBackgroundColour(palette.panelBg);
dlg.SetForegroundColour(palette.text);
}
if (dlg.ShowModal() != wxID_OK) return; if (dlg.ShowModal() != wxID_OK) return;
auto updated = collection_.update(Game::YuGiOh, dlg.card()); auto updated = collection_.update(Game::YuGiOh, dlg.card());
if (!updated) { if (!updated) {