mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 20:01:13 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a50e8daba | |||
| 98f2575b5a | |||
| d6c7f60aee | |||
| 7935f2b18e | |||
| 5805101d24 | |||
| 8b7d45fdac | |||
| c1d42bdadd |
@@ -38,6 +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`.
|
||||
- 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`
|
||||
|
||||
@@ -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
|
||||
@@ -69,6 +70,8 @@ jobs:
|
||||
-Dsonar.sources=core,ui_wx,app
|
||||
-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
|
||||
|
||||
@@ -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
|
||||
@@ -69,6 +70,8 @@ jobs:
|
||||
-Dsonar.sources=core,ui_wx,app
|
||||
-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
|
||||
|
||||
@@ -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: **180 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.
|
||||
>
|
||||
@@ -96,11 +104,19 @@ Run from the **workspace root**.
|
||||
- After modifying a domain type's fields or JSON layout you **must** update the matching round-trip test in `tests/domain_json_tests.cpp` and re-run tests.
|
||||
- After 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.
|
||||
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
|
||||
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
|
||||
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high. Exercise both outcomes of meaningful conditionals (success vs error, empty vs non-empty, cache hit vs miss, `NotFound` vs `Transient`, early return vs fall-through), not only the happy path — Sonar condition coverage on `core/` is a separate signal from line coverage.
|
||||
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR. When checking coverage locally, also review branch/condition metrics (for example `gcovr ... --txt-metric branch` or Sonar's condition coverage on the same `core/` surface); there is no repo-wide condition threshold in CI yet — use Sonar's per-file condition list to prioritize gaps.
|
||||
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
|
||||
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
|
||||
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
|
||||
|
||||
## Agent collaboration (Cursor / AI)
|
||||
|
||||
- **Never** `git commit` or `git push` unless the user **explicitly** asked you to commit and/or push (e.g. “commit this”, “push to origin”). Preparing diffs and suggesting commands is fine; performing those Git writes without explicit instruction is not.
|
||||
- **Never** check out another branch **to change it** unless the user **explicitly** asked you to work on that branch. Temporarily checking out another branch **read-only** (inspect history, compare files, run `git show`) is fine without asking; switch back to the working branch before making edits unless instructed otherwise.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build).
|
||||
|
||||
@@ -20,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_;
|
||||
};
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ enum class YuGiOhSortColumn {
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Rarity,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 picker’s **`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 card’s `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 Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<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 Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<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.
|
||||
|
||||
@@ -17,6 +17,10 @@ The repository uses GitHub Actions workflows split by branch intent, with one or
|
||||
- `master-ci.yml`: single workflow run on merged PRs to `master`; computes semver, invokes Windows reusable build, then tags/publishes release assets.
|
||||
- `master-windows.yml`: reusable Windows build/test/package workflow invoked by `master-ci.yml`.
|
||||
|
||||
### SonarQube Cloud (coverage quality gate)
|
||||
|
||||
Both `feature-ci.yml` and `master-ci.yml` include a Linux job that configures with GCC coverage flags, builds, runs `ctest`, generates `build/sonarqube-coverage.xml` via `gcovr`, and runs the SonarCloud scan. **`sonar.coverage.exclusions`** omit `ui_wx/` and `app/` from the coverage denominator because only `ccm_core` is exercised by automated tests. The scan also sets **`sonar.cpd.exclusions`** for the repeated per-game wx scaffolding files (`*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so intentional parallel UI implementations do not drive the duplication gate. See [Testing Guide And Test Code Of Conduct](testing-and-test-code-of-conduct.md).
|
||||
|
||||
## Version Flow
|
||||
|
||||
Feature branches and `master` use different version modes because they solve different problems: feature builds need traceability to a commit, while `master` builds need stable semantic releases.
|
||||
|
||||
@@ -30,6 +30,8 @@ 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 Sonar’s **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:
|
||||
|
||||
- filesystem naming and parsing behavior
|
||||
|
||||
+6
-3
@@ -12,15 +12,18 @@
|
||||
- `collection_service_tests.cpp` — `CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
|
||||
- `config_service_tests.cpp` — `ConfigService` against `InMemoryFileSystem`.
|
||||
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`.
|
||||
- `local_image_store_tests.cpp` — `LocalImageStore` against `InMemoryFileSystem` + `ConfigService`: `copyIn` (extension preserved, missing source errors), `remove` (existing file deleted; absent path is a no-op), `resolvePath` layout under `dataStorage/<game>/images/`.
|
||||
- `set_service_tests.cpp` — `SetService` with `FakeSetSource` + `InMemSetRepo`.
|
||||
- `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).
|
||||
@@ -28,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".
|
||||
|
||||
@@ -12,17 +12,24 @@ add_executable(ccm_core_tests
|
||||
config_service_tests.cpp
|
||||
json_collection_repository_tests.cpp
|
||||
json_set_repository_tests.cpp
|
||||
local_image_store_tests.cpp
|
||||
set_service_tests.cpp
|
||||
magic_set_source_tests.cpp
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/infra/LocalImageStore.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
using namespace ccm;
|
||||
using ccm::testing::InMemoryFileSystem;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string dirNameForGame(Game g) {
|
||||
switch (g) {
|
||||
case Game::Magic: return "magic";
|
||||
case Game::Pokemon: return "pokemon";
|
||||
case Game::YuGiOh: return "yugioh";
|
||||
}
|
||||
return "magic";
|
||||
}
|
||||
|
||||
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") {
|
||||
TEST_CASE("copyIn creates images directory and preserves source extension") {
|
||||
InMemoryFileSystem mem;
|
||||
ConfigService cfg{mem, "/app/config.json", "/coll"};
|
||||
REQUIRE(cfg.initialize().isOk());
|
||||
LocalImageStore store(mem, cfg, dirNameForGame);
|
||||
|
||||
REQUIRE(mem.writeText("/incoming/card.PNG", "img-bytes").isOk());
|
||||
|
||||
auto r = store.copyIn(Game::Magic, "/incoming/card.PNG", "id001");
|
||||
REQUIRE(r.isOk());
|
||||
CHECK(r.value() == "id001.PNG");
|
||||
|
||||
const std::filesystem::path dest =
|
||||
std::filesystem::path(cfg.current().dataStorage) / "magic" / "images" / "id001.PNG";
|
||||
REQUIRE(mem.exists(dest));
|
||||
auto body = mem.readText(dest);
|
||||
REQUIRE(body.isOk());
|
||||
CHECK(body.value() == "img-bytes");
|
||||
}
|
||||
|
||||
TEST_CASE("copyIn errors when source file is missing") {
|
||||
InMemoryFileSystem mem;
|
||||
ConfigService cfg{mem, "/app/config.json", "/coll"};
|
||||
REQUIRE(cfg.initialize().isOk());
|
||||
LocalImageStore store(mem, cfg, dirNameForGame);
|
||||
|
||||
auto r = store.copyIn(Game::Pokemon, "/nope/missing.jpg", "x");
|
||||
REQUIRE(r.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("remove deletes an existing image") {
|
||||
InMemoryFileSystem mem;
|
||||
ConfigService cfg{mem, "/app/config.json", "/coll"};
|
||||
REQUIRE(cfg.initialize().isOk());
|
||||
LocalImageStore store(mem, cfg, dirNameForGame);
|
||||
|
||||
const std::filesystem::path imagePath =
|
||||
std::filesystem::path(cfg.current().dataStorage) / "magic" / "images" / "a.png";
|
||||
REQUIRE(mem.ensureDirectory(imagePath.parent_path()).isOk());
|
||||
REQUIRE(mem.writeText(imagePath, "x").isOk());
|
||||
|
||||
REQUIRE(store.remove(Game::Magic, "a.png").isOk());
|
||||
CHECK_FALSE(mem.exists(imagePath));
|
||||
}
|
||||
|
||||
TEST_CASE("remove succeeds when file is already absent") {
|
||||
InMemoryFileSystem mem;
|
||||
ConfigService cfg{mem, "/app/config.json", "/coll"};
|
||||
REQUIRE(cfg.initialize().isOk());
|
||||
LocalImageStore store(mem, cfg, dirNameForGame);
|
||||
|
||||
REQUIRE(store.remove(Game::YuGiOh, "ghost.bin").isOk());
|
||||
}
|
||||
|
||||
TEST_CASE("resolvePath joins data storage game images and filename") {
|
||||
InMemoryFileSystem mem;
|
||||
ConfigService cfg{mem, "/app/config.json", "/coll"};
|
||||
REQUIRE(cfg.initialize().isOk());
|
||||
LocalImageStore store(mem, cfg, dirNameForGame);
|
||||
|
||||
const std::filesystem::path got = store.resolvePath(Game::Pokemon, "pic.jpg");
|
||||
CHECK(got.generic_string() == "/coll/pokemon/images/pic.jpg");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
- Apply this rule consistently in shared templates (`BaseCardListPanel`, `BaseSelectedCardPanel`, `BaseCardEditDialog`) because a single implicit conversion in those bases affects every game view.
|
||||
14. **Theme consistency rules (Windows):**
|
||||
- Treat dialog roots as `panelBg`, not a separate shade, otherwise label rows can look like mismatched darker boxes.
|
||||
- Theme dialogs before `ShowModal()` with `applyThemeToWindowTree(...)`; this includes Settings, Create/Edit dialogs, image viewer, About, and custom popup dialogs.
|
||||
- Theme dialogs before `ShowModal()` with `applyThemeToWindowTree(...)` (and root background/foreground colors as needed); this includes Settings, image viewer, About, and custom popup dialogs. Per-game **Add/Edit** flows use `themeModalDialog(wxDialog*, Theme)` from `Theme.hpp` so `MagicGameView` / `PokemonGameView` / `YuGiOhGameView` share one path instead of duplicating palette wiring.
|
||||
- Do not use native `wxMessageBox` / `wxAboutBox` for app-facing flows that must match dark mode. Use themed popup helpers (or a custom themed `wxDialog`) so body/buttons stay in sync with the app palette.
|
||||
- Center popup dialogs on the app window (`CentreOnParent()`) so confirmations/info boxes open relative to the current app window.
|
||||
- Include `wxSpinCtrl` in themed input controls (Amount field) or it will keep a mismatched native background.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <wx/colour.h>
|
||||
|
||||
class wxDialog;
|
||||
class wxWindow;
|
||||
class wxString;
|
||||
|
||||
@@ -22,6 +23,7 @@ struct ThemePalette {
|
||||
ThemePalette paletteForTheme(Theme theme);
|
||||
Theme inferThemeFromWindow(const wxWindow* window);
|
||||
void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme);
|
||||
void themeModalDialog(wxDialog* dlg, Theme theme);
|
||||
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style);
|
||||
int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "ccm/ui/MagicCardEditDialog.hpp"
|
||||
#include "ccm/ui/MagicCardListPanel.hpp"
|
||||
#include "ccm/ui/MagicSelectedCardPanel.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
|
||||
@@ -94,13 +95,7 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) {
|
||||
|
||||
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::Magic, dlg.card());
|
||||
@@ -139,13 +134,7 @@ void MagicGameView::onEditCard(wxWindow* parentWindow) {
|
||||
}
|
||||
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::Magic, dlg.card());
|
||||
if (!updated) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "ccm/ui/PokemonCardEditDialog.hpp"
|
||||
#include "ccm/ui/PokemonCardListPanel.hpp"
|
||||
#include "ccm/ui/PokemonSelectedCardPanel.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
|
||||
@@ -89,15 +90,9 @@ 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());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::Pokemon, dlg.card());
|
||||
@@ -134,15 +129,9 @@ 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());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::Pokemon, dlg.card());
|
||||
if (!updated) {
|
||||
|
||||
@@ -609,6 +609,14 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
|
||||
}
|
||||
}
|
||||
|
||||
void themeModalDialog(wxDialog* dlg, Theme theme) {
|
||||
if (dlg == nullptr) return;
|
||||
const ThemePalette palette = paletteForTheme(theme);
|
||||
applyThemeToWindowTree(dlg, palette, theme);
|
||||
dlg->SetBackgroundColour(palette.panelBg);
|
||||
dlg->SetForegroundColour(palette.text);
|
||||
}
|
||||
|
||||
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style) {
|
||||
wxDialog dlg(parent, wxID_ANY, caption, wxDefaultPosition, wxDefaultSize,
|
||||
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "ccm/ui/YuGiOhCardEditDialog.hpp"
|
||||
#include "ccm/ui/YuGiOhCardListPanel.hpp"
|
||||
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
|
||||
@@ -100,13 +101,7 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
|
||||
|
||||
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::YuGiOh, dlg.card());
|
||||
@@ -118,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();
|
||||
@@ -145,13 +143,7 @@ void YuGiOhGameView::onEditCard(wxWindow* parentWindow) {
|
||||
}
|
||||
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::YuGiOh, dlg.card());
|
||||
if (!updated) {
|
||||
|
||||
Reference in New Issue
Block a user