mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 21:01:13 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42926f2fb5 | |||
| 8a50e8daba | |||
| 98f2575b5a |
@@ -106,8 +106,8 @@ Run from the **workspace root**.
|
||||
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
|
||||
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
|
||||
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
|
||||
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high.
|
||||
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR.
|
||||
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high. Exercise both outcomes of meaningful conditionals (success vs error, empty vs non-empty, cache hit vs miss, `NotFound` vs `Transient`, early return vs fall-through), not only the happy path — Sonar condition coverage on `core/` is a separate signal from line coverage.
|
||||
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR. When checking coverage locally, also review branch/condition metrics (for example `gcovr ... --txt-metric branch` or Sonar's condition coverage on the same `core/` surface); there is no repo-wide condition threshold in CI yet — use Sonar's per-file condition list to prioritize gaps.
|
||||
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
|
||||
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
|
||||
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Card Collection Manager 3
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
|
||||
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, and `yugioh/` are the reference implementations — all three expose a fully working set source + card preview source.
|
||||
- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`).
|
||||
- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
|
||||
- `src/` mirrors `include/ccm/` for non-template implementations.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
@@ -19,10 +20,16 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit PokemonCardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
|
||||
// Exposed for unit testing and to keep encoding rules in one place.
|
||||
@@ -30,6 +37,11 @@ public:
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
// Slimmer search URL for auto-detect: omits the number clause and asks the
|
||||
// API for only the fields the print-variant parser needs.
|
||||
static std::string buildDetectSearchUrl(std::string_view name,
|
||||
std::string_view setId);
|
||||
|
||||
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
|
||||
// for the first matching card. Prefers `images.large`, falls back to
|
||||
// `images.small`. Errors are classified:
|
||||
@@ -38,6 +50,13 @@ public:
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseResponse(const std::string& body);
|
||||
|
||||
// Enumerate distinct collector numbers (and rarities) for an exact card
|
||||
// name inside the chosen set. Exposed for unit testing without HTTP.
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
// Resolves a Yu-Gi-Oh! product code (YGOPRODeck `set_code`, stored as `Set.id`)
|
||||
// against a cached set list. Used by the Yu-Gi-Oh! edit dialog "set code" mode.
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhSetShorthandLookup {
|
||||
enum class Kind { Unique, NotFound, Ambiguous };
|
||||
|
||||
Kind kind{Kind::NotFound};
|
||||
std::size_t index{0};
|
||||
};
|
||||
|
||||
[[nodiscard]] inline std::string normalizeYuGiOhSetIdForLookup(std::string_view id) {
|
||||
std::string out;
|
||||
out.reserve(id.size());
|
||||
for (unsigned char uch : id) {
|
||||
out.push_back(static_cast<char>(std::tolower(uch)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline std::string_view trimAsciiWhitespace(std::string_view s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline YuGiOhSetShorthandLookup lookupYuGiOhSetByShorthand(
|
||||
std::string_view query, const std::vector<Set>& sets) {
|
||||
const std::string_view trimmed = trimAsciiWhitespace(query);
|
||||
if (trimmed.empty()) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
|
||||
}
|
||||
const std::string qNorm = normalizeYuGiOhSetIdForLookup(trimmed);
|
||||
|
||||
std::size_t firstIdx = 0;
|
||||
int matchCount = 0;
|
||||
for (std::size_t i = 0; i < sets.size(); ++i) {
|
||||
if (normalizeYuGiOhSetIdForLookup(sets[i].id) == qNorm) {
|
||||
if (matchCount == 0) firstIdx = i;
|
||||
++matchCount;
|
||||
if (matchCount > 1) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::Ambiguous, 0};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchCount == 1) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::Unique, firstIdx};
|
||||
}
|
||||
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -3,6 +3,12 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#define CCM_UNREACHABLE() __builtin_unreachable()
|
||||
#else
|
||||
#define CCM_UNREACHABLE() ((void)0)
|
||||
#endif
|
||||
|
||||
namespace ccm {
|
||||
|
||||
std::string_view to_string(Game g) noexcept {
|
||||
@@ -11,7 +17,7 @@ std::string_view to_string(Game g) noexcept {
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
}
|
||||
return "Magic";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Language l) noexcept {
|
||||
@@ -25,7 +31,7 @@ std::string_view to_string(Language l) noexcept {
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Russian: return "Russian";
|
||||
}
|
||||
return "English";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Condition c) noexcept {
|
||||
@@ -38,7 +44,7 @@ std::string_view to_string(Condition c) noexcept {
|
||||
case Condition::Played: return "Played";
|
||||
case Condition::Poor: return "Poor";
|
||||
}
|
||||
return "Mint";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Theme t) noexcept {
|
||||
@@ -46,7 +52,7 @@ std::string_view to_string(Theme t) noexcept {
|
||||
case Theme::Light: return "Light";
|
||||
case Theme::Dark: return "Dark";
|
||||
}
|
||||
return "Light";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
@@ -23,6 +25,19 @@ std::string normalizeNumber(std::string_view setNo) {
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string toLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
@@ -48,6 +63,14 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
rfc3986PercentEncode(query);
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name,
|
||||
std::string_view setId) {
|
||||
std::string url = buildSearchUrl(name, setId, "");
|
||||
url += "&select=name,number,rarity,set";
|
||||
url += "&pageSize=50";
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
@@ -91,4 +114,87 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
|
||||
return R::err("Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
const std::string wantedSetId = trim(std::string(setId));
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
auto pushCard = [&collected](const nlohmann::json& card) {
|
||||
AutoDetectedPrint out;
|
||||
out.setNo = trim(card.value("number", ""));
|
||||
out.rarity = trim(card.value("rarity", ""));
|
||||
if (out.setNo.empty() && out.rarity.empty()) return;
|
||||
collected.push_back(std::move(out));
|
||||
};
|
||||
|
||||
for (const auto& card : j.at("data")) {
|
||||
if (!wantedNameLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedNameLower) continue;
|
||||
}
|
||||
if (!wantedSetId.empty()) {
|
||||
std::string cardSetId;
|
||||
if (card.contains("set") && card.at("set").is_object()) {
|
||||
cardSetId = trim(card.at("set").value("id", ""));
|
||||
}
|
||||
if (cardSetId != wantedSetId) continue;
|
||||
}
|
||||
pushCard(card);
|
||||
}
|
||||
|
||||
if (collected.empty()) {
|
||||
if (!wantedNameLower.empty() && !wantedSetId.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return R::err("Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> deduped;
|
||||
deduped.reserve(collected.size());
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(collected.size() * 2);
|
||||
for (auto& p : collected) {
|
||||
const std::string key = p.setNo + '\0' + p.rarity;
|
||||
if (seen.insert(key).second) deduped.push_back(std::move(p));
|
||||
}
|
||||
return R::ok(std::move(deduped));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto list = detectPrintVariants(name, setId);
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVariants(
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string url = buildDetectSearchUrl(name, setId);
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
return parsePrintVariants(resp.value(), setId, name);
|
||||
}
|
||||
const std::string fallbackUrl = buildDetectSearchUrl(name, "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) return R::err(fallback.error());
|
||||
return parsePrintVariants(fallback.value(), setId, name);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -31,6 +31,17 @@ std::string toLower(std::string s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string canonicalizeSetNameForAutoDetect(std::string_view setName) {
|
||||
std::string canonical = trim(std::string(setName));
|
||||
constexpr std::string_view k25thSuffix = " (25th Anniversary Edition)";
|
||||
if (canonical.size() > k25thSuffix.size()
|
||||
&& canonical.ends_with(k25thSuffix)) {
|
||||
canonical.erase(canonical.size() - k25thSuffix.size());
|
||||
canonical = trim(std::move(canonical));
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
|
||||
// always return card_images[0]: when no `cardset=` filter is applied, that
|
||||
// slot is the original/standard artwork (alt-art passcodes follow), which is
|
||||
@@ -366,7 +377,7 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVarian
|
||||
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
|
||||
return R::err("YGOPRODeck returned no matching cards.");
|
||||
}
|
||||
const std::string wantedSet = trim(std::string(preferredSetName));
|
||||
const std::string wantedSet = canonicalizeSetNameForAutoDetect(preferredSetName);
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
@@ -532,15 +543,16 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVaria
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string url = buildSearchUrl(name, setId);
|
||||
const std::string canonicalSetName = canonicalizeSetNameForAutoDetect(setId);
|
||||
const std::string url = buildSearchUrl(name, canonicalSetName);
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
return parsePrintVariants(resp.value(), setId, name);
|
||||
return parsePrintVariants(resp.value(), canonicalSetName, name);
|
||||
}
|
||||
const std::string fallbackUrl = buildSearchUrl(name, "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) return R::err(fallback.error());
|
||||
return parsePrintVariants(fallback.value(), setId, name);
|
||||
return parsePrintVariants(fallback.value(), canonicalSetName, name);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -16,6 +16,7 @@ struct YuGiOhSetAlias {
|
||||
};
|
||||
|
||||
constexpr std::array<YuGiOhSetAlias, 6> kMissing25thAnniversaryReprints{{
|
||||
// Keep this list in sync with docs/assets-and-info-apis.md (Info API section).
|
||||
{"LOB-25TH", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"MRD-25TH", "Metal Raiders (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"SRL-25TH", "Spell Ruler (25th Anniversary Edition)", "2023/04/20"},
|
||||
|
||||
@@ -218,18 +218,12 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
|
||||
// and, if needed, fetch+store.
|
||||
const std::string key = makeUrlKey(url);
|
||||
std::string cached;
|
||||
switch (cacheLookup(key, cached)) {
|
||||
case CacheLookupKind::Hit:
|
||||
return Result<std::string>::ok(std::move(cached));
|
||||
case CacheLookupKind::NegativeHit:
|
||||
// Defensive: nothing in this code path ever stores a negative
|
||||
// entry under a URL key, but if one ever ends up here (cache
|
||||
// file tampering, future code paths) treat it as a miss so the
|
||||
// fallback fetch can still run.
|
||||
break;
|
||||
case CacheLookupKind::Miss:
|
||||
break;
|
||||
const auto mem = cacheLookup(key, cached);
|
||||
if (mem == CacheLookupKind::Hit) {
|
||||
return Result<std::string>::ok(std::move(cached));
|
||||
}
|
||||
// Miss, or a spurious negative under a URL key (never written by normal
|
||||
// code) — both continue to disk / network.
|
||||
if (persistentCache_ != nullptr) {
|
||||
const auto disk = persistentCache_->load(key);
|
||||
if (disk.kind == IPreviewByteCache::HitKind::Hit) {
|
||||
|
||||
@@ -78,7 +78,6 @@ std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
value = value * 10 + static_cast<unsigned int>(filename[i] - '0');
|
||||
}
|
||||
if (value > 255) value = 255;
|
||||
return static_cast<std::uint8_t>(value);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Long-form contributor documentation that lives outside the source tree.
|
||||
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
|
||||
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
|
||||
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
|
||||
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, and Yu-Gi-Oh! modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png`), and error-surface conventions.
|
||||
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, and Yu-Gi-Oh! modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png`), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
|
||||
- `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching).
|
||||
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
|
||||
|
||||
|
||||
@@ -326,6 +326,12 @@ Derive from `BaseCardEditDialog<<Name>Card>`. Override:
|
||||
- `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`.
|
||||
- `updateMenuName()` — return `"Update <Display>"`. This is what the dialog's "no sets cached" hint shows the user.
|
||||
|
||||
Optional `BaseCardEditDialog` extension points (defaults keep a single read-only set combo in the **Set** row):
|
||||
|
||||
- `customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo)` — the base wraps the combo in a host panel and calls this so a game can add adjacent controls (Yu-Gi-Oh! adds a **Set code** toggle, a text field, and **Auto detect** beside the combo). The default implementation only does `row.Add(combo, 1, wxEXPAND)`.
|
||||
- `applySetSelectionByIndex(std::size_t index)` (non-virtual helper on the base) — selects a row in the combo and assigns `card_.set` from `availableSets()[index]` when the combo is enabled.
|
||||
- `onSetSelectionApplied()` — called after `applySetSelectionByIndex` completes; default no-op. Yu-Gi-Oh! overrides it to clear cached print-variant metadata and reschedule the same follow-up as a manual `wxEVT_COMBOBOX` set change.
|
||||
|
||||
In the constructor:
|
||||
|
||||
1. Pass through to the `BaseCardEditDialog` constructor with the dialog title (e.g. `"Add <Display> Card"` or `"Edit <Display> Card"` based on `EditMode`), `imageService`, `setService`, `mode`, `std::move(initial)`, `Game::<Name>`, and the optional `preloadedSets` pointer.
|
||||
@@ -354,10 +360,10 @@ Implement the virtuals:
|
||||
|
||||
- `gameId()` returns `Game::<Name>`.
|
||||
- `displayName()` returns `"<Display>"`.
|
||||
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`. **The binding must live here**, in the typed `IGameView`, not in `MainFrame` — `MainFrame` only sees `IGameView` and never `<Name>Card`.
|
||||
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`, and `Bind(EVT_CARD_ACTIVATED, ...)` so a double-click (or Enter on the focused row) calls `onEditCard` with `wxGetTopLevelParent(listPanel_)` as the modal owner when available. **The binding must live here**, in the typed `IGameView`, not in `MainFrame` — `MainFrame` only sees `IGameView` and never `<Name>Card`.
|
||||
- `selectedPanel(parent)` — lazily allocates the selected panel.
|
||||
- `refreshCollection()` — calls `collection_.list(Game::<Name>)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(...)`. Also re-syncs the selected panel.
|
||||
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success.
|
||||
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. For Add/Edit, follow the built-in game views: if `cardEditModalIsActive()` from `ccm/ui/CardEditModalGuard.hpp`, show a themed info dialog and return; otherwise wrap `ShowModal()` with `CardEditModalGuard` so a second Add/Edit cannot stack while one card dialog is already open.
|
||||
- `onUpdateSets(parent)` — calls `sets_.updateSets(Game::<Name>)`, refreshes `setsCache_`, returns a status string.
|
||||
- `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`.
|
||||
- `applyTheme(palette)` — forwards to both panels' `applyTheme`.
|
||||
|
||||
@@ -22,9 +22,13 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
|
||||
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
|
||||
|
||||
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
|
||||
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
|
||||
Used by `PokemonCardPreviewSource` in two ways:
|
||||
|
||||
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
|
||||
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
|
||||
|
||||
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the picker’s **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the card’s `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
|
||||
|
||||
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
|
||||
|
||||
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
|
||||
|
||||
@@ -39,6 +43,10 @@ Upstream documentation:
|
||||
`https://db.ygoprodeck.com/api/v7/cardsets.php`
|
||||
Used by `YuGiOhSetSource`. The response is a top-level JSON array. Each object maps `set_code` → internal `Set.id`, `set_name` → `Set.name`, and `tcg_date` → `Set.releaseDate` with `-` rewritten to `/` for consistency with other games’ date strings. Results are sorted ascending by `releaseDate`.
|
||||
|
||||
CCM3 also applies a deterministic local patch step in `YuGiOhSetSource::appendMissingSetAliases(...)` after parsing: if upstream omits known 25th Anniversary TCG reprints, the app injects missing aliases for `LOB-25TH`, `MRD-25TH`, `SRL-25TH`, `PSV-25TH`, `DCR-25TH`, and `IOC-25TH` (with fixed release dates) so users can still select those products in the set picker.
|
||||
|
||||
**UI note (set code entry, no extra HTTP):** The Yu-Gi-Oh! Add/Edit dialog can resolve a typed **product code** against the **already cached** set vector (same data as the set dropdown). Matching is implemented in `core/include/ccm/util/YuGiOhSetLookup.hpp` as `lookupYuGiOhSetByShorthand(...)`: trim ASCII whitespace, ASCII case-fold, then require an **exact** match on `Set.id` (the YGOPRODeck `set_code`). Zero matches → user error; more than one row with the same normalized id → ambiguous error (defensive). On a unique hit the dialog returns to the dropdown and selects that set.
|
||||
|
||||
### Asset API: Yugipedia `api.php` (primary)
|
||||
|
||||
`https://yugipedia.com/api.php?action=query&prop=imageinfo&iiprop=url&titles=...`
|
||||
@@ -112,6 +120,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
|
||||
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
|
||||
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
|
||||
|
||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
||||
|
||||
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the 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.
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
|
||||
- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
|
||||
- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
|
||||
- `yugioh_set_lookup_tests.cpp` — `lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous).
|
||||
- `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`.
|
||||
- `yugioh_card_preview_source_tests.cpp` — `YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005` → `LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
|
||||
- `card_sorter_tests.cpp` — `sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key.
|
||||
|
||||
@@ -23,10 +23,12 @@ add_executable(ccm_core_tests
|
||||
pokemon_card_preview_source_tests.cpp
|
||||
icard_preview_source_tests.cpp
|
||||
yugioh_set_source_tests.cpp
|
||||
yugioh_set_lookup_tests.cpp
|
||||
yugioh_card_preview_source_tests.cpp
|
||||
game_module_tests.cpp
|
||||
card_sorter_tests.cpp
|
||||
card_filter_tests.cpp
|
||||
ascii_utils_tests.cpp
|
||||
http_get_mapping_tests.cpp
|
||||
cpr_http_client_tests.cpp
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
TEST_CASE("bytes above ASCII range are passed through tolower unchanged") {
|
||||
const std::string input(1, static_cast<char>('\x80'));
|
||||
CHECK(asciiLower(input) == input);
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,13 @@ YuGiOhCard yc(std::string name,
|
||||
std::string setName,
|
||||
std::string setNo = "",
|
||||
std::string rarity = "",
|
||||
std::uint8_t amount = 1) {
|
||||
std::uint8_t amount = 1,
|
||||
Language lang = Language::English,
|
||||
Condition cond = Condition::NearMint,
|
||||
std::string note = "",
|
||||
bool firstEdition = false,
|
||||
bool sgnd = false,
|
||||
bool altered = false) {
|
||||
YuGiOhCard c;
|
||||
c.id = 1;
|
||||
c.name = std::move(name);
|
||||
@@ -65,6 +71,12 @@ YuGiOhCard yc(std::string name,
|
||||
c.setNo = std::move(setNo);
|
||||
c.rarity = std::move(rarity);
|
||||
c.amount = amount;
|
||||
c.language = lang;
|
||||
c.condition = cond;
|
||||
c.note = std::move(note);
|
||||
c.firstEdition = firstEdition;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -176,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +334,78 @@ TEST_SUITE("CardPreviewService caching") {
|
||||
CHECK(http.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("memory-only caching works when no persistent cache is configured") {
|
||||
FakeSource source;
|
||||
source.url = "https://example.com/img.png";
|
||||
FakeGameModule module;
|
||||
module.gameId = Game::Magic;
|
||||
module.preview = &source;
|
||||
|
||||
FixedHttpClient http;
|
||||
http.body = "PNG-bytes";
|
||||
|
||||
CardPreviewService svc{http, nullptr};
|
||||
svc.registerModule(module);
|
||||
|
||||
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "").isOk());
|
||||
http.body = "OTHER";
|
||||
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
||||
REQUIRE(second.isOk());
|
||||
CHECK(second.value() == "PNG-bytes");
|
||||
CHECK(http.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("registerModule replaces the preview source for the same game") {
|
||||
FakeSource firstSource;
|
||||
firstSource.url = "https://example.com/first.png";
|
||||
FakeGameModule firstModule;
|
||||
firstModule.gameId = Game::Magic;
|
||||
firstModule.preview = &firstSource;
|
||||
|
||||
FakeSource secondSource;
|
||||
secondSource.url = "https://example.com/second.png";
|
||||
FakeGameModule secondModule;
|
||||
secondModule.gameId = Game::Magic;
|
||||
secondModule.preview = &secondSource;
|
||||
|
||||
FixedHttpClient http;
|
||||
http.body = "SECOND";
|
||||
|
||||
CardPreviewService svc{http};
|
||||
svc.registerModule(firstModule);
|
||||
svc.registerModule(secondModule);
|
||||
|
||||
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "SECOND");
|
||||
CHECK(http.lastUrl == "https://example.com/second.png");
|
||||
CHECK(firstSource.calls == 0);
|
||||
CHECK(secondSource.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("NotFound without persistent cache still negative-caches in memory") {
|
||||
FakeSource source;
|
||||
source.ok = false;
|
||||
source.errKind = PreviewLookupError::Kind::NotFound;
|
||||
source.err = "not found";
|
||||
FakeGameModule module;
|
||||
module.gameId = Game::Magic;
|
||||
module.preview = &source;
|
||||
|
||||
FixedHttpClient http;
|
||||
CardPreviewService svc{http, nullptr};
|
||||
svc.registerModule(module);
|
||||
|
||||
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "X", "abc", "").isErr());
|
||||
CHECK(source.calls == 1);
|
||||
|
||||
const auto second = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
|
||||
REQUIRE(second.isErr());
|
||||
CHECK(second.error() == "No preview available for this card.");
|
||||
CHECK(source.calls == 1);
|
||||
CHECK(http.calls == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("HTTP success writes through to the persistent cache") {
|
||||
// The persistent tier is fire-and-forget on the way down (HTTP -> disk)
|
||||
// and consulted on the way up (cache miss -> disk -> HTTP). This first
|
||||
@@ -703,6 +775,17 @@ TEST_SUITE("CardPreviewService caching") {
|
||||
CHECK(http.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("fetchImageBytesByUrl propagates HTTP errors when uncached") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
http.err = "url fetch failed";
|
||||
CardPreviewService svc{http};
|
||||
|
||||
const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "url fetch failed");
|
||||
}
|
||||
|
||||
TEST_CASE("fetchImageBytesByUrl serves from persistent cache hit without HTTP") {
|
||||
FixedHttpClient http;
|
||||
http.body = "warm-card-back";
|
||||
|
||||
@@ -430,6 +430,17 @@ TEST_SUITE("CardSorter - YuGiOh columns") {
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // C, ScR, UR
|
||||
}
|
||||
|
||||
TEST_CASE("Rarity treats unknown labels as equal empty shorthand") {
|
||||
std::vector<YuGiOhCard> v = {
|
||||
yc(1, "alpha", "X", "2000/01/01", "", "Mythic Cosmic Rare", 1),
|
||||
yc(2, "beta", "X", "2000/01/01", "", "Other Unknown", 1),
|
||||
};
|
||||
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
|
||||
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Amount sorts numerically") {
|
||||
std::vector<YuGiOhCard> v = {
|
||||
yc(1, "a", "X", "2000/01/01", "", "", 9),
|
||||
|
||||
@@ -59,6 +59,16 @@ MagicCard makeCard(const std::string& name, std::vector<std::string> imgs = {})
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CollectionService<MagicCard>") {
|
||||
TEST_CASE("nextId uses highest existing id plus one") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
repo.storage.emplace(2, makeCard("A"));
|
||||
repo.storage.emplace(9, makeCard("B"));
|
||||
CHECK(CollectionService<MagicCard>::nextId(repo.storage) == 10);
|
||||
}
|
||||
|
||||
TEST_CASE("nextId on empty map is 0, then strictly increments") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
@@ -163,6 +173,21 @@ TEST_SUITE("CollectionService<MagicCard>") {
|
||||
CHECK(updateRes.error() == "save failed");
|
||||
}
|
||||
|
||||
TEST_CASE("add overwrites input card id with generated id") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
MagicCard card = makeCard("Has User Id");
|
||||
card.id = 777;
|
||||
const auto out = svc.add(Game::Magic, card);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == 0);
|
||||
REQUIRE(repo.storage.count(0) == 1);
|
||||
CHECK(repo.storage.at(0).name == "Has User Id");
|
||||
CHECK(repo.storage.count(777) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("findById returns nullopt for missing id") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
@@ -193,4 +218,22 @@ TEST_SUITE("CollectionService<MagicCard>") {
|
||||
REQUIRE(listed.isOk());
|
||||
CHECK(listed.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("remove propagates save failure after image cleanup") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto id = svc.add(
|
||||
Game::Magic, makeCard("With Images", {"a.png", "b.png"}));
|
||||
REQUIRE(id.isOk());
|
||||
|
||||
repo.failSave = true;
|
||||
const auto removed = svc.remove(Game::Magic, id.value());
|
||||
REQUIRE(removed.isErr());
|
||||
CHECK(removed.error() == "save failed");
|
||||
REQUIRE(store.removed.size() == 2);
|
||||
CHECK(store.removed[0].second == "a.png");
|
||||
CHECK(store.removed[1].second == "b.png");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,59 @@ TEST_SUITE("CprHttpClient injected raw executor") {
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("timeout") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("passes URL through raw executor unchanged") {
|
||||
std::string seenUrl;
|
||||
CprHttpClient client{
|
||||
[&seenUrl](std::string_view url) -> CprHttpClient::RawResponse {
|
||||
seenUrl = std::string(url);
|
||||
return CprHttpClient::RawResponse{
|
||||
.transportError = false,
|
||||
.transportMessage = "",
|
||||
.statusCode = 200,
|
||||
.body = "ok",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const auto out = client.get("https://example.com/raw?q=a%20b");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(seenUrl == "https://example.com/raw?q=a%20b");
|
||||
}
|
||||
|
||||
TEST_CASE("maps non-2xx status to error") {
|
||||
CprHttpClient client{
|
||||
[](std::string_view) -> CprHttpClient::RawResponse {
|
||||
return CprHttpClient::RawResponse{
|
||||
.transportError = false,
|
||||
.transportMessage = "",
|
||||
.statusCode = 503,
|
||||
.body = "service unavailable",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const auto out = client.get("https://example.com/fail");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("HTTP 503") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("transport error takes precedence over status code") {
|
||||
CprHttpClient client{
|
||||
[](std::string_view) -> CprHttpClient::RawResponse {
|
||||
return CprHttpClient::RawResponse{
|
||||
.transportError = true,
|
||||
.transportMessage = "socket closed",
|
||||
.statusCode = 200,
|
||||
.body = "ignored",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const auto out = client.get("https://example.com/transport");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("socket closed") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CprHttpClient real session") {
|
||||
|
||||
@@ -235,6 +235,103 @@ TEST_SUITE("YuGiOhCard JSON") {
|
||||
CHECK(card.setNo == "SDY-006");
|
||||
CHECK(card.set.id == "SDY");
|
||||
}
|
||||
|
||||
TEST_CASE("serializes non-default flags and metadata fields") {
|
||||
YuGiOhCard c;
|
||||
c.id = 3;
|
||||
c.amount = 4;
|
||||
c.name = "Red-Eyes Black Dragon";
|
||||
c.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
|
||||
c.setNo = "LOB-070";
|
||||
c.rarity = "Secret Rare";
|
||||
c.note = "graded";
|
||||
c.images = {};
|
||||
c.language = Language::German;
|
||||
c.condition = Condition::Played;
|
||||
c.firstEdition = false;
|
||||
c.signed_ = true;
|
||||
c.altered = true;
|
||||
|
||||
const nlohmann::json j = c;
|
||||
CHECK(j.at("firstEdition") == false);
|
||||
CHECK(j.at("signed") == true);
|
||||
CHECK(j.at("altered") == true);
|
||||
CHECK(j.at("language") == "German");
|
||||
CHECK(j.at("condition") == "Played");
|
||||
CHECK(j.at("images") == nlohmann::json::array());
|
||||
|
||||
const YuGiOhCard back = j.get<YuGiOhCard>();
|
||||
CHECK(back == c);
|
||||
}
|
||||
|
||||
TEST_CASE("operator== distinguishes each field") {
|
||||
YuGiOhCard base;
|
||||
base.id = 10;
|
||||
base.amount = 2;
|
||||
base.name = "Dark Magician";
|
||||
base.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
|
||||
base.setNo = "LOB-005";
|
||||
base.rarity = "Ultra Rare";
|
||||
base.note = "note";
|
||||
base.images = {"a.png"};
|
||||
base.language = Language::English;
|
||||
base.condition = Condition::NearMint;
|
||||
base.firstEdition = true;
|
||||
base.signed_ = false;
|
||||
base.altered = false;
|
||||
|
||||
auto changed = base;
|
||||
changed.id = 11;
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.amount = 3;
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.name = "Other";
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.set.name = "Other Set";
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.setNo = "LOB-006";
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.rarity = "Rare";
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.note = "other";
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.images = {};
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.language = Language::Japanese;
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.condition = Condition::Played;
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.firstEdition = false;
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.signed_ = true;
|
||||
CHECK_FALSE(changed == base);
|
||||
|
||||
changed = base;
|
||||
changed.altered = true;
|
||||
CHECK_FALSE(changed == base);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("Domain JSON required fields") {
|
||||
@@ -290,6 +387,116 @@ TEST_SUITE("Domain JSON required fields") {
|
||||
CHECK_THROWS(j.get<PokemonCard>());
|
||||
}
|
||||
|
||||
TEST_CASE("YuGiOhCard missing required key throws") {
|
||||
const nlohmann::json j = {
|
||||
{"id", 7},
|
||||
{"amount", 1},
|
||||
{"name", "Blue-Eyes White Dragon"},
|
||||
{"set", nlohmann::json{
|
||||
{"id", "sdk"},
|
||||
{"name", "Starter Deck Kaiba"},
|
||||
{"releaseDate", "2002/03/29"},
|
||||
}},
|
||||
{"setNo", "SDK-001"},
|
||||
{"note", ""},
|
||||
{"images", nlohmann::json::array()},
|
||||
{"language", "English"},
|
||||
{"condition", "NearMint"},
|
||||
{"firstEdition", true},
|
||||
// rarity missing on purpose
|
||||
{"signed", false},
|
||||
{"altered", false},
|
||||
};
|
||||
CHECK_THROWS(j.get<YuGiOhCard>());
|
||||
}
|
||||
|
||||
TEST_CASE("YuGiOhCard missing each required key throws") {
|
||||
const nlohmann::json full = {
|
||||
{"id", 7},
|
||||
{"amount", 1},
|
||||
{"name", "Blue-Eyes White Dragon"},
|
||||
{"set", nlohmann::json{
|
||||
{"id", "sdk"},
|
||||
{"name", "Starter Deck Kaiba"},
|
||||
{"releaseDate", "2002/03/29"},
|
||||
}},
|
||||
{"setNo", "SDK-001"},
|
||||
{"rarity", "Ultra Rare"},
|
||||
{"note", ""},
|
||||
{"images", nlohmann::json::array()},
|
||||
{"language", "English"},
|
||||
{"condition", "NearMint"},
|
||||
{"firstEdition", true},
|
||||
{"signed", false},
|
||||
{"altered", false},
|
||||
};
|
||||
|
||||
for (const char* key : {
|
||||
"id", "amount", "name", "set", "setNo", "note", "images",
|
||||
"language", "condition", "firstEdition", "rarity", "signed", "altered"}) {
|
||||
nlohmann::json partial = full;
|
||||
partial.erase(key);
|
||||
CHECK_THROWS(partial.get<YuGiOhCard>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("MagicCard missing each required key throws") {
|
||||
const nlohmann::json full = {
|
||||
{"id", 10},
|
||||
{"amount", 1},
|
||||
{"name", "Lightning Bolt"},
|
||||
{"set", nlohmann::json{
|
||||
{"id", "lea"},
|
||||
{"name", "Limited Edition Alpha"},
|
||||
{"releaseDate", "1993/08/05"},
|
||||
}},
|
||||
{"note", ""},
|
||||
{"images", nlohmann::json::array()},
|
||||
{"language", "English"},
|
||||
{"condition", "NearMint"},
|
||||
{"foil", false},
|
||||
{"signed", false},
|
||||
{"altered", false},
|
||||
};
|
||||
|
||||
for (const char* key : {"id", "amount", "name", "set", "note", "images", "language",
|
||||
"condition", "foil", "signed", "altered"}) {
|
||||
nlohmann::json partial = full;
|
||||
partial.erase(key);
|
||||
CHECK_THROWS(partial.get<MagicCard>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PokemonCard missing each required key throws") {
|
||||
const nlohmann::json full = {
|
||||
{"id", 7},
|
||||
{"amount", 1},
|
||||
{"name", "Charizard"},
|
||||
{"set", nlohmann::json{
|
||||
{"id", "base1"},
|
||||
{"name", "Base Set"},
|
||||
{"releaseDate", "1999/01/09"},
|
||||
}},
|
||||
{"setNo", "4/102"},
|
||||
{"note", ""},
|
||||
{"images", nlohmann::json::array()},
|
||||
{"language", "English"},
|
||||
{"condition", "Excellent"},
|
||||
{"firstEdition", true},
|
||||
{"holo", true},
|
||||
{"signed", false},
|
||||
{"altered", false},
|
||||
};
|
||||
|
||||
for (const char* key :
|
||||
{"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition",
|
||||
"firstEdition", "holo", "signed", "altered"}) {
|
||||
nlohmann::json partial = full;
|
||||
partial.erase(key);
|
||||
CHECK_THROWS(partial.get<PokemonCard>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Configuration missing required key throws") {
|
||||
const nlohmann::json j = {
|
||||
{"defaultGame", "Magic"},
|
||||
@@ -297,4 +504,13 @@ TEST_SUITE("Domain JSON required fields") {
|
||||
};
|
||||
CHECK_THROWS(j.get<Configuration>());
|
||||
}
|
||||
|
||||
TEST_CASE("Configuration invalid theme value throws when present") {
|
||||
const nlohmann::json j = {
|
||||
{"dataStorage", "/portable/data"},
|
||||
{"defaultGame", "Magic"},
|
||||
{"theme", "Neon"},
|
||||
};
|
||||
CHECK_THROWS(j.get<Configuration>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
|
||||
CHECK(module.dirName() == "magic");
|
||||
CHECK(module.displayName() == "Magic");
|
||||
CHECK(module.cardPreviewSource() != nullptr);
|
||||
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||
}
|
||||
|
||||
TEST_CASE("Pokemon module reports canonical metadata") {
|
||||
@@ -37,6 +38,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
|
||||
CHECK(module.dirName() == "pokemon");
|
||||
CHECK(module.displayName() == "Pokemon");
|
||||
CHECK(module.cardPreviewSource() != nullptr);
|
||||
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||
}
|
||||
|
||||
TEST_CASE("YuGiOh module reports canonical metadata") {
|
||||
@@ -47,5 +49,6 @@ TEST_SUITE("game modules expose stable identity and wiring") {
|
||||
CHECK(module.dirName() == "yugioh");
|
||||
CHECK(module.displayName() == "Yu-Gi-Oh!");
|
||||
CHECK(module.cardPreviewSource() != nullptr);
|
||||
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,20 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
class AutoDetectPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
|
||||
return true;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view,
|
||||
std::string_view,
|
||||
std::string_view) override {
|
||||
return Result<std::string, PreviewLookupError>::ok("https://example.test/card.png");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("ICardPreviewSource defaults") {
|
||||
@@ -25,6 +39,11 @@ TEST_SUITE("ICardPreviewSource defaults") {
|
||||
CHECK_FALSE(src.supportsAutoDetectPrint());
|
||||
}
|
||||
|
||||
TEST_CASE("implementations may override supportsAutoDetectPrint") {
|
||||
AutoDetectPreviewSource src;
|
||||
CHECK(src.supportsAutoDetectPrint());
|
||||
}
|
||||
|
||||
TEST_CASE("default detectFirstPrint returns explicit unsupported error") {
|
||||
MinimalPreviewSource src;
|
||||
const auto out = src.detectFirstPrint("Card", "Set");
|
||||
|
||||
@@ -68,6 +68,11 @@ TEST_SUITE("ImageService::nextImageIndex") {
|
||||
std::vector<std::string> imgs = {"set+name+99.png"};
|
||||
CHECK(ImageService::nextImageIndex(imgs) == 100);
|
||||
}
|
||||
|
||||
TEST_CASE("three-digit filename index follows two-digit compatibility parser") {
|
||||
std::vector<std::string> imgs = {"set+name+255.png"};
|
||||
CHECK(ImageService::nextImageIndex(imgs) == 56);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("ImageService::buildTargetName") {
|
||||
@@ -98,6 +103,40 @@ TEST_SUITE("ImageService::addImage") {
|
||||
CHECK(store.copies[0].game == Game::Magic);
|
||||
CHECK(store.copies[0].target == "Beta+BlackLotus+0");
|
||||
}
|
||||
|
||||
TEST_CASE("propagates copyIn failures") {
|
||||
RecordingImageStore store;
|
||||
store.failCopyAt = 1;
|
||||
ImageService svc{store};
|
||||
|
||||
std::vector<std::string> existing;
|
||||
const auto out = svc.addImage(Game::Magic, "/tmp/source.png",
|
||||
/*newEntry=*/true, /*cardId=*/0,
|
||||
"Beta", "Black Lotus", existing);
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("copy failed at 1") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("ImageService::removeImage and resolveImagePath") {
|
||||
TEST_CASE("removeImage delegates to store remove") {
|
||||
RecordingImageStore store;
|
||||
ImageService svc{store};
|
||||
|
||||
const auto out = svc.removeImage(Game::Magic, "x.png");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(store.removes.size() == 1);
|
||||
CHECK(store.removes[0].first == Game::Magic);
|
||||
CHECK(store.removes[0].second == "x.png");
|
||||
}
|
||||
|
||||
TEST_CASE("resolveImagePath delegates to store resolvePath") {
|
||||
RecordingImageStore store;
|
||||
ImageService svc{store};
|
||||
|
||||
const auto p = svc.resolveImagePath(Game::Pokemon, "pikachu.jpg");
|
||||
CHECK(p == std::filesystem::path("/fake/pikachu.jpg"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
|
||||
@@ -142,6 +181,20 @@ TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
|
||||
CHECK(store.removes.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("skips rename when computed output name equals input") {
|
||||
RecordingImageStore store;
|
||||
ImageService svc{store};
|
||||
|
||||
const std::vector<std::string> images{"42+Beta+BlackLotus+0.png"};
|
||||
auto normalized = svc.normalizeNamesForPersistedCard(
|
||||
Game::Magic, 42, "Beta", "Black Lotus", images);
|
||||
|
||||
REQUIRE(normalized.isOk());
|
||||
CHECK(normalized.value() == images);
|
||||
CHECK(store.copies.empty());
|
||||
CHECK(store.removes.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("copy failure rolls back already-created names and returns error") {
|
||||
RecordingImageStore store;
|
||||
store.failCopyAt = 2;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
using namespace ccm;
|
||||
using ccm::testing::InMemoryFileSystem;
|
||||
|
||||
@@ -27,6 +29,37 @@ ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
|
||||
|
||||
std::string magicDir(Game g) { return g == Game::Magic ? "magic" : "pokemon"; }
|
||||
|
||||
class FailingCollectionFs final : public IFileSystem {
|
||||
public:
|
||||
bool existsValue{true};
|
||||
bool ensureOk{true};
|
||||
bool writeOk{true};
|
||||
bool readOk{true};
|
||||
std::string readPayload{"{}"};
|
||||
|
||||
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return existsValue; }
|
||||
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
|
||||
Result<void> ensureDirectory(const std::filesystem::path&) override {
|
||||
if (!ensureOk) return Result<void>::err("ensure failed");
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<std::string> readText(const std::filesystem::path&) override {
|
||||
if (!readOk) return Result<std::string>::err("read failed");
|
||||
return Result<std::string>::ok(readPayload);
|
||||
}
|
||||
Result<void> writeText(const std::filesystem::path&, std::string_view) override {
|
||||
if (!writeOk) return Result<void>::err("write failed");
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<void> remove(const std::filesystem::path&) override { return Result<void>::ok(); }
|
||||
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
|
||||
return Result<std::vector<std::filesystem::path>>::ok({});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("JsonCollectionRepository<MagicCard>") {
|
||||
@@ -85,4 +118,73 @@ TEST_SUITE("JsonCollectionRepository<MagicCard>") {
|
||||
REQUIRE(j.contains("17"));
|
||||
CHECK(j.at("17").at("id") == 17);
|
||||
}
|
||||
|
||||
TEST_CASE("load returns parse error for non-object root") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
fs.writeText("/data/magic/collection.json", R"(["not","an","object"])");
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("load returns parse error for non-numeric object keys") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
fs.writeText("/data/magic/collection.json", R"({"abc":{"id":1}})");
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("save and initialize-on-load propagate ensureDirectory/write errors") {
|
||||
InMemoryFileSystem configFs;
|
||||
auto cfg = makeConfig(configFs, "/data");
|
||||
FailingCollectionFs fs;
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
|
||||
fs.ensureOk = false;
|
||||
const auto saveEnsureFail = repo.save(Game::Magic, {});
|
||||
REQUIRE(saveEnsureFail.isErr());
|
||||
CHECK(saveEnsureFail.error() == "ensure failed");
|
||||
|
||||
fs.ensureOk = true;
|
||||
fs.writeOk = false;
|
||||
const auto saveWriteFail = repo.save(Game::Magic, {});
|
||||
REQUIRE(saveWriteFail.isErr());
|
||||
CHECK(saveWriteFail.error() == "write failed");
|
||||
|
||||
fs.existsValue = false;
|
||||
const auto loadCreateFail = repo.load(Game::Magic);
|
||||
REQUIRE(loadCreateFail.isErr());
|
||||
CHECK(loadCreateFail.error() == "write failed");
|
||||
}
|
||||
|
||||
TEST_CASE("load returns read error when collection exists but read fails") {
|
||||
InMemoryFileSystem configFs;
|
||||
auto cfg = makeConfig(configFs, "/data");
|
||||
FailingCollectionFs fs;
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
fs.existsValue = true;
|
||||
fs.readOk = false;
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error() == "read failed");
|
||||
}
|
||||
|
||||
TEST_CASE("load returns parse error when card object does not deserialize") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
fs.writeText("/data/magic/collection.json", R"({"0":{"id":"not-a-number"}})");
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ public:
|
||||
std::string readPayload{"[]"};
|
||||
std::filesystem::path lastWritePath;
|
||||
std::string lastWriteBody;
|
||||
std::filesystem::path lastReadPath;
|
||||
|
||||
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
|
||||
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
|
||||
@@ -40,6 +41,7 @@ public:
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<std::string> readText(const std::filesystem::path&) override {
|
||||
lastReadPath = std::filesystem::path("/tracked/read/path");
|
||||
if (!readOk) return Result<std::string>::err("read failed");
|
||||
return Result<std::string>::ok(readPayload);
|
||||
}
|
||||
@@ -109,6 +111,18 @@ TEST_SUITE("JsonSetRepository") {
|
||||
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("load reports parse error for wrong JSON shape") {
|
||||
InMemoryFileSystem configFs;
|
||||
auto cfg = makeConfig(configFs, "/data");
|
||||
FailingSetFs fs;
|
||||
fs.readPayload = R"({"not":"an array"})";
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("save propagates ensureDirectory and writeText failures") {
|
||||
InMemoryFileSystem configFs;
|
||||
auto cfg = makeConfig(configFs, "/data");
|
||||
@@ -128,4 +142,14 @@ TEST_SUITE("JsonSetRepository") {
|
||||
REQUIRE(writeFail.isErr());
|
||||
CHECK(writeFail.error() == "write failed");
|
||||
}
|
||||
|
||||
TEST_CASE("paths are composed from dataStorage and game dir") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
|
||||
|
||||
REQUIRE(repo.save(Game::Pokemon, sets).isOk());
|
||||
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,37 @@ std::string dirNameForGame(Game g) {
|
||||
return "magic";
|
||||
}
|
||||
|
||||
class FailingImageFs final : public IFileSystem {
|
||||
public:
|
||||
bool ensureOk{true};
|
||||
bool copyOk{true};
|
||||
bool removeOk{true};
|
||||
|
||||
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
|
||||
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
|
||||
Result<void> ensureDirectory(const std::filesystem::path&) override {
|
||||
if (!ensureOk) return Result<void>::err("ensure failed");
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<std::string> readText(const std::filesystem::path&) override {
|
||||
return Result<std::string>::ok({});
|
||||
}
|
||||
Result<void> writeText(const std::filesystem::path&, std::string_view) override {
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
|
||||
if (!copyOk) return Result<void>::err("copy failed");
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<void> remove(const std::filesystem::path&) override {
|
||||
if (!removeOk) return Result<void>::err("remove failed");
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
|
||||
return Result<std::vector<std::filesystem::path>>::ok({});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("LocalImageStore") {
|
||||
@@ -87,4 +118,30 @@ TEST_SUITE("LocalImageStore") {
|
||||
const std::filesystem::path got = store.resolvePath(Game::Pokemon, "pic.jpg");
|
||||
CHECK(got.generic_string() == "/coll/pokemon/images/pic.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("copyIn propagates ensureDirectory failure") {
|
||||
InMemoryFileSystem configFs;
|
||||
ConfigService cfg{configFs, "/app/config.json", "/coll"};
|
||||
REQUIRE(cfg.initialize().isOk());
|
||||
FailingImageFs fs;
|
||||
fs.ensureOk = false;
|
||||
LocalImageStore store(fs, cfg, dirNameForGame);
|
||||
|
||||
const auto out = store.copyIn(Game::Magic, "/incoming/a.png", "id001");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "ensure failed");
|
||||
}
|
||||
|
||||
TEST_CASE("remove propagates filesystem remove failure when file exists") {
|
||||
InMemoryFileSystem configFs;
|
||||
ConfigService cfg{configFs, "/app/config.json", "/coll"};
|
||||
REQUIRE(cfg.initialize().isOk());
|
||||
FailingImageFs fs;
|
||||
fs.removeOk = false;
|
||||
LocalImageStore store(fs, cfg, dirNameForGame);
|
||||
|
||||
const auto out = store.remove(Game::Magic, "a.png");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "remove failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#include "ccm/infra/LocalPreviewByteCache.hpp"
|
||||
#include "ccm/infra/StdFileSystem.hpp"
|
||||
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <random>
|
||||
@@ -56,6 +58,59 @@ void backdate(const fs::path& p, int seconds) {
|
||||
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
|
||||
}
|
||||
|
||||
class FailingEnsureDirFs final : public IFileSystem {
|
||||
public:
|
||||
explicit FailingEnsureDirFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
|
||||
|
||||
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
|
||||
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
|
||||
Result<void> ensureDirectory(const fs::path& p) override {
|
||||
(void)p;
|
||||
return Result<void>::err("ensure failed");
|
||||
}
|
||||
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
|
||||
Result<void> writeText(const fs::path& p, std::string_view contents) override {
|
||||
return inner_.writeText(p, contents);
|
||||
}
|
||||
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
|
||||
return inner_.copyFile(from, to, overwrite);
|
||||
}
|
||||
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
|
||||
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
|
||||
return inner_.listDirectory(p);
|
||||
}
|
||||
|
||||
private:
|
||||
ccm::testing::InMemoryFileSystem& inner_;
|
||||
};
|
||||
|
||||
class FailingIndexWriteFs final : public IFileSystem {
|
||||
public:
|
||||
explicit FailingIndexWriteFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
|
||||
|
||||
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
|
||||
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
|
||||
Result<void> ensureDirectory(const fs::path& p) override { return inner_.ensureDirectory(p); }
|
||||
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
|
||||
Result<void> writeText(const fs::path& p, std::string_view contents) override {
|
||||
const auto path = p.generic_string();
|
||||
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".idx") == 0) {
|
||||
return Result<void>::err("idx write failed");
|
||||
}
|
||||
return inner_.writeText(p, contents);
|
||||
}
|
||||
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
|
||||
return inner_.copyFile(from, to, overwrite);
|
||||
}
|
||||
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
|
||||
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
|
||||
return inner_.listDirectory(p);
|
||||
}
|
||||
|
||||
private:
|
||||
ccm::testing::InMemoryFileSystem& inner_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("LocalPreviewByteCache") {
|
||||
@@ -234,6 +289,55 @@ TEST_SUITE("LocalPreviewByteCache") {
|
||||
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
||||
}
|
||||
|
||||
TEST_CASE("entry with payload but missing sidecar is treated as miss") {
|
||||
TempDir td;
|
||||
StdFileSystem fs;
|
||||
LocalPreviewByteCache cache(fs, td.path);
|
||||
cache.store("real-key", "REAL");
|
||||
|
||||
for (const auto& entry : fs::directory_iterator(td.path)) {
|
||||
if (entry.path().extension() == ".idx") {
|
||||
std::error_code ec;
|
||||
fs::remove(entry.path(), ec);
|
||||
}
|
||||
}
|
||||
|
||||
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
||||
}
|
||||
|
||||
TEST_CASE("entry with negative marker but missing sidecar is treated as miss") {
|
||||
TempDir td;
|
||||
StdFileSystem fs;
|
||||
LocalPreviewByteCache cache(fs, td.path);
|
||||
cache.storeNegative("real-key");
|
||||
|
||||
for (const auto& entry : fs::directory_iterator(td.path)) {
|
||||
if (entry.path().extension() == ".idx") {
|
||||
std::error_code ec;
|
||||
fs::remove(entry.path(), ec);
|
||||
}
|
||||
}
|
||||
|
||||
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
||||
}
|
||||
|
||||
TEST_CASE("entry with unreadable payload file is treated as miss") {
|
||||
TempDir td;
|
||||
StdFileSystem fs;
|
||||
LocalPreviewByteCache cache(fs, td.path);
|
||||
cache.store("real-key", "REAL");
|
||||
|
||||
for (const auto& entry : fs::directory_iterator(td.path)) {
|
||||
if (entry.path().extension() == ".bin") {
|
||||
std::error_code ec;
|
||||
fs::remove(entry.path(), ec);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
||||
}
|
||||
|
||||
TEST_CASE("evicts oldest entry when the size cap would be exceeded") {
|
||||
TempDir td;
|
||||
StdFileSystem fs;
|
||||
@@ -291,3 +395,32 @@ TEST_SUITE("LocalPreviewByteCache") {
|
||||
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("LocalPreviewByteCache in-memory filesystem failures") {
|
||||
TEST_CASE("store is a silent no-op when ensureDirectory fails") {
|
||||
ccm::testing::InMemoryFileSystem inner;
|
||||
FailingEnsureDirFs fs{inner};
|
||||
LocalPreviewByteCache cache(fs, "/cache");
|
||||
|
||||
cache.store("k", "payload");
|
||||
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
|
||||
}
|
||||
|
||||
TEST_CASE("store rolls back payload when sidecar write fails") {
|
||||
ccm::testing::InMemoryFileSystem inner;
|
||||
FailingIndexWriteFs fs{inner};
|
||||
LocalPreviewByteCache cache(fs, "/cache");
|
||||
|
||||
cache.store("k", "payload");
|
||||
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
|
||||
}
|
||||
|
||||
TEST_CASE("storeNegative rolls back marker when sidecar write fails") {
|
||||
ccm::testing::InMemoryFileSystem inner;
|
||||
FailingIndexWriteFs fs{inner};
|
||||
LocalPreviewByteCache cache(fs, "/cache");
|
||||
|
||||
cache.storeNegative("k");
|
||||
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ TEST_SUITE("MagicCardPreviewSource::buildSearchUrl") {
|
||||
const auto url = MagicCardPreviewSource::buildSearchUrl("X", "swsh10");
|
||||
CHECK(url.find("set%3Aswsh10") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("replaces every ampersand in the card name") {
|
||||
const auto url = MagicCardPreviewSource::buildSearchUrl("A & B & C", "abc");
|
||||
CHECK(url.find("A%20and%20B%20and%20C") != std::string::npos);
|
||||
CHECK(url.find("%26") == std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("MagicCardPreviewSource::parseResponse") {
|
||||
|
||||
@@ -170,3 +170,252 @@ 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_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");
|
||||
}
|
||||
|
||||
TEST_CASE("empty setId skips set filter and collects prints across sets") {
|
||||
const char* crossSet = R"({
|
||||
"data": [
|
||||
{"name":"Pikachu","number":"1","rarity":"Common","set":{"id":"base1"}},
|
||||
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base2"}}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(crossSet, "", "Pikachu");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2u);
|
||||
}
|
||||
|
||||
TEST_CASE("empty wanted card name skips name filter within the set") {
|
||||
const char* twoInSet = R"({
|
||||
"data": [
|
||||
{"name":"Electabuzz","number":"1","rarity":"Common","set":{"id":"base1"}},
|
||||
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base1"}}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(twoInSet, "base1", "");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2u);
|
||||
}
|
||||
|
||||
TEST_CASE("cards with empty number and rarity are skipped for auto-detect metadata") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
|
||||
"data": [
|
||||
{"name":"Pikachu","number":"","rarity":"","set":{"id":"base1"}}
|
||||
]
|
||||
})",
|
||||
"base1", "Pikachu");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
||||
}
|
||||
|
||||
TEST_CASE("no matches with empty setId yields generic no matching cards message") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(
|
||||
R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})",
|
||||
"",
|
||||
"Nobody");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON in parsePrintVariants yields parse error") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("Pokemon TCG JSON parse error:") == 0);
|
||||
}
|
||||
}
|
||||
|
||||
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("detectPrintVariants surfaces fallback HTTP error when both requests fail") {
|
||||
class AlwaysFailHttp final : public IHttpClient {
|
||||
public:
|
||||
int calls = 0;
|
||||
Result<std::string> get(std::string_view) override {
|
||||
++calls;
|
||||
return Result<std::string>::err("offline");
|
||||
}
|
||||
} http;
|
||||
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.detectPrintVariants("Pikachu", "base1");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "offline");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,13 @@ class InMemSetRepo final : public ISetRepository {
|
||||
public:
|
||||
std::vector<Set> stored;
|
||||
bool hasStored = false;
|
||||
bool failSave = false;
|
||||
Result<std::vector<Set>> load(Game) override {
|
||||
if (!hasStored) return Result<std::vector<Set>>::err("no cache");
|
||||
return Result<std::vector<Set>>::ok(stored);
|
||||
}
|
||||
Result<void> save(Game, const std::vector<Set>& s) override {
|
||||
if (failSave) return Result<void>::err("save failed");
|
||||
stored = s;
|
||||
hasStored = true;
|
||||
return Result<void>::ok();
|
||||
@@ -145,4 +147,36 @@ TEST_SUITE("SetService") {
|
||||
CHECK(pokemon.source.calls == 1);
|
||||
CHECK(yugioh.source.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("updateSets propagates repository save failures") {
|
||||
InMemSetRepo repo;
|
||||
repo.failSave = true;
|
||||
SetService svc{repo};
|
||||
FakeGameModule magic{Game::Magic};
|
||||
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
|
||||
svc.registerModule(&magic);
|
||||
|
||||
const auto out = svc.updateSets(Game::Magic);
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "save failed");
|
||||
}
|
||||
|
||||
TEST_CASE("registering a second module for same game id overwrites previous one") {
|
||||
InMemSetRepo repo;
|
||||
SetService svc{repo};
|
||||
FakeGameModule firstMagic{Game::Magic};
|
||||
firstMagic.source.result = Result<std::vector<Set>>::ok({{"a", "First", "2000/01/01"}});
|
||||
FakeGameModule secondMagic{Game::Magic};
|
||||
secondMagic.source.result = Result<std::vector<Set>>::ok({{"b", "Second", "2001/01/01"}});
|
||||
|
||||
svc.registerModule(&firstMagic);
|
||||
svc.registerModule(&secondMagic);
|
||||
|
||||
const auto out = svc.updateSets(Game::Magic);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().id == "b");
|
||||
CHECK(firstMagic.source.calls == 0);
|
||||
CHECK(secondMagic.source.calls == 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,22 @@ TEST_SUITE("StdFileSystem") {
|
||||
CHECK(r.value() == "hi");
|
||||
}
|
||||
|
||||
TEST_CASE("writeText/readText work for top-level relative files") {
|
||||
TempDir td;
|
||||
StdFileSystem fs;
|
||||
const auto oldCwd = fs::current_path();
|
||||
fs::current_path(td.path);
|
||||
|
||||
const fs::path topLevel = "top-level.txt";
|
||||
REQUIRE(fs.writeText(topLevel, "hello").isOk());
|
||||
const auto r = fs.readText(topLevel);
|
||||
REQUIRE(r.isOk());
|
||||
CHECK(r.value() == "hello");
|
||||
|
||||
std::error_code ec;
|
||||
fs::current_path(oldCwd, ec);
|
||||
}
|
||||
|
||||
TEST_CASE("copyFile copies bytes and respects overwrite flag") {
|
||||
TempDir td;
|
||||
StdFileSystem fs;
|
||||
@@ -186,4 +202,15 @@ TEST_SUITE("StdFileSystem") {
|
||||
REQUIRE(filled.isOk());
|
||||
CHECK(filled.value().size() == 2u);
|
||||
}
|
||||
|
||||
TEST_CASE("writeText fails when the path names an existing directory") {
|
||||
TempDir td;
|
||||
StdFileSystem fs;
|
||||
const auto dir = td.path / "is_dir";
|
||||
REQUIRE(fs.ensureDirectory(dir).isOk());
|
||||
|
||||
const auto r = fs.writeText(dir, "cannot-write-here");
|
||||
REQUIRE(r.isErr());
|
||||
CHECK(r.error().find("Unable to create file") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,24 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-E"));
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EX005"));
|
||||
}
|
||||
}
|
||||
|
||||
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 +128,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") {
|
||||
@@ -144,6 +172,12 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty());
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("uses dialog synonym table when ygoRarityShortCode does not match") {
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mosaic Rare") == "MSR");
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Parallel Rare") == "PR");
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Gold Rare") == "GUR");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") {
|
||||
@@ -281,6 +315,33 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
|
||||
TEST_CASE("page without imageinfo is treated as missing") {
|
||||
const std::string body = R"({
|
||||
"query":{"pages":{
|
||||
"1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png"}
|
||||
}}
|
||||
})";
|
||||
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
|
||||
body, {"DarkMagician-LOB-EN-UR-UE.png"});
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhCardPreviewSource::buildSearchUrl") {
|
||||
TEST_CASE("percent-encodes name and optional set name filter") {
|
||||
const auto url = YuGiOhCardPreviewSource::buildSearchUrl(
|
||||
"Dark Magician", "Legend of Blue Eyes White Dragon");
|
||||
CHECK(url.find("https://db.ygoprodeck.com/api/v7/cardinfo.php") == 0);
|
||||
CHECK(url.find("fname=Dark%20Magician") != std::string::npos);
|
||||
CHECK(url.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("omits cardset when set name is empty") {
|
||||
const auto url = YuGiOhCardPreviewSource::buildSearchUrl("Dark Magician", "");
|
||||
CHECK(url.find("cardset=") == std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhCardPreviewSource::parseFallbackImageUrl") {
|
||||
@@ -446,6 +507,22 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("YGOPRODeck JSON parse error") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("maps 25th Anniversary display-set alias to original set name") {
|
||||
const std::string json = R"({
|
||||
"data":[
|
||||
{"name":"Dark Magician",
|
||||
"card_sets":[
|
||||
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
|
||||
]}
|
||||
]
|
||||
})";
|
||||
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
|
||||
json, "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "Dark Magician");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].setNo == "LOB-005");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhCardPreviewSource::detectPrintVariants HTTP fallback") {
|
||||
@@ -467,6 +544,26 @@ TEST_SUITE("YuGiOhCardPreviewSource::detectPrintVariants HTTP fallback") {
|
||||
CHECK(out.value()[0].setNo == "MP21-EN001");
|
||||
REQUIRE(http.calls == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("uses original set name in cardset query for 25th alias") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"({
|
||||
"data":[{
|
||||
"name":"Dark Magician",
|
||||
"card_sets":[
|
||||
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
|
||||
]
|
||||
}]
|
||||
})";
|
||||
|
||||
YuGiOhCardPreviewSource src{http};
|
||||
const auto out = src.detectPrintVariants(
|
||||
"Dark Magician", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon")
|
||||
!= std::string::npos);
|
||||
CHECK(http.lastUrl.find("25th") == std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers aligned with external fixture `yugioh_same_card_set_variant_tests`
|
||||
@@ -760,6 +857,34 @@ TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
|
||||
TEST_CASE("Yugipedia clean-miss + YGOPRODeck transient is overall Transient") {
|
||||
RoutingHttpClient http;
|
||||
http.yugipediaBody = R"({"query":{"pages":{
|
||||
"-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""}
|
||||
}}})";
|
||||
http.ygoprodeckOk = false;
|
||||
|
||||
YuGiOhCardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl(
|
||||
"No Such Card", "Legend of Blue Eyes White Dragon", "LOB-999||Ultra Rare||UE");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
|
||||
TEST_CASE("tuple-style setNo parsing trims fields and supports empty set code") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"({"data":[{"name":"Dark Magician",
|
||||
"card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})";
|
||||
|
||||
YuGiOhCardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl(
|
||||
"Dark Magician", "Legend of Blue Eyes White Dragon", " || Ultra Rare || 1E ");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
|
||||
CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("skips Yugipedia entirely when the set code is missing") {
|
||||
// Without a set code we can't construct any candidate filename - go
|
||||
// straight to the YGOPRODeck fallback to avoid wasting an HTTP call.
|
||||
@@ -777,3 +902,36 @@ TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
|
||||
CHECK(http.lastUrl.find("yugipedia.com") == std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") {
|
||||
TEST_CASE("returns first variant from filtered request") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"({
|
||||
"data":[
|
||||
{"name":"Dark Magician",
|
||||
"card_sets":[
|
||||
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
|
||||
]}
|
||||
]
|
||||
})";
|
||||
YuGiOhCardPreviewSource src{http};
|
||||
|
||||
const auto out = src.detectFirstPrint(
|
||||
"Dark Magician", "Legend of Blue Eyes White Dragon");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().setNo == "LOB-005");
|
||||
CHECK(out.value().rarity == "Ultra Rare");
|
||||
CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon")
|
||||
!= std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("propagates unfiltered fallback errors when both requests fail") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
YuGiOhCardPreviewSource src{http};
|
||||
|
||||
const auto out = src.detectFirstPrint("Any", "Any Set");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "offline");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/util/YuGiOhSetLookup.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<Set> sampleSets() {
|
||||
return {
|
||||
Set{.id = "LOB", .name = "Legend of Blue Eyes White Dragon", .releaseDate = "2002/03/08"},
|
||||
Set{.id = "MRD", .name = "Metal Raiders", .releaseDate = "2002/06/26"},
|
||||
Set{.id = "LOB-25TH", .name = "Legend of Blue Eyes White Dragon (25th Anniversary Edition)",
|
||||
.releaseDate = "2023/04/20"},
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("lookupYuGiOhSetByShorthand") {
|
||||
using Kind = YuGiOhSetShorthandLookup::Kind;
|
||||
|
||||
TEST_CASE("empty and whitespace-only query is NotFound") {
|
||||
const auto sets = sampleSets();
|
||||
CHECK(lookupYuGiOhSetByShorthand("", sets).kind == Kind::NotFound);
|
||||
CHECK(lookupYuGiOhSetByShorthand(" ", sets).kind == Kind::NotFound);
|
||||
CHECK(lookupYuGiOhSetByShorthand("\t\n", sets).kind == Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("case-insensitive exact id match is Unique") {
|
||||
const auto sets = sampleSets();
|
||||
auto r = lookupYuGiOhSetByShorthand("lob", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 0);
|
||||
CHECK(sets[r.index].id == "LOB");
|
||||
|
||||
r = lookupYuGiOhSetByShorthand("MRD", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("trim ASCII whitespace around query") {
|
||||
const auto sets = sampleSets();
|
||||
const auto r = lookupYuGiOhSetByShorthand(" LOB ", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("hyphenated set codes match") {
|
||||
const auto sets = sampleSets();
|
||||
const auto r = lookupYuGiOhSetByShorthand("lob-25th", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 2);
|
||||
CHECK(sets[r.index].id == "LOB-25TH");
|
||||
}
|
||||
|
||||
TEST_CASE("unknown code is NotFound") {
|
||||
const auto sets = sampleSets();
|
||||
CHECK(lookupYuGiOhSetByShorthand("NOPE", sets).kind == Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("Ambiguous when two sets share the same normalized id") {
|
||||
std::vector<Set> dup = {
|
||||
Set{.id = "X1", .name = "A", .releaseDate = "2000/01/01"},
|
||||
Set{.id = "x1", .name = "B", .releaseDate = "2000/01/02"},
|
||||
};
|
||||
CHECK(lookupYuGiOhSetByShorthand("X1", dup).kind == Kind::Ambiguous);
|
||||
}
|
||||
|
||||
TEST_CASE("first matching index is stable when Unique among similar prefixes") {
|
||||
const auto sets = sampleSets();
|
||||
const auto r = lookupYuGiOhSetByShorthand("LOB", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 0);
|
||||
CHECK(sets[r.index].id == "LOB");
|
||||
}
|
||||
|
||||
TEST_CASE("normalizeYuGiOhSetIdForLookup lowercases ASCII") {
|
||||
CHECK(normalizeYuGiOhSetIdForLookup("Ra04-EN001") == "ra04-en001");
|
||||
}
|
||||
|
||||
TEST_CASE("trimAsciiWhitespace handles empty") {
|
||||
CHECK(trimAsciiWhitespace("") == "");
|
||||
CHECK(trimAsciiWhitespace("x") == "x");
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,20 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
|
||||
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("empty upstream array still appends missing 25th aliases") {
|
||||
const auto out = YuGiOhSetSource::parseResponse("[]");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().size() == 6);
|
||||
bool foundLob25th = false;
|
||||
bool foundIoc25th = false;
|
||||
for (const auto& set : out.value()) {
|
||||
if (set.id == "LOB-25TH") foundLob25th = true;
|
||||
if (set.id == "IOC-25TH") foundIoc25th = true;
|
||||
}
|
||||
CHECK(foundLob25th);
|
||||
CHECK(foundIoc25th);
|
||||
}
|
||||
|
||||
TEST_CASE("adds 25th Anniversary aliases when upstream list misses them") {
|
||||
const auto out = YuGiOhSetSource::parseResponse(R"([
|
||||
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}
|
||||
@@ -98,6 +112,44 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("YGOPRODeck set parse error:") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("missing fields fall back to empty strings and keep parsing") {
|
||||
const std::string json = R"([
|
||||
{"set_name":"Set A"},
|
||||
{"set_code":"BBB","tcg_date":"2021-02-03"}
|
||||
])";
|
||||
const auto out = YuGiOhSetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
bool foundMissingCode = false;
|
||||
bool foundMissingName = false;
|
||||
for (const auto& set : out.value()) {
|
||||
if (set.name == "Set A" && set.id.empty() && set.releaseDate.empty()) {
|
||||
foundMissingCode = true;
|
||||
}
|
||||
if (set.id == "BBB" && set.name.empty() && set.releaseDate == "2021/02/03") {
|
||||
foundMissingName = true;
|
||||
}
|
||||
}
|
||||
CHECK(foundMissingCode);
|
||||
CHECK(foundMissingName);
|
||||
}
|
||||
|
||||
TEST_CASE("preserves slash-formatted dates and normalizes hyphen dates") {
|
||||
const std::string json = R"([
|
||||
{"set_name":"Slash Date","set_code":"S","tcg_date":"2024/01/01"},
|
||||
{"set_name":"Hyphen Date","set_code":"H","tcg_date":"2024-01-02"}
|
||||
])";
|
||||
const auto out = YuGiOhSetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
bool sawSlash = false;
|
||||
bool sawHyphenNormalized = false;
|
||||
for (const auto& set : out.value()) {
|
||||
if (set.id == "S" && set.releaseDate == "2024/01/01") sawSlash = true;
|
||||
if (set.id == "H" && set.releaseDate == "2024/01/02") sawHyphenNormalized = true;
|
||||
}
|
||||
CHECK(sawSlash);
|
||||
CHECK(sawHyphenNormalized);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhSetSource::fetchAll") {
|
||||
|
||||
+5
-4
@@ -10,18 +10,19 @@
|
||||
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
|
||||
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
|
||||
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png` (copied next to the executable by `app/CMakeLists.txt` on link — source file `ui_wx/assets/ygo_card_back.png`). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant.
|
||||
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
|
||||
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
|
||||
- `include/ccm/ui/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`.
|
||||
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50–100 lines of hook overrides on top of the matching base template.
|
||||
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`).
|
||||
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
|
||||
- `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types.
|
||||
- `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`.
|
||||
- `include/ccm/ui/ImageViewerDialog.hpp` + `src/ImageViewerDialog.cpp` — full-size viewer with prev/next.
|
||||
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs.
|
||||
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`).
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Only consume core through `AppContext`.** Do not include any header from `ccm/infra/` here. The set of allowed `ccm/...` includes is `domain/`, `services/`, `games/IGameModule.hpp`, `ports/ICardPreviewSource.hpp`, and `util/Result.hpp`.
|
||||
1. **Only consume core through `AppContext`.** Do not include any header from `ccm/infra/` here. The set of allowed `ccm/...` includes is `domain/`, `services/`, `games/IGameModule.hpp`, `ports/ICardPreviewSource.hpp`, and `util/` headers that remain UI-agnostic (for example `util/Result.hpp`, `util/YuGiOhPrintingSlot.hpp`, `util/YuGiOhSetLookup.hpp`). Do not pull arbitrary `util/` or `games/` implementation headers beyond what a panel/dialog already needs for display or small shared helpers.
|
||||
2. **Image decoding lives here, not in core.** Use `wxImage::LoadFile(path.string())` against the path returned by `IImageStore::resolvePath`. Core stays free of any image library.
|
||||
3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount.
|
||||
4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`.
|
||||
@@ -70,7 +71,7 @@
|
||||
- If you change fallback sourcing (URLs or bundled asset), keep the "always show a reasonable card-back fallback" behavior intact for **every** game with remote previews.
|
||||
16. **Per-game auto-detect controls:**
|
||||
- Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game.
|
||||
- Keep shared templates game-agnostic: put buttons and detection behavior in `<Name>CardEditDialog`, not in `BaseCardEditDialog`.
|
||||
- Keep shared templates game-agnostic: put buttons and detection behavior in `<Name>CardEditDialog`, not in `BaseCardEditDialog`. Yu-Gi-Oh!'s **Set code** entry (`SwitchCtrl` + text + **Auto detect** against cached sets) is wired through the template hook `customizeSetPickerRow` so Magic/Pokemon keep the default single-combo row unchanged.
|
||||
- For games that use composed print IDs (prefix + numeric suffix), allow user editing on the numeric portion and render the full code as a read-only derived label beside the input.
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
@@ -21,6 +21,7 @@ add_library(ccm_ui_wx STATIC
|
||||
src/YuGiOhGameView.cpp
|
||||
|
||||
src/SettingsDialog.cpp
|
||||
src/SwitchCtrl.cpp
|
||||
src/ImageViewerDialog.cpp
|
||||
src/IconListCtrl.cpp
|
||||
src/SvgIcons.cpp
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
#include <wx/filedlg.h>
|
||||
#include <wx/listbox.h>
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/panel.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/spinctrl.h>
|
||||
#include <wx/stattext.h>
|
||||
@@ -146,6 +147,28 @@ protected:
|
||||
return &available[static_cast<std::size_t>(sel)];
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::vector<Set>& availableSets() const noexcept {
|
||||
return preloadedSets_ != nullptr ? *preloadedSets_ : sets_;
|
||||
}
|
||||
|
||||
// Default: combo only. Yu-Gi-Oh! overrides to add set-code entry + toggle.
|
||||
virtual void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) {
|
||||
row.Add(combo, 1, wxEXPAND);
|
||||
}
|
||||
|
||||
// After programmatically changing the set combo + `card_.set` (see
|
||||
// `applySetSelectionByIndex`). Default no-op; Yu-Gi-Oh! clears print-variant cache.
|
||||
virtual void onSetSelectionApplied() {}
|
||||
|
||||
void applySetSelectionByIndex(std::size_t index) {
|
||||
const auto& available = availableSets();
|
||||
if (!setCombo_ || !setCombo_->IsEnabled()) return;
|
||||
if (index >= available.size()) return;
|
||||
setCombo_->SetSelection(static_cast<int>(index));
|
||||
card_.set = available[index];
|
||||
onSetSelectionApplied();
|
||||
}
|
||||
|
||||
private:
|
||||
void readSets() {
|
||||
auto loaded = setService_.getSets(game_);
|
||||
@@ -158,10 +181,6 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::vector<Set>& availableSets() const noexcept {
|
||||
return preloadedSets_ != nullptr ? *preloadedSets_ : sets_;
|
||||
}
|
||||
|
||||
void buildLayout() {
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
auto* grid = new wxFlexGridSizer(2, 6, 8);
|
||||
@@ -174,9 +193,16 @@ private:
|
||||
});
|
||||
appendRow(grid, "Name", nameCtrl_);
|
||||
|
||||
setCombo_ = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0,
|
||||
// `setCombo_` must be parented to `setHost` so every control in the Set row
|
||||
// shares the same `wxPanel`; otherwise the combo stays a direct child of the
|
||||
// dialog while the sizer lives on `setHost`, which corrupts layout on MSW.
|
||||
auto* setHost = new wxPanel(this, wxID_ANY);
|
||||
auto* setRow = new wxBoxSizer(wxHORIZONTAL);
|
||||
setHost->SetSizer(setRow);
|
||||
setCombo_ = new wxComboBox(setHost, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0,
|
||||
nullptr, wxCB_READONLY);
|
||||
appendRow(grid, "Set", setCombo_);
|
||||
customizeSetPickerRow(*setRow, setCombo_);
|
||||
appendRow(grid, "Set", setHost);
|
||||
|
||||
// Subclass extra rows go between Set and Amount (Pokemon adds Set #).
|
||||
appendExtraRows(grid);
|
||||
@@ -237,8 +263,13 @@ private:
|
||||
|
||||
CallAfter([this]() {
|
||||
if (nameCtrl_) {
|
||||
nameCtrl_->SetInsertionPoint(0);
|
||||
nameCtrl_->ShowPosition(0);
|
||||
nameCtrl_->SetFocus();
|
||||
if (mode_ == EditMode::Edit && !nameCtrl_->IsEmpty()) {
|
||||
nameCtrl_->SetInsertionPointEnd();
|
||||
} else {
|
||||
nameCtrl_->SetInsertionPoint(0);
|
||||
nameCtrl_->ShowPosition(0);
|
||||
}
|
||||
}
|
||||
if (noteCtrl_) {
|
||||
noteCtrl_->SetInsertionPoint(0);
|
||||
@@ -352,11 +383,14 @@ private:
|
||||
failed.reserve(static_cast<std::size_t>(paths.size()));
|
||||
|
||||
for (const auto& path : paths) {
|
||||
const std::string setNameForImage = (game_ == Game::YuGiOh && !card_.set.id.empty())
|
||||
? card_.set.id
|
||||
: card_.set.name;
|
||||
auto added = imageService_.addImage(game_,
|
||||
std::filesystem::path(path.ToStdString()),
|
||||
mode_ == EditMode::Create,
|
||||
card_.id,
|
||||
card_.set.name,
|
||||
setNameForImage,
|
||||
card_.name,
|
||||
card_.images);
|
||||
if (!added) {
|
||||
|
||||
@@ -70,6 +70,10 @@ namespace ccm::ui {
|
||||
// not duplicated per template instantiation.
|
||||
wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
|
||||
|
||||
// Raised on `wxEVT_LIST_ITEM_ACTIVATED` (double-click / Enter on a row).
|
||||
// `IGameView` implementations bind this to open Edit for `selected()`.
|
||||
wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
|
||||
|
||||
template <typename TCard, typename TSortColumn>
|
||||
class BaseCardListPanel : public wxPanel {
|
||||
public:
|
||||
@@ -238,6 +242,7 @@ protected:
|
||||
|
||||
list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this);
|
||||
list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
|
||||
list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this);
|
||||
}
|
||||
|
||||
// Forwarded helpers ------------------------------------------------------
|
||||
@@ -642,6 +647,14 @@ private:
|
||||
notifySelectionChanged();
|
||||
}
|
||||
|
||||
void onListItemActivated(wxListEvent& event) {
|
||||
(void)event;
|
||||
if (inRebuild_) return;
|
||||
wxCommandEvent ev(EVT_CARD_ACTIVATED, GetId());
|
||||
ev.SetEventObject(this);
|
||||
ProcessWindowEvent(ev);
|
||||
}
|
||||
|
||||
// ----- members ----------------------------------------------------------
|
||||
|
||||
static constexpr int kFlagIconSize = 14;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
// Tracks when a modal Add/Edit card dialog is on screen so a second one
|
||||
// cannot be stacked (toolbar + list activation, or rare re-entrant cases).
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
// User-visible hint when Add/Edit is requested while a card dialog is already modal.
|
||||
inline constexpr const char* kCardEditModalBlockedUtf8 =
|
||||
"Close the open card dialog (save or cancel) before opening another card.";
|
||||
|
||||
[[nodiscard]] inline std::atomic<int>& cardEditModalDepthRef() noexcept {
|
||||
static std::atomic<int> depth{0};
|
||||
return depth;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline bool cardEditModalIsActive() noexcept {
|
||||
return cardEditModalDepthRef().load(std::memory_order_relaxed) > 0;
|
||||
}
|
||||
|
||||
struct CardEditModalGuard {
|
||||
CardEditModalGuard() {
|
||||
cardEditModalDepthRef().fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
~CardEditModalGuard() {
|
||||
cardEditModalDepthRef().fetch_sub(1, std::memory_order_relaxed);
|
||||
}
|
||||
CardEditModalGuard(const CardEditModalGuard&) = delete;
|
||||
CardEditModalGuard& operator=(const CardEditModalGuard&) = delete;
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -7,7 +7,15 @@
|
||||
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
|
||||
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
#include "ccm/ui/BaseCardEditDialog.hpp"
|
||||
#include <wx/button.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
@@ -16,9 +24,11 @@ public:
|
||||
PokemonCardEditDialog(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
SetService& setService,
|
||||
CardPreviewService& cardPreview,
|
||||
EditMode mode,
|
||||
PokemonCard initial,
|
||||
const std::vector<Set>* preloadedSets = nullptr);
|
||||
~PokemonCardEditDialog() override;
|
||||
|
||||
protected:
|
||||
void buildFlagsRow(wxBoxSizer* flagsBox) override;
|
||||
@@ -26,13 +36,50 @@ protected:
|
||||
void readExtraFromCard() override;
|
||||
void writeExtraToCard() override;
|
||||
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
|
||||
void onCardLookupContextChanged() override;
|
||||
|
||||
private:
|
||||
wxTextCtrl* setNoCtrl_{nullptr};
|
||||
wxCheckBox* holoCheck_{nullptr};
|
||||
wxCheckBox* firstEditionCheck_{nullptr};
|
||||
wxCheckBox* signedCheck_{nullptr};
|
||||
wxCheckBox* alteredCheck_{nullptr};
|
||||
struct VariantFetchState {
|
||||
std::atomic<bool> alive{true};
|
||||
};
|
||||
|
||||
void onAutoDetectSetNo(wxCommandEvent&);
|
||||
void onNextSetNo(wxCommandEvent&);
|
||||
void onSetSelectionChanged(wxCommandEvent&);
|
||||
void autoDetectFromApi();
|
||||
void clearCachedPrintVariants();
|
||||
void requestVariantsAsync(unsigned capturedEpoch,
|
||||
std::string name,
|
||||
std::string setId,
|
||||
bool fillSetNoOnSuccess,
|
||||
bool showFailureDialog);
|
||||
void applyDetectedVariants(unsigned capturedEpoch,
|
||||
Result<std::vector<AutoDetectedPrint>> detected,
|
||||
bool fillSetNoOnSuccess,
|
||||
bool showFailureDialog);
|
||||
void rebuildVariantRingFromCache();
|
||||
void syncRingPositionToControls();
|
||||
void refreshVariantNextControls();
|
||||
void scheduleDeferredVariantPrefetch();
|
||||
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
|
||||
[[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl);
|
||||
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
|
||||
|
||||
EditMode dialogMode_;
|
||||
unsigned variantFetchEpoch_{0};
|
||||
CardPreviewService& cardPreview_;
|
||||
std::shared_ptr<VariantFetchState> variantFetchState_;
|
||||
wxTextCtrl* setNoCtrl_{nullptr};
|
||||
wxButton* autoSetNoBtn_{nullptr};
|
||||
wxButton* nextSetNoBtn_{nullptr};
|
||||
wxCheckBox* holoCheck_{nullptr};
|
||||
wxCheckBox* firstEditionCheck_{nullptr};
|
||||
wxCheckBox* signedCheck_{nullptr};
|
||||
wxCheckBox* alteredCheck_{nullptr};
|
||||
|
||||
std::vector<AutoDetectedPrint> cachedVariants_;
|
||||
std::vector<std::string> uniqueSetNos_;
|
||||
std::size_t setNoRingPos_{0};
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <wx/event.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
wxDECLARE_EVENT(EVT_CCM_SWITCH, wxCommandEvent);
|
||||
|
||||
// Small on/off switch (pill track + thumb) for modal dialogs. Fires `EVT_CCM_SWITCH`
|
||||
// when the user toggles; bind with the control pointer as the event source.
|
||||
class SwitchCtrl final : public wxWindow {
|
||||
public:
|
||||
explicit SwitchCtrl(wxWindow* parent, wxWindowID id = wxID_ANY, bool initialOn = false);
|
||||
|
||||
[[nodiscard]] bool GetValue() const noexcept { return on_; }
|
||||
void SetValue(bool on, bool notify = false);
|
||||
|
||||
bool Enable(bool enable = true) override;
|
||||
|
||||
private:
|
||||
void onPaint(wxPaintEvent&);
|
||||
void onLeftDown(wxMouseEvent&);
|
||||
void onEnter(wxMouseEvent&);
|
||||
void onLeave(wxMouseEvent&);
|
||||
|
||||
bool on_{false};
|
||||
bool hovered_{false};
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
#include "ccm/ui/BaseCardEditDialog.hpp"
|
||||
#include "ccm/ui/SwitchCtrl.hpp"
|
||||
#include <wx/button.h>
|
||||
#include <wx/stattext.h>
|
||||
|
||||
@@ -21,11 +22,13 @@ public:
|
||||
|
||||
protected:
|
||||
void buildFlagsRow(wxBoxSizer* flagsBox) override;
|
||||
void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) override;
|
||||
void appendExtraRows(wxFlexGridSizer* grid) override;
|
||||
void readExtraFromCard() override;
|
||||
void writeExtraToCard() override;
|
||||
[[nodiscard]] std::string updateMenuName() const override { return "Update Yu-Gi-Oh!"; }
|
||||
void onCardLookupContextChanged() override;
|
||||
void onSetSelectionApplied() override;
|
||||
|
||||
private:
|
||||
void onAutoDetectSetNo(wxCommandEvent&);
|
||||
@@ -34,6 +37,10 @@ private:
|
||||
void onNextRarity(wxCommandEvent&);
|
||||
void onSetNoTextChanged(wxCommandEvent&);
|
||||
void onSetSelectionChanged(wxCommandEvent&);
|
||||
void handleSetSelectionChanged();
|
||||
void onSetRowSwitch(wxCommandEvent&);
|
||||
void onSetCodeAutoDetect(wxCommandEvent&);
|
||||
void syncSetModeHint();
|
||||
void autoDetectFromApi(bool fillSetNo, bool fillRarity);
|
||||
void refreshSetNoFullPreview();
|
||||
void clearCachedPrintVariants();
|
||||
@@ -63,6 +70,12 @@ private:
|
||||
wxCheckBox* signedCheck_{nullptr};
|
||||
wxCheckBox* alteredCheck_{nullptr};
|
||||
|
||||
wxPanel* setCodeRowPanel_{nullptr};
|
||||
wxTextCtrl* setCodeText_{nullptr};
|
||||
wxButton* setCodeAutoBtn_{nullptr};
|
||||
wxStaticText* setModeHint_{nullptr};
|
||||
SwitchCtrl* setPickerSwitch_{nullptr};
|
||||
|
||||
std::vector<AutoDetectedPrint> cachedVariants_;
|
||||
std::vector<std::string> uniqueSetCodes_;
|
||||
std::vector<std::string> raritiesForCurrentSetCode_;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// declared in the corresponding base headers (BaseCardListPanel.hpp,
|
||||
// BaseSelectedCardPanel.hpp) and defined exactly once here, so that template
|
||||
// instantiations (Magic, Pokemon, ...) all use the same event type tag.
|
||||
// EVT_CARD_ACTIVATED is declared alongside EVT_CARD_SELECTED in
|
||||
// BaseCardListPanel.hpp.
|
||||
|
||||
#include "ccm/ui/BaseCardListPanel.hpp"
|
||||
#include "ccm/ui/BaseSelectedCardPanel.hpp"
|
||||
@@ -9,6 +11,7 @@
|
||||
namespace ccm::ui {
|
||||
|
||||
wxDEFINE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
|
||||
wxDEFINE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
|
||||
wxDEFINE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent);
|
||||
|
||||
} // namespace ccm::ui
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#include "ccm/ui/MagicGameView.hpp"
|
||||
|
||||
#include "ccm/ui/CardEditModalGuard.hpp"
|
||||
#include "ccm/ui/MagicCardEditDialog.hpp"
|
||||
#include "ccm/ui/MagicCardListPanel.hpp"
|
||||
#include "ccm/ui/MagicSelectedCardPanel.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -54,6 +56,10 @@ wxPanel* MagicGameView::listPanel(wxWindow* parent) {
|
||||
selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
});
|
||||
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
|
||||
wxWindow* owner = wxGetTopLevelParent(listPanel_);
|
||||
onEditCard(owner != nullptr ? owner : static_cast<wxWindow*>(listPanel_));
|
||||
});
|
||||
}
|
||||
return listPanel_;
|
||||
}
|
||||
@@ -88,6 +94,11 @@ const std::vector<Set>& MagicGameView::setsForDialog() {
|
||||
}
|
||||
|
||||
void MagicGameView::onAddCard(wxWindow* parentWindow) {
|
||||
if (cardEditModalIsActive()) {
|
||||
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
|
||||
wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
MagicCard fresh;
|
||||
fresh.amount = 1;
|
||||
fresh.language = Language::English;
|
||||
@@ -96,6 +107,7 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) {
|
||||
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
|
||||
&setsForDialog());
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
CardEditModalGuard modalGuard;
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::Magic, dlg.card());
|
||||
@@ -132,9 +144,15 @@ void MagicGameView::onEditCard(wxWindow* parentWindow) {
|
||||
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
if (cardEditModalIsActive()) {
|
||||
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
|
||||
wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
|
||||
&setsForDialog());
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
CardEditModalGuard modalGuard;
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::Magic, dlg.card());
|
||||
if (!updated) {
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
#include "ccm/ui/PokemonCardEditDialog.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include <wx/app.h>
|
||||
#include <wx/panel.h>
|
||||
#include <thread>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
SetService& setService,
|
||||
CardPreviewService& cardPreview,
|
||||
EditMode mode,
|
||||
PokemonCard initial,
|
||||
const std::vector<Set>* preloadedSets)
|
||||
: BaseCardEditDialog<PokemonCard>(
|
||||
parent,
|
||||
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
|
||||
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets) {
|
||||
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets),
|
||||
dialogMode_(mode),
|
||||
cardPreview_(cardPreview),
|
||||
variantFetchState_(std::make_shared<VariantFetchState>()) {
|
||||
buildAndPopulate();
|
||||
if (dialogMode_ == EditMode::Edit) {
|
||||
scheduleDeferredVariantPrefetch();
|
||||
}
|
||||
}
|
||||
|
||||
PokemonCardEditDialog::~PokemonCardEditDialog() {
|
||||
if (variantFetchState_) {
|
||||
variantFetchState_->alive.store(false);
|
||||
}
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::onCardLookupContextChanged() {
|
||||
clearCachedPrintVariants();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
||||
@@ -27,12 +50,46 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
||||
setNoCtrl_ = new wxTextCtrl(this, wxID_ANY, constCard().setNo);
|
||||
appendRow(grid, "Set #", setNoCtrl_);
|
||||
auto* setNoPanel = new wxPanel(this, wxID_ANY);
|
||||
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
|
||||
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
|
||||
autoSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onAutoDetectSetNo, this);
|
||||
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
|
||||
nextSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onNextSetNo, this);
|
||||
nextSetNoBtn_->Show(false);
|
||||
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
|
||||
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
||||
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
||||
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
|
||||
setNoPanel->SetSizer(setNoRow);
|
||||
|
||||
appendRow(grid, "Set #", setNoPanel);
|
||||
|
||||
if (auto* setCombo = setComboControl()) {
|
||||
setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this);
|
||||
}
|
||||
}
|
||||
|
||||
std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
|
||||
std::string out(setNo);
|
||||
const auto slash = out.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
out.resize(slash);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
|
||||
if (ctrl == nullptr) return {};
|
||||
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::readExtraFromCard() {
|
||||
if (setNoCtrl_) setNoCtrl_->ChangeValue(constCard().setNo);
|
||||
clearCachedPrintVariants();
|
||||
if (setNoCtrl_) {
|
||||
setNoCtrl_->ChangeValue(
|
||||
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
|
||||
}
|
||||
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
|
||||
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
|
||||
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
|
||||
@@ -40,11 +97,159 @@ void PokemonCardEditDialog::readExtraFromCard() {
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::writeExtraToCard() {
|
||||
if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString();
|
||||
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
|
||||
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
|
||||
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
|
||||
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
|
||||
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::clearCachedPrintVariants() {
|
||||
++variantFetchEpoch_;
|
||||
cachedVariants_.clear();
|
||||
uniqueSetNos_.clear();
|
||||
setNoRingPos_ = 0;
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::scheduleDeferredVariantPrefetch() {
|
||||
const unsigned epoch = variantFetchEpoch_;
|
||||
wxTheApp->CallAfter([this, epoch]() {
|
||||
prefetchVariantsForCurrentCardSilent(epoch);
|
||||
});
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
|
||||
if (capturedEpoch != variantFetchEpoch_) return;
|
||||
if (!cachedVariants_.empty()) return;
|
||||
const auto& card = constCard();
|
||||
if (card.name.empty() || card.set.id.empty()) return;
|
||||
|
||||
requestVariantsAsync(capturedEpoch, card.name, card.set.id, false, false);
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
|
||||
std::string name,
|
||||
std::string setId,
|
||||
bool fillSetNoOnSuccess,
|
||||
bool showFailureDialog) {
|
||||
if (capturedEpoch != variantFetchEpoch_) return;
|
||||
|
||||
if (fillSetNoOnSuccess && autoSetNoBtn_) {
|
||||
autoSetNoBtn_->Disable();
|
||||
}
|
||||
|
||||
auto state = variantFetchState_;
|
||||
CardPreviewService* svc = &cardPreview_;
|
||||
PokemonCardEditDialog* self = this;
|
||||
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
|
||||
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog]() {
|
||||
auto detected = svc->detectPrintVariants(Game::Pokemon, name, setId);
|
||||
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
|
||||
fillSetNoOnSuccess, showFailureDialog]() mutable {
|
||||
if (!state->alive.load()) return;
|
||||
self->applyDetectedVariants(capturedEpoch, std::move(detected),
|
||||
fillSetNoOnSuccess, showFailureDialog);
|
||||
});
|
||||
}).detach();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
|
||||
Result<std::vector<AutoDetectedPrint>> detected,
|
||||
bool fillSetNoOnSuccess,
|
||||
bool showFailureDialog) {
|
||||
if (capturedEpoch != variantFetchEpoch_) return;
|
||||
|
||||
if (fillSetNoOnSuccess && autoSetNoBtn_) {
|
||||
autoSetNoBtn_->Enable();
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
if (showFailureDialog) {
|
||||
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
|
||||
wxOK | wxICON_WARNING);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cachedVariants_ = std::move(detected).value();
|
||||
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
|
||||
setNoCtrl_->ChangeValue(
|
||||
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
|
||||
}
|
||||
|
||||
rebuildVariantRingFromCache();
|
||||
syncRingPositionToControls();
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::rebuildVariantRingFromCache() {
|
||||
uniqueSetNos_.clear();
|
||||
if (cachedVariants_.empty()) return;
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(cachedVariants_.size());
|
||||
for (const auto& p : cachedVariants_) {
|
||||
if (p.setNo.empty()) continue;
|
||||
if (!seen.insert(p.setNo).second) continue;
|
||||
uniqueSetNos_.push_back(p.setNo);
|
||||
}
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::syncRingPositionToControls() {
|
||||
if (!setNoCtrl_) return;
|
||||
const std::string current = storedSetNoFromControls(setNoCtrl_);
|
||||
setNoRingPos_ = 0;
|
||||
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
|
||||
if (uniqueSetNos_[i] == current) {
|
||||
setNoRingPos_ = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::refreshVariantNextControls() {
|
||||
if (!nextSetNoBtn_) return;
|
||||
nextSetNoBtn_->Show(uniqueSetNos_.size() > 1);
|
||||
Layout();
|
||||
if (GetSizer()) Fit();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
|
||||
autoDetectFromApi();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
|
||||
if (uniqueSetNos_.size() <= 1) return;
|
||||
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
|
||||
if (setNoCtrl_) {
|
||||
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
|
||||
}
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::autoDetectFromApi() {
|
||||
syncCardFromControls();
|
||||
const auto& card = constCard();
|
||||
if (card.name.empty()) {
|
||||
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
|
||||
wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
if (card.set.id.empty()) {
|
||||
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
|
||||
wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsigned epoch = variantFetchEpoch_;
|
||||
requestVariantsAsync(epoch, card.name, card.set.id, true, true);
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
|
||||
clearCachedPrintVariants();
|
||||
scheduleDeferredVariantPrefetch();
|
||||
ev.Skip();
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#include "ccm/ui/PokemonGameView.hpp"
|
||||
|
||||
#include "ccm/ui/CardEditModalGuard.hpp"
|
||||
#include "ccm/ui/PokemonCardEditDialog.hpp"
|
||||
#include "ccm/ui/PokemonCardListPanel.hpp"
|
||||
#include "ccm/ui/PokemonSelectedCardPanel.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -51,6 +53,10 @@ wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
|
||||
selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
});
|
||||
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
|
||||
wxWindow* owner = wxGetTopLevelParent(listPanel_);
|
||||
onEditCard(owner != nullptr ? owner : static_cast<wxWindow*>(listPanel_));
|
||||
});
|
||||
}
|
||||
return listPanel_;
|
||||
}
|
||||
@@ -85,14 +91,20 @@ const std::vector<Set>& PokemonGameView::setsForDialog() {
|
||||
}
|
||||
|
||||
void PokemonGameView::onAddCard(wxWindow* parentWindow) {
|
||||
if (cardEditModalIsActive()) {
|
||||
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
|
||||
wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
PokemonCard fresh;
|
||||
fresh.amount = 1;
|
||||
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);
|
||||
CardEditModalGuard modalGuard;
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::Pokemon, dlg.card());
|
||||
@@ -129,9 +141,15 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
|
||||
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
|
||||
if (cardEditModalIsActive()) {
|
||||
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
|
||||
wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
|
||||
&setsForDialog());
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
CardEditModalGuard modalGuard;
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::Pokemon, dlg.card());
|
||||
if (!updated) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "ccm/ui/SwitchCtrl.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/dcbuffer.h>
|
||||
#include <wx/dcclient.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
wxDEFINE_EVENT(EVT_CCM_SWITCH, wxCommandEvent);
|
||||
|
||||
namespace {
|
||||
|
||||
wxColour liftRgb(const wxColour& c, int delta) {
|
||||
auto lift = [delta](unsigned char ch) -> unsigned char {
|
||||
const int v = static_cast<int>(ch) + delta;
|
||||
return static_cast<unsigned char>(v > 255 ? 255 : (v < 0 ? 0 : v));
|
||||
};
|
||||
return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue()));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SwitchCtrl::SwitchCtrl(wxWindow* parent, wxWindowID id, bool initialOn)
|
||||
: wxWindow(parent, id, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE, wxString()),
|
||||
on_(initialOn) {
|
||||
SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||
SetCursor(wxCURSOR_HAND);
|
||||
const wxSize sz = FromDIP(wxSize(40, 20));
|
||||
SetMinSize(sz);
|
||||
SetMaxSize(sz);
|
||||
SetInitialSize(sz);
|
||||
|
||||
Bind(wxEVT_PAINT, &SwitchCtrl::onPaint, this);
|
||||
Bind(wxEVT_LEFT_DOWN, &SwitchCtrl::onLeftDown, this);
|
||||
Bind(wxEVT_ENTER_WINDOW, &SwitchCtrl::onEnter, this);
|
||||
Bind(wxEVT_LEAVE_WINDOW, &SwitchCtrl::onLeave, this);
|
||||
Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
|
||||
}
|
||||
|
||||
void SwitchCtrl::SetValue(bool on, bool notify) {
|
||||
if (on_ == on) return;
|
||||
on_ = on;
|
||||
Refresh();
|
||||
if (notify) {
|
||||
wxCommandEvent e(EVT_CCM_SWITCH, GetId());
|
||||
e.SetEventObject(this);
|
||||
e.SetInt(on_ ? 1 : 0);
|
||||
ProcessEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
bool SwitchCtrl::Enable(bool enable) {
|
||||
const bool ok = wxWindow::Enable(enable);
|
||||
SetCursor(enable ? wxCURSOR_HAND : wxCURSOR_ARROW);
|
||||
Refresh();
|
||||
return ok;
|
||||
}
|
||||
|
||||
void SwitchCtrl::onEnter(wxMouseEvent& ev) {
|
||||
hovered_ = true;
|
||||
Refresh();
|
||||
ev.Skip();
|
||||
}
|
||||
|
||||
void SwitchCtrl::onLeave(wxMouseEvent& ev) {
|
||||
hovered_ = false;
|
||||
Refresh();
|
||||
ev.Skip();
|
||||
}
|
||||
|
||||
void SwitchCtrl::onLeftDown(wxMouseEvent& ev) {
|
||||
if (!IsEnabled()) {
|
||||
ev.Skip();
|
||||
return;
|
||||
}
|
||||
on_ = !on_;
|
||||
Refresh();
|
||||
wxCommandEvent e(EVT_CCM_SWITCH, GetId());
|
||||
e.SetEventObject(this);
|
||||
e.SetInt(on_ ? 1 : 0);
|
||||
ProcessEvent(e);
|
||||
ev.Skip(false);
|
||||
}
|
||||
|
||||
void SwitchCtrl::onPaint(wxPaintEvent&) {
|
||||
wxAutoBufferedPaintDC dc(this);
|
||||
const wxRect rect = GetClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
|
||||
const Theme theme = inferThemeFromWindow(this);
|
||||
const ThemePalette p = paletteForTheme(theme);
|
||||
const bool dark = theme == Theme::Dark;
|
||||
|
||||
wxColour trackOff = p.inputBg;
|
||||
wxColour trackOn = p.buttonBg;
|
||||
wxColour thumb = dark ? wxColour(240, 240, 240) : wxColour(252, 252, 252);
|
||||
wxColour border = dark ? wxColour(72, 72, 72) : wxColour(158, 158, 158);
|
||||
|
||||
wxColour track = on_ ? trackOn : trackOff;
|
||||
if (hovered_ && IsEnabled()) {
|
||||
track = liftRgb(track, dark ? 14 : 10);
|
||||
}
|
||||
if (!IsEnabled()) {
|
||||
track = liftRgb(track, dark ? -22 : -25);
|
||||
thumb = liftRgb(thumb, dark ? -55 : -35);
|
||||
border = liftRgb(border, dark ? -15 : 10);
|
||||
}
|
||||
|
||||
// Fill the full client rect first so rounded-track corners do not show
|
||||
// undrawn pixels (often black) against the parent panel.
|
||||
dc.SetPen(*wxTRANSPARENT_PEN);
|
||||
dc.SetBrush(wxBrush(p.panelBg));
|
||||
dc.DrawRectangle(rect);
|
||||
|
||||
dc.SetPen(wxPen(border));
|
||||
dc.SetBrush(wxBrush(track));
|
||||
const int radius = rect.height / 2;
|
||||
dc.DrawRoundedRectangle(rect, radius);
|
||||
|
||||
const int pad = FromDIP(2);
|
||||
const int thumbD = std::max(4, rect.height - 2 * pad);
|
||||
const int travel = std::max(0, rect.width - 2 * pad - thumbD);
|
||||
const int thumbX = pad + (on_ ? travel : 0);
|
||||
const int thumbY = rect.y + (rect.height - thumbD) / 2;
|
||||
|
||||
wxColour thumbBorder = liftRgb(border, dark ? 18 : -12);
|
||||
dc.SetPen(wxPen(thumbBorder));
|
||||
dc.SetBrush(wxBrush(thumb));
|
||||
dc.DrawEllipse(thumbX, thumbY, thumbD, thumbD);
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
+33
-5
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <wx/button.h>
|
||||
#include <wx/bmpbuttn.h>
|
||||
#include <wx/tglbtn.h>
|
||||
#include <wx/choice.h>
|
||||
#include <wx/dcbuffer.h>
|
||||
#include <wx/frame.h>
|
||||
@@ -444,8 +445,11 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
|
||||
#endif
|
||||
}
|
||||
|
||||
// `wxToggleButton` is not a `wxButton` on MSW; without this branch it keeps
|
||||
// native visual styles (e.g. light hover flashes) under dark palette dialogs.
|
||||
if (dynamic_cast<wxButton*>(root) != nullptr ||
|
||||
dynamic_cast<wxBitmapButton*>(root) != nullptr) {
|
||||
dynamic_cast<wxBitmapButton*>(root) != nullptr ||
|
||||
dynamic_cast<wxToggleButton*>(root) != nullptr) {
|
||||
const bool darkLike = isDarkLikeTheme(theme);
|
||||
root->SetThemeEnabled(!darkLike);
|
||||
root->SetBackgroundColour(palette.buttonBg);
|
||||
@@ -485,7 +489,11 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
|
||||
return;
|
||||
}
|
||||
it->second.hovered = false;
|
||||
const wxColour bg = it->second.focused ? it->second.hoverBg : it->second.normalBg;
|
||||
const bool toggleOn =
|
||||
dynamic_cast<wxToggleButton*>(root) != nullptr &&
|
||||
static_cast<wxToggleButton*>(root)->GetValue();
|
||||
const wxColour bg =
|
||||
(it->second.focused || toggleOn) ? it->second.hoverBg : it->second.normalBg;
|
||||
root->SetBackgroundColour(bg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
@@ -513,7 +521,11 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
|
||||
const wxPoint localPos = root->ScreenToClient(mousePos);
|
||||
const bool inside = root->GetClientRect().Contains(localPos);
|
||||
it->second.hovered = inside;
|
||||
const wxColour bg = (inside || it->second.focused) ? it->second.hoverBg : it->second.normalBg;
|
||||
const bool toggleOn =
|
||||
dynamic_cast<wxToggleButton*>(root) != nullptr &&
|
||||
static_cast<wxToggleButton*>(root)->GetValue();
|
||||
const wxColour bg =
|
||||
(inside || it->second.focused || toggleOn) ? it->second.hoverBg : it->second.normalBg;
|
||||
root->SetBackgroundColour(bg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
@@ -539,12 +551,25 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
|
||||
}
|
||||
it->second.focused = false;
|
||||
it->second.pressed = false;
|
||||
const wxColour bg = it->second.hovered ? it->second.hoverBg : it->second.normalBg;
|
||||
const bool toggleOn =
|
||||
dynamic_cast<wxToggleButton*>(root) != nullptr &&
|
||||
static_cast<wxToggleButton*>(root)->GetValue();
|
||||
const wxColour bg =
|
||||
(it->second.hovered || toggleOn) ? it->second.hoverBg : it->second.normalBg;
|
||||
root->SetBackgroundColour(bg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
event.Skip();
|
||||
});
|
||||
if (auto* toggle = dynamic_cast<wxToggleButton*>(root)) {
|
||||
toggle->Bind(wxEVT_TOGGLEBUTTON, [root](wxCommandEvent& event) {
|
||||
auto it = gButtonVisualStates.find(root);
|
||||
if (it != gButtonVisualStates.end() && it->second.darkLike) {
|
||||
root->Refresh();
|
||||
}
|
||||
event.Skip();
|
||||
});
|
||||
}
|
||||
root->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||
root->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
|
||||
root->Bind(wxEVT_PAINT, [root](wxPaintEvent& event) {
|
||||
@@ -555,10 +580,13 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
|
||||
}
|
||||
wxAutoBufferedPaintDC dc(root);
|
||||
const wxRect rect = root->GetClientRect();
|
||||
const bool toggleOn =
|
||||
dynamic_cast<wxToggleButton*>(root) != nullptr &&
|
||||
static_cast<wxToggleButton*>(root)->GetValue();
|
||||
wxColour bg = it->second.normalBg;
|
||||
if (it->second.pressed) {
|
||||
bg = it->second.pressedBg;
|
||||
} else if (it->second.hovered || it->second.focused) {
|
||||
} else if (it->second.hovered || it->second.focused || toggleOn) {
|
||||
bg = it->second.hoverBg;
|
||||
}
|
||||
const wxColour fg = it->second.text;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "ccm/ui/YuGiOhCardEditDialog.hpp"
|
||||
#include "ccm/ui/SwitchCtrl.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
#include "ccm/util/YuGiOhSetLookup.hpp"
|
||||
#include <wx/app.h>
|
||||
#include <wx/panel.h>
|
||||
#include <algorithm>
|
||||
@@ -57,6 +59,33 @@ void YuGiOhCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
||||
flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12);
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) {
|
||||
wxWindow* const host = combo->GetParent();
|
||||
setCodeRowPanel_ = new wxPanel(host, wxID_ANY);
|
||||
auto* inner = new wxBoxSizer(wxHORIZONTAL);
|
||||
setCodeText_ = new wxTextCtrl(setCodeRowPanel_, wxID_ANY);
|
||||
setCodeAutoBtn_ = new wxButton(setCodeRowPanel_, wxID_ANY, "Auto detect");
|
||||
inner->Add(setCodeText_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
||||
inner->Add(setCodeAutoBtn_, 0, wxALIGN_CENTER_VERTICAL);
|
||||
setCodeRowPanel_->SetSizer(inner);
|
||||
setCodeRowPanel_->Show(false);
|
||||
|
||||
setModeHint_ = new wxStaticText(host, wxID_ANY, wxString());
|
||||
setPickerSwitch_ = new SwitchCtrl(host, wxID_ANY, false);
|
||||
setPickerSwitch_->Bind(EVT_CCM_SWITCH, &YuGiOhCardEditDialog::onSetRowSwitch, this);
|
||||
setCodeAutoBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onSetCodeAutoDetect, this);
|
||||
|
||||
row.Add(combo, 1, wxEXPAND);
|
||||
row.Add(setCodeRowPanel_, 1, wxEXPAND);
|
||||
row.Add(setModeHint_, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, 5);
|
||||
row.Add(setPickerSwitch_, 0, wxALIGN_CENTER_VERTICAL);
|
||||
|
||||
if (availableSets().empty()) {
|
||||
setPickerSwitch_->Enable(false);
|
||||
}
|
||||
syncSetModeHint();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
||||
auto* setNoPanel = new wxPanel(this, wxID_ANY);
|
||||
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
|
||||
@@ -340,10 +369,78 @@ void YuGiOhCardEditDialog::onSetNoTextChanged(wxCommandEvent&) {
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
|
||||
handleSetSelectionChanged();
|
||||
ev.Skip();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onSetSelectionApplied() {
|
||||
handleSetSelectionChanged();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::handleSetSelectionChanged() {
|
||||
clearCachedPrintVariants();
|
||||
refreshSetNoFullPreview();
|
||||
scheduleDeferredVariantPrefetch();
|
||||
ev.Skip();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::syncSetModeHint() {
|
||||
if (!setModeHint_ || !setPickerSwitch_) return;
|
||||
// Switch on = set-code entry; hint tells user how to return to the name list.
|
||||
setModeHint_->SetLabel(setPickerSwitch_->GetValue() ? wxString::FromUTF8("Set name")
|
||||
: wxString::FromUTF8("Set code"));
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onSetRowSwitch(wxCommandEvent&) {
|
||||
if (!setPickerSwitch_ || !setComboControl() || !setCodeRowPanel_) return;
|
||||
syncSetModeHint();
|
||||
const bool codeMode = setPickerSwitch_->GetValue();
|
||||
setComboControl()->Show(!codeMode);
|
||||
setCodeRowPanel_->Show(codeMode);
|
||||
wxWindow* host = setComboControl()->GetParent();
|
||||
if (host) {
|
||||
host->Layout();
|
||||
}
|
||||
Layout();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onSetCodeAutoDetect(wxCommandEvent&) {
|
||||
if (!setCodeText_ || !setPickerSwitch_) return;
|
||||
const auto& sets = availableSets();
|
||||
if (sets.empty()) {
|
||||
showThemedMessageDialog(this,
|
||||
"No sets are cached. Use Sets > Update Yu-Gi-Oh! first.",
|
||||
"Set code", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string raw = setCodeText_->GetValue().ToStdString(wxConvUTF8);
|
||||
const auto r = lookupYuGiOhSetByShorthand(raw, sets);
|
||||
using Kind = YuGiOhSetShorthandLookup::Kind;
|
||||
if (r.kind == Kind::NotFound) {
|
||||
showThemedMessageDialog(
|
||||
this,
|
||||
"No set matches that code. Check the code spelling or use Sets > Update Yu-Gi-Oh! to refresh the list.",
|
||||
"Set code", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
if (r.kind == Kind::Ambiguous) {
|
||||
showThemedMessageDialog(this,
|
||||
"Multiple cached sets match that code. Refresh the set list or pick the set from the list.",
|
||||
"Set code", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
|
||||
applySetSelectionByIndex(r.index);
|
||||
|
||||
setPickerSwitch_->SetValue(false, false);
|
||||
syncSetModeHint();
|
||||
setComboControl()->Show(true);
|
||||
setCodeRowPanel_->Show(false);
|
||||
wxWindow* host = setComboControl()->GetParent();
|
||||
if (host) {
|
||||
host->Layout();
|
||||
}
|
||||
Layout();
|
||||
}
|
||||
|
||||
std::string YuGiOhCardEditDialog::extractSetNoNumeric(std::string_view fullSetNo) const {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#include "ccm/ui/YuGiOhGameView.hpp"
|
||||
|
||||
#include "ccm/ui/CardEditModalGuard.hpp"
|
||||
#include "ccm/ui/YuGiOhCardEditDialog.hpp"
|
||||
#include "ccm/ui/YuGiOhCardListPanel.hpp"
|
||||
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
#include <optional>
|
||||
#include <algorithm>
|
||||
@@ -56,6 +58,10 @@ wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) {
|
||||
selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
});
|
||||
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
|
||||
wxWindow* owner = wxGetTopLevelParent(listPanel_);
|
||||
onEditCard(owner != nullptr ? owner : static_cast<wxWindow*>(listPanel_));
|
||||
});
|
||||
}
|
||||
return listPanel_;
|
||||
}
|
||||
@@ -94,6 +100,11 @@ const std::vector<Set>& YuGiOhGameView::setsForDialog() {
|
||||
}
|
||||
|
||||
void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
|
||||
if (cardEditModalIsActive()) {
|
||||
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
|
||||
wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
YuGiOhCard fresh;
|
||||
fresh.amount = 1;
|
||||
fresh.language = Language::English;
|
||||
@@ -102,6 +113,7 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
|
||||
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
|
||||
&setsForDialog());
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
CardEditModalGuard modalGuard;
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::YuGiOh, dlg.card());
|
||||
@@ -113,8 +125,11 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
|
||||
|
||||
YuGiOhCard persisted = dlg.card();
|
||||
persisted.id = added.value();
|
||||
const std::string setNameForImage = persisted.set.id.empty()
|
||||
? persisted.set.name
|
||||
: persisted.set.id;
|
||||
auto normalized = images_.normalizeNamesForPersistedCard(
|
||||
Game::YuGiOh, persisted.id, persisted.set.name, persisted.name, persisted.images);
|
||||
Game::YuGiOh, persisted.id, setNameForImage, persisted.name, persisted.images);
|
||||
if (normalized) {
|
||||
if (normalized.value() != persisted.images) {
|
||||
persisted.images = std::move(normalized).value();
|
||||
@@ -138,9 +153,15 @@ void YuGiOhGameView::onEditCard(wxWindow* parentWindow) {
|
||||
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
if (cardEditModalIsActive()) {
|
||||
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
|
||||
wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
|
||||
&setsForDialog());
|
||||
themeModalDialog(&dlg, config_.current().theme);
|
||||
CardEditModalGuard modalGuard;
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::YuGiOh, dlg.card());
|
||||
if (!updated) {
|
||||
|
||||
Reference in New Issue
Block a user