Compare commits

...

3 Commits

Author SHA1 Message Date
Sebastian Dine 42926f2fb5 patch: Feature/ygo set selection (#16) 2026-05-13 21:16:41 +02:00
Sebastian Dine 8a50e8daba patch: Feature/pkm autodetect (#15) 2026-05-12 15:49:37 +02:00
Sebastian Dine 98f2575b5a patch: Patch/ygo 25th set 2 (#14) 2026-05-11 11:27:32 +02:00
54 changed files with 2478 additions and 63 deletions
+2 -2
View File
@@ -106,8 +106,8 @@ Run from the **workspace root**.
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high.
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR.
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high. Exercise both outcomes of meaningful conditionals (success vs error, empty vs non-empty, cache hit vs miss, `NotFound` vs `Transient`, early return vs fall-through), not only the happy path — Sonar condition coverage on `core/` is a separate signal from line coverage.
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR. When checking coverage locally, also review branch/condition metrics (for example `gcovr ... --txt-metric branch` or Sonar's condition coverage on the same `core/` surface); there is no repo-wide condition threshold in CI yet — use Sonar's per-file condition list to prioritize gaps.
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
-1
View File
@@ -1,6 +1,5 @@
# Card Collection Manager 3
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=alert_status&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=bugs&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=security_rating&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
+1 -1
View File
@@ -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_;
};
+67
View File
@@ -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
+10 -4
View File
@@ -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"},
+5 -11
View File
@@ -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) {
-1
View File
@@ -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
View File
@@ -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.
+8 -2
View File
@@ -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`.
+11 -3
View File
@@ -22,9 +22,13 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
Used by `PokemonCardPreviewSource` in two ways:
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the cards `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
@@ -39,6 +43,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 Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
+1
View File
@@ -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.
+2
View File
@@ -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
+25
View File
@@ -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);
}
}
+55 -1
View File
@@ -57,7 +57,13 @@ YuGiOhCard yc(std::string name,
std::string setName,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "",
bool firstEdition = false,
bool sgnd = false,
bool altered = false) {
YuGiOhCard c;
c.id = 1;
c.name = std::move(name);
@@ -65,6 +71,12 @@ YuGiOhCard yc(std::string name,
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
return c;
}
@@ -176,6 +188,10 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
}
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
TEST_CASE("empty filter matches every row") {
CHECK(matchesYuGiOhFilter(yc("Dark Magician", "Legend of Blue Eyes"), ""));
}
TEST_CASE("matches by set number and rarity") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "lob-005"));
@@ -183,4 +199,42 @@ TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
CHECK(matchesYuGiOhFilter(c, "ur"));
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
}
TEST_CASE("matches by name and set.name") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "dark"));
CHECK(matchesYuGiOhFilter(c, "blue eyes"));
CHECK_FALSE(matchesYuGiOhFilter(c, "spell"));
}
TEST_CASE("matches by language, condition, amount, and note") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare",
12, Language::German, Condition::Played, "binder copy");
CHECK(matchesYuGiOhFilter(c, "german"));
CHECK(matchesYuGiOhFilter(c, "played"));
CHECK(matchesYuGiOhFilter(c, "12"));
CHECK(matchesYuGiOhFilter(c, "binder"));
CHECK_FALSE(matchesYuGiOhFilter(c, "english"));
}
TEST_CASE("matches rarity shorthand when the long rarity string does not") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005",
"Quarter Century Secret Rare");
CHECK(matchesYuGiOhFilter(c, "qcscr"));
CHECK_FALSE(matchesYuGiOhFilter(c, "mythic"));
}
TEST_CASE("boolean flag columns are not matched") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare",
1, Language::English, Condition::NearMint, "",
/*firstEdition=*/true, /*sgnd=*/true, /*altered=*/true);
CHECK_FALSE(matchesYuGiOhFilter(c, "true"));
CHECK_FALSE(matchesYuGiOhFilter(c, "false"));
CHECK(matchesYuGiOhFilter(c, "dark"));
}
TEST_CASE("no column hit returns false") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK_FALSE(matchesYuGiOhFilter(c, "zzznomatch"));
}
}
+83
View File
@@ -334,6 +334,78 @@ TEST_SUITE("CardPreviewService caching") {
CHECK(http.calls == 1);
}
TEST_CASE("memory-only caching works when no persistent cache is configured") {
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "PNG-bytes";
CardPreviewService svc{http, nullptr};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "").isOk());
http.body = "OTHER";
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(second.isOk());
CHECK(second.value() == "PNG-bytes");
CHECK(http.calls == 1);
}
TEST_CASE("registerModule replaces the preview source for the same game") {
FakeSource firstSource;
firstSource.url = "https://example.com/first.png";
FakeGameModule firstModule;
firstModule.gameId = Game::Magic;
firstModule.preview = &firstSource;
FakeSource secondSource;
secondSource.url = "https://example.com/second.png";
FakeGameModule secondModule;
secondModule.gameId = Game::Magic;
secondModule.preview = &secondSource;
FixedHttpClient http;
http.body = "SECOND";
CardPreviewService svc{http};
svc.registerModule(firstModule);
svc.registerModule(secondModule);
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(out.isOk());
CHECK(out.value() == "SECOND");
CHECK(http.lastUrl == "https://example.com/second.png");
CHECK(firstSource.calls == 0);
CHECK(secondSource.calls == 1);
}
TEST_CASE("NotFound without persistent cache still negative-caches in memory") {
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "not found";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http, nullptr};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "X", "abc", "").isErr());
CHECK(source.calls == 1);
const auto second = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
REQUIRE(second.isErr());
CHECK(second.error() == "No preview available for this card.");
CHECK(source.calls == 1);
CHECK(http.calls == 0);
}
TEST_CASE("HTTP success writes through to the persistent cache") {
// The persistent tier is fire-and-forget on the way down (HTTP -> disk)
// and consulted on the way up (cache miss -> disk -> HTTP). This first
@@ -703,6 +775,17 @@ TEST_SUITE("CardPreviewService caching") {
CHECK(http.calls == 1);
}
TEST_CASE("fetchImageBytesByUrl propagates HTTP errors when uncached") {
FixedHttpClient http;
http.ok = false;
http.err = "url fetch failed";
CardPreviewService svc{http};
const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(out.isErr());
CHECK(out.error() == "url fetch failed");
}
TEST_CASE("fetchImageBytesByUrl serves from persistent cache hit without HTTP") {
FixedHttpClient http;
http.body = "warm-card-back";
+11
View File
@@ -430,6 +430,17 @@ TEST_SUITE("CardSorter - YuGiOh columns") {
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // C, ScR, UR
}
TEST_CASE("Rarity treats unknown labels as equal empty shorthand") {
std::vector<YuGiOhCard> v = {
yc(1, "alpha", "X", "2000/01/01", "", "Mythic Cosmic Rare", 1),
yc(2, "beta", "X", "2000/01/01", "", "Other Unknown", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
}
TEST_CASE("Amount sorts numerically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 9),
+43
View File
@@ -59,6 +59,16 @@ MagicCard makeCard(const std::string& name, std::vector<std::string> imgs = {})
} // namespace
TEST_SUITE("CollectionService<MagicCard>") {
TEST_CASE("nextId uses highest existing id plus one") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
repo.storage.emplace(2, makeCard("A"));
repo.storage.emplace(9, makeCard("B"));
CHECK(CollectionService<MagicCard>::nextId(repo.storage) == 10);
}
TEST_CASE("nextId on empty map is 0, then strictly increments") {
InMemoryRepo repo;
StubImageStore store;
@@ -163,6 +173,21 @@ TEST_SUITE("CollectionService<MagicCard>") {
CHECK(updateRes.error() == "save failed");
}
TEST_CASE("add overwrites input card id with generated id") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
MagicCard card = makeCard("Has User Id");
card.id = 777;
const auto out = svc.add(Game::Magic, card);
REQUIRE(out.isOk());
CHECK(out.value() == 0);
REQUIRE(repo.storage.count(0) == 1);
CHECK(repo.storage.at(0).name == "Has User Id");
CHECK(repo.storage.count(777) == 0);
}
TEST_CASE("findById returns nullopt for missing id") {
InMemoryRepo repo;
StubImageStore store;
@@ -193,4 +218,22 @@ TEST_SUITE("CollectionService<MagicCard>") {
REQUIRE(listed.isOk());
CHECK(listed.value().empty());
}
TEST_CASE("remove propagates save failure after image cleanup") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto id = svc.add(
Game::Magic, makeCard("With Images", {"a.png", "b.png"}));
REQUIRE(id.isOk());
repo.failSave = true;
const auto removed = svc.remove(Game::Magic, id.value());
REQUIRE(removed.isErr());
CHECK(removed.error() == "save failed");
REQUIRE(store.removed.size() == 2);
CHECK(store.removed[0].second == "a.png");
CHECK(store.removed[1].second == "b.png");
}
}
+53
View File
@@ -79,6 +79,59 @@ TEST_SUITE("CprHttpClient injected raw executor") {
REQUIRE(out.isErr());
CHECK(out.error().find("timeout") != std::string::npos);
}
TEST_CASE("passes URL through raw executor unchanged") {
std::string seenUrl;
CprHttpClient client{
[&seenUrl](std::string_view url) -> CprHttpClient::RawResponse {
seenUrl = std::string(url);
return CprHttpClient::RawResponse{
.transportError = false,
.transportMessage = "",
.statusCode = 200,
.body = "ok",
};
}
};
const auto out = client.get("https://example.com/raw?q=a%20b");
REQUIRE(out.isOk());
CHECK(seenUrl == "https://example.com/raw?q=a%20b");
}
TEST_CASE("maps non-2xx status to error") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = false,
.transportMessage = "",
.statusCode = 503,
.body = "service unavailable",
};
}
};
const auto out = client.get("https://example.com/fail");
REQUIRE(out.isErr());
CHECK(out.error().find("HTTP 503") != std::string::npos);
}
TEST_CASE("transport error takes precedence over status code") {
CprHttpClient client{
[](std::string_view) -> CprHttpClient::RawResponse {
return CprHttpClient::RawResponse{
.transportError = true,
.transportMessage = "socket closed",
.statusCode = 200,
.body = "ignored",
};
}
};
const auto out = client.get("https://example.com/transport");
REQUIRE(out.isErr());
CHECK(out.error().find("socket closed") != std::string::npos);
}
}
TEST_SUITE("CprHttpClient real session") {
+216
View File
@@ -235,6 +235,103 @@ TEST_SUITE("YuGiOhCard JSON") {
CHECK(card.setNo == "SDY-006");
CHECK(card.set.id == "SDY");
}
TEST_CASE("serializes non-default flags and metadata fields") {
YuGiOhCard c;
c.id = 3;
c.amount = 4;
c.name = "Red-Eyes Black Dragon";
c.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
c.setNo = "LOB-070";
c.rarity = "Secret Rare";
c.note = "graded";
c.images = {};
c.language = Language::German;
c.condition = Condition::Played;
c.firstEdition = false;
c.signed_ = true;
c.altered = true;
const nlohmann::json j = c;
CHECK(j.at("firstEdition") == false);
CHECK(j.at("signed") == true);
CHECK(j.at("altered") == true);
CHECK(j.at("language") == "German");
CHECK(j.at("condition") == "Played");
CHECK(j.at("images") == nlohmann::json::array());
const YuGiOhCard back = j.get<YuGiOhCard>();
CHECK(back == c);
}
TEST_CASE("operator== distinguishes each field") {
YuGiOhCard base;
base.id = 10;
base.amount = 2;
base.name = "Dark Magician";
base.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
base.setNo = "LOB-005";
base.rarity = "Ultra Rare";
base.note = "note";
base.images = {"a.png"};
base.language = Language::English;
base.condition = Condition::NearMint;
base.firstEdition = true;
base.signed_ = false;
base.altered = false;
auto changed = base;
changed.id = 11;
CHECK_FALSE(changed == base);
changed = base;
changed.amount = 3;
CHECK_FALSE(changed == base);
changed = base;
changed.name = "Other";
CHECK_FALSE(changed == base);
changed = base;
changed.set.name = "Other Set";
CHECK_FALSE(changed == base);
changed = base;
changed.setNo = "LOB-006";
CHECK_FALSE(changed == base);
changed = base;
changed.rarity = "Rare";
CHECK_FALSE(changed == base);
changed = base;
changed.note = "other";
CHECK_FALSE(changed == base);
changed = base;
changed.images = {};
CHECK_FALSE(changed == base);
changed = base;
changed.language = Language::Japanese;
CHECK_FALSE(changed == base);
changed = base;
changed.condition = Condition::Played;
CHECK_FALSE(changed == base);
changed = base;
changed.firstEdition = false;
CHECK_FALSE(changed == base);
changed = base;
changed.signed_ = true;
CHECK_FALSE(changed == base);
changed = base;
changed.altered = true;
CHECK_FALSE(changed == base);
}
}
TEST_SUITE("Domain JSON required fields") {
@@ -290,6 +387,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>());
}
}
+3
View File
@@ -27,6 +27,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(module.dirName() == "magic");
CHECK(module.displayName() == "Magic");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("Pokemon module reports canonical metadata") {
@@ -37,6 +38,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(module.dirName() == "pokemon");
CHECK(module.displayName() == "Pokemon");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("YuGiOh module reports canonical metadata") {
@@ -47,5 +49,6 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(module.dirName() == "yugioh");
CHECK(module.displayName() == "Yu-Gi-Oh!");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
}
+7
View File
@@ -46,4 +46,11 @@ TEST_SUITE("mapHttpGetResponse") {
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 404 from https://api.example/r");
}
TEST_CASE("HTTP 200 with empty body still maps to success") {
const auto out =
mapHttpGetResponse(false, {}, 200, "", "https://api.example/empty");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
}
+19
View File
@@ -17,6 +17,20 @@ public:
}
};
class AutoDetectPreviewSource final : public ICardPreviewSource {
public:
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return true;
}
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view,
std::string_view,
std::string_view) override {
return Result<std::string, PreviewLookupError>::ok("https://example.test/card.png");
}
};
} // namespace
TEST_SUITE("ICardPreviewSource defaults") {
@@ -25,6 +39,11 @@ TEST_SUITE("ICardPreviewSource defaults") {
CHECK_FALSE(src.supportsAutoDetectPrint());
}
TEST_CASE("implementations may override supportsAutoDetectPrint") {
AutoDetectPreviewSource src;
CHECK(src.supportsAutoDetectPrint());
}
TEST_CASE("default detectFirstPrint returns explicit unsupported error") {
MinimalPreviewSource src;
const auto out = src.detectFirstPrint("Card", "Set");
+53
View File
@@ -68,6 +68,11 @@ TEST_SUITE("ImageService::nextImageIndex") {
std::vector<std::string> imgs = {"set+name+99.png"};
CHECK(ImageService::nextImageIndex(imgs) == 100);
}
TEST_CASE("three-digit filename index follows two-digit compatibility parser") {
std::vector<std::string> imgs = {"set+name+255.png"};
CHECK(ImageService::nextImageIndex(imgs) == 56);
}
}
TEST_SUITE("ImageService::buildTargetName") {
@@ -98,6 +103,40 @@ TEST_SUITE("ImageService::addImage") {
CHECK(store.copies[0].game == Game::Magic);
CHECK(store.copies[0].target == "Beta+BlackLotus+0");
}
TEST_CASE("propagates copyIn failures") {
RecordingImageStore store;
store.failCopyAt = 1;
ImageService svc{store};
std::vector<std::string> existing;
const auto out = svc.addImage(Game::Magic, "/tmp/source.png",
/*newEntry=*/true, /*cardId=*/0,
"Beta", "Black Lotus", existing);
REQUIRE(out.isErr());
CHECK(out.error().find("copy failed at 1") != std::string::npos);
}
}
TEST_SUITE("ImageService::removeImage and resolveImagePath") {
TEST_CASE("removeImage delegates to store remove") {
RecordingImageStore store;
ImageService svc{store};
const auto out = svc.removeImage(Game::Magic, "x.png");
REQUIRE(out.isOk());
REQUIRE(store.removes.size() == 1);
CHECK(store.removes[0].first == Game::Magic);
CHECK(store.removes[0].second == "x.png");
}
TEST_CASE("resolveImagePath delegates to store resolvePath") {
RecordingImageStore store;
ImageService svc{store};
const auto p = svc.resolveImagePath(Game::Pokemon, "pikachu.jpg");
CHECK(p == std::filesystem::path("/fake/pikachu.jpg"));
}
}
TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
@@ -142,6 +181,20 @@ TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
CHECK(store.removes.empty());
}
TEST_CASE("skips rename when computed output name equals input") {
RecordingImageStore store;
ImageService svc{store};
const std::vector<std::string> images{"42+Beta+BlackLotus+0.png"};
auto normalized = svc.normalizeNamesForPersistedCard(
Game::Magic, 42, "Beta", "Black Lotus", images);
REQUIRE(normalized.isOk());
CHECK(normalized.value() == images);
CHECK(store.copies.empty());
CHECK(store.removes.empty());
}
TEST_CASE("copy failure rolls back already-created names and returns error") {
RecordingImageStore store;
store.failCopyAt = 2;
+102
View File
@@ -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);
}
}
+24
View File
@@ -31,6 +31,7 @@ public:
std::string readPayload{"[]"};
std::filesystem::path lastWritePath;
std::string lastWriteBody;
std::filesystem::path lastReadPath;
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
@@ -40,6 +41,7 @@ public:
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
lastReadPath = std::filesystem::path("/tracked/read/path");
if (!readOk) return Result<std::string>::err("read failed");
return Result<std::string>::ok(readPayload);
}
@@ -109,6 +111,18 @@ TEST_SUITE("JsonSetRepository") {
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
}
TEST_CASE("load reports parse error for wrong JSON shape") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
FailingSetFs fs;
fs.readPayload = R"({"not":"an array"})";
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Magic);
REQUIRE(loaded.isErr());
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
}
TEST_CASE("save propagates ensureDirectory and writeText failures") {
InMemoryFileSystem configFs;
auto cfg = makeConfig(configFs, "/data");
@@ -128,4 +142,14 @@ TEST_SUITE("JsonSetRepository") {
REQUIRE(writeFail.isErr());
CHECK(writeFail.error() == "write failed");
}
TEST_CASE("paths are composed from dataStorage and game dir") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonSetRepository repo{fs, cfg, dirNameFn};
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
REQUIRE(repo.save(Game::Pokemon, sets).isOk());
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
}
}
+57
View File
@@ -21,6 +21,37 @@ std::string dirNameForGame(Game g) {
return "magic";
}
class FailingImageFs final : public IFileSystem {
public:
bool ensureOk{true};
bool copyOk{true};
bool removeOk{true};
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
Result<void> ensureDirectory(const std::filesystem::path&) override {
if (!ensureOk) return Result<void>::err("ensure failed");
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path&) override {
return Result<std::string>::ok({});
}
Result<void> writeText(const std::filesystem::path&, std::string_view) override {
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
if (!copyOk) return Result<void>::err("copy failed");
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path&) override {
if (!removeOk) return Result<void>::err("remove failed");
return Result<void>::ok();
}
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
} // namespace
TEST_SUITE("LocalImageStore") {
@@ -87,4 +118,30 @@ TEST_SUITE("LocalImageStore") {
const std::filesystem::path got = store.resolvePath(Game::Pokemon, "pic.jpg");
CHECK(got.generic_string() == "/coll/pokemon/images/pic.jpg");
}
TEST_CASE("copyIn propagates ensureDirectory failure") {
InMemoryFileSystem configFs;
ConfigService cfg{configFs, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
FailingImageFs fs;
fs.ensureOk = false;
LocalImageStore store(fs, cfg, dirNameForGame);
const auto out = store.copyIn(Game::Magic, "/incoming/a.png", "id001");
REQUIRE(out.isErr());
CHECK(out.error() == "ensure failed");
}
TEST_CASE("remove propagates filesystem remove failure when file exists") {
InMemoryFileSystem configFs;
ConfigService cfg{configFs, "/app/config.json", "/coll"};
REQUIRE(cfg.initialize().isOk());
FailingImageFs fs;
fs.removeOk = false;
LocalImageStore store(fs, cfg, dirNameForGame);
const auto out = store.remove(Game::Magic, "a.png");
REQUIRE(out.isErr());
CHECK(out.error() == "remove failed");
}
}
+133
View File
@@ -12,6 +12,8 @@
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
@@ -56,6 +58,59 @@ void backdate(const fs::path& p, int seconds) {
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
}
class FailingEnsureDirFs final : public IFileSystem {
public:
explicit FailingEnsureDirFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override {
(void)p;
return Result<void>::err("ensure failed");
}
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
class FailingIndexWriteFs final : public IFileSystem {
public:
explicit FailingIndexWriteFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override { return inner_.ensureDirectory(p); }
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
const auto path = p.generic_string();
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".idx") == 0) {
return Result<void>::err("idx write failed");
}
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
} // namespace
TEST_SUITE("LocalPreviewByteCache") {
@@ -234,6 +289,55 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with payload but missing sidecar is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with negative marker but missing sidecar is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.storeNegative("real-key");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("entry with unreadable payload file is treated as miss") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".bin") {
std::error_code ec;
fs::remove(entry.path(), ec);
break;
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("evicts oldest entry when the size cap would be exceeded") {
TempDir td;
StdFileSystem fs;
@@ -291,3 +395,32 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
}
}
TEST_SUITE("LocalPreviewByteCache in-memory filesystem failures") {
TEST_CASE("store is a silent no-op when ensureDirectory fails") {
ccm::testing::InMemoryFileSystem inner;
FailingEnsureDirFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("store rolls back payload when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("storeNegative rolls back marker when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.storeNegative("k");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
}
@@ -43,6 +43,12 @@ TEST_SUITE("MagicCardPreviewSource::buildSearchUrl") {
const auto url = MagicCardPreviewSource::buildSearchUrl("X", "swsh10");
CHECK(url.find("set%3Aswsh10") != std::string::npos);
}
TEST_CASE("replaces every ampersand in the card name") {
const auto url = MagicCardPreviewSource::buildSearchUrl("A & B & C", "abc");
CHECK(url.find("A%20and%20B%20and%20C") != std::string::npos);
CHECK(url.find("%26") == std::string::npos);
}
}
TEST_SUITE("MagicCardPreviewSource::parseResponse") {
+249
View File
@@ -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.");
}
}
+34
View File
@@ -39,11 +39,13 @@ class InMemSetRepo final : public ISetRepository {
public:
std::vector<Set> stored;
bool hasStored = false;
bool failSave = false;
Result<std::vector<Set>> load(Game) override {
if (!hasStored) return Result<std::vector<Set>>::err("no cache");
return Result<std::vector<Set>>::ok(stored);
}
Result<void> save(Game, const std::vector<Set>& s) override {
if (failSave) return Result<void>::err("save failed");
stored = s;
hasStored = true;
return Result<void>::ok();
@@ -145,4 +147,36 @@ TEST_SUITE("SetService") {
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
}
TEST_CASE("updateSets propagates repository save failures") {
InMemSetRepo repo;
repo.failSave = true;
SetService svc{repo};
FakeGameModule magic{Game::Magic};
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
svc.registerModule(&magic);
const auto out = svc.updateSets(Game::Magic);
REQUIRE(out.isErr());
CHECK(out.error() == "save failed");
}
TEST_CASE("registering a second module for same game id overwrites previous one") {
InMemSetRepo repo;
SetService svc{repo};
FakeGameModule firstMagic{Game::Magic};
firstMagic.source.result = Result<std::vector<Set>>::ok({{"a", "First", "2000/01/01"}});
FakeGameModule secondMagic{Game::Magic};
secondMagic.source.result = Result<std::vector<Set>>::ok({{"b", "Second", "2001/01/01"}});
svc.registerModule(&firstMagic);
svc.registerModule(&secondMagic);
const auto out = svc.updateSets(Game::Magic);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().id == "b");
CHECK(firstMagic.source.calls == 0);
CHECK(secondMagic.source.calls == 1);
}
}
+27
View File
@@ -112,6 +112,22 @@ TEST_SUITE("StdFileSystem") {
CHECK(r.value() == "hi");
}
TEST_CASE("writeText/readText work for top-level relative files") {
TempDir td;
StdFileSystem fs;
const auto oldCwd = fs::current_path();
fs::current_path(td.path);
const fs::path topLevel = "top-level.txt";
REQUIRE(fs.writeText(topLevel, "hello").isOk());
const auto r = fs.readText(topLevel);
REQUIRE(r.isOk());
CHECK(r.value() == "hello");
std::error_code ec;
fs::current_path(oldCwd, ec);
}
TEST_CASE("copyFile copies bytes and respects overwrite flag") {
TempDir td;
StdFileSystem fs;
@@ -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);
}
}
+158
View File
@@ -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");
}
}
+87
View File
@@ -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");
}
}
+52
View File
@@ -57,6 +57,20 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
}
TEST_CASE("empty upstream array still appends missing 25th aliases") {
const auto out = YuGiOhSetSource::parseResponse("[]");
REQUIRE(out.isOk());
CHECK(out.value().size() == 6);
bool foundLob25th = false;
bool foundIoc25th = false;
for (const auto& set : out.value()) {
if (set.id == "LOB-25TH") foundLob25th = true;
if (set.id == "IOC-25TH") foundIoc25th = true;
}
CHECK(foundLob25th);
CHECK(foundIoc25th);
}
TEST_CASE("adds 25th Anniversary aliases when upstream list misses them") {
const auto out = YuGiOhSetSource::parseResponse(R"([
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}
@@ -98,6 +112,44 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
REQUIRE(out.isErr());
CHECK(out.error().find("YGOPRODeck set parse error:") != std::string::npos);
}
TEST_CASE("missing fields fall back to empty strings and keep parsing") {
const std::string json = R"([
{"set_name":"Set A"},
{"set_code":"BBB","tcg_date":"2021-02-03"}
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
bool foundMissingCode = false;
bool foundMissingName = false;
for (const auto& set : out.value()) {
if (set.name == "Set A" && set.id.empty() && set.releaseDate.empty()) {
foundMissingCode = true;
}
if (set.id == "BBB" && set.name.empty() && set.releaseDate == "2021/02/03") {
foundMissingName = true;
}
}
CHECK(foundMissingCode);
CHECK(foundMissingName);
}
TEST_CASE("preserves slash-formatted dates and normalizes hyphen dates") {
const std::string json = R"([
{"set_name":"Slash Date","set_code":"S","tcg_date":"2024/01/01"},
{"set_name":"Hyphen Date","set_code":"H","tcg_date":"2024-01-02"}
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
bool sawSlash = false;
bool sawHyphenNormalized = false;
for (const auto& set : out.value()) {
if (set.id == "S" && set.releaseDate == "2024/01/01") sawSlash = true;
if (set.id == "H" && set.releaseDate == "2024/01/02") sawHyphenNormalized = true;
}
CHECK(sawSlash);
CHECK(sawHyphenNormalized);
}
}
TEST_SUITE("YuGiOhSetSource::fetchAll") {
+5 -4
View File
@@ -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 ~50100 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
+1
View File
@@ -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
+43 -9
View File
@@ -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
+52 -5
View File
@@ -7,7 +7,15 @@
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include <wx/button.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
@@ -16,9 +24,11 @@ public:
PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets = nullptr);
~PokemonCardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
@@ -26,13 +36,50 @@ protected:
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
void onCardLookupContextChanged() override;
private:
wxTextCtrl* setNoCtrl_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void autoDetectFromApi();
void clearCachedPrintVariants();
void requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
void refreshVariantNextControls();
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
[[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl);
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetNos_;
std::size_t setNoRingPos_{0};
};
} // namespace ccm::ui
+31
View File
@@ -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_;
+3
View File
@@ -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
+18
View File
@@ -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) {
+210 -5
View File
@@ -1,18 +1,41 @@
#include "ccm/ui/PokemonCardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <thread>
#include <unordered_set>
namespace ccm::ui {
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets)
: BaseCardEditDialog<PokemonCard>(
parent,
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets) {
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets),
dialogMode_(mode),
cardPreview_(cardPreview),
variantFetchState_(std::make_shared<VariantFetchState>()) {
buildAndPopulate();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
}
PokemonCardEditDialog::~PokemonCardEditDialog() {
if (variantFetchState_) {
variantFetchState_->alive.store(false);
}
}
void PokemonCardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
@@ -27,12 +50,46 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
}
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
setNoCtrl_ = new wxTextCtrl(this, wxID_ANY, constCard().setNo);
appendRow(grid, "Set #", setNoCtrl_);
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
autoSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onAutoDetectSetNo, this);
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
setNoPanel->SetSizer(setNoRow);
appendRow(grid, "Set #", setNoPanel);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this);
}
}
std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
std::string out(setNo);
const auto slash = out.find('/');
if (slash != std::string::npos) {
out.resize(slash);
}
return out;
}
std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
if (ctrl == nullptr) return {};
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
}
void PokemonCardEditDialog::readExtraFromCard() {
if (setNoCtrl_) setNoCtrl_->ChangeValue(constCard().setNo);
clearCachedPrintVariants();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
}
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
@@ -40,11 +97,159 @@ void PokemonCardEditDialog::readExtraFromCard() {
}
void PokemonCardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString();
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
}
void PokemonCardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
cachedVariants_.clear();
uniqueSetNos_.clear();
setNoRingPos_ = 0;
refreshVariantNextControls();
}
void PokemonCardEditDialog::scheduleDeferredVariantPrefetch() {
const unsigned epoch = variantFetchEpoch_;
wxTheApp->CallAfter([this, epoch]() {
prefetchVariantsForCurrentCardSilent(epoch);
});
}
void PokemonCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
if (capturedEpoch != variantFetchEpoch_) return;
if (!cachedVariants_.empty()) return;
const auto& card = constCard();
if (card.name.empty() || card.set.id.empty()) return;
requestVariantsAsync(capturedEpoch, card.name, card.set.id, false, false);
}
void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Disable();
}
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
PokemonCardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog]() {
auto detected = svc->detectPrintVariants(Game::Pokemon, name, setId);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
});
}).detach();
}
void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
if (!detected) {
if (showFailureDialog) {
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
wxOK | wxICON_WARNING);
}
return;
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
}
rebuildVariantRingFromCache();
syncRingPositionToControls();
refreshVariantNextControls();
}
void PokemonCardEditDialog::rebuildVariantRingFromCache() {
uniqueSetNos_.clear();
if (cachedVariants_.empty()) return;
std::unordered_set<std::string> seen;
seen.reserve(cachedVariants_.size());
for (const auto& p : cachedVariants_) {
if (p.setNo.empty()) continue;
if (!seen.insert(p.setNo).second) continue;
uniqueSetNos_.push_back(p.setNo);
}
}
void PokemonCardEditDialog::syncRingPositionToControls() {
if (!setNoCtrl_) return;
const std::string current = storedSetNoFromControls(setNoCtrl_);
setNoRingPos_ = 0;
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
if (uniqueSetNos_[i] == current) {
setNoRingPos_ = i;
break;
}
}
}
void PokemonCardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_) return;
nextSetNoBtn_->Show(uniqueSetNos_.size() > 1);
Layout();
if (GetSizer()) Fit();
}
void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
autoDetectFromApi();
}
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
if (uniqueSetNos_.size() <= 1) return;
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
}
refreshVariantNextControls();
}
void PokemonCardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.id.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.id, true, true);
}
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
} // namespace ccm::ui
+20 -2
View File
@@ -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) {
+135
View File
@@ -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
View File
@@ -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;
+98 -1
View File
@@ -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 {
+22 -1
View File
@@ -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) {