diff --git a/AGENTS.md b/AGENTS.md index 6432964..377e2b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `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`. diff --git a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp index febc2ff..4dd9721 100644 --- a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp +++ b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp @@ -12,6 +12,7 @@ #include #include +#include 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 fetchImageUrl(std::string_view name, std::string_view setId, std::string_view setNo) override; + Result detectFirstPrint(std::string_view name, + std::string_view setId) override; + Result> 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 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> + parsePrintVariants(const std::string& body, + std::string_view setId, + std::string_view wantedCardName); + private: IHttpClient& http_; }; diff --git a/core/src/games/pokemon/PokemonCardPreviewSource.cpp b/core/src/games/pokemon/PokemonCardPreviewSource.cpp index 8085ce4..cf88579 100644 --- a/core/src/games/pokemon/PokemonCardPreviewSource.cpp +++ b/core/src/games/pokemon/PokemonCardPreviewSource.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include 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(s.front()))) s.erase(s.begin()); + while (!s.empty() && std::isspace(static_cast(s.back()))) s.pop_back(); + return s; +} + +std::string toLower(std::string s) { + for (char& ch : s) { + ch = static_cast(std::tolower(static_cast(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 PokemonCardPreviewSource::parseResponse(const std::string& body) { using R = Result; @@ -91,4 +114,87 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name, return parseResponse(resp.value()); } +Result> PokemonCardPreviewSource::parsePrintVariants( + const std::string& body, + std::string_view setId, + std::string_view wantedCardName) { + using R = Result>; + 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 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 deduped; + deduped.reserve(collected.size()); + std::unordered_set 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 PokemonCardPreviewSource::detectFirstPrint(std::string_view name, + std::string_view setId) { + auto list = detectPrintVariants(name, setId); + if (!list || list.value().empty()) { + if (!list) return Result::err(list.error()); + return Result::err("Could not auto-detect set print metadata."); + } + return Result::ok(list.value().front()); +} + +Result> PokemonCardPreviewSource::detectPrintVariants( + std::string_view name, + std::string_view setId) { + using R = Result>; + 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 diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index 7e1c78d..a95dbb8 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -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:""` and `set.id:` 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) @@ -114,6 +118,6 @@ All source types return `Result` 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..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..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:` 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. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8f8c5d9..416c826 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 diff --git a/tests/ascii_utils_tests.cpp b/tests/ascii_utils_tests.cpp new file mode 100644 index 0000000..8feff69 --- /dev/null +++ b/tests/ascii_utils_tests.cpp @@ -0,0 +1,20 @@ +#include + +#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); + } +} diff --git a/tests/card_filter_tests.cpp b/tests/card_filter_tests.cpp index 1764256..9501a99 100644 --- a/tests/card_filter_tests.cpp +++ b/tests/card_filter_tests.cpp @@ -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")); + } } diff --git a/tests/card_preview_service_tests.cpp b/tests/card_preview_service_tests.cpp index d0bca9c..f77882f 100644 --- a/tests/card_preview_service_tests.cpp +++ b/tests/card_preview_service_tests.cpp @@ -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 diff --git a/tests/card_sorter_tests.cpp b/tests/card_sorter_tests.cpp index adaa6af..de5f8d4 100644 --- a/tests/card_sorter_tests.cpp +++ b/tests/card_sorter_tests.cpp @@ -430,6 +430,17 @@ TEST_SUITE("CardSorter - YuGiOh columns") { CHECK(ids(v) == std::vector{2, 3, 1}); // C, ScR, UR } + TEST_CASE("Rarity treats unknown labels as equal empty shorthand") { + std::vector 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{1, 2}); + sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true); + CHECK(ids(v) == std::vector{1, 2}); + } + TEST_CASE("Amount sorts numerically") { std::vector v = { yc(1, "a", "X", "2000/01/01", "", "", 9), diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index fe608d8..e50b289 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -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(); + 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") { @@ -313,6 +410,36 @@ TEST_SUITE("Domain JSON required fields") { CHECK_THROWS(j.get()); } + 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()); + } + } + TEST_CASE("Configuration missing required key throws") { const nlohmann::json j = { {"defaultGame", "Magic"}, diff --git a/tests/game_module_tests.cpp b/tests/game_module_tests.cpp index 57a8db0..a571206 100644 --- a/tests/game_module_tests.cpp +++ b/tests/game_module_tests.cpp @@ -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(&module.setSource()) != static_cast(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(&module.setSource()) != static_cast(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(&module.setSource()) != static_cast(module.cardPreviewSource())); } } diff --git a/tests/http_get_mapping_tests.cpp b/tests/http_get_mapping_tests.cpp index ed41006..3649324 100644 --- a/tests/http_get_mapping_tests.cpp +++ b/tests/http_get_mapping_tests.cpp @@ -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()); + } } diff --git a/tests/icard_preview_source_tests.cpp b/tests/icard_preview_source_tests.cpp index 7b9bb55..a8e1299 100644 --- a/tests/icard_preview_source_tests.cpp +++ b/tests/icard_preview_source_tests.cpp @@ -17,6 +17,20 @@ public: } }; +class AutoDetectPreviewSource final : public ICardPreviewSource { +public: + [[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { + return true; + } + + Result + fetchImageUrl(std::string_view, + std::string_view, + std::string_view) override { + return Result::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"); diff --git a/tests/local_preview_byte_cache_tests.cpp b/tests/local_preview_byte_cache_tests.cpp index 301e679..3858c68 100644 --- a/tests/local_preview_byte_cache_tests.cpp +++ b/tests/local_preview_byte_cache_tests.cpp @@ -12,6 +12,8 @@ #include "ccm/infra/LocalPreviewByteCache.hpp" #include "ccm/infra/StdFileSystem.hpp" +#include "fakes/InMemoryFileSystem.hpp" + #include #include #include @@ -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 ensureDirectory(const fs::path& p) override { + (void)p; + return Result::err("ensure failed"); + } + Result readText(const fs::path& p) override { return inner_.readText(p); } + Result writeText(const fs::path& p, std::string_view contents) override { + return inner_.writeText(p, contents); + } + Result copyFile(const fs::path& from, const fs::path& to, bool overwrite) override { + return inner_.copyFile(from, to, overwrite); + } + Result remove(const fs::path& p) override { return inner_.remove(p); } + Result> 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 ensureDirectory(const fs::path& p) override { return inner_.ensureDirectory(p); } + Result readText(const fs::path& p) override { return inner_.readText(p); } + Result 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::err("idx write failed"); + } + return inner_.writeText(p, contents); + } + Result copyFile(const fs::path& from, const fs::path& to, bool overwrite) override { + return inner_.copyFile(from, to, overwrite); + } + Result remove(const fs::path& p) override { return inner_.remove(p); } + Result> listDirectory(const fs::path& p) override { + return inner_.listDirectory(p); + } + +private: + ccm::testing::InMemoryFileSystem& inner_; +}; + } // namespace TEST_SUITE("LocalPreviewByteCache") { @@ -340,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); + } +} diff --git a/tests/magic_card_preview_source_tests.cpp b/tests/magic_card_preview_source_tests.cpp index 4389c23..8e3f910 100644 --- a/tests/magic_card_preview_source_tests.cpp +++ b/tests/magic_card_preview_source_tests.cpp @@ -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") { diff --git a/tests/pokemon_card_preview_source_tests.cpp b/tests/pokemon_card_preview_source_tests.cpp index 822076b..e746abd 100644 --- a/tests/pokemon_card_preview_source_tests.cpp +++ b/tests/pokemon_card_preview_source_tests.cpp @@ -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 get(std::string_view url) override { + ++calls; + if (calls == 1) return Result::err("offline"); + if (url.find("set.id") != std::string::npos) { + return Result::err("unexpected set-scoped retry"); + } + return Result::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"); + } +} diff --git a/tests/yugioh_card_preview_source_tests.cpp b/tests/yugioh_card_preview_source_tests.cpp index d12728c..08dd458 100644 --- a/tests/yugioh_card_preview_source_tests.cpp +++ b/tests/yugioh_card_preview_source_tests.cpp @@ -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") { diff --git a/tests/yugioh_set_source_tests.cpp b/tests/yugioh_set_source_tests.cpp index 6272c89..c8a5307 100644 --- a/tests/yugioh_set_source_tests.cpp +++ b/tests/yugioh_set_source_tests.cpp @@ -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"} diff --git a/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp b/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp index 80541af..1eb1698 100644 --- a/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp @@ -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 + +#include +#include +#include +#include namespace ccm::ui { @@ -16,9 +24,11 @@ public: PokemonCardEditDialog(wxWindow* parent, ImageService& imageService, SetService& setService, + CardPreviewService& cardPreview, EditMode mode, PokemonCard initial, const std::vector* 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 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> 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_; + wxTextCtrl* setNoCtrl_{nullptr}; + wxButton* autoSetNoBtn_{nullptr}; + wxButton* nextSetNoBtn_{nullptr}; + wxCheckBox* holoCheck_{nullptr}; + wxCheckBox* firstEditionCheck_{nullptr}; + wxCheckBox* signedCheck_{nullptr}; + wxCheckBox* alteredCheck_{nullptr}; + + std::vector cachedVariants_; + std::vector uniqueSetNos_; + std::size_t setNoRingPos_{0}; }; } // namespace ccm::ui diff --git a/ui_wx/src/PokemonCardEditDialog.cpp b/ui_wx/src/PokemonCardEditDialog.cpp index 4550c52..57e61dd 100644 --- a/ui_wx/src/PokemonCardEditDialog.cpp +++ b/ui_wx/src/PokemonCardEditDialog.cpp @@ -1,18 +1,41 @@ #include "ccm/ui/PokemonCardEditDialog.hpp" +#include "ccm/domain/Enums.hpp" +#include +#include +#include +#include + namespace ccm::ui { PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent, ImageService& imageService, SetService& setService, + CardPreviewService& cardPreview, EditMode mode, PokemonCard initial, const std::vector* preloadedSets) : BaseCardEditDialog( 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()) { 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> 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 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 diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index 5947e6a..1a6ff2d 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -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;