mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
patch: Feature/pkm autodetect (#15)
This commit is contained in:
@@ -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 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.
|
- 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, 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, 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.
|
- 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 adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
|
||||||
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
|
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
|
||||||
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
|
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace ccm {
|
namespace ccm {
|
||||||
|
|
||||||
@@ -19,10 +20,16 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
|
|||||||
public:
|
public:
|
||||||
explicit PokemonCardPreviewSource(IHttpClient& http);
|
explicit PokemonCardPreviewSource(IHttpClient& http);
|
||||||
|
|
||||||
|
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||||
|
|
||||||
Result<std::string, PreviewLookupError>
|
Result<std::string, PreviewLookupError>
|
||||||
fetchImageUrl(std::string_view name,
|
fetchImageUrl(std::string_view name,
|
||||||
std::string_view setId,
|
std::string_view setId,
|
||||||
std::string_view setNo) override;
|
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.
|
// 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.
|
// Exposed for unit testing and to keep encoding rules in one place.
|
||||||
@@ -30,6 +37,11 @@ public:
|
|||||||
std::string_view setId,
|
std::string_view setId,
|
||||||
std::string_view setNo);
|
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
|
// 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
|
// for the first matching card. Prefers `images.large`, falls back to
|
||||||
// `images.small`. Errors are classified:
|
// `images.small`. Errors are classified:
|
||||||
@@ -38,6 +50,13 @@ public:
|
|||||||
static Result<std::string, PreviewLookupError>
|
static Result<std::string, PreviewLookupError>
|
||||||
parseResponse(const std::string& body);
|
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:
|
private:
|
||||||
IHttpClient& http_;
|
IHttpClient& http_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace ccm {
|
namespace ccm {
|
||||||
|
|
||||||
@@ -23,6 +25,19 @@ std::string normalizeNumber(std::string_view setNo) {
|
|||||||
return s;
|
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
|
} // namespace
|
||||||
|
|
||||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||||
@@ -48,6 +63,14 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
|||||||
rfc3986PercentEncode(query);
|
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>
|
Result<std::string, PreviewLookupError>
|
||||||
PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||||
using R = Result<std::string, PreviewLookupError>;
|
using R = Result<std::string, PreviewLookupError>;
|
||||||
@@ -91,4 +114,87 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
|||||||
return parseResponse(resp.value());
|
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
|
} // namespace ccm
|
||||||
|
|||||||
@@ -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.
|
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=...`
|
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
|
||||||
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
|
Used by `PokemonCardPreviewSource` in two ways:
|
||||||
|
|
||||||
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
|
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
|
||||||
|
|
||||||
|
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the picker’s **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the card’s `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
|
||||||
|
|
||||||
|
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
|
||||||
|
|
||||||
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
|
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
|
||||||
|
|
||||||
@@ -114,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
|
- 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).
|
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
|
||||||
|
|
||||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
||||||
|
|
||||||
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the 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.
|
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ add_executable(ccm_core_tests
|
|||||||
game_module_tests.cpp
|
game_module_tests.cpp
|
||||||
card_sorter_tests.cpp
|
card_sorter_tests.cpp
|
||||||
card_filter_tests.cpp
|
card_filter_tests.cpp
|
||||||
|
ascii_utils_tests.cpp
|
||||||
http_get_mapping_tests.cpp
|
http_get_mapping_tests.cpp
|
||||||
cpr_http_client_tests.cpp
|
cpr_http_client_tests.cpp
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include "ccm/util/AsciiUtils.hpp"
|
||||||
|
|
||||||
|
using namespace ccm;
|
||||||
|
|
||||||
|
TEST_SUITE("asciiLower") {
|
||||||
|
TEST_CASE("empty string stays empty") {
|
||||||
|
CHECK(asciiLower("").empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("lowercases ASCII letters and leaves other ASCII bytes unchanged") {
|
||||||
|
CHECK(asciiLower("AbC123!@#") == "abc123!@#");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("non-ASCII UTF-8 bytes pass through unchanged") {
|
||||||
|
const std::string input = "caf\u00e9";
|
||||||
|
CHECK(asciiLower(input) == input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,13 @@ YuGiOhCard yc(std::string name,
|
|||||||
std::string setName,
|
std::string setName,
|
||||||
std::string setNo = "",
|
std::string setNo = "",
|
||||||
std::string rarity = "",
|
std::string rarity = "",
|
||||||
std::uint8_t amount = 1) {
|
std::uint8_t amount = 1,
|
||||||
|
Language lang = Language::English,
|
||||||
|
Condition cond = Condition::NearMint,
|
||||||
|
std::string note = "",
|
||||||
|
bool firstEdition = false,
|
||||||
|
bool sgnd = false,
|
||||||
|
bool altered = false) {
|
||||||
YuGiOhCard c;
|
YuGiOhCard c;
|
||||||
c.id = 1;
|
c.id = 1;
|
||||||
c.name = std::move(name);
|
c.name = std::move(name);
|
||||||
@@ -65,6 +71,12 @@ YuGiOhCard yc(std::string name,
|
|||||||
c.setNo = std::move(setNo);
|
c.setNo = std::move(setNo);
|
||||||
c.rarity = std::move(rarity);
|
c.rarity = std::move(rarity);
|
||||||
c.amount = amount;
|
c.amount = amount;
|
||||||
|
c.language = lang;
|
||||||
|
c.condition = cond;
|
||||||
|
c.note = std::move(note);
|
||||||
|
c.firstEdition = firstEdition;
|
||||||
|
c.signed_ = sgnd;
|
||||||
|
c.altered = altered;
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +188,10 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
|
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") {
|
TEST_CASE("matches by set number and rarity") {
|
||||||
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
|
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
|
||||||
CHECK(matchesYuGiOhFilter(c, "lob-005"));
|
CHECK(matchesYuGiOhFilter(c, "lob-005"));
|
||||||
@@ -183,4 +199,42 @@ TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
|
|||||||
CHECK(matchesYuGiOhFilter(c, "ur"));
|
CHECK(matchesYuGiOhFilter(c, "ur"));
|
||||||
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,6 +334,78 @@ TEST_SUITE("CardPreviewService caching") {
|
|||||||
CHECK(http.calls == 1);
|
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") {
|
TEST_CASE("HTTP success writes through to the persistent cache") {
|
||||||
// The persistent tier is fire-and-forget on the way down (HTTP -> disk)
|
// 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
|
// and consulted on the way up (cache miss -> disk -> HTTP). This first
|
||||||
|
|||||||
@@ -430,6 +430,17 @@ TEST_SUITE("CardSorter - YuGiOh columns") {
|
|||||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // C, ScR, UR
|
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") {
|
TEST_CASE("Amount sorts numerically") {
|
||||||
std::vector<YuGiOhCard> v = {
|
std::vector<YuGiOhCard> v = {
|
||||||
yc(1, "a", "X", "2000/01/01", "", "", 9),
|
yc(1, "a", "X", "2000/01/01", "", "", 9),
|
||||||
|
|||||||
@@ -235,6 +235,103 @@ TEST_SUITE("YuGiOhCard JSON") {
|
|||||||
CHECK(card.setNo == "SDY-006");
|
CHECK(card.setNo == "SDY-006");
|
||||||
CHECK(card.set.id == "SDY");
|
CHECK(card.set.id == "SDY");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("serializes non-default flags and metadata fields") {
|
||||||
|
YuGiOhCard c;
|
||||||
|
c.id = 3;
|
||||||
|
c.amount = 4;
|
||||||
|
c.name = "Red-Eyes Black Dragon";
|
||||||
|
c.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
|
||||||
|
c.setNo = "LOB-070";
|
||||||
|
c.rarity = "Secret Rare";
|
||||||
|
c.note = "graded";
|
||||||
|
c.images = {};
|
||||||
|
c.language = Language::German;
|
||||||
|
c.condition = Condition::Played;
|
||||||
|
c.firstEdition = false;
|
||||||
|
c.signed_ = true;
|
||||||
|
c.altered = true;
|
||||||
|
|
||||||
|
const nlohmann::json j = c;
|
||||||
|
CHECK(j.at("firstEdition") == false);
|
||||||
|
CHECK(j.at("signed") == true);
|
||||||
|
CHECK(j.at("altered") == true);
|
||||||
|
CHECK(j.at("language") == "German");
|
||||||
|
CHECK(j.at("condition") == "Played");
|
||||||
|
CHECK(j.at("images") == nlohmann::json::array());
|
||||||
|
|
||||||
|
const YuGiOhCard back = j.get<YuGiOhCard>();
|
||||||
|
CHECK(back == c);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("operator== distinguishes each field") {
|
||||||
|
YuGiOhCard base;
|
||||||
|
base.id = 10;
|
||||||
|
base.amount = 2;
|
||||||
|
base.name = "Dark Magician";
|
||||||
|
base.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
|
||||||
|
base.setNo = "LOB-005";
|
||||||
|
base.rarity = "Ultra Rare";
|
||||||
|
base.note = "note";
|
||||||
|
base.images = {"a.png"};
|
||||||
|
base.language = Language::English;
|
||||||
|
base.condition = Condition::NearMint;
|
||||||
|
base.firstEdition = true;
|
||||||
|
base.signed_ = false;
|
||||||
|
base.altered = false;
|
||||||
|
|
||||||
|
auto changed = base;
|
||||||
|
changed.id = 11;
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.amount = 3;
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.name = "Other";
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.set.name = "Other Set";
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.setNo = "LOB-006";
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.rarity = "Rare";
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.note = "other";
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.images = {};
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.language = Language::Japanese;
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.condition = Condition::Played;
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.firstEdition = false;
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.signed_ = true;
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
|
||||||
|
changed = base;
|
||||||
|
changed.altered = true;
|
||||||
|
CHECK_FALSE(changed == base);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_SUITE("Domain JSON required fields") {
|
TEST_SUITE("Domain JSON required fields") {
|
||||||
@@ -313,6 +410,36 @@ TEST_SUITE("Domain JSON required fields") {
|
|||||||
CHECK_THROWS(j.get<YuGiOhCard>());
|
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") {
|
TEST_CASE("Configuration missing required key throws") {
|
||||||
const nlohmann::json j = {
|
const nlohmann::json j = {
|
||||||
{"defaultGame", "Magic"},
|
{"defaultGame", "Magic"},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
|
|||||||
CHECK(module.dirName() == "magic");
|
CHECK(module.dirName() == "magic");
|
||||||
CHECK(module.displayName() == "Magic");
|
CHECK(module.displayName() == "Magic");
|
||||||
CHECK(module.cardPreviewSource() != nullptr);
|
CHECK(module.cardPreviewSource() != nullptr);
|
||||||
|
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Pokemon module reports canonical metadata") {
|
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.dirName() == "pokemon");
|
||||||
CHECK(module.displayName() == "Pokemon");
|
CHECK(module.displayName() == "Pokemon");
|
||||||
CHECK(module.cardPreviewSource() != nullptr);
|
CHECK(module.cardPreviewSource() != nullptr);
|
||||||
|
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("YuGiOh module reports canonical metadata") {
|
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.dirName() == "yugioh");
|
||||||
CHECK(module.displayName() == "Yu-Gi-Oh!");
|
CHECK(module.displayName() == "Yu-Gi-Oh!");
|
||||||
CHECK(module.cardPreviewSource() != nullptr);
|
CHECK(module.cardPreviewSource() != nullptr);
|
||||||
|
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,11 @@ TEST_SUITE("mapHttpGetResponse") {
|
|||||||
REQUIRE(out.isErr());
|
REQUIRE(out.isErr());
|
||||||
CHECK(out.error() == "HTTP 404 from https://api.example/r");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
} // namespace
|
||||||
|
|
||||||
TEST_SUITE("ICardPreviewSource defaults") {
|
TEST_SUITE("ICardPreviewSource defaults") {
|
||||||
@@ -25,6 +39,11 @@ TEST_SUITE("ICardPreviewSource defaults") {
|
|||||||
CHECK_FALSE(src.supportsAutoDetectPrint());
|
CHECK_FALSE(src.supportsAutoDetectPrint());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("implementations may override supportsAutoDetectPrint") {
|
||||||
|
AutoDetectPreviewSource src;
|
||||||
|
CHECK(src.supportsAutoDetectPrint());
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("default detectFirstPrint returns explicit unsupported error") {
|
TEST_CASE("default detectFirstPrint returns explicit unsupported error") {
|
||||||
MinimalPreviewSource src;
|
MinimalPreviewSource src;
|
||||||
const auto out = src.detectFirstPrint("Card", "Set");
|
const auto out = src.detectFirstPrint("Card", "Set");
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
#include "ccm/infra/LocalPreviewByteCache.hpp"
|
#include "ccm/infra/LocalPreviewByteCache.hpp"
|
||||||
#include "ccm/infra/StdFileSystem.hpp"
|
#include "ccm/infra/StdFileSystem.hpp"
|
||||||
|
|
||||||
|
#include "fakes/InMemoryFileSystem.hpp"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <random>
|
#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);
|
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
|
} // namespace
|
||||||
|
|
||||||
TEST_SUITE("LocalPreviewByteCache") {
|
TEST_SUITE("LocalPreviewByteCache") {
|
||||||
@@ -340,3 +395,32 @@ TEST_SUITE("LocalPreviewByteCache") {
|
|||||||
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
|
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");
|
const auto url = MagicCardPreviewSource::buildSearchUrl("X", "swsh10");
|
||||||
CHECK(url.find("set%3Aswsh10") != std::string::npos);
|
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") {
|
TEST_SUITE("MagicCardPreviewSource::parseResponse") {
|
||||||
|
|||||||
@@ -170,3 +170,184 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
|
|||||||
CHECK(http.lastUrl.find("number%3A25") != std::string::npos);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,23 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
|
|||||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
|
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
|
||||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
|
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
|
||||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
|
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("Platinum Secret Rare") == "PlScR");
|
||||||
CHECK(ygoRarityShortCode("Prismatic Secret Rare") == "PScR");
|
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") {
|
TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
|
||||||
@@ -281,6 +308,33 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
|
|||||||
REQUIRE(out.isErr());
|
REQUIRE(out.isErr());
|
||||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("page without imageinfo is treated as missing") {
|
||||||
|
const std::string body = R"({
|
||||||
|
"query":{"pages":{
|
||||||
|
"1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png"}
|
||||||
|
}}
|
||||||
|
})";
|
||||||
|
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
|
||||||
|
body, {"DarkMagician-LOB-EN-UR-UE.png"});
|
||||||
|
REQUIRE(out.isErr());
|
||||||
|
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_SUITE("YuGiOhCardPreviewSource::buildSearchUrl") {
|
||||||
|
TEST_CASE("percent-encodes name and optional set name filter") {
|
||||||
|
const auto url = YuGiOhCardPreviewSource::buildSearchUrl(
|
||||||
|
"Dark Magician", "Legend of Blue Eyes White Dragon");
|
||||||
|
CHECK(url.find("https://db.ygoprodeck.com/api/v7/cardinfo.php") == 0);
|
||||||
|
CHECK(url.find("fname=Dark%20Magician") != std::string::npos);
|
||||||
|
CHECK(url.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("omits cardset when set name is empty") {
|
||||||
|
const auto url = YuGiOhCardPreviewSource::buildSearchUrl("Dark Magician", "");
|
||||||
|
CHECK(url.find("cardset=") == std::string::npos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_SUITE("YuGiOhCardPreviewSource::parseFallbackImageUrl") {
|
TEST_SUITE("YuGiOhCardPreviewSource::parseFallbackImageUrl") {
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
|
|||||||
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
|
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") {
|
TEST_CASE("adds 25th Anniversary aliases when upstream list misses them") {
|
||||||
const auto out = YuGiOhSetSource::parseResponse(R"([
|
const auto out = YuGiOhSetSource::parseResponse(R"([
|
||||||
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}
|
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}
|
||||||
|
|||||||
@@ -7,7 +7,15 @@
|
|||||||
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
|
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
|
||||||
|
|
||||||
#include "ccm/domain/PokemonCard.hpp"
|
#include "ccm/domain/PokemonCard.hpp"
|
||||||
|
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||||
|
#include "ccm/services/CardPreviewService.hpp"
|
||||||
#include "ccm/ui/BaseCardEditDialog.hpp"
|
#include "ccm/ui/BaseCardEditDialog.hpp"
|
||||||
|
#include <wx/button.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace ccm::ui {
|
namespace ccm::ui {
|
||||||
|
|
||||||
@@ -16,9 +24,11 @@ public:
|
|||||||
PokemonCardEditDialog(wxWindow* parent,
|
PokemonCardEditDialog(wxWindow* parent,
|
||||||
ImageService& imageService,
|
ImageService& imageService,
|
||||||
SetService& setService,
|
SetService& setService,
|
||||||
|
CardPreviewService& cardPreview,
|
||||||
EditMode mode,
|
EditMode mode,
|
||||||
PokemonCard initial,
|
PokemonCard initial,
|
||||||
const std::vector<Set>* preloadedSets = nullptr);
|
const std::vector<Set>* preloadedSets = nullptr);
|
||||||
|
~PokemonCardEditDialog() override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void buildFlagsRow(wxBoxSizer* flagsBox) override;
|
void buildFlagsRow(wxBoxSizer* flagsBox) override;
|
||||||
@@ -26,13 +36,50 @@ protected:
|
|||||||
void readExtraFromCard() override;
|
void readExtraFromCard() override;
|
||||||
void writeExtraToCard() override;
|
void writeExtraToCard() override;
|
||||||
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
|
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
|
||||||
|
void onCardLookupContextChanged() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
wxTextCtrl* setNoCtrl_{nullptr};
|
struct VariantFetchState {
|
||||||
wxCheckBox* holoCheck_{nullptr};
|
std::atomic<bool> alive{true};
|
||||||
wxCheckBox* firstEditionCheck_{nullptr};
|
};
|
||||||
wxCheckBox* signedCheck_{nullptr};
|
|
||||||
wxCheckBox* alteredCheck_{nullptr};
|
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
|
} // namespace ccm::ui
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
#include "ccm/ui/PokemonCardEditDialog.hpp"
|
#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 {
|
namespace ccm::ui {
|
||||||
|
|
||||||
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
|
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
|
||||||
ImageService& imageService,
|
ImageService& imageService,
|
||||||
SetService& setService,
|
SetService& setService,
|
||||||
|
CardPreviewService& cardPreview,
|
||||||
EditMode mode,
|
EditMode mode,
|
||||||
PokemonCard initial,
|
PokemonCard initial,
|
||||||
const std::vector<Set>* preloadedSets)
|
const std::vector<Set>* preloadedSets)
|
||||||
: BaseCardEditDialog<PokemonCard>(
|
: BaseCardEditDialog<PokemonCard>(
|
||||||
parent,
|
parent,
|
||||||
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
|
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();
|
buildAndPopulate();
|
||||||
|
if (dialogMode_ == EditMode::Edit) {
|
||||||
|
scheduleDeferredVariantPrefetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PokemonCardEditDialog::~PokemonCardEditDialog() {
|
||||||
|
if (variantFetchState_) {
|
||||||
|
variantFetchState_->alive.store(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PokemonCardEditDialog::onCardLookupContextChanged() {
|
||||||
|
clearCachedPrintVariants();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
||||||
@@ -27,12 +50,46 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
||||||
setNoCtrl_ = new wxTextCtrl(this, wxID_ANY, constCard().setNo);
|
auto* setNoPanel = new wxPanel(this, wxID_ANY);
|
||||||
appendRow(grid, "Set #", setNoCtrl_);
|
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() {
|
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 (holoCheck_) holoCheck_->SetValue(constCard().holo);
|
||||||
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
|
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
|
||||||
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
|
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
|
||||||
@@ -40,11 +97,159 @@ void PokemonCardEditDialog::readExtraFromCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::writeExtraToCard() {
|
void PokemonCardEditDialog::writeExtraToCard() {
|
||||||
if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString();
|
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
|
||||||
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
|
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
|
||||||
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
|
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
|
||||||
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
|
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
|
||||||
if (alteredCheck_) mutableCard().altered = alteredCheck_->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
|
} // namespace ccm::ui
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
|
|||||||
fresh.language = Language::English;
|
fresh.language = Language::English;
|
||||||
fresh.condition = Condition::NearMint;
|
fresh.condition = Condition::NearMint;
|
||||||
|
|
||||||
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
|
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
|
||||||
&setsForDialog());
|
&setsForDialog());
|
||||||
themeModalDialog(&dlg, config_.current().theme);
|
themeModalDialog(&dlg, config_.current().theme);
|
||||||
if (dlg.ShowModal() != wxID_OK) return;
|
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);
|
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
|
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
|
||||||
&setsForDialog());
|
&setsForDialog());
|
||||||
themeModalDialog(&dlg, config_.current().theme);
|
themeModalDialog(&dlg, config_.current().theme);
|
||||||
if (dlg.ShowModal() != wxID_OK) return;
|
if (dlg.ShowModal() != wxID_OK) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user