Compare commits

..

2 Commits

Author SHA1 Message Date
Sebastian Dine 8a50e8daba patch: Feature/pkm autodetect (#15) 2026-05-12 15:49:37 +02:00
Sebastian Dine 98f2575b5a patch: Patch/ygo 25th set 2 (#14) 2026-05-11 11:27:32 +02:00
33 changed files with 1664 additions and 24 deletions
+2 -2
View File
@@ -106,8 +106,8 @@ Run from the **workspace root**.
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high.
- 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.
- 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`.
@@ -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_;
};
@@ -6,6 +6,8 @@
#include <cctype>
#include <string>
#include <unordered_set>
#include <vector>
namespace ccm {
@@ -23,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) {}
@@ -48,6 +63,14 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
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>
PokemonCardPreviewSource::parseResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
@@ -91,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
@@ -31,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
@@ -366,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;
@@ -532,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
@@ -16,6 +16,7 @@ struct YuGiOhSetAlias {
};
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"},
+9 -3
View File
@@ -22,9 +22,13 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
Used by `PokemonCardPreviewSource` in two ways:
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the cards `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
@@ -39,6 +43,8 @@ Upstream documentation:
`https://db.ygoprodeck.com/api/v7/cardsets.php`
Used by `YuGiOhSetSource`. The response is a top-level JSON array. Each object maps `set_code` → internal `Set.id`, `set_name``Set.name`, and `tcg_date``Set.releaseDate` with `-` rewritten to `/` for consistency with other games date strings. Results are sorted ascending by `releaseDate`.
CCM3 also applies a deterministic local patch step in `YuGiOhSetSource::appendMissingSetAliases(...)` after parsing: if upstream omits known 25th Anniversary TCG reprints, the app injects missing aliases for `LOB-25TH`, `MRD-25TH`, `SRL-25TH`, `PSV-25TH`, `DCR-25TH`, and `IOC-25TH` (with fixed release dates) so users can still select those products in the set picker.
### Asset API: Yugipedia `api.php` (primary)
`https://yugipedia.com/api.php?action=query&prop=imageinfo&iiprop=url&titles=...`
@@ -112,6 +118,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
+1
View File
@@ -27,6 +27,7 @@ add_executable(ccm_core_tests
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
+20
View File
@@ -0,0 +1,20 @@
#include <doctest/doctest.h>
#include "ccm/util/AsciiUtils.hpp"
using namespace ccm;
TEST_SUITE("asciiLower") {
TEST_CASE("empty string stays empty") {
CHECK(asciiLower("").empty());
}
TEST_CASE("lowercases ASCII letters and leaves other ASCII bytes unchanged") {
CHECK(asciiLower("AbC123!@#") == "abc123!@#");
}
TEST_CASE("non-ASCII UTF-8 bytes pass through unchanged") {
const std::string input = "caf\u00e9";
CHECK(asciiLower(input) == input);
}
}
+55 -1
View File
@@ -57,7 +57,13 @@ YuGiOhCard yc(std::string name,
std::string setName,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "",
bool firstEdition = false,
bool sgnd = false,
bool altered = false) {
YuGiOhCard c;
c.id = 1;
c.name = std::move(name);
@@ -65,6 +71,12 @@ YuGiOhCard yc(std::string name,
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
return c;
}
@@ -176,6 +188,10 @@ 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"));
@@ -183,4 +199,42 @@ TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
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"));
}
}
+83
View File
@@ -334,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
@@ -703,6 +775,17 @@ TEST_SUITE("CardPreviewService caching") {
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";
+11
View File
@@ -430,6 +430,17 @@ TEST_SUITE("CardSorter - YuGiOh columns") {
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),
+43
View File
@@ -59,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;
@@ -163,6 +173,21 @@ TEST_SUITE("CollectionService<MagicCard>") {
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;
@@ -193,4 +218,22 @@ TEST_SUITE("CollectionService<MagicCard>") {
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");
}
}
+53
View File
@@ -79,6 +79,59 @@ TEST_SUITE("CprHttpClient injected raw executor") {
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") {
+159
View File
@@ -235,6 +235,103 @@ TEST_SUITE("YuGiOhCard JSON") {
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") {
@@ -290,6 +387,59 @@ TEST_SUITE("Domain JSON required fields") {
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"},
@@ -297,4 +447,13 @@ TEST_SUITE("Domain JSON required fields") {
};
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>());
}
}
+3
View File
@@ -27,6 +27,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
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") {
@@ -37,6 +38,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
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") {
@@ -47,5 +49,6 @@ TEST_SUITE("game modules expose stable identity and wiring") {
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()));
}
}
+7
View File
@@ -46,4 +46,11 @@ TEST_SUITE("mapHttpGetResponse") {
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());
}
}
+19
View File
@@ -17,6 +17,20 @@ public:
}
};
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") {
@@ -25,6 +39,11 @@ TEST_SUITE("ICardPreviewSource defaults") {
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");
+53
View File
@@ -68,6 +68,11 @@ TEST_SUITE("ImageService::nextImageIndex") {
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") {
@@ -98,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") {
@@ -142,6 +181,20 @@ TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
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;
@@ -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");
}
}
+24
View File
@@ -31,6 +31,7 @@ public:
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; }
@@ -40,6 +41,7 @@ public:
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);
}
@@ -109,6 +111,18 @@ TEST_SUITE("JsonSetRepository") {
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");
@@ -128,4 +142,14 @@ TEST_SUITE("JsonSetRepository") {
REQUIRE(writeFail.isErr());
CHECK(writeFail.error() == "write failed");
}
TEST_CASE("paths are composed from dataStorage and game dir") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonSetRepository repo{fs, cfg, dirNameFn};
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
REQUIRE(repo.save(Game::Pokemon, sets).isOk());
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
}
}
+57
View File
@@ -21,6 +21,37 @@ std::string dirNameForGame(Game g) {
return "magic";
}
class FailingImageFs final : public IFileSystem {
public:
bool ensureOk{true};
bool copyOk{true};
bool removeOk{true};
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
Result<void> ensureDirectory(const std::filesystem::path&) override {
if (!ensureOk) return Result<void>::err("ensure failed");
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
return Result<std::string>::ok({});
}
Result<void> writeText(const std::filesystem::path&, std::string_view) override {
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
if (!copyOk) return Result<void>::err("copy failed");
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path&) override {
if (!removeOk) return Result<void>::err("remove failed");
return Result<void>::ok();
}
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
} // namespace
TEST_SUITE("LocalImageStore") {
@@ -87,4 +118,30 @@ TEST_SUITE("LocalImageStore") {
const std::filesystem::path got = store.resolvePath(Game::Pokemon, "pic.jpg");
CHECK(got.generic_string() == "/coll/pokemon/images/pic.jpg");
}
TEST_CASE("copyIn propagates ensureDirectory failure") {
InMemoryFileSystem configFs;
ConfigService cfg{configFs, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
FailingImageFs fs;
fs.ensureOk = false;
LocalImageStore store(fs, cfg, dirNameForGame);
const auto out = store.copyIn(Game::Magic, "/incoming/a.png", "id001");
REQUIRE(out.isErr());
CHECK(out.error() == "ensure failed");
}
TEST_CASE("remove propagates filesystem remove failure when file exists") {
InMemoryFileSystem configFs;
ConfigService cfg{configFs, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
FailingImageFs fs;
fs.removeOk = false;
LocalImageStore store(fs, cfg, dirNameForGame);
const auto out = store.remove(Game::Magic, "a.png");
REQUIRE(out.isErr());
CHECK(out.error() == "remove failed");
}
}
+133
View File
@@ -12,6 +12,8 @@
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
@@ -56,6 +58,59 @@ void backdate(const fs::path& p, int seconds) {
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
}
class FailingEnsureDirFs final : public IFileSystem {
public:
explicit FailingEnsureDirFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override {
(void)p;
return Result<void>::err("ensure failed");
}
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
class FailingIndexWriteFs final : public IFileSystem {
public:
explicit FailingIndexWriteFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override { return inner_.ensureDirectory(p); }
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
const auto path = p.generic_string();
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".idx") == 0) {
return Result<void>::err("idx write failed");
}
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
} // namespace
TEST_SUITE("LocalPreviewByteCache") {
@@ -234,6 +289,55 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with payload but missing sidecar is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with negative marker but missing sidecar is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.storeNegative("real-key");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with unreadable payload file is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".bin") {
std::error_code ec;
fs::remove(entry.path(), ec);
break;
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("evicts oldest entry when the size cap would be exceeded") {
TempDir td;
StdFileSystem fs;
@@ -291,3 +395,32 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
}
}
TEST_SUITE("LocalPreviewByteCache in-memory filesystem failures") {
TEST_CASE("store is a silent no-op when ensureDirectory fails") {
ccm::testing::InMemoryFileSystem inner;
FailingEnsureDirFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("store rolls back payload when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("storeNegative rolls back marker when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.storeNegative("k");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
}
@@ -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") {
+181
View File
@@ -170,3 +170,184 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
CHECK(http.lastUrl.find("number%3A25") != std::string::npos);
}
}
namespace {
const char* kCharizardSwsh4 = R"({
"data": [
{
"name": "Charizard",
"number": "25",
"rarity": "Rare",
"set": {
"id": "swsh4",
"name": "Vivid Voltage",
"printedTotal": 185
}
}
]
})";
const char* kMultiVariantPayload = R"({
"data": [
{
"name": "Pikachu",
"number": "25",
"rarity": "Common",
"set": {"id": "base1", "printedTotal": 102}
},
{
"name": "Pikachu",
"number": "58",
"rarity": "Rare",
"set": {"id": "base1", "printedTotal": 102}
},
{
"name": "Pikachu",
"number": "25",
"rarity": "Common",
"set": {"id": "base2", "printedTotal": 64}
}
]
})";
} // namespace
TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") {
TEST_CASE("maps API number into setNo without printedTotal suffix") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Charizard");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25");
CHECK(out.value().front().rarity == "Rare");
}
TEST_CASE("filters by set id and keeps multiple numbers in the same set") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base1", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "25");
CHECK(out.value()[1].setNo == "58");
}
TEST_CASE("wrong set id yields explicit error when name and set are supplied") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "base1", "Charizard");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("wrong card name is filtered out") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Blastoise");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("empty data array yields error") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(R"({"data":[]})", "base1", "Pikachu");
REQUIRE(out.isErr());
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
}
TEST_CASE("name-only payload still filters to requested set id") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base2", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25");
}
TEST_CASE("keeps bare number when printedTotal is zero") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data": [
{
"name": "Promo",
"number": "7",
"rarity": "Promo",
"set": {"id": "promo1", "printedTotal": 0}
}
]
})",
"promo1", "Promo");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "7");
}
}
TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
TEST_CASE("supports auto-detect and returns first print") {
FixedHttpClient http;
http.body = kCharizardSwsh4;
PokemonCardPreviewSource src{http};
CHECK(src.supportsAutoDetectPrint());
const auto first = src.detectFirstPrint("Charizard", "swsh4");
REQUIRE(first.isOk());
CHECK(first.value().setNo == "25");
}
TEST_CASE("uses slim set-scoped search URL without number clause") {
FixedHttpClient http;
http.body = kCharizardSwsh4;
PokemonCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Charizard", "swsh4");
REQUIRE(out.isOk());
CHECK(http.lastUrl.find("number%3A") == std::string::npos);
CHECK(http.lastUrl.find("set.id%3Aswsh4") != std::string::npos);
CHECK(http.lastUrl.find("select=name,number,rarity,set") != std::string::npos);
CHECK(http.lastUrl.find("pageSize=50") != std::string::npos);
}
TEST_CASE("buildDetectSearchUrl requests only parser fields") {
const auto url = PokemonCardPreviewSource::buildDetectSearchUrl("Charizard", "swsh4");
CHECK(url.find("select=name,number,rarity,set") != std::string::npos);
CHECK(url.find("pageSize=50") != std::string::npos);
}
TEST_CASE("retries name-only query when the set-scoped request fails") {
class FallbackHttpClient final : public IHttpClient {
public:
int calls = 0;
Result<std::string> get(std::string_view url) override {
++calls;
if (calls == 1) return Result<std::string>::err("offline");
if (url.find("set.id") != std::string::npos) {
return Result<std::string>::err("unexpected set-scoped retry");
}
return Result<std::string>::ok(kMultiVariantPayload);
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Pikachu", "base1");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(http.calls == 2);
}
TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") {
FixedHttpClient http;
http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})";
PokemonCardPreviewSource src{http};
const auto out = src.detectFirstPrint("Promo", "promo1");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data":[
{"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"},
{"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}}
]
})",
"base1", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "26");
}
}
+34
View File
@@ -39,11 +39,13 @@ class InMemSetRepo final : public ISetRepository {
public:
std::vector<Set> stored;
bool hasStored = false;
bool failSave = false;
Result<std::vector<Set>> load(Game) override {
if (!hasStored) return Result<std::vector<Set>>::err("no cache");
return Result<std::vector<Set>>::ok(stored);
}
Result<void> save(Game, const std::vector<Set>& s) override {
if (failSave) return Result<void>::err("save failed");
stored = s;
hasStored = true;
return Result<void>::ok();
@@ -145,4 +147,36 @@ TEST_SUITE("SetService") {
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
}
TEST_CASE("updateSets propagates repository save failures") {
InMemSetRepo repo;
repo.failSave = true;
SetService svc{repo};
FakeGameModule magic{Game::Magic};
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
svc.registerModule(&magic);
const auto out = svc.updateSets(Game::Magic);
REQUIRE(out.isErr());
CHECK(out.error() == "save failed");
}
TEST_CASE("registering a second module for same game id overwrites previous one") {
InMemSetRepo repo;
SetService svc{repo};
FakeGameModule firstMagic{Game::Magic};
firstMagic.source.result = Result<std::vector<Set>>::ok({{"a", "First", "2000/01/01"}});
FakeGameModule secondMagic{Game::Magic};
secondMagic.source.result = Result<std::vector<Set>>::ok({{"b", "Second", "2001/01/01"}});
svc.registerModule(&firstMagic);
svc.registerModule(&secondMagic);
const auto out = svc.updateSets(Game::Magic);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().id == "b");
CHECK(firstMagic.source.calls == 0);
CHECK(secondMagic.source.calls == 1);
}
}
+16
View File
@@ -112,6 +112,22 @@ TEST_SUITE("StdFileSystem") {
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;
+151
View File
@@ -92,6 +92,23 @@ 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") == "");
}
}
@@ -110,6 +127,16 @@ TEST_SUITE("ygoRarityShortCode") {
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());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
@@ -281,6 +308,33 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
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") {
@@ -446,6 +500,22 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
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") {
@@ -467,6 +537,26 @@ TEST_SUITE("YuGiOhCardPreviewSource::detectPrintVariants HTTP fallback") {
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`
@@ -760,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.
@@ -777,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");
}
}
+52
View File
@@ -57,6 +57,20 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
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"}
@@ -98,6 +112,44 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
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") {
+4 -1
View File
@@ -352,11 +352,14 @@ private:
failed.reserve(static_cast<std::size_t>(paths.size()));
for (const auto& path : paths) {
const std::string setNameForImage = (game_ == Game::YuGiOh && !card_.set.id.empty())
? card_.set.id
: card_.set.name;
auto added = imageService_.addImage(game_,
std::filesystem::path(path.ToStdString()),
mode_ == EditMode::Create,
card_.id,
card_.set.name,
setNameForImage,
card_.name,
card_.images);
if (!added) {
+52 -5
View File
@@ -7,7 +7,15 @@
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include <wx/button.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
@@ -16,9 +24,11 @@ public:
PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets = nullptr);
~PokemonCardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
@@ -26,13 +36,50 @@ protected:
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
void onCardLookupContextChanged() override;
private:
wxTextCtrl* setNoCtrl_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void autoDetectFromApi();
void clearCachedPrintVariants();
void requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
void refreshVariantNextControls();
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
[[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl);
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetNos_;
std::size_t setNoRingPos_{0};
};
} // namespace ccm::ui
+210 -5
View File
@@ -1,18 +1,41 @@
#include "ccm/ui/PokemonCardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <thread>
#include <unordered_set>
namespace ccm::ui {
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets)
: BaseCardEditDialog<PokemonCard>(
parent,
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets) {
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets),
dialogMode_(mode),
cardPreview_(cardPreview),
variantFetchState_(std::make_shared<VariantFetchState>()) {
buildAndPopulate();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
}
PokemonCardEditDialog::~PokemonCardEditDialog() {
if (variantFetchState_) {
variantFetchState_->alive.store(false);
}
}
void PokemonCardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
@@ -27,12 +50,46 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
}
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
setNoCtrl_ = new wxTextCtrl(this, wxID_ANY, constCard().setNo);
appendRow(grid, "Set #", setNoCtrl_);
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
autoSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onAutoDetectSetNo, this);
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
setNoPanel->SetSizer(setNoRow);
appendRow(grid, "Set #", setNoPanel);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this);
}
}
std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
std::string out(setNo);
const auto slash = out.find('/');
if (slash != std::string::npos) {
out.resize(slash);
}
return out;
}
std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
if (ctrl == nullptr) return {};
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
}
void PokemonCardEditDialog::readExtraFromCard() {
if (setNoCtrl_) setNoCtrl_->ChangeValue(constCard().setNo);
clearCachedPrintVariants();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
}
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
@@ -40,11 +97,159 @@ void PokemonCardEditDialog::readExtraFromCard() {
}
void PokemonCardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString();
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
}
void PokemonCardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
cachedVariants_.clear();
uniqueSetNos_.clear();
setNoRingPos_ = 0;
refreshVariantNextControls();
}
void PokemonCardEditDialog::scheduleDeferredVariantPrefetch() {
const unsigned epoch = variantFetchEpoch_;
wxTheApp->CallAfter([this, epoch]() {
prefetchVariantsForCurrentCardSilent(epoch);
});
}
void PokemonCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
if (capturedEpoch != variantFetchEpoch_) return;
if (!cachedVariants_.empty()) return;
const auto& card = constCard();
if (card.name.empty() || card.set.id.empty()) return;
requestVariantsAsync(capturedEpoch, card.name, card.set.id, false, false);
}
void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Disable();
}
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
PokemonCardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog]() {
auto detected = svc->detectPrintVariants(Game::Pokemon, name, setId);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
});
}).detach();
}
void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
if (!detected) {
if (showFailureDialog) {
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
wxOK | wxICON_WARNING);
}
return;
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
}
rebuildVariantRingFromCache();
syncRingPositionToControls();
refreshVariantNextControls();
}
void PokemonCardEditDialog::rebuildVariantRingFromCache() {
uniqueSetNos_.clear();
if (cachedVariants_.empty()) return;
std::unordered_set<std::string> seen;
seen.reserve(cachedVariants_.size());
for (const auto& p : cachedVariants_) {
if (p.setNo.empty()) continue;
if (!seen.insert(p.setNo).second) continue;
uniqueSetNos_.push_back(p.setNo);
}
}
void PokemonCardEditDialog::syncRingPositionToControls() {
if (!setNoCtrl_) return;
const std::string current = storedSetNoFromControls(setNoCtrl_);
setNoRingPos_ = 0;
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
if (uniqueSetNos_[i] == current) {
setNoRingPos_ = i;
break;
}
}
}
void PokemonCardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_) return;
nextSetNoBtn_->Show(uniqueSetNos_.size() > 1);
Layout();
if (GetSizer()) Fit();
}
void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
autoDetectFromApi();
}
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
if (uniqueSetNos_.size() <= 1) return;
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
}
refreshVariantNextControls();
}
void PokemonCardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.id.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.id, true, true);
}
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
} // namespace ccm::ui
+2 -2
View File
@@ -90,7 +90,7 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
fresh.language = Language::English;
fresh.condition = Condition::NearMint;
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
&setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
if (dlg.ShowModal() != wxID_OK) return;
@@ -129,7 +129,7 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
return;
}
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
&setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
if (dlg.ShowModal() != wxID_OK) return;
+4 -1
View File
@@ -113,8 +113,11 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
YuGiOhCard persisted = dlg.card();
persisted.id = added.value();
const std::string setNameForImage = persisted.set.id.empty()
? persisted.set.name
: persisted.set.id;
auto normalized = images_.normalizeNamesForPersistedCard(
Game::YuGiOh, persisted.id, persisted.set.name, persisted.name, persisted.images);
Game::YuGiOh, persisted.id, setNameForImage, persisted.name, persisted.images);
if (normalized) {
if (normalized.value() != persisted.images) {
persisted.images = std::move(normalized).value();