Compare commits

..

6 Commits

Author SHA1 Message Date
Sebastian Dine 8a50e8daba patch: Feature/pkm autodetect (#15) 2026-05-12 15:49:37 +02:00
Sebastian Dine 98f2575b5a patch: Patch/ygo 25th set 2 (#14) 2026-05-11 11:27:32 +02:00
Sebastian Dine d6c7f60aee patch: Patch/ygo set 25th (#13) 2026-05-11 09:51:19 +02:00
Sebastian Dine 7935f2b18e fix: Fix/ci cd issues (#12) 2026-05-11 08:42:07 +02:00
Sebastian Dine 5805101d24 fix: unittests 2026-05-10 12:18:45 +02:00
Sebastian Dine 8b7d45fdac patch: Patch/misc (#10)
* yugioh adjustments

* test coverage
2026-05-10 11:43:25 +02:00
52 changed files with 3270 additions and 175 deletions
+2 -1
View File
@@ -38,7 +38,8 @@ GitHub Actions workflows for CI, release automation, and policy checks.
- Prefer minimal, surgical edits; avoid large workflow rewrites unless requested.
- Reusable workflows should declare explicit `workflow_call` inputs for required context (e.g., version, merge SHA).
- Sonar coverage steps that use `gcovr` must exclude third-party build trees at discovery time with `--exclude-directories` (for example `build/_deps`) so gcov does not process dependency `.gcda` files.
- The Sonar scan step passes `-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**` so the coverage quality gate reflects **`ccm_core_tests`** only (wx UI and the composition root are not executed under test). `sonar.sources` stays `core,ui_wx,app`; bugs/security/duplications still analyze those trees.
- The Sonar scan step passes `-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**` so the coverage quality gate reflects **`ccm_core_tests`** only (wx UI and the composition root are not executed under test). `sonar.sources` stays `core,ui_wx,app`.
- The Sonar scan also sets `-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp` so intentionally parallel wx per-game UI scaffolding does not dominate the duplication quality gate.
- For Linux Sonar coverage jobs, keep compiler and gcov toolchain aligned; because `cmake/Toolchain.cmake` prefers Clang by default, set `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++` explicitly in the coverage configure step when using gcovr default `gcov`.
- Keep `permissions` least-privilege:
- reusable build workflows: `contents: read`
+2
View File
@@ -55,6 +55,7 @@ jobs:
--sonarqube build/sonarqube-coverage.xml
--exclude "build/_deps/"
--exclude-directories "build/_deps"
--exclude "^tests/"
- name: SonarQube Cloud scan
uses: SonarSource/sonarqube-scan-action@v5
@@ -70,6 +71,7 @@ jobs:
-Dsonar.cfamily.compile-commands=build/compile_commands.json
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
linux:
name: Linux build + tests
+2
View File
@@ -55,6 +55,7 @@ jobs:
--sonarqube build/sonarqube-coverage.xml
--exclude "build/_deps/"
--exclude-directories "build/_deps"
--exclude "^tests/"
- name: SonarQube Cloud scan
uses: SonarSource/sonarqube-scan-action@v5
@@ -70,6 +71,7 @@ jobs:
-Dsonar.cfamily.compile-commands=build/compile_commands.json
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
compute-version:
name: Determine semantic version
+17 -1
View File
@@ -52,9 +52,17 @@ Run from the **workspace root**.
- Run the app:
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
- Run tests (CCM_BUILD_TESTS defaults to ON):
`ctest --test-dir build --output-on-failure` — current baseline: **185 tests, all green**.
`ctest --test-dir build --output-on-failure` — current baseline: **226 tests, all green**.
- Build tests only:
`cmake --build build --target ccm_core_tests`
- Local coverage env setup (one-time, Windows/MSYS2):
`python -m venv .venv_cov`
`& "P:/msys2/msys64/usr/bin/pacman.exe" -S --noconfirm mingw-w64-ucrt-x86_64-python-lxml mingw-w64-ucrt-x86_64-python-gcovr`
- Coverage check (core-focused):
`cmake -S . -B build-cov -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Debug -DCCM_BUILD_TESTS=ON -DCMAKE_C_FLAGS=--coverage -DCMAKE_CXX_FLAGS=--coverage -DCMAKE_EXPORT_COMPILE_COMMANDS=ON`
`cmake --build build-cov --target ccm_core_tests --parallel`
`ctest --test-dir build-cov --output-on-failure`
`& "P:/msys2/msys64/ucrt64/bin/gcovr.exe" -r . --object-directory build-cov --filter "core/" --exclude "build/_deps/" --exclude "build-cov/_deps/" --exclude-directories "build/_deps" --exclude-directories "build-cov/_deps" --print-summary`
> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm3.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer.
>
@@ -97,10 +105,18 @@ Run from the **workspace root**.
- After adding a new `.cpp` to `core/` or `ui_wx/` you **must** add it to that package's `CMakeLists.txt`. There is no glob.
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high. Exercise both outcomes of meaningful conditionals (success vs error, empty vs non-empty, cache hit vs miss, `NotFound` vs `Transient`, early return vs fall-through), not only the happy path — Sonar condition coverage on `core/` is a separate signal from line coverage.
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR. When checking coverage locally, also review branch/condition metrics (for example `gcovr ... --txt-metric branch` or Sonar's condition coverage on the same `core/` surface); there is no repo-wide condition threshold in CI yet — use Sonar's per-file condition list to prioritize gaps.
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
## Agent collaboration (Cursor / AI)
- **Never** `git commit` or `git push` unless the user **explicitly** asked you to commit and/or push (e.g. “commit this”, “push to origin”). Preparing diffs and suggesting commands is fine; performing those Git writes without explicit instruction is not.
- **Never** check out another branch **to change it** unless the user **explicitly** asked you to work on that branch. Temporarily checking out another branch **read-only** (inspect history, compare files, run `git show`) is fine without asking; switch back to the working branch before making edits unless instructed otherwise.
## Anti-patterns
- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build).
-1
View File
@@ -20,7 +20,6 @@ struct YuGiOhCard {
Set set;
std::string setNo;
std::string rarity;
std::string rarityCode;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
@@ -12,6 +12,7 @@
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
@@ -19,10 +20,16 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
public:
explicit PokemonCardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
@@ -30,6 +37,11 @@ public:
std::string_view setId,
std::string_view setNo);
// Slimmer search URL for auto-detect: omits the number clause and asks the
// API for only the fields the print-variant parser needs.
static std::string buildDetectSearchUrl(std::string_view name,
std::string_view setId);
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
// for the first matching card. Prefers `images.large`, falls back to
// `images.small`. Errors are classified:
@@ -38,6 +50,13 @@ public:
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
// Enumerate distinct collector numbers (and rarities) for an exact card
// name inside the chosen set. Exposed for unit testing without HTTP.
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setId,
std::string_view wantedCardName);
private:
IHttpClient& http_;
};
+17
View File
@@ -7,6 +7,7 @@
#include "ccm/ports/IHttpClient.hpp"
#include <chrono>
#include <functional>
#include <memory>
#include <mutex>
@@ -23,7 +24,21 @@ namespace ccm {
// only fires one outbound request at a time anyway.
class CprHttpClient final : public IHttpClient {
public:
struct RawResponse {
bool transportError{false};
std::string transportMessage;
int statusCode{0};
std::string body;
};
using GetExecutor = std::function<Result<std::string>(std::string_view)>;
using RawGetExecutor = std::function<RawResponse(std::string_view)>;
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
CprHttpClient(GetExecutor executor,
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
CprHttpClient(RawGetExecutor rawExecutor,
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
~CprHttpClient() override;
Result<std::string> get(std::string_view url) override;
@@ -31,6 +46,8 @@ public:
private:
std::chrono::milliseconds timeout_;
std::unique_ptr<cpr::Session> session_;
GetExecutor executor_;
RawGetExecutor rawExecutor_;
std::mutex sessionMutex_;
};
+1
View File
@@ -59,6 +59,7 @@ enum class YuGiOhSortColumn {
Language,
Condition,
Amount,
Rarity,
FirstEdition,
Signed,
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;
}
// 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
-3
View File
@@ -15,7 +15,6 @@ void to_json(nlohmann::json& j, const YuGiOhCard& c) {
{"condition", c.condition},
{"firstEdition", c.firstEdition},
{"rarity", c.rarity},
{"rarityCode", c.rarityCode},
{"signed", c.signed_},
{"altered", c.altered},
};
@@ -33,8 +32,6 @@ void from_json(const nlohmann::json& j, YuGiOhCard& c) {
j.at("condition").get_to(c.condition);
j.at("firstEdition").get_to(c.firstEdition);
j.at("rarity").get_to(c.rarity);
if (j.contains("rarityCode")) j.at("rarityCode").get_to(c.rarityCode);
else c.rarityCode.clear();
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
@@ -1,40 +1,16 @@
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
namespace ccm {
namespace {
// Percent-encode all bytes that are not unreserved per RFC 3986
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
// which does not URL-encode the URL string we hand it.
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
// Apply the same name massaging as the legacy query path before sending.
std::string sanitizeName(std::string_view name) {
std::string s(name);
@@ -59,7 +35,8 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
query += sanitized;
query += "\" AND set:";
query += std::string(setId);
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
return std::string("https://api.scryfall.com/cards/search?q=") +
rfc3986PercentEncode(query);
}
Result<std::string, PreviewLookupError>
@@ -1,39 +1,18 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
#include <unordered_set>
#include <vector>
namespace ccm {
namespace {
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
// Magic implementation; kept private so the two can drift independently if a
// future API requires it.
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
// Strip everything after the first '/' in a Pokemon collector number.
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
// `4/102`. Without this, no API match is found.
@@ -46,6 +25,19 @@ std::string normalizeNumber(std::string_view setNo) {
return s;
}
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
std::string toLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
@@ -67,7 +59,16 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
query += " number:";
query += num;
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
rfc3986PercentEncode(query);
}
std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name,
std::string_view setId) {
std::string url = buildSearchUrl(name, setId, "");
url += "&select=name,number,rarity,set";
url += "&pageSize=50";
return url;
}
Result<std::string, PreviewLookupError>
@@ -113,4 +114,87 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
return parseResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setId,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("Pokemon TCG returned no matching cards.");
}
const std::string wantedSetId = trim(std::string(setId));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushCard = [&collected](const nlohmann::json& card) {
AutoDetectedPrint out;
out.setNo = trim(card.value("number", ""));
out.rarity = trim(card.value("rarity", ""));
if (out.setNo.empty() && out.rarity.empty()) return;
collected.push_back(std::move(out));
};
for (const auto& card : j.at("data")) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!wantedSetId.empty()) {
std::string cardSetId;
if (card.contains("set") && card.at("set").is_object()) {
cardSetId = trim(card.at("set").value("id", ""));
}
if (cardSetId != wantedSetId) continue;
}
pushCard(card);
}
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSetId.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::err("Pokemon TCG returned no matching cards.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
const std::string key = p.setNo + '\0' + p.rarity;
if (seen.insert(key).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("Pokemon TCG JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildDetectSearchUrl(name, setId);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
}
const std::string fallbackUrl = buildDetectSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
}
} // namespace ccm
@@ -1,10 +1,12 @@
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <array>
#include <cctype>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -16,31 +18,6 @@ namespace ccm {
namespace {
// RFC 3986 percent-encoder. Same rules as the Magic implementation; private
// here so the YGO and Magic code paths can drift independently if the future
// requires it (Yugipedia's MediaWiki API is fine with %20 for spaces and %7C
// for the `|` separator inside `titles=`).
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
@@ -54,6 +31,17 @@ std::string toLower(std::string s) {
return s;
}
std::string canonicalizeSetNameForAutoDetect(std::string_view setName) {
std::string canonical = trim(std::string(setName));
constexpr std::string_view k25thSuffix = " (25th Anniversary Edition)";
if (canonical.size() > k25thSuffix.size()
&& canonical.ends_with(k25thSuffix)) {
canonical.erase(canonical.size() - k25thSuffix.size());
canonical = trim(std::move(canonical));
}
return canonical;
}
// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
// always return card_images[0]: when no `cardset=` filter is applied, that
// slot is the original/standard artwork (alt-art passcodes follow), which is
@@ -134,6 +122,10 @@ std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
}
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
if (const std::string canonical = ygoRarityShortCode(rarityName); !canonical.empty()) {
return canonical;
}
// Compare case-insensitively, ignoring whitespace, against a table of
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
// plus a few extras occasionally seen in imported collections. The codes
@@ -171,7 +163,7 @@ std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName)
{"ultraparallelrare", "UPR"},
{"holographicrare", "HGR"},
{"starlightrare", "StR"},
{"collectorsrare", "ColR"},
{"collectorsrare", "CR"},
{"prismaticcollectorsrare", "PColR"},
{"quartercenturysecretrare", "QCScR"},
{"prismaticultimaterare", "PUtR"},
@@ -266,7 +258,7 @@ std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
std::string url =
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=imageinfo&iiprop=url&titles=";
url += urlEncode(joined);
url += rfc3986PercentEncode(joined);
return url;
}
@@ -330,10 +322,10 @@ Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaR
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setName) {
std::string url =
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + urlEncode(name);
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + rfc3986PercentEncode(name);
if (!setName.empty()) {
url += "&cardset=";
url += urlEncode(setName);
url += rfc3986PercentEncode(setName);
}
return url;
}
@@ -385,7 +377,7 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVarian
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("YGOPRODeck returned no matching cards.");
}
const std::string wantedSet = trim(std::string(preferredSetName));
const std::string wantedSet = canonicalizeSetNameForAutoDetect(preferredSetName);
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
@@ -551,15 +543,16 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVaria
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildSearchUrl(name, setId);
const std::string canonicalSetName = canonicalizeSetNameForAutoDetect(setId);
const std::string url = buildSearchUrl(name, canonicalSetName);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
return parsePrintVariants(resp.value(), canonicalSetName, name);
}
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
return parsePrintVariants(fallback.value(), canonicalSetName, name);
}
} // namespace ccm
+35
View File
@@ -3,9 +3,43 @@
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <string>
namespace ccm {
namespace {
struct YuGiOhSetAlias {
const char* code;
const char* name;
const char* releaseDate;
};
constexpr std::array<YuGiOhSetAlias, 6> kMissing25thAnniversaryReprints{{
// Keep this list in sync with docs/assets-and-info-apis.md (Info API section).
{"LOB-25TH", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "2023/04/20"},
{"MRD-25TH", "Metal Raiders (25th Anniversary Edition)", "2023/04/20"},
{"SRL-25TH", "Spell Ruler (25th Anniversary Edition)", "2023/04/20"},
{"PSV-25TH", "Pharaoh's Servant (25th Anniversary Edition)", "2023/04/20"},
{"DCR-25TH", "Dark Crisis (25th Anniversary Edition)", "2023/04/20"},
{"IOC-25TH", "Invasion of Chaos (25th Anniversary Edition)", "2023/06/08"},
}};
void appendMissingSetAliases(std::vector<Set>& sets) {
for (const auto& alias : kMissing25thAnniversaryReprints) {
const bool exists = std::any_of(
sets.begin(), sets.end(), [&](const Set& s) { return s.name == alias.name; });
if (exists) continue;
Set s;
s.id = alias.code;
s.name = alias.name;
s.releaseDate = alias.releaseDate;
sets.push_back(std::move(s));
}
}
} // namespace
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
@@ -29,6 +63,7 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
s.releaseDate = std::move(release);
out.push_back(std::move(s));
}
appendMissingSetAliases(out);
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
+34 -10
View File
@@ -1,5 +1,7 @@
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/util/HttpGetMapping.hpp"
#include <cpr/cpr.h>
#include <string>
@@ -24,8 +26,30 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
/*follow=*/true,
/*cont_send_cred=*/false,
cpr::PostRedirectFlags::POST_ALL});
rawExecutor_ = [this](std::string_view url) -> RawResponse {
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
return RawResponse{
.transportError = static_cast<bool>(r.error),
.transportMessage = r.error.message,
.statusCode = static_cast<int>(r.status_code),
.body = std::move(r.text),
};
};
}
CprHttpClient::CprHttpClient(GetExecutor executor,
std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(nullptr),
executor_(std::move(executor)) {}
CprHttpClient::CprHttpClient(RawGetExecutor rawExecutor,
std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(nullptr),
rawExecutor_(std::move(rawExecutor)) {}
CprHttpClient::~CprHttpClient() = default;
Result<std::string> CprHttpClient::get(std::string_view url) {
@@ -34,18 +58,18 @@ Result<std::string> CprHttpClient::get(std::string_view url) {
// (one fetch per BaseSelectedCardPanel selection change), so contention
// is negligible.
std::lock_guard<std::mutex> lock(sessionMutex_);
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
if (executor_) {
return executor_(url);
}
if (r.status_code < 200 || r.status_code >= 300) {
return Result<std::string>::err(
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
if (rawExecutor_) {
RawResponse raw = rawExecutor_(url);
return mapHttpGetResponse(raw.transportError,
raw.transportMessage,
raw.statusCode,
std::move(raw.body),
url);
}
return Result<std::string>::ok(std::move(r.text));
return Result<std::string>::err("HTTP error: no executor configured");
}
} // namespace ccm
+3 -15
View File
@@ -1,28 +1,15 @@
#include "ccm/services/CardFilter.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/util/AsciiUtils.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used
// String.prototype.toLowerCase() which on the realistic ASCII-only data set
// (English/German set names, Scryfall-fed labels, integer amounts) behaves
// identically.
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
bool containsLower(std::string_view haystack, std::string_view needleLower) {
return asciiLower(haystack).find(needleLower) != std::string::npos;
}
@@ -72,6 +59,7 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
if (containsLower(card.rarity, needle)) return true;
if (containsLower(ygoRarityShortCode(card.rarity), needle)) return true;
if (containsLower(to_string(card.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
+8 -15
View File
@@ -1,29 +1,16 @@
#include "ccm/services/CardSorter.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/util/AsciiUtils.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// The comparator lowercases strings before compare via String.toLowerCase()-style behavior.
// We use ASCII-only tolower; the original TS app processed the same fields and
// never special-cased Unicode either, so this stays byte-compatible for the
// realistic data set (English/German/etc. names already lowercase identically).
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
// Wrap a less-than predicate so that ascending=false flips its meaning,
// mirroring `byField(field, asc)` in TableTemplate.tsx.
template <typename Less>
@@ -203,6 +190,12 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
return a.amount < b.amount;
}, ascending));
break;
case YuGiOhSortColumn::Rarity:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(ygoRarityShortCode(a.rarity)) < asciiLower(ygoRarityShortCode(b.rarity));
}, ascending));
break;
case YuGiOhSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
+11 -5
View File
@@ -22,9 +22,13 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
Used by `PokemonCardPreviewSource` in two ways:
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the cards `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
@@ -39,6 +43,8 @@ Upstream documentation:
`https://db.ygoprodeck.com/api/v7/cardsets.php`
Used by `YuGiOhSetSource`. The response is a top-level JSON array. Each object maps `set_code` → internal `Set.id`, `set_name``Set.name`, and `tcg_date``Set.releaseDate` with `-` rewritten to `/` for consistency with other games date strings. Results are sorted ascending by `releaseDate`.
CCM3 also applies a deterministic local patch step in `YuGiOhSetSource::appendMissingSetAliases(...)` after parsing: if upstream omits known 25th Anniversary TCG reprints, the app injects missing aliases for `LOB-25TH`, `MRD-25TH`, `SRL-25TH`, `PSV-25TH`, `DCR-25TH`, and `IOC-25TH` (with fixed release dates) so users can still select those products in the set picker.
### Asset API: Yugipedia `api.php` (primary)
`https://yugipedia.com/api.php?action=query&prop=imageinfo&iiprop=url&titles=...`
@@ -47,7 +53,7 @@ Used by `YuGiOhCardPreviewSource::fetchImageUrl` for the actual per-printing car
The UI passes a positional tuple in `setNo` of the form `set_code||rarity||edition` (for example `SDK-001||Ultra Rare||UE`); the source splits on `||` before building filenames. Field meanings:
- `set_code` — full code as printed (`LOB-005`, `SDK-001`, `RA04-EN001`). Everything before the first `-` becomes the Yugipedia `<SET>` slot (`LOB`, `SDK`, `RA04`).
- `rarity` — full English rarity name from the edit dialog (`Ultra Rare``UR`, `Quarter Century Secret Rare``QCScR`, …). The mapping table lives in `rarityCodeFor(...)`. Unknown values fall back to the rarity-less filename pattern.
- `rarity` — full English rarity name from the edit dialog (`Ultra Rare``UR`, `Quarter Century Secret Rare``QCScR`, …). The canonical short-form mapping lives in `ygoRarityShortCode(...)` (`core/include/ccm/util/YuGiOhPrintingSlot.hpp`) and is reused by both the Yu-Gi-Oh overview-table rarity rendering and preview filename construction (`rarityCodeFor(...)`). Unknown values fall back to the rarity-less filename pattern.
- `edition``1E` when the user marked the card as 1st Edition, otherwise `UE` (Unlimited).
`buildCandidateFilenames(...)` then produces a priority-ordered list:
@@ -112,6 +118,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the right code in `rarityCodeFor(...)` (extend the table when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
+1 -1
View File
@@ -19,7 +19,7 @@ The repository uses GitHub Actions workflows split by branch intent, with one or
### SonarQube Cloud (coverage quality gate)
Both `feature-ci.yml` and `master-ci.yml` include a Linux job that configures with GCC coverage flags, builds, runs `ctest`, generates `build/sonarqube-coverage.xml` via `gcovr`, and runs the SonarCloud scan. **`sonar.coverage.exclusions`** omit `ui_wx/` and `app/` from the coverage denominator because only `ccm_core` is exercised by automated tests; Sonar still analyzes those directories for bugs, vulnerabilities, and duplications. See [Testing Guide And Test Code Of Conduct](testing-and-test-code-of-conduct.md).
Both `feature-ci.yml` and `master-ci.yml` include a Linux job that configures with GCC coverage flags, builds, runs `ctest`, generates `build/sonarqube-coverage.xml` via `gcovr`, and runs the SonarCloud scan. **`sonar.coverage.exclusions`** omit `ui_wx/` and `app/` from the coverage denominator because only `ccm_core` is exercised by automated tests. The scan also sets **`sonar.cpd.exclusions`** for the repeated per-game wx scaffolding files (`*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so intentional parallel UI implementations do not drive the duplication gate. See [Testing Guide And Test Code Of Conduct](testing-and-test-code-of-conduct.md).
## Version Flow
+1 -1
View File
@@ -30,7 +30,7 @@ Windows and Linux use the same logical flow; only generator and compiler setup d
## Coverage Surface
**SonarCloud:** The CI Sonar scan reports coverage against `core/` paths that `ccm_core_tests` can execute. `ui_wx/` and the `app/` composition root are excluded from Sonars **coverage** calculation (`sonar.coverage.exclusions`) because they are not run under the doctest suite; UI behavior is covered by manual validation below. Other Sonar metrics still include those directories.
**SonarCloud:** The CI Sonar scan reports coverage against `core/` paths that `ccm_core_tests` can execute. `ui_wx/` and the `app/` composition root are excluded from Sonars **coverage** calculation (`sonar.coverage.exclusions`) because they are not run under the doctest suite; UI behavior is covered by manual validation below. For duplication, the scan excludes intentionally parallel per-game wx scaffolding (`sonar.cpd.exclusions` on `*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so CPD focuses on shared logic rather than mirrored UI wiring.
Current automated tests cover non-UI behavior, including:
+5 -3
View File
@@ -17,11 +17,13 @@
- `magic_set_source_tests.cpp``MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
- `magic_card_preview_source_tests.cpp``MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`.
- `card_preview_service_tests.cpp``CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly.
- `local_preview_byte_cache_tests.cpp``LocalPreviewByteCache` adapter against `StdFileSystem` (the only test in the suite that touches real disk; each case scopes itself to a unique `temp_directory_path()/ccm_preview_cache_test_*` directory and cleans up via an RAII `TempDir`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
- `local_preview_byte_cache_tests.cpp``LocalPreviewByteCache` adapter against `StdFileSystem` (real disk under a unique `temp_directory_path()/ccm_preview_cache_test_*` per case, RAII `TempDir` cleanup; see also `std_file_system_tests.cpp`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
- `std_file_system_tests.cpp``StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
- `pokemon_set_source_tests.cpp``PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
- `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_filter_tests.cpp``matchesMagicFilter` / `matchesPokemonFilter` / `matchesYuGiOhFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`; Yu-Gi-Oh adds `setNo` + `rarity`), boolean flag columns intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
- `CMakeLists.txt` — explicit list of every `.cpp` (no glob).
@@ -29,7 +31,7 @@
## Conventions
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`.
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`. **One narrow exception**: `local_preview_byte_cache_tests.cpp` exercises the real filesystem because `LocalPreviewByteCache` uses `std::filesystem` directly for size + mtime queries that the `IFileSystem` port deliberately does not expose. Those tests scope themselves to a unique temp directory and clean up unconditionally — do not extend the exception to other test files.
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`. **Narrow exceptions** (unique temp dirs + RAII cleanup): `local_preview_byte_cache_tests.cpp` (mtime/size semantics tied to real `std::filesystem`) and `std_file_system_tests.cpp` (`StdFileSystem` integration). Do not add further real-disk suites without the same cleanup guarantees.
3. **Fakes for narrow concerns stay in the test file** as anonymous-namespace classes (e.g. `RecordingImageStore`, `InMemoryRepo`). Promote a fake to `tests/fakes/` only when more than one test file needs it.
4. **Path strings** in expectations must use forward slashes. The fake normalizes everything to `generic_string()`. Do not hard-code `\` separators.
5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file".
+6
View File
@@ -18,12 +18,18 @@ add_executable(ccm_core_tests
magic_card_preview_source_tests.cpp
card_preview_service_tests.cpp
local_preview_byte_cache_tests.cpp
std_file_system_tests.cpp
pokemon_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp
icard_preview_source_tests.cpp
yugioh_set_source_tests.cpp
yugioh_card_preview_source_tests.cpp
game_module_tests.cpp
card_sorter_tests.cpp
card_filter_tests.cpp
ascii_utils_tests.cpp
http_get_mapping_tests.cpp
cpr_http_client_tests.cpp
main.cpp
)
+20
View File
@@ -0,0 +1,20 @@
#include <doctest/doctest.h>
#include "ccm/util/AsciiUtils.hpp"
using namespace ccm;
TEST_SUITE("asciiLower") {
TEST_CASE("empty string stays empty") {
CHECK(asciiLower("").empty());
}
TEST_CASE("lowercases ASCII letters and leaves other ASCII bytes unchanged") {
CHECK(asciiLower("AbC123!@#") == "abc123!@#");
}
TEST_CASE("non-ASCII UTF-8 bytes pass through unchanged") {
const std::string input = "caf\u00e9";
CHECK(asciiLower(input) == input);
}
}
+56 -1
View File
@@ -57,7 +57,13 @@ YuGiOhCard yc(std::string name,
std::string setName,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "",
bool firstEdition = false,
bool sgnd = false,
bool altered = false) {
YuGiOhCard c;
c.id = 1;
c.name = std::move(name);
@@ -65,6 +71,12 @@ YuGiOhCard yc(std::string name,
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
return c;
}
@@ -176,10 +188,53 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
}
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
TEST_CASE("empty filter matches every row") {
CHECK(matchesYuGiOhFilter(yc("Dark Magician", "Legend of Blue Eyes"), ""));
}
TEST_CASE("matches by set number and rarity") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "lob-005"));
CHECK(matchesYuGiOhFilter(c, "ultra"));
CHECK(matchesYuGiOhFilter(c, "ur"));
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
}
TEST_CASE("matches by name and set.name") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "dark"));
CHECK(matchesYuGiOhFilter(c, "blue eyes"));
CHECK_FALSE(matchesYuGiOhFilter(c, "spell"));
}
TEST_CASE("matches by language, condition, amount, and note") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare",
12, Language::German, Condition::Played, "binder copy");
CHECK(matchesYuGiOhFilter(c, "german"));
CHECK(matchesYuGiOhFilter(c, "played"));
CHECK(matchesYuGiOhFilter(c, "12"));
CHECK(matchesYuGiOhFilter(c, "binder"));
CHECK_FALSE(matchesYuGiOhFilter(c, "english"));
}
TEST_CASE("matches rarity shorthand when the long rarity string does not") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005",
"Quarter Century Secret Rare");
CHECK(matchesYuGiOhFilter(c, "qcscr"));
CHECK_FALSE(matchesYuGiOhFilter(c, "mythic"));
}
TEST_CASE("boolean flag columns are not matched") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare",
1, Language::English, Condition::NearMint, "",
/*firstEdition=*/true, /*sgnd=*/true, /*altered=*/true);
CHECK_FALSE(matchesYuGiOhFilter(c, "true"));
CHECK_FALSE(matchesYuGiOhFilter(c, "false"));
CHECK(matchesYuGiOhFilter(c, "dark"));
}
TEST_CASE("no column hit returns false") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK_FALSE(matchesYuGiOhFilter(c, "zzznomatch"));
}
}
+196
View File
@@ -118,6 +118,18 @@ public:
}
};
class AlwaysNegativeUrlCache final : public IPreviewByteCache {
public:
[[nodiscard]] LoadResult load(std::string_view key) override {
if (!key.empty() && key.front() == 'u') {
return {HitKind::NegativeHit, {}};
}
return {HitKind::Miss, {}};
}
void store(std::string_view, const std::string&) override {}
void storeNegative(std::string_view) override {}
};
// Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule {
public:
@@ -322,6 +334,78 @@ TEST_SUITE("CardPreviewService caching") {
CHECK(http.calls == 1);
}
TEST_CASE("memory-only caching works when no persistent cache is configured") {
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "PNG-bytes";
CardPreviewService svc{http, nullptr};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "").isOk());
http.body = "OTHER";
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(second.isOk());
CHECK(second.value() == "PNG-bytes");
CHECK(http.calls == 1);
}
TEST_CASE("registerModule replaces the preview source for the same game") {
FakeSource firstSource;
firstSource.url = "https://example.com/first.png";
FakeGameModule firstModule;
firstModule.gameId = Game::Magic;
firstModule.preview = &firstSource;
FakeSource secondSource;
secondSource.url = "https://example.com/second.png";
FakeGameModule secondModule;
secondModule.gameId = Game::Magic;
secondModule.preview = &secondSource;
FixedHttpClient http;
http.body = "SECOND";
CardPreviewService svc{http};
svc.registerModule(firstModule);
svc.registerModule(secondModule);
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(out.isOk());
CHECK(out.value() == "SECOND");
CHECK(http.lastUrl == "https://example.com/second.png");
CHECK(firstSource.calls == 0);
CHECK(secondSource.calls == 1);
}
TEST_CASE("NotFound without persistent cache still negative-caches in memory") {
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "not found";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http, nullptr};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "X", "abc", "").isErr());
CHECK(source.calls == 1);
const auto second = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
REQUIRE(second.isErr());
CHECK(second.error() == "No preview available for this card.");
CHECK(source.calls == 1);
CHECK(http.calls == 0);
}
TEST_CASE("HTTP success writes through to the persistent cache") {
// The persistent tier is fire-and-forget on the way down (HTTP -> disk)
// and consulted on the way up (cache miss -> disk -> HTTP). This first
@@ -655,6 +739,98 @@ TEST_SUITE("CardPreviewService caching") {
CHECK(second.value() == "card-back-bytes");
CHECK(http.calls == 1);
}
TEST_CASE("empty HTTP body is rejected and not cached") {
FakeSource source;
source.url = "https://example.com/empty.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "";
CardPreviewService svc{http};
svc.registerModule(module);
const auto first = svc.fetchPreviewBytes(Game::Magic, "Any", "set", "1");
CHECK(first.isErr());
CHECK(first.error().find("Empty response body") != std::string::npos);
CHECK(http.calls == 1);
const auto second = svc.fetchPreviewBytes(Game::Magic, "Any", "set", "1");
CHECK(second.isErr());
CHECK(http.calls == 2);
}
TEST_CASE("url negative entry on disk is treated as miss and refetched") {
FixedHttpClient http;
http.body = "card-back";
AlwaysNegativeUrlCache disk;
CardPreviewService svc{http, &disk};
const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(out.isOk());
CHECK(out.value() == "card-back");
CHECK(http.calls == 1);
}
TEST_CASE("fetchImageBytesByUrl propagates HTTP errors when uncached") {
FixedHttpClient http;
http.ok = false;
http.err = "url fetch failed";
CardPreviewService svc{http};
const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(out.isErr());
CHECK(out.error() == "url fetch failed");
}
TEST_CASE("fetchImageBytesByUrl serves from persistent cache hit without HTTP") {
FixedHttpClient http;
http.body = "warm-card-back";
InMemoryByteCache disk;
// Seed persistent cache via first service instance.
{
CardPreviewService seed{http, &disk};
const auto seeded = seed.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(seeded.isOk());
CHECK(seeded.value() == "warm-card-back");
}
REQUIRE(http.calls == 1);
// Fresh service instance: force HTTP failure and ensure disk hit is used.
CardPreviewService warm{http, &disk};
http.ok = false;
const auto warmHit = warm.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(warmHit.isOk());
CHECK(warmHit.value() == "warm-card-back");
CHECK(http.calls == 1);
}
TEST_CASE("in-memory LRU evicts oldest entry after exceeding capacity") {
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "x";
CardPreviewService svc{http};
svc.registerModule(module);
const auto cap = CardPreviewService::kCacheCapacity;
for (std::size_t i = 0; i < cap + 1; ++i) {
const std::string name = std::string("LRU-") + std::to_string(i);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, name, "lea", "").isOk());
}
REQUIRE(http.calls == cap + 1);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "LRU-0", "lea", "").isOk());
CHECK(http.calls == cap + 2);
}
}
TEST_SUITE("CardPreviewService::detectFirstPrint") {
@@ -692,6 +868,16 @@ TEST_SUITE("CardPreviewService::detectFirstPrint") {
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
TEST_CASE("unregistered game returns explicit error") {
FixedHttpClient http;
CardPreviewService svc{http};
const auto out =
svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "LOB");
CHECK(out.isErr());
CHECK(out.error().find("No preview source registered") !=
std::string::npos);
}
}
TEST_SUITE("CardPreviewService::detectPrintVariants") {
@@ -729,4 +915,14 @@ TEST_SUITE("CardPreviewService::detectPrintVariants") {
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
TEST_CASE("unregistered game returns explicit error") {
FixedHttpClient http;
CardPreviewService svc{http};
const auto out =
svc.detectPrintVariants(Game::YuGiOh, "Dark Magician", "LOB");
CHECK(out.isErr());
CHECK(out.error().find("No preview source registered") !=
std::string::npos);
}
}
+171 -2
View File
@@ -46,7 +46,10 @@ PokemonCard pc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::uint8_t amount = 1,
bool holo = false, bool firstEdition = false,
bool sgnd = false, bool altered = false) {
bool sgnd = false, bool altered = false,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "") {
PokemonCard c;
c.id = id;
c.name = std::move(name);
@@ -57,6 +60,9 @@ PokemonCard pc(std::uint32_t id, std::string name,
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
return c;
}
@@ -64,7 +70,13 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
bool firstEdition = false,
bool sgnd = false,
bool altered = false,
std::string note = "") {
YuGiOhCard c;
c.id = id;
c.name = std::move(name);
@@ -73,6 +85,12 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
c.note = std::move(note);
return c;
}
@@ -141,6 +159,9 @@ TEST_SUITE("CardSorter - Magic columns") {
};
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
@@ -254,6 +275,67 @@ TEST_SUITE("CardSorter - Pokemon-specific columns") {
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
TEST_CASE("Name sorts case-insensitively") {
std::vector<PokemonCard> v = {
pc(1, "piKAchu", "X", "2000/01/01"),
pc(2, "abra", "X", "2000/01/01"),
pc(3, "CHARMANDER", "X", "2000/01/01"),
};
sortPokemonCards(v, PokemonSortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
}
TEST_CASE("Language and Condition sort by lowercased labels") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, false, false,
Language::Japanese, Condition::Mint),
pc(2, "b", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::Played),
pc(3, "c", "X", "2000/01/01", 1, false, false, false, false,
Language::German, Condition::NearMint),
};
sortPokemonCards(v, PokemonSortColumn::Language, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortPokemonCards(v, PokemonSortColumn::Condition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("Signed and Altered sort like Magic booleans") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, /*sgnd=*/false, /*alt=*/true),
pc(2, "b", "X", "2000/01/01", 1, false, false, /*sgnd=*/true, /*alt=*/false),
};
sortPokemonCards(v, PokemonSortColumn::Signed, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
sortPokemonCards(v, PokemonSortColumn::Altered, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
TEST_CASE("Note sorts case-insensitively") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "ZETA"),
pc(2, "b", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "alpha"),
pc(3, "c", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "Beta"),
};
sortPokemonCards(v, PokemonSortColumn::Note, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
}
TEST_CASE("descending SetReleaseDate reverses chronological order") {
std::vector<PokemonCard> v = {
pc(1, "x", "Late", "2020/01/01"),
pc(2, "y", "Early", "1999/01/01"),
pc(3, "z", "Mid", "2015/06/01"),
};
sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
}
TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
@@ -272,6 +354,93 @@ TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
}
TEST_SUITE("CardSorter - YuGiOh columns") {
TEST_CASE("Name sorts case-insensitively") {
std::vector<YuGiOhCard> v = {
yc(1, "BLUE-EYES", "X", "2000/01/01"),
yc(2, "dark magician", "X", "2000/01/01"),
yc(3, "CELTIC_GUARDIAN", "X", "2000/01/01"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("SetReleaseDate sorts chronologically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "Late", "2020/01/01"),
yc(2, "b", "Early", "1999/01/01"),
yc(3, "c", "Mid", "2015/06/01"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("Language and Condition sort by lowercased labels") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 1,
Language::Japanese, Condition::Mint),
yc(2, "b", "X", "2000/01/01", "", "", 1,
Language::English, Condition::Played),
yc(3, "c", "X", "2000/01/01", "", "", 1,
Language::German, Condition::NearMint),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Language, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Condition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("FirstEdition Signed Altered and Note columns sort consistently") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/true, /*sgnd=*/false, /*alt=*/true, "Z"),
yc(2, "b", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/false, /*sgnd=*/true, /*alt=*/false, "a"),
yc(3, "c", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/false, /*sgnd=*/false, /*alt=*/false, "m"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::FirstEdition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Signed, /*ascending=*/true);
// After FirstEdition sort order is {2,3,1}: signed=false wins first (stable: 3 then 1).
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
sortYuGiOhCards(v, YuGiOhSortColumn::Altered, /*ascending=*/true);
// Previous order {3,1,2}: altered=false entries are 3 and 2 before true (1).
CHECK(ids(v) == std::vector<std::uint32_t>{3, 2, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Note, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // a, m, Z (case-insensitive)
}
TEST_CASE("Rarity sorts by rarity shorthand") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "Ultra Rare", 1),
yc(2, "b", "X", "2000/01/01", "", "Common", 1),
yc(3, "c", "X", "2000/01/01", "", "Secret Rare", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // C, ScR, UR
}
TEST_CASE("Rarity treats unknown labels as equal empty shorthand") {
std::vector<YuGiOhCard> v = {
yc(1, "alpha", "X", "2000/01/01", "", "Mythic Cosmic Rare", 1),
yc(2, "beta", "X", "2000/01/01", "", "Other Unknown", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
}
TEST_CASE("Amount sorts numerically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 9),
+120 -1
View File
@@ -16,9 +16,15 @@ namespace {
class InMemoryRepo final : public ICollectionRepository<MagicCard> {
public:
Map storage;
bool failLoad{false};
bool failSave{false};
Result<Map> load(Game) override { return Result<Map>::ok(storage); }
Result<Map> load(Game) override {
if (failLoad) return Result<Map>::err("load failed");
return Result<Map>::ok(storage);
}
Result<void> save(Game, const Map& m) override {
if (failSave) return Result<void>::err("save failed");
storage = m;
return Result<void>::ok();
}
@@ -27,12 +33,14 @@ public:
class StubImageStore final : public IImageStore {
public:
std::vector<std::pair<Game, std::string>> removed;
bool failRemove{false};
Result<std::string> copyIn(Game, const std::filesystem::path&, const std::string& n) override {
return Result<std::string>::ok(n);
}
Result<void> remove(Game g, const std::string& n) override {
removed.emplace_back(g, n);
if (failRemove) return Result<void>::err("remove failed for " + n);
return Result<void>::ok();
}
std::filesystem::path resolvePath(Game, const std::string& n) const override {
@@ -51,6 +59,16 @@ MagicCard makeCard(const std::string& name, std::vector<std::string> imgs = {})
} // namespace
TEST_SUITE("CollectionService<MagicCard>") {
TEST_CASE("nextId uses highest existing id plus one") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
repo.storage.emplace(2, makeCard("A"));
repo.storage.emplace(9, makeCard("B"));
CHECK(CollectionService<MagicCard>::nextId(repo.storage) == 10);
}
TEST_CASE("nextId on empty map is 0, then strictly increments") {
InMemoryRepo repo;
StubImageStore store;
@@ -117,4 +135,105 @@ TEST_SUITE("CollectionService<MagicCard>") {
CHECK(svc.remove(Game::Magic, 12345).isErr());
}
TEST_CASE("load errors are propagated by list and findById") {
InMemoryRepo repo;
repo.failLoad = true;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto listed = svc.list(Game::Magic);
REQUIRE(listed.isErr());
CHECK(listed.error() == "load failed");
const auto found = svc.findById(Game::Magic, 1);
REQUIRE(found.isErr());
CHECK(found.error() == "load failed");
}
TEST_CASE("save errors are propagated by add and update") {
InMemoryRepo repo;
repo.failSave = true;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto addRes = svc.add(Game::Magic, makeCard("A"));
REQUIRE(addRes.isErr());
CHECK(addRes.error() == "save failed");
repo.failSave = false;
const auto id = svc.add(Game::Magic, makeCard("B"));
REQUIRE(id.isOk());
repo.failSave = true;
MagicCard updated = makeCard("Renamed");
updated.id = id.value();
const auto updateRes = svc.update(Game::Magic, updated);
REQUIRE(updateRes.isErr());
CHECK(updateRes.error() == "save failed");
}
TEST_CASE("add overwrites input card id with generated id") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
MagicCard card = makeCard("Has User Id");
card.id = 777;
const auto out = svc.add(Game::Magic, card);
REQUIRE(out.isOk());
CHECK(out.value() == 0);
REQUIRE(repo.storage.count(0) == 1);
CHECK(repo.storage.at(0).name == "Has User Id");
CHECK(repo.storage.count(777) == 0);
}
TEST_CASE("findById returns nullopt for missing id") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto out = svc.findById(Game::Magic, 99);
REQUIRE(out.isOk());
CHECK_FALSE(out.value().has_value());
}
TEST_CASE("remove reports image cleanup issues but still removes card") {
InMemoryRepo repo;
StubImageStore store;
store.failRemove = true;
CollectionService<MagicCard> svc{repo, store};
const auto id = svc.add(
Game::Magic, makeCard("With Images", {"a.png", "b.png"}));
REQUIRE(id.isOk());
const auto removed = svc.remove(Game::Magic, id.value());
REQUIRE(removed.isErr());
CHECK(removed.error().find("Card removed but image cleanup had issues:") != std::string::npos);
CHECK(removed.error().find("remove failed for a.png") != std::string::npos);
CHECK(removed.error().find("remove failed for b.png") != std::string::npos);
const auto listed = svc.list(Game::Magic);
REQUIRE(listed.isOk());
CHECK(listed.value().empty());
}
TEST_CASE("remove propagates save failure after image cleanup") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto id = svc.add(
Game::Magic, makeCard("With Images", {"a.png", "b.png"}));
REQUIRE(id.isOk());
repo.failSave = true;
const auto removed = svc.remove(Game::Magic, id.value());
REQUIRE(removed.isErr());
CHECK(removed.error() == "save failed");
REQUIRE(store.removed.size() == 2);
CHECK(store.removed[0].second == "a.png");
CHECK(store.removed[1].second == "b.png");
}
}
+146
View File
@@ -0,0 +1,146 @@
#include <doctest/doctest.h>
#include "ccm/infra/CprHttpClient.hpp"
#include <string>
#include <string_view>
using namespace ccm;
TEST_SUITE("CprHttpClient injected executor") {
TEST_CASE("forwards URL to injected executor and returns payload") {
std::string seenUrl;
CprHttpClient client{
[&seenUrl](std::string_view url) -> Result<std::string> {
seenUrl = std::string(url);
return Result<std::string>::ok("body");
}
};
const auto out = client.get("https://example.com/api?q=1");
REQUIRE(out.isOk());
CHECK(out.value() == "body");
CHECK(seenUrl == "https://example.com/api?q=1");
}
TEST_CASE("propagates injected executor error as-is") {
CprHttpClient client{
[](std::string_view) -> Result<std::string> {
return Result<std::string>::err("HTTP 503 from https://example.com");
}
};
const auto out = client.get("https://example.com");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 503 from https://example.com");
}
TEST_CASE("returns an explicit error when executor is empty") {
CprHttpClient::GetExecutor empty;
CprHttpClient client{empty};
const auto out = client.get("https://example.com");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP error: no executor configured");
}
}
TEST_SUITE("CprHttpClient injected raw executor") {
TEST_CASE("maps 2xx raw response to success body") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = false,
.transportMessage = "",
.statusCode = 200,
.body = "ok-body",
};
}
};
const auto out = client.get("https://example.com/success");
REQUIRE(out.isOk());
CHECK(out.value() == "ok-body");
}
TEST_CASE("maps transport error via shared http mapping") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = true,
.transportMessage = "timeout",
.statusCode = 0,
.body = "",
};
}
};
const auto out = client.get("https://example.com/timeout");
REQUIRE(out.isErr());
CHECK(out.error().find("timeout") != std::string::npos);
}
TEST_CASE("passes URL through raw executor unchanged") {
std::string seenUrl;
CprHttpClient client{
[&seenUrl](std::string_view url) -> CprHttpClient::RawResponse {
seenUrl = std::string(url);
return CprHttpClient::RawResponse{
.transportError = false,
.transportMessage = "",
.statusCode = 200,
.body = "ok",
};
}
};
const auto out = client.get("https://example.com/raw?q=a%20b");
REQUIRE(out.isOk());
CHECK(seenUrl == "https://example.com/raw?q=a%20b");
}
TEST_CASE("maps non-2xx status to error") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = false,
.transportMessage = "",
.statusCode = 503,
.body = "service unavailable",
};
}
};
const auto out = client.get("https://example.com/fail");
REQUIRE(out.isErr());
CHECK(out.error().find("HTTP 503") != std::string::npos);
}
TEST_CASE("transport error takes precedence over status code") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = true,
.transportMessage = "socket closed",
.statusCode = 200,
.body = "ignored",
};
}
};
const auto out = client.get("https://example.com/transport");
REQUIRE(out.isErr());
CHECK(out.error().find("socket closed") != std::string::npos);
}
}
TEST_SUITE("CprHttpClient real session") {
TEST_CASE("default constructor handles malformed URL without crashing") {
// Exercise the real cpr::Session-backed constructor/lambda path
// without depending on external network availability.
CprHttpClient client{};
const auto out = client.get("://not-a-valid-url");
REQUIRE(out.isErr());
CHECK(out.error().find("HTTP") != std::string::npos);
}
}
+305 -2
View File
@@ -42,6 +42,50 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json bad = "Spanglish";
CHECK_THROWS(bad.get<Language>());
}
TEST_CASE("all enum values round-trip through string helpers") {
for (const auto game : allGames()) {
const auto name = to_string(game);
CHECK(gameFromString(name).has_value());
CHECK(*gameFromString(name) == game);
}
for (const auto language : allLanguages()) {
const auto name = to_string(language);
CHECK(languageFromString(name).has_value());
CHECK(*languageFromString(name) == language);
}
for (const auto condition : allConditions()) {
const auto name = to_string(condition);
CHECK(conditionFromString(name).has_value());
CHECK(*conditionFromString(name) == condition);
}
for (const auto theme : allThemes()) {
const auto name = to_string(theme);
CHECK(themeFromString(name).has_value());
CHECK(*themeFromString(name) == theme);
}
}
TEST_CASE("invalid enum helper inputs return nullopt") {
CHECK_FALSE(gameFromString("magic").has_value());
CHECK_FALSE(languageFromString("EN").has_value());
CHECK_FALSE(conditionFromString("Near Mint").has_value());
CHECK_FALSE(themeFromString("OLED").has_value());
}
TEST_CASE("invalid game, condition and theme JSON values throw") {
nlohmann::json badGame = "YGO";
CHECK_THROWS(badGame.get<Game>());
nlohmann::json badCondition = "Pristine";
CHECK_THROWS(badCondition.get<Condition>());
nlohmann::json badTheme = "Midnight";
CHECK_THROWS(badTheme.get<Theme>());
}
}
TEST_SUITE("Set JSON shape stays stable") {
@@ -124,6 +168,18 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
const auto back = j.get<Configuration>();
CHECK(back == cfg);
}
TEST_CASE("missing theme key defaults to Light") {
const nlohmann::json j = {
{"dataStorage", "/portable/data"},
{"defaultGame", "YuGiOh"},
};
const auto cfg = j.get<Configuration>();
CHECK(cfg.dataStorage == "/portable/data");
CHECK(cfg.defaultGame == Game::YuGiOh);
CHECK(cfg.theme == Theme::Light);
}
}
TEST_SUITE("YuGiOhCard JSON") {
@@ -135,7 +191,6 @@ TEST_SUITE("YuGiOhCard JSON") {
c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"};
c.setNo = "SDK-001";
c.rarity = "Ultra Rare";
c.rarityCode = "(UR)";
c.note = "classic";
c.images = {"77+starter+blue-eyes+0.png"};
c.language = Language::English;
@@ -147,10 +202,258 @@ TEST_SUITE("YuGiOhCard JSON") {
nlohmann::json j = c;
CHECK(j.at("setNo") == "SDK-001");
CHECK(j.at("rarity") == "Ultra Rare");
CHECK(j.at("rarityCode") == "(UR)");
CHECK_FALSE(j.contains("rarityCode"));
CHECK(j.at("signed") == false);
const YuGiOhCard back = j.get<YuGiOhCard>();
CHECK(back == c);
}
TEST_CASE("legacy JSON without rarityCode key parses (domain uses rarity only)") {
const nlohmann::json j = {
{"id", 1},
{"amount", 1},
{"name", "Dark Magician"},
{"set", nlohmann::json{
{"id", "SDY"},
{"name", "Starter Deck: Yugi"},
{"releaseDate", "2002/03/29"},
}},
{"setNo", "SDY-006"},
{"rarity", "Ultra Rare"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", false},
{"signed", false},
{"altered", false},
};
const auto card = j.get<YuGiOhCard>();
CHECK(card.rarity == "Ultra Rare");
CHECK(card.setNo == "SDY-006");
CHECK(card.set.id == "SDY");
}
TEST_CASE("serializes non-default flags and metadata fields") {
YuGiOhCard c;
c.id = 3;
c.amount = 4;
c.name = "Red-Eyes Black Dragon";
c.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
c.setNo = "LOB-070";
c.rarity = "Secret Rare";
c.note = "graded";
c.images = {};
c.language = Language::German;
c.condition = Condition::Played;
c.firstEdition = false;
c.signed_ = true;
c.altered = true;
const nlohmann::json j = c;
CHECK(j.at("firstEdition") == false);
CHECK(j.at("signed") == true);
CHECK(j.at("altered") == true);
CHECK(j.at("language") == "German");
CHECK(j.at("condition") == "Played");
CHECK(j.at("images") == nlohmann::json::array());
const YuGiOhCard back = j.get<YuGiOhCard>();
CHECK(back == c);
}
TEST_CASE("operator== distinguishes each field") {
YuGiOhCard base;
base.id = 10;
base.amount = 2;
base.name = "Dark Magician";
base.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
base.setNo = "LOB-005";
base.rarity = "Ultra Rare";
base.note = "note";
base.images = {"a.png"};
base.language = Language::English;
base.condition = Condition::NearMint;
base.firstEdition = true;
base.signed_ = false;
base.altered = false;
auto changed = base;
changed.id = 11;
CHECK_FALSE(changed == base);
changed = base;
changed.amount = 3;
CHECK_FALSE(changed == base);
changed = base;
changed.name = "Other";
CHECK_FALSE(changed == base);
changed = base;
changed.set.name = "Other Set";
CHECK_FALSE(changed == base);
changed = base;
changed.setNo = "LOB-006";
CHECK_FALSE(changed == base);
changed = base;
changed.rarity = "Rare";
CHECK_FALSE(changed == base);
changed = base;
changed.note = "other";
CHECK_FALSE(changed == base);
changed = base;
changed.images = {};
CHECK_FALSE(changed == base);
changed = base;
changed.language = Language::Japanese;
CHECK_FALSE(changed == base);
changed = base;
changed.condition = Condition::Played;
CHECK_FALSE(changed == base);
changed = base;
changed.firstEdition = false;
CHECK_FALSE(changed == base);
changed = base;
changed.signed_ = true;
CHECK_FALSE(changed == base);
changed = base;
changed.altered = true;
CHECK_FALSE(changed == base);
}
}
TEST_SUITE("Domain JSON required fields") {
TEST_CASE("Set missing required key throws") {
const nlohmann::json j = {
{"id", "lea"},
{"name", "Limited Edition Alpha"},
};
CHECK_THROWS(j.get<Set>());
}
TEST_CASE("MagicCard missing required key throws") {
const nlohmann::json j = {
{"id", 10},
{"amount", 1},
{"name", "Lightning Bolt"},
{"set", nlohmann::json{
{"id", "lea"},
{"name", "Limited Edition Alpha"},
{"releaseDate", "1993/08/05"},
}},
// note missing on purpose
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"foil", false},
{"signed", false},
{"altered", false},
};
CHECK_THROWS(j.get<MagicCard>());
}
TEST_CASE("PokemonCard missing required key throws") {
const nlohmann::json j = {
{"id", 7},
{"amount", 1},
{"name", "Charizard"},
{"set", nlohmann::json{
{"id", "base1"},
{"name", "Base Set"},
{"releaseDate", "1999/01/09"},
}},
{"setNo", "4/102"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "Excellent"},
{"firstEdition", true},
// holo missing on purpose
{"signed", false},
{"altered", false},
};
CHECK_THROWS(j.get<PokemonCard>());
}
TEST_CASE("YuGiOhCard missing required key throws") {
const nlohmann::json j = {
{"id", 7},
{"amount", 1},
{"name", "Blue-Eyes White Dragon"},
{"set", nlohmann::json{
{"id", "sdk"},
{"name", "Starter Deck Kaiba"},
{"releaseDate", "2002/03/29"},
}},
{"setNo", "SDK-001"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", true},
// rarity missing on purpose
{"signed", false},
{"altered", false},
};
CHECK_THROWS(j.get<YuGiOhCard>());
}
TEST_CASE("YuGiOhCard missing each required key throws") {
const nlohmann::json full = {
{"id", 7},
{"amount", 1},
{"name", "Blue-Eyes White Dragon"},
{"set", nlohmann::json{
{"id", "sdk"},
{"name", "Starter Deck Kaiba"},
{"releaseDate", "2002/03/29"},
}},
{"setNo", "SDK-001"},
{"rarity", "Ultra Rare"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", true},
{"signed", false},
{"altered", false},
};
for (const char* key : {
"id", "amount", "name", "set", "setNo", "note", "images",
"language", "condition", "firstEdition", "rarity", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<YuGiOhCard>());
}
}
TEST_CASE("Configuration missing required key throws") {
const nlohmann::json j = {
{"defaultGame", "Magic"},
{"theme", "Dark"},
};
CHECK_THROWS(j.get<Configuration>());
}
TEST_CASE("Configuration invalid theme value throws when present") {
const nlohmann::json j = {
{"dataStorage", "/portable/data"},
{"defaultGame", "Magic"},
{"theme", "Neon"},
};
CHECK_THROWS(j.get<Configuration>());
}
}
+54
View File
@@ -0,0 +1,54 @@
#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);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("Pokemon module reports canonical metadata") {
NoopHttpClient http;
PokemonGameModule module(http);
CHECK(module.id() == Game::Pokemon);
CHECK(module.dirName() == "pokemon");
CHECK(module.displayName() == "Pokemon");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("YuGiOh module reports canonical metadata") {
NoopHttpClient http;
YuGiOhGameModule module(http);
CHECK(module.id() == Game::YuGiOh);
CHECK(module.dirName() == "yugioh");
CHECK(module.displayName() == "Yu-Gi-Oh!");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
}
+56
View File
@@ -0,0 +1,56 @@
#include <doctest/doctest.h>
#include "ccm/util/HttpGetMapping.hpp"
using namespace ccm;
TEST_SUITE("mapHttpGetResponse") {
TEST_CASE("curl transport error ignores HTTP status and body") {
const auto out =
mapHttpGetResponse(true, "connection refused", 0, "ignored", "http://x");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP error: connection refused");
}
TEST_CASE("HTTP status below 200 is an error") {
const auto out =
mapHttpGetResponse(false, {}, 199, "body", "http://example/a");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 199 from http://example/a");
}
TEST_CASE("HTTP status 200 returns body") {
const auto out =
mapHttpGetResponse(false, {}, 200, "payload", "http://example/a");
REQUIRE(out.isOk());
CHECK(out.value() == "payload");
}
TEST_CASE("HTTP status 299 returns body") {
const auto out =
mapHttpGetResponse(false, {}, 299, "ok", "http://example/a");
REQUIRE(out.isOk());
CHECK(out.value() == "ok");
}
TEST_CASE("HTTP status 300 and above is an error") {
const auto out =
mapHttpGetResponse(false, {}, 300, "redirect", "http://example/a");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 300 from http://example/a");
}
TEST_CASE("HTTP 404 formats URL into message") {
const auto out =
mapHttpGetResponse(false, {}, 404, "", "https://api.example/r");
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 404 from https://api.example/r");
}
TEST_CASE("HTTP 200 with empty body still maps to success") {
const auto out =
mapHttpGetResponse(false, {}, 200, "", "https://api.example/empty");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
}
+60
View File
@@ -0,0 +1,60 @@
#include <doctest/doctest.h>
#include "ccm/ports/ICardPreviewSource.hpp"
using namespace ccm;
namespace {
class MinimalPreviewSource final : public ICardPreviewSource {
public:
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view,
std::string_view,
std::string_view) override {
return Result<std::string, PreviewLookupError>::err(
PreviewLookupError{PreviewLookupError::Kind::NotFound, "not found"});
}
};
class AutoDetectPreviewSource final : public ICardPreviewSource {
public:
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return true;
}
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view,
std::string_view,
std::string_view) override {
return Result<std::string, PreviewLookupError>::ok("https://example.test/card.png");
}
};
} // namespace
TEST_SUITE("ICardPreviewSource defaults") {
TEST_CASE("auto-detect is disabled by default") {
MinimalPreviewSource src;
CHECK_FALSE(src.supportsAutoDetectPrint());
}
TEST_CASE("implementations may override supportsAutoDetectPrint") {
AutoDetectPreviewSource src;
CHECK(src.supportsAutoDetectPrint());
}
TEST_CASE("default detectFirstPrint returns explicit unsupported error") {
MinimalPreviewSource src;
const auto out = src.detectFirstPrint("Card", "Set");
REQUIRE(out.isErr());
CHECK(out.error() == "Auto-detect not supported by this game.");
}
TEST_CASE("default detectPrintVariants returns explicit unsupported error") {
MinimalPreviewSource src;
const auto out = src.detectPrintVariants("Card", "Set");
REQUIRE(out.isErr());
CHECK(out.error() == "Print variant listing not supported by this game.");
}
}
+100
View File
@@ -19,16 +19,24 @@ public:
std::vector<Call> copies;
std::vector<std::pair<Game, std::string>> removes;
std::string returnedExt = ".png";
int failCopyAt = -1;
int failRemoveAt = -1;
Result<std::string> copyIn(Game game,
const std::filesystem::path& srcPath,
const std::string& targetName) override {
copies.push_back({game, srcPath, targetName});
if (failCopyAt >= 0 && static_cast<int>(copies.size()) == failCopyAt) {
return Result<std::string>::err("copy failed at " + std::to_string(failCopyAt));
}
return Result<std::string>::ok(targetName + returnedExt);
}
Result<void> remove(Game game, const std::string& imageName) override {
removes.emplace_back(game, imageName);
if (failRemoveAt >= 0 && static_cast<int>(removes.size()) == failRemoveAt) {
return Result<void>::err("remove failed at " + std::to_string(failRemoveAt));
}
return Result<void>::ok();
}
@@ -55,6 +63,16 @@ TEST_SUITE("ImageService::nextImageIndex") {
std::vector<std::string> imgs2 = {"otherIMG_BACK.png"};
CHECK(ImageService::nextImageIndex(imgs2) == 0);
}
TEST_CASE("index increments from two-digit legacy cap") {
std::vector<std::string> imgs = {"set+name+99.png"};
CHECK(ImageService::nextImageIndex(imgs) == 100);
}
TEST_CASE("three-digit filename index follows two-digit compatibility parser") {
std::vector<std::string> imgs = {"set+name+255.png"};
CHECK(ImageService::nextImageIndex(imgs) == 56);
}
}
TEST_SUITE("ImageService::buildTargetName") {
@@ -85,6 +103,40 @@ TEST_SUITE("ImageService::addImage") {
CHECK(store.copies[0].game == Game::Magic);
CHECK(store.copies[0].target == "Beta+BlackLotus+0");
}
TEST_CASE("propagates copyIn failures") {
RecordingImageStore store;
store.failCopyAt = 1;
ImageService svc{store};
std::vector<std::string> existing;
const auto out = svc.addImage(Game::Magic, "/tmp/source.png",
/*newEntry=*/true, /*cardId=*/0,
"Beta", "Black Lotus", existing);
REQUIRE(out.isErr());
CHECK(out.error().find("copy failed at 1") != std::string::npos);
}
}
TEST_SUITE("ImageService::removeImage and resolveImagePath") {
TEST_CASE("removeImage delegates to store remove") {
RecordingImageStore store;
ImageService svc{store};
const auto out = svc.removeImage(Game::Magic, "x.png");
REQUIRE(out.isOk());
REQUIRE(store.removes.size() == 1);
CHECK(store.removes[0].first == Game::Magic);
CHECK(store.removes[0].second == "x.png");
}
TEST_CASE("resolveImagePath delegates to store resolvePath") {
RecordingImageStore store;
ImageService svc{store};
const auto p = svc.resolveImagePath(Game::Pokemon, "pikachu.jpg");
CHECK(p == std::filesystem::path("/fake/pikachu.jpg"));
}
}
TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
@@ -128,4 +180,52 @@ TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
CHECK(store.copies.empty());
CHECK(store.removes.empty());
}
TEST_CASE("skips rename when computed output name equals input") {
RecordingImageStore store;
ImageService svc{store};
const std::vector<std::string> images{"42+Beta+BlackLotus+0.png"};
auto normalized = svc.normalizeNamesForPersistedCard(
Game::Magic, 42, "Beta", "Black Lotus", images);
REQUIRE(normalized.isOk());
CHECK(normalized.value() == images);
CHECK(store.copies.empty());
CHECK(store.removes.empty());
}
TEST_CASE("copy failure rolls back already-created names and returns error") {
RecordingImageStore store;
store.failCopyAt = 2;
ImageService svc{store};
const std::vector<std::string> images{
"Beta+BlackLotus+0.png",
"Beta+BlackLotus+1.jpg"
};
const auto out = svc.normalizeNamesForPersistedCard(
Game::Magic, 42, "Beta", "Black Lotus", images);
REQUIRE(out.isErr());
CHECK(out.error().find("copy failed at 2") != std::string::npos);
// Second copy failed, so first created file should be rolled back.
REQUIRE(store.removes.size() == 1);
CHECK(store.removes[0].second == "42+Beta+BlackLotus+0.png");
}
TEST_CASE("remove failure after rename returns error") {
RecordingImageStore store;
store.failRemoveAt = 1;
ImageService svc{store};
const std::vector<std::string> images{
"Beta+BlackLotus+0.png"
};
const auto out = svc.normalizeNamesForPersistedCard(
Game::Magic, 42, "Beta", "Black Lotus", images);
REQUIRE(out.isErr());
CHECK(out.error().find("remove failed at 1") != std::string::npos);
}
}
@@ -8,6 +8,8 @@
#include <nlohmann/json.hpp>
#include <filesystem>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
@@ -27,6 +29,37 @@ ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
std::string magicDir(Game g) { return g == Game::Magic ? "magic" : "pokemon"; }
class FailingCollectionFs final : public IFileSystem {
public:
bool existsValue{true};
bool ensureOk{true};
bool writeOk{true};
bool readOk{true};
std::string readPayload{"{}"};
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return existsValue; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
Result<void> ensureDirectory(const std::filesystem::path&) override {
if (!ensureOk) return Result<void>::err("ensure failed");
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
if (!readOk) return Result<std::string>::err("read failed");
return Result<std::string>::ok(readPayload);
}
Result<void> writeText(const std::filesystem::path&, std::string_view) override {
if (!writeOk) return Result<void>::err("write failed");
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path&) override { return Result<void>::ok(); }
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
} // namespace
TEST_SUITE("JsonCollectionRepository<MagicCard>") {
@@ -85,4 +118,49 @@ TEST_SUITE("JsonCollectionRepository<MagicCard>") {
REQUIRE(j.contains("17"));
CHECK(j.at("17").at("id") == 17);
}
TEST_CASE("load returns parse error for non-object root") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
fs.writeText("/data/magic/collection.json", R"(["not","an","object"])");
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
}
TEST_CASE("load returns parse error for non-numeric object keys") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
fs.writeText("/data/magic/collection.json", R"({"abc":{"id":1}})");
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
}
TEST_CASE("save and initialize-on-load propagate ensureDirectory/write errors") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingCollectionFs fs;
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
fs.ensureOk = false;
const auto saveEnsureFail = repo.save(Game::Magic, {});
REQUIRE(saveEnsureFail.isErr());
CHECK(saveEnsureFail.error() == "ensure failed");
fs.ensureOk = true;
fs.writeOk = false;
const auto saveWriteFail = repo.save(Game::Magic, {});
REQUIRE(saveWriteFail.isErr());
CHECK(saveWriteFail.error() == "write failed");
fs.existsValue = false;
const auto loadCreateFail = repo.load(Game::Magic);
REQUIRE(loadCreateFail.isErr());
CHECK(loadCreateFail.error() == "write failed");
}
}
+103
View File
@@ -22,6 +22,43 @@ ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
cfg.initialize();
return cfg;
}
class FailingSetFs final : public IFileSystem {
public:
bool ensureOk{true};
bool writeOk{true};
bool readOk{true};
std::string readPayload{"[]"};
std::filesystem::path lastWritePath;
std::string lastWriteBody;
std::filesystem::path lastReadPath;
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
Result<void> ensureDirectory(const std::filesystem::path&) override {
if (!ensureOk) return Result<void>::err("ensure failed");
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
lastReadPath = std::filesystem::path("/tracked/read/path");
if (!readOk) return Result<std::string>::err("read failed");
return Result<std::string>::ok(readPayload);
}
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override {
if (!writeOk) return Result<void>::err("write failed");
lastWritePath = p;
lastWriteBody = std::string(contents);
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path&) override { return Result<void>::ok(); }
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
} // namespace
TEST_SUITE("JsonSetRepository") {
@@ -49,4 +86,70 @@ TEST_SUITE("JsonSetRepository") {
const auto loaded = repo.load(Game::Pokemon);
CHECK(loaded.isErr());
}
TEST_CASE("load propagates read errors from filesystem") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
fs.readOk = false;
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error() == "read failed");
}
TEST_CASE("load reports parse error for malformed sets.json") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
fs.readPayload = "{bad json";
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
}
TEST_CASE("load reports parse error for wrong JSON shape") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
fs.readPayload = R"({"not":"an array"})";
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
}
TEST_CASE("save propagates ensureDirectory and writeText failures") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
JsonSetRepository repo{fs, cfg, dirNameFn};
const std::vector<Set> sets = {{"lea", "Limited Edition Alpha", "1993/08/05"}};
fs.ensureOk = false;
const auto ensureFail = repo.save(Game::Magic, sets);
REQUIRE(ensureFail.isErr());
CHECK(ensureFail.error() == "ensure failed");
fs.ensureOk = true;
fs.writeOk = false;
const auto writeFail = repo.save(Game::Magic, sets);
REQUIRE(writeFail.isErr());
CHECK(writeFail.error() == "write failed");
}
TEST_CASE("paths are composed from dataStorage and game dir") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonSetRepository repo{fs, cfg, dirNameFn};
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
REQUIRE(repo.save(Game::Pokemon, sets).isOk());
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
}
}
+57
View File
@@ -21,6 +21,37 @@ std::string dirNameForGame(Game g) {
return "magic";
}
class FailingImageFs final : public IFileSystem {
public:
bool ensureOk{true};
bool copyOk{true};
bool removeOk{true};
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
Result<void> ensureDirectory(const std::filesystem::path&) override {
if (!ensureOk) return Result<void>::err("ensure failed");
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
return Result<std::string>::ok({});
}
Result<void> writeText(const std::filesystem::path&, std::string_view) override {
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
if (!copyOk) return Result<void>::err("copy failed");
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path&) override {
if (!removeOk) return Result<void>::err("remove failed");
return Result<void>::ok();
}
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
} // namespace
TEST_SUITE("LocalImageStore") {
@@ -87,4 +118,30 @@ TEST_SUITE("LocalImageStore") {
const std::filesystem::path got = store.resolvePath(Game::Pokemon, "pic.jpg");
CHECK(got.generic_string() == "/coll/pokemon/images/pic.jpg");
}
TEST_CASE("copyIn propagates ensureDirectory failure") {
InMemoryFileSystem configFs;
ConfigService cfg{configFs, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
FailingImageFs fs;
fs.ensureOk = false;
LocalImageStore store(fs, cfg, dirNameForGame);
const auto out = store.copyIn(Game::Magic, "/incoming/a.png", "id001");
REQUIRE(out.isErr());
CHECK(out.error() == "ensure failed");
}
TEST_CASE("remove propagates filesystem remove failure when file exists") {
InMemoryFileSystem configFs;
ConfigService cfg{configFs, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
FailingImageFs fs;
fs.removeOk = false;
LocalImageStore store(fs, cfg, dirNameForGame);
const auto out = store.remove(Game::Magic, "a.png");
REQUIRE(out.isErr());
CHECK(out.error() == "remove failed");
}
}
+133
View File
@@ -12,6 +12,8 @@
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
@@ -56,6 +58,59 @@ void backdate(const fs::path& p, int seconds) {
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
}
class FailingEnsureDirFs final : public IFileSystem {
public:
explicit FailingEnsureDirFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override {
(void)p;
return Result<void>::err("ensure failed");
}
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
class FailingIndexWriteFs final : public IFileSystem {
public:
explicit FailingIndexWriteFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override { return inner_.ensureDirectory(p); }
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
const auto path = p.generic_string();
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".idx") == 0) {
return Result<void>::err("idx write failed");
}
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
} // namespace
TEST_SUITE("LocalPreviewByteCache") {
@@ -234,6 +289,55 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with payload but missing sidecar is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with negative marker but missing sidecar is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.storeNegative("real-key");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with unreadable payload file is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".bin") {
std::error_code ec;
fs::remove(entry.path(), ec);
break;
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("evicts oldest entry when the size cap would be exceeded") {
TempDir td;
StdFileSystem fs;
@@ -291,3 +395,32 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
}
}
TEST_SUITE("LocalPreviewByteCache in-memory filesystem failures") {
TEST_CASE("store is a silent no-op when ensureDirectory fails") {
ccm::testing::InMemoryFileSystem inner;
FailingEnsureDirFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("store rolls back payload when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("storeNegative rolls back marker when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.storeNegative("k");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
}
+26
View File
@@ -43,6 +43,12 @@ TEST_SUITE("MagicCardPreviewSource::buildSearchUrl") {
const auto url = MagicCardPreviewSource::buildSearchUrl("X", "swsh10");
CHECK(url.find("set%3Aswsh10") != std::string::npos);
}
TEST_CASE("replaces every ampersand in the card name") {
const auto url = MagicCardPreviewSource::buildSearchUrl("A & B & C", "abc");
CHECK(url.find("A%20and%20B%20and%20C") != std::string::npos);
CHECK(url.find("%26") == std::string::npos);
}
}
TEST_SUITE("MagicCardPreviewSource::parseResponse") {
@@ -79,6 +85,26 @@ TEST_SUITE("MagicCardPreviewSource::parseResponse") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("'data' present but not an array is Transient") {
const auto out = MagicCardPreviewSource::parseResponse(R"({"data":{}})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("image_uris present but not an object is NotFound") {
const auto out = MagicCardPreviewSource::parseResponse(
R"({"data":[{"name":"X","image_uris":[]}]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("'normal' present but not a string is NotFound") {
const auto out = MagicCardPreviewSource::parseResponse(
R"({"data":[{"image_uris":{"normal":null}}]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("entry without image_uris is classified as NotFound (double-faced cards)") {
const std::string json = R"({
"data": [
+217
View File
@@ -54,6 +54,13 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
"Mr. Mime", "base1", "");
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos);
}
TEST_CASE("empty setId omits the set.id clause") {
const auto url =
PokemonCardPreviewSource::buildSearchUrl("Pikachu", "", "25");
CHECK(url.find("set.id") == std::string::npos);
CHECK(url.find("number%3A25") != std::string::npos);
}
}
TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
@@ -97,6 +104,35 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("'data' present but not an array is Transient") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":{}})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("'images' present but not an object is NotFound") {
const auto out =
PokemonCardPreviewSource::parseResponse(R"({"data":[{"images":[]}]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("large unusable type falls back to small string") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({
"data":[{"images":{"large":123,"small":"https://only.small/img.png"}}]
})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://only.small/img.png");
}
TEST_CASE("no usable large or small string yields NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({
"data":[{"images":{"large":null,"small":false}}]
})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("entry without images is classified as NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(
R"({"data":[{"name":"Pikachu"}]})");
@@ -134,3 +170,184 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
CHECK(http.lastUrl.find("number%3A25") != std::string::npos);
}
}
namespace {
const char* kCharizardSwsh4 = R"({
"data": [
{
"name": "Charizard",
"number": "25",
"rarity": "Rare",
"set": {
"id": "swsh4",
"name": "Vivid Voltage",
"printedTotal": 185
}
}
]
})";
const char* kMultiVariantPayload = R"({
"data": [
{
"name": "Pikachu",
"number": "25",
"rarity": "Common",
"set": {"id": "base1", "printedTotal": 102}
},
{
"name": "Pikachu",
"number": "58",
"rarity": "Rare",
"set": {"id": "base1", "printedTotal": 102}
},
{
"name": "Pikachu",
"number": "25",
"rarity": "Common",
"set": {"id": "base2", "printedTotal": 64}
}
]
})";
} // namespace
TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") {
TEST_CASE("maps API number into setNo without printedTotal suffix") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Charizard");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25");
CHECK(out.value().front().rarity == "Rare");
}
TEST_CASE("filters by set id and keeps multiple numbers in the same set") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base1", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "25");
CHECK(out.value()[1].setNo == "58");
}
TEST_CASE("wrong set id yields explicit error when name and set are supplied") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "base1", "Charizard");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("wrong card name is filtered out") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Blastoise");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("empty data array yields error") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(R"({"data":[]})", "base1", "Pikachu");
REQUIRE(out.isErr());
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
}
TEST_CASE("name-only payload still filters to requested set id") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base2", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25");
}
TEST_CASE("keeps bare number when printedTotal is zero") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data": [
{
"name": "Promo",
"number": "7",
"rarity": "Promo",
"set": {"id": "promo1", "printedTotal": 0}
}
]
})",
"promo1", "Promo");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "7");
}
}
TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
TEST_CASE("supports auto-detect and returns first print") {
FixedHttpClient http;
http.body = kCharizardSwsh4;
PokemonCardPreviewSource src{http};
CHECK(src.supportsAutoDetectPrint());
const auto first = src.detectFirstPrint("Charizard", "swsh4");
REQUIRE(first.isOk());
CHECK(first.value().setNo == "25");
}
TEST_CASE("uses slim set-scoped search URL without number clause") {
FixedHttpClient http;
http.body = kCharizardSwsh4;
PokemonCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Charizard", "swsh4");
REQUIRE(out.isOk());
CHECK(http.lastUrl.find("number%3A") == std::string::npos);
CHECK(http.lastUrl.find("set.id%3Aswsh4") != std::string::npos);
CHECK(http.lastUrl.find("select=name,number,rarity,set") != std::string::npos);
CHECK(http.lastUrl.find("pageSize=50") != std::string::npos);
}
TEST_CASE("buildDetectSearchUrl requests only parser fields") {
const auto url = PokemonCardPreviewSource::buildDetectSearchUrl("Charizard", "swsh4");
CHECK(url.find("select=name,number,rarity,set") != std::string::npos);
CHECK(url.find("pageSize=50") != std::string::npos);
}
TEST_CASE("retries name-only query when the set-scoped request fails") {
class FallbackHttpClient final : public IHttpClient {
public:
int calls = 0;
Result<std::string> get(std::string_view url) override {
++calls;
if (calls == 1) return Result<std::string>::err("offline");
if (url.find("set.id") != std::string::npos) {
return Result<std::string>::err("unexpected set-scoped retry");
}
return Result<std::string>::ok(kMultiVariantPayload);
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Pikachu", "base1");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(http.calls == 2);
}
TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") {
FixedHttpClient http;
http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})";
PokemonCardPreviewSource src{http};
const auto out = src.detectFirstPrint("Promo", "promo1");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data":[
{"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"},
{"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}}
]
})",
"base1", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "26");
}
}
+34
View File
@@ -39,11 +39,13 @@ class InMemSetRepo final : public ISetRepository {
public:
std::vector<Set> stored;
bool hasStored = false;
bool failSave = false;
Result<std::vector<Set>> load(Game) override {
if (!hasStored) return Result<std::vector<Set>>::err("no cache");
return Result<std::vector<Set>>::ok(stored);
}
Result<void> save(Game, const std::vector<Set>& s) override {
if (failSave) return Result<void>::err("save failed");
stored = s;
hasStored = true;
return Result<void>::ok();
@@ -145,4 +147,36 @@ TEST_SUITE("SetService") {
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
}
TEST_CASE("updateSets propagates repository save failures") {
InMemSetRepo repo;
repo.failSave = true;
SetService svc{repo};
FakeGameModule magic{Game::Magic};
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
svc.registerModule(&magic);
const auto out = svc.updateSets(Game::Magic);
REQUIRE(out.isErr());
CHECK(out.error() == "save failed");
}
TEST_CASE("registering a second module for same game id overwrites previous one") {
InMemSetRepo repo;
SetService svc{repo};
FakeGameModule firstMagic{Game::Magic};
firstMagic.source.result = Result<std::vector<Set>>::ok({{"a", "First", "2000/01/01"}});
FakeGameModule secondMagic{Game::Magic};
secondMagic.source.result = Result<std::vector<Set>>::ok({{"b", "Second", "2001/01/01"}});
svc.registerModule(&firstMagic);
svc.registerModule(&secondMagic);
const auto out = svc.updateSets(Game::Magic);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().id == "b");
CHECK(firstMagic.source.calls == 0);
CHECK(secondMagic.source.calls == 1);
}
}
+205
View File
@@ -0,0 +1,205 @@
#include <doctest/doctest.h>
// StdFileSystem translates IFileSystem onto std::filesystem. Like
// LocalPreviewByteCache tests, these exercise real disk under a dedicated
// temp directory so permission errors and path normalization behave like production.
#include "ccm/infra/StdFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
#include <string>
using namespace ccm;
namespace fs = std::filesystem;
namespace {
struct TempDir {
fs::path path;
TempDir() {
std::random_device rd;
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
path = fs::temp_directory_path() /
(std::string("ccm_std_fs_test_") + std::to_string(stamp) + "_" +
std::to_string(rd()));
std::error_code ec;
fs::create_directories(path, ec);
}
~TempDir() {
std::error_code ec;
fs::remove_all(path, ec);
}
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
};
} // namespace
TEST_SUITE("StdFileSystem") {
TEST_CASE("exists and isDirectory reflect real paths") {
TempDir td;
StdFileSystem fs;
const auto nested = td.path / "a" / "b";
CHECK_FALSE(fs.exists(nested));
REQUIRE(fs.ensureDirectory(nested).isOk());
CHECK(fs.exists(nested));
CHECK(fs.isDirectory(nested));
const auto filePath = td.path / "file.bin";
REQUIRE(fs.writeText(filePath, "x").isOk());
CHECK(fs.exists(filePath));
CHECK_FALSE(fs.isDirectory(filePath));
}
TEST_CASE("ensureDirectory succeeds when directory already exists") {
TempDir td;
StdFileSystem fs;
const auto dir = td.path / "existing";
REQUIRE(fs.ensureDirectory(dir).isOk());
REQUIRE(fs.ensureDirectory(dir).isOk());
CHECK(fs.isDirectory(dir));
}
TEST_CASE("ensureDirectory errors when path is a regular file") {
TempDir td;
StdFileSystem fs;
const auto clash = td.path / "notadir";
REQUIRE(fs.writeText(clash, "block").isOk());
const auto r = fs.ensureDirectory(clash);
REQUIRE(r.isErr());
CHECK(r.error().find("not a directory") != std::string::npos);
}
TEST_CASE("readText round-trips bytes written by writeText") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "sub" / "cfg.json";
const std::string payload(std::string("{\"x\":") + std::string(4, '\0') + "}");
REQUIRE(fs.writeText(p, payload).isOk());
const auto readBack = fs.readText(p);
REQUIRE(readBack.isOk());
CHECK(readBack.value() == payload);
}
TEST_CASE("readText errors when file does not exist") {
TempDir td;
StdFileSystem fs;
const auto missing = td.path / "missing.txt";
const auto r = fs.readText(missing);
REQUIRE(r.isErr());
CHECK(r.error().find("Unable to open") != std::string::npos);
}
TEST_CASE("writeText truncates an existing file") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "t.txt";
REQUIRE(fs.writeText(p, "aaaaaaaaaa").isOk());
REQUIRE(fs.writeText(p, "hi").isOk());
const auto r = fs.readText(p);
REQUIRE(r.isOk());
CHECK(r.value() == "hi");
}
TEST_CASE("writeText/readText work for top-level relative files") {
TempDir td;
StdFileSystem fs;
const auto oldCwd = fs::current_path();
fs::current_path(td.path);
const fs::path topLevel = "top-level.txt";
REQUIRE(fs.writeText(topLevel, "hello").isOk());
const auto r = fs.readText(topLevel);
REQUIRE(r.isOk());
CHECK(r.value() == "hello");
std::error_code ec;
fs::current_path(oldCwd, ec);
}
TEST_CASE("copyFile copies bytes and respects overwrite flag") {
TempDir td;
StdFileSystem fs;
const auto src = td.path / "src.bin";
const auto dst = td.path / "nested" / "dst.bin";
REQUIRE(fs.writeText(src, "alpha").isOk());
REQUIRE(fs.copyFile(src, dst, /*overwrite=*/false).isOk());
auto rd = fs.readText(dst);
REQUIRE(rd.isOk());
CHECK(rd.value() == "alpha");
REQUIRE(fs.writeText(src, "beta").isOk());
const auto noOverwrite = fs.copyFile(src, dst, /*overwrite=*/false);
REQUIRE(noOverwrite.isErr());
REQUIRE(fs.copyFile(src, dst, /*overwrite=*/true).isOk());
rd = fs.readText(dst);
REQUIRE(rd.isOk());
CHECK(rd.value() == "beta");
}
TEST_CASE("copyFile fails cleanly when source is missing") {
TempDir td;
StdFileSystem fs;
const auto src = td.path / "ghost.dat";
const auto dst = td.path / "out.dat";
const auto r = fs.copyFile(src, dst, /*overwrite=*/false);
REQUIRE(r.isErr());
CHECK(r.error().find("copy_file failed") != std::string::npos);
}
TEST_CASE("remove deletes a file and tolerates repeated removes") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "gone.txt";
REQUIRE(fs.writeText(p, "body").isOk());
REQUIRE(fs.remove(p).isOk());
CHECK_FALSE(fs.exists(p));
REQUIRE(fs.remove(p).isOk());
}
TEST_CASE("listDirectory errors when path is not a directory") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "single.dat";
REQUIRE(fs.writeText(p, "").isOk());
const auto r = fs.listDirectory(p);
REQUIRE(r.isErr());
CHECK(r.error().find("Not a directory") != std::string::npos);
}
TEST_CASE("listDirectory returns entries for an empty and populated folder") {
TempDir td;
StdFileSystem fs;
const auto dir = td.path / "list_me";
REQUIRE(fs.ensureDirectory(dir).isOk());
auto empty = fs.listDirectory(dir);
REQUIRE(empty.isOk());
CHECK(empty.value().empty());
REQUIRE(fs.writeText(dir / "a.txt", "a").isOk());
REQUIRE(fs.writeText(dir / "b.txt", "b").isOk());
auto filled = fs.listDirectory(dir);
REQUIRE(filled.isOk());
CHECK(filled.value().size() == 2u);
}
}
+317
View File
@@ -54,6 +54,23 @@ public:
}
};
// First GET (filtered `cardset=` URL) fails; second GET (unfiltered) succeeds.
// Exercises `YuGiOhCardPreviewSource::detectPrintVariants` narrow-query fallback.
class FailFilteredThenOkHttpClient final : public IHttpClient {
public:
std::string unfilteredBody;
int calls{0};
Result<std::string> get(std::string_view url) override {
++calls;
std::string u(url);
if (u.find("cardset=") != std::string::npos) {
return Result<std::string>::err("filtered endpoint unavailable");
}
return Result<std::string>::ok(unfilteredBody);
}
};
} // namespace
TEST_SUITE("ygoPrintingSlotsMatch") {
@@ -75,6 +92,50 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-E"));
}
}
TEST_SUITE("YuGiOhPrintingSlot helpers") {
TEST_CASE("trimAsciiSpaces handles empty and surrounding whitespace") {
CHECK(trimAsciiSpaces("").empty());
CHECK(trimAsciiSpaces(" ").empty());
CHECK(trimAsciiSpaces(" LOB-005 ") == "LOB-005");
}
TEST_CASE("ygoAbbrevBeforeDash and ygoCollectorDigitsOnly cover no-dash and mixed tails") {
CHECK(ygoAbbrevBeforeDash("lob") == "lob");
CHECK(ygoAbbrevBeforeDash(" SOD-015 ") == "sod");
CHECK(ygoCollectorDigitsOnly("SOD").empty());
CHECK(ygoCollectorDigitsOnly("SOD-EN015") == "015");
CHECK(ygoCollectorDigitsOnly("SOD-ABC") == "");
}
}
TEST_SUITE("ygoRarityShortCode") {
TEST_CASE("maps supported Yu-Gi-Oh rarity names to short form") {
CHECK(ygoRarityShortCode("Common") == "C");
CHECK(ygoRarityShortCode("Rare") == "R");
CHECK(ygoRarityShortCode("Super Rare") == "SR");
CHECK(ygoRarityShortCode("Ultra Rare") == "UR");
CHECK(ygoRarityShortCode("Secret Rare") == "ScR");
CHECK(ygoRarityShortCode("Quarter Century Secret Rare") == "QCScR");
CHECK(ygoRarityShortCode("Starlight Rare") == "StR");
CHECK(ygoRarityShortCode("Collector's Rare") == "CR");
CHECK(ygoRarityShortCode("Ghost Rare") == "GR");
CHECK(ygoRarityShortCode("Ultimate Rare") == "UtR");
CHECK(ygoRarityShortCode("Platinum Secret Rare") == "PlScR");
CHECK(ygoRarityShortCode("Prismatic Secret Rare") == "PScR");
}
TEST_CASE("normalizes punctuation and spacing and accepts QCSR alias") {
CHECK(ygoRarityShortCode("Ultra-Rare") == "UR");
CHECK(ygoRarityShortCode("Collector`s Rare") == "CR");
CHECK(ygoRarityShortCode("QCSR") == "QCScR");
}
TEST_CASE("unknown rarity returns empty") {
CHECK(ygoRarityShortCode("Mythic Cosmic Rare").empty());
}
}
@@ -101,6 +162,8 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
== "QCScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Collector's Rare") == "CR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Platinum Secret Rare") == "PlScR");
}
TEST_CASE("returns empty string for unknown rarity names") {
// Unknown rarity should fall through to the rarity-less filename
@@ -231,6 +294,117 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("missing top-level query object is Transient") {
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
R"({"not_query":{}})", {"File.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("query.pages not an object is Transient") {
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
R"({"query":{"pages":[]}})", {"X.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("page without imageinfo is treated as missing") {
const std::string body = R"({
"query":{"pages":{
"1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png"}
}}
})";
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
body, {"DarkMagician-LOB-EN-UR-UE.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::buildSearchUrl") {
TEST_CASE("percent-encodes name and optional set name filter") {
const auto url = YuGiOhCardPreviewSource::buildSearchUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon");
CHECK(url.find("https://db.ygoprodeck.com/api/v7/cardinfo.php") == 0);
CHECK(url.find("fname=Dark%20Magician") != std::string::npos);
CHECK(url.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon") != std::string::npos);
}
TEST_CASE("omits cardset when set name is empty") {
const auto url = YuGiOhCardPreviewSource::buildSearchUrl("Dark Magician", "");
CHECK(url.find("cardset=") == std::string::npos);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parseFallbackImageUrl") {
TEST_CASE("missing data array is Transient") {
const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"meta":{}})", "Dark Magician");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("data present but not an array is Transient") {
const auto out =
YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"data":{}})", "X");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("empty data array is NotFound") {
const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"data":[]})", "X");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("prefers exact-name row with image_url_small when image_url absent") {
const std::string json = R"({"data":[
{"name":"Dark Magician Girl","card_images":[{"image_url_small":"https://small.only/a.jpg"}]},
{"name":"Dark Magician","card_images":[{"image_url":"https://ignored/wrong.jpg"}]}
]})";
const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Dark Magician Girl");
REQUIRE(out.isOk());
CHECK(out.value() == "https://small.only/a.jpg");
}
TEST_CASE("exact-name match uses image_url_cropped when earlier slots absent") {
const std::string json = R"({"data":[{
"name":"Slifer",
"card_images":[{"image_url_cropped":"https://crop/z.jpg"}]
}]})";
const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Slifer");
REQUIRE(out.isOk());
CHECK(out.value() == "https://crop/z.jpg");
}
TEST_CASE("no exact name match falls back to first ranked card_images row") {
const std::string json = R"({"data":[{
"name":"Dark Magician Girl",
"card_images":[{"image_url":"https://images/std-from-ranked-first.jpg"}]
}]})";
const auto out =
YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Dark Magician");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images/std-from-ranked-first.jpg");
}
TEST_CASE("matching cards without usable images is NotFound") {
const std::string json = R"({"data":[{
"name":"Empty Card",
"card_images":[{}]
}]})";
const auto out =
YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Empty Card");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("malformed JSON is Transient") {
const auto out =
YuGiOhCardPreviewSource::parseFallbackImageUrl("{not json", "Any");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
@@ -249,6 +423,18 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
CHECK(out.value().setNo == "LOB-001");
CHECK(out.value().rarity == "Ultra Rare");
}
TEST_CASE("empty data array yields error") {
const auto out =
YuGiOhCardPreviewSource::parseFirstPrint(R"({"data":[]})", "Any Set");
CHECK(out.isErr());
}
TEST_CASE("card row without card_sets yields error") {
const auto out = YuGiOhCardPreviewSource::parseFirstPrint(
R"({"data":[{"name":"Solo"}]})", "Any Display Set");
CHECK(out.isErr());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
@@ -307,6 +493,70 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
CHECK(out.value()[0].setNo == "SDY-043");
CHECK(out.value()[0].rarity == "Super Rare");
}
TEST_CASE("malformed JSON surfaces as parse error") {
const auto out =
YuGiOhCardPreviewSource::parsePrintVariants("{bad json", "Mega Pack", "X");
REQUIRE(out.isErr());
CHECK(out.error().find("YGOPRODeck JSON parse error") != std::string::npos);
}
TEST_CASE("maps 25th Anniversary display-set alias to original set name") {
const std::string json = R"({
"data":[
{"name":"Dark Magician",
"card_sets":[
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "Dark Magician");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "LOB-005");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::detectPrintVariants HTTP fallback") {
TEST_CASE("retries without cardset when the filtered request fails") {
FailFilteredThenOkHttpClient http;
http.unfilteredBody = R"({
"data":[{
"name":"Test Goblin",
"card_sets":[
{"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"}
]
}]
})";
YuGiOhCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Test Goblin", "Mega Pack");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "MP21-EN001");
REQUIRE(http.calls == 2);
}
TEST_CASE("uses original set name in cardset query for 25th alias") {
FixedHttpClient http;
http.body = R"({
"data":[{
"name":"Dark Magician",
"card_sets":[
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
]
}]
})";
YuGiOhCardPreviewSource src{http};
const auto out = src.detectPrintVariants(
"Dark Magician", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)");
REQUIRE(out.isOk());
CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon")
!= std::string::npos);
CHECK(http.lastUrl.find("25th") == std::string::npos);
}
}
// Helpers aligned with external fixture `yugioh_same_card_set_variant_tests`
@@ -482,6 +732,12 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_var
}
TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
TEST_CASE("supports auto-detect print metadata") {
FixedHttpClient http;
YuGiOhCardPreviewSource src{http};
CHECK(src.supportsAutoDetectPrint());
}
TEST_CASE("queries Yugipedia first and uses the per-printing scan when found") {
// Two same-passcode reprints with genuinely different art (LOB vs
// SDK Blue-Eyes). Yugipedia hosts both, so we should always pick the
@@ -594,6 +850,34 @@ TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("Yugipedia clean-miss + YGOPRODeck transient is overall Transient") {
RoutingHttpClient http;
http.yugipediaBody = R"({"query":{"pages":{
"-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""}
}}})";
http.ygoprodeckOk = false;
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"No Such Card", "Legend of Blue Eyes White Dragon", "LOB-999||Ultra Rare||UE");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("tuple-style setNo parsing trims fields and supports empty set code") {
FixedHttpClient http;
http.ok = true;
http.body = R"({"data":[{"name":"Dark Magician",
"card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})";
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon", " || Ultra Rare || 1E ");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos);
}
TEST_CASE("skips Yugipedia entirely when the set code is missing") {
// Without a set code we can't construct any candidate filename - go
// straight to the YGOPRODeck fallback to avoid wasting an HTTP call.
@@ -611,3 +895,36 @@ TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
CHECK(http.lastUrl.find("yugipedia.com") == std::string::npos);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") {
TEST_CASE("returns first variant from filtered request") {
FixedHttpClient http;
http.body = R"({
"data":[
{"name":"Dark Magician",
"card_sets":[
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
]}
]
})";
YuGiOhCardPreviewSource src{http};
const auto out = src.detectFirstPrint(
"Dark Magician", "Legend of Blue Eyes White Dragon");
REQUIRE(out.isOk());
CHECK(out.value().setNo == "LOB-005");
CHECK(out.value().rarity == "Ultra Rare");
CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon")
!= std::string::npos);
}
TEST_CASE("propagates unfiltered fallback errors when both requests fail") {
FixedHttpClient http;
http.ok = false;
YuGiOhCardPreviewSource src{http};
const auto out = src.detectFirstPrint("Any", "Any Set");
REQUIRE(out.isErr());
CHECK(out.error() == "offline");
}
}
+115 -3
View File
@@ -29,9 +29,18 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].id == "AAA");
CHECK(out.value()[0].releaseDate == "2020/01/01");
bool foundA = false;
bool foundB = false;
for (const auto& set : out.value()) {
if (set.id == "AAA" && set.name == "Set A" && set.releaseDate == "2020/01/01") {
foundA = true;
}
if (set.id == "BBB" && set.name == "Set B" && set.releaseDate == "2021/02/03") {
foundB = true;
}
}
CHECK(foundA);
CHECK(foundB);
}
TEST_CASE("sorts by release date ascending") {
@@ -47,6 +56,100 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
TEST_CASE("missing array returns error") {
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
}
TEST_CASE("empty upstream array still appends missing 25th aliases") {
const auto out = YuGiOhSetSource::parseResponse("[]");
REQUIRE(out.isOk());
CHECK(out.value().size() == 6);
bool foundLob25th = false;
bool foundIoc25th = false;
for (const auto& set : out.value()) {
if (set.id == "LOB-25TH") foundLob25th = true;
if (set.id == "IOC-25TH") foundIoc25th = true;
}
CHECK(foundLob25th);
CHECK(foundIoc25th);
}
TEST_CASE("adds 25th Anniversary aliases when upstream list misses them") {
const auto out = YuGiOhSetSource::parseResponse(R"([
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}
])");
REQUIRE(out.isOk());
bool foundLob25th = false;
bool foundIoc25th = false;
for (const auto& set : out.value()) {
if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)"
&& set.id == "LOB-25TH") {
foundLob25th = true;
}
if (set.name == "Invasion of Chaos (25th Anniversary Edition)" && set.id == "IOC-25TH") {
foundIoc25th = true;
}
}
CHECK(foundLob25th);
CHECK(foundIoc25th);
}
TEST_CASE("does not duplicate aliases that already exist by name") {
const auto out = YuGiOhSetSource::parseResponse(R"json([
{"set_name":"Legend of Blue Eyes White Dragon (25th Anniversary Edition)","set_code":"LOB-25TH","tcg_date":"2023-04-20"}
])json");
REQUIRE(out.isOk());
int aliasCount = 0;
for (const auto& set : out.value()) {
if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)") {
++aliasCount;
}
}
CHECK(aliasCount == 1);
}
TEST_CASE("malformed json returns parse error") {
const auto out = YuGiOhSetSource::parseResponse("{bad json");
REQUIRE(out.isErr());
CHECK(out.error().find("YGOPRODeck set parse error:") != std::string::npos);
}
TEST_CASE("missing fields fall back to empty strings and keep parsing") {
const std::string json = R"([
{"set_name":"Set A"},
{"set_code":"BBB","tcg_date":"2021-02-03"}
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
bool foundMissingCode = false;
bool foundMissingName = false;
for (const auto& set : out.value()) {
if (set.name == "Set A" && set.id.empty() && set.releaseDate.empty()) {
foundMissingCode = true;
}
if (set.id == "BBB" && set.name.empty() && set.releaseDate == "2021/02/03") {
foundMissingName = true;
}
}
CHECK(foundMissingCode);
CHECK(foundMissingName);
}
TEST_CASE("preserves slash-formatted dates and normalizes hyphen dates") {
const std::string json = R"([
{"set_name":"Slash Date","set_code":"S","tcg_date":"2024/01/01"},
{"set_name":"Hyphen Date","set_code":"H","tcg_date":"2024-01-02"}
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
bool sawSlash = false;
bool sawHyphenNormalized = false;
for (const auto& set : out.value()) {
if (set.id == "S" && set.releaseDate == "2024/01/01") sawSlash = true;
if (set.id == "H" && set.releaseDate == "2024/01/02") sawHyphenNormalized = true;
}
CHECK(sawSlash);
CHECK(sawHyphenNormalized);
}
}
TEST_SUITE("YuGiOhSetSource::fetchAll") {
@@ -59,4 +162,13 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") {
CHECK(out.value().front().id == "X");
CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php");
}
TEST_CASE("network error is propagated") {
FixedHttpClient http;
http.ok = false;
YuGiOhSetSource src{http};
const auto out = src.fetchAll();
REQUIRE(out.isErr());
CHECK(out.error() == "offline");
}
}
+4 -1
View File
@@ -352,11 +352,14 @@ private:
failed.reserve(static_cast<std::size_t>(paths.size()));
for (const auto& path : paths) {
const std::string setNameForImage = (game_ == Game::YuGiOh && !card_.set.id.empty())
? card_.set.id
: card_.set.name;
auto added = imageService_.addImage(game_,
std::filesystem::path(path.ToStdString()),
mode_ == EditMode::Create,
card_.id,
card_.set.name,
setNameForImage,
card_.name,
card_.images);
if (!added) {
+52 -5
View File
@@ -7,7 +7,15 @@
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include <wx/button.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
@@ -16,9 +24,11 @@ public:
PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets = nullptr);
~PokemonCardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
@@ -26,13 +36,50 @@ protected:
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
void onCardLookupContextChanged() override;
private:
wxTextCtrl* setNoCtrl_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void autoDetectFromApi();
void clearCachedPrintVariants();
void requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
void refreshVariantNextControls();
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
[[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl);
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetNos_;
std::size_t setNoRingPos_{0};
};
} // namespace ccm::ui
+210 -5
View File
@@ -1,18 +1,41 @@
#include "ccm/ui/PokemonCardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <thread>
#include <unordered_set>
namespace ccm::ui {
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets)
: BaseCardEditDialog<PokemonCard>(
parent,
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets) {
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets),
dialogMode_(mode),
cardPreview_(cardPreview),
variantFetchState_(std::make_shared<VariantFetchState>()) {
buildAndPopulate();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
}
PokemonCardEditDialog::~PokemonCardEditDialog() {
if (variantFetchState_) {
variantFetchState_->alive.store(false);
}
}
void PokemonCardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
@@ -27,12 +50,46 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
}
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
setNoCtrl_ = new wxTextCtrl(this, wxID_ANY, constCard().setNo);
appendRow(grid, "Set #", setNoCtrl_);
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
autoSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onAutoDetectSetNo, this);
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
setNoPanel->SetSizer(setNoRow);
appendRow(grid, "Set #", setNoPanel);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this);
}
}
std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
std::string out(setNo);
const auto slash = out.find('/');
if (slash != std::string::npos) {
out.resize(slash);
}
return out;
}
std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
if (ctrl == nullptr) return {};
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
}
void PokemonCardEditDialog::readExtraFromCard() {
if (setNoCtrl_) setNoCtrl_->ChangeValue(constCard().setNo);
clearCachedPrintVariants();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
}
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
@@ -40,11 +97,159 @@ void PokemonCardEditDialog::readExtraFromCard() {
}
void PokemonCardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString();
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
}
void PokemonCardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
cachedVariants_.clear();
uniqueSetNos_.clear();
setNoRingPos_ = 0;
refreshVariantNextControls();
}
void PokemonCardEditDialog::scheduleDeferredVariantPrefetch() {
const unsigned epoch = variantFetchEpoch_;
wxTheApp->CallAfter([this, epoch]() {
prefetchVariantsForCurrentCardSilent(epoch);
});
}
void PokemonCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
if (capturedEpoch != variantFetchEpoch_) return;
if (!cachedVariants_.empty()) return;
const auto& card = constCard();
if (card.name.empty() || card.set.id.empty()) return;
requestVariantsAsync(capturedEpoch, card.name, card.set.id, false, false);
}
void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Disable();
}
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
PokemonCardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog]() {
auto detected = svc->detectPrintVariants(Game::Pokemon, name, setId);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
});
}).detach();
}
void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
if (!detected) {
if (showFailureDialog) {
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
wxOK | wxICON_WARNING);
}
return;
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
}
rebuildVariantRingFromCache();
syncRingPositionToControls();
refreshVariantNextControls();
}
void PokemonCardEditDialog::rebuildVariantRingFromCache() {
uniqueSetNos_.clear();
if (cachedVariants_.empty()) return;
std::unordered_set<std::string> seen;
seen.reserve(cachedVariants_.size());
for (const auto& p : cachedVariants_) {
if (p.setNo.empty()) continue;
if (!seen.insert(p.setNo).second) continue;
uniqueSetNos_.push_back(p.setNo);
}
}
void PokemonCardEditDialog::syncRingPositionToControls() {
if (!setNoCtrl_) return;
const std::string current = storedSetNoFromControls(setNoCtrl_);
setNoRingPos_ = 0;
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
if (uniqueSetNos_[i] == current) {
setNoRingPos_ = i;
break;
}
}
}
void PokemonCardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_) return;
nextSetNoBtn_->Show(uniqueSetNos_.size() > 1);
Layout();
if (GetSizer()) Fit();
}
void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
autoDetectFromApi();
}
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
if (uniqueSetNos_.size() <= 1) return;
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
}
refreshVariantNextControls();
}
void PokemonCardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.id.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.id, true, true);
}
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
} // namespace ccm::ui
+2 -2
View File
@@ -90,7 +90,7 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
fresh.language = Language::English;
fresh.condition = Condition::NearMint;
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
&setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
if (dlg.ShowModal() != wxID_OK) return;
@@ -129,7 +129,7 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
return;
}
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
&setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
if (dlg.ShowModal() != wxID_OK) return;
+12 -9
View File
@@ -2,6 +2,7 @@
#include "ccm/services/CardFilter.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <string>
@@ -15,12 +16,13 @@ YuGiOhCardListPanel::YuGiOhCardListPanel(wxWindow* parent)
std::vector<YuGiOhCardListPanel::TextColumnSpec>
YuGiOhCardListPanel::declareTextColumns() const {
return {
{"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name},
{"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate},
{"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount},
{"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition},
{"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Language},
{"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Note},
{"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name},
{"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate},
{"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount},
{"Rarity", 90, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Rarity},
{"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition},
{"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 1: return card.set.name;
case 2: return std::to_string(card.amount);
case 3: return std::string(to_string(card.condition));
case 4: return std::string(to_string(card.language));
case 5: return card.note;
case 3: return ygoRarityShortCode(card.rarity);
case 4: return std::string(to_string(card.condition));
case 5: return std::string(to_string(card.language));
case 6: return card.note;
}
return {};
}
+4 -1
View File
@@ -113,8 +113,11 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
YuGiOhCard persisted = dlg.card();
persisted.id = added.value();
const std::string setNameForImage = persisted.set.id.empty()
? persisted.set.name
: persisted.set.id;
auto normalized = images_.normalizeNamesForPersistedCard(
Game::YuGiOh, persisted.id, persisted.set.name, persisted.name, persisted.images);
Game::YuGiOh, persisted.id, setNameForImage, persisted.name, persisted.images);
if (normalized) {
if (normalized.value() != persisted.images) {
persisted.images = std::move(normalized).value();