minor: yugioh support added

This commit is contained in:
Sebastian Dine
2026-05-09 19:32:18 +02:00
committed by GitHub
parent 6f575f4cec
commit 6ff4406638
68 changed files with 4994 additions and 134 deletions
+7 -3
View File
@@ -15,17 +15,20 @@
- `set_service_tests.cpp``SetService` with `FakeSetSource` + `InMemSetRepo`.
- `magic_set_source_tests.cpp``MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
- `magic_card_preview_source_tests.cpp``MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`.
- `card_preview_service_tests.cpp``CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` and a `FixedHttpClient`. Pin-down for the "module returning nullptr is silently skipped" rule.
- `card_preview_service_tests.cpp``CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly.
- `local_preview_byte_cache_tests.cpp``LocalPreviewByteCache` adapter against `StdFileSystem` (the only test in the suite that touches real disk; each case scopes itself to a unique `temp_directory_path()/ccm_preview_cache_test_*` directory and cleans up via an RAII `TempDir`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
- `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_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `rarityCodeFor` (CCM3 dialog rarity names → Yugipedia codes, 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.
- `card_filter_tests.cpp``matchesMagicFilter` / `matchesPokemonFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`), boolean flag columns (foil/signed/altered/holo/firstEdition) intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
- `card_filter_tests.cpp``matchesMagicFilter` / `matchesPokemonFilter` / `matchesYuGiOhFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`; Yu-Gi-Oh adds `setNo` + `rarity`), boolean flag columns intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
- `CMakeLists.txt` — explicit list of every `.cpp` (no glob).
## Conventions
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`.
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`.
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`. **One narrow exception**: `local_preview_byte_cache_tests.cpp` exercises the real filesystem because `LocalPreviewByteCache` uses `std::filesystem` directly for size + mtime queries that the `IFileSystem` port deliberately does not expose. Those tests scope themselves to a unique temp directory and clean up unconditionally — do not extend the exception to other test files.
3. **Fakes for narrow concerns stay in the test file** as anonymous-namespace classes (e.g. `RecordingImageStore`, `InMemoryRepo`). Promote a fake to `tests/fakes/` only when more than one test file needs it.
4. **Path strings** in expectations must use forward slashes. The fake normalizes everything to `generic_string()`. Do not hard-code `\` separators.
5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file".
@@ -37,6 +40,7 @@
- After modifying `formatTextForFs` or `parseIndexFromFilename` you **must** extend `fs_names_tests.cpp` — these are byte-compatibility shims with the original Rust code.
- After adding a new service in `core/` you **must** add a corresponding `<name>_service_tests.cpp` with at least the happy-path and one error-path test.
- After adding a new game's set source / card preview source you **must** add `tests/<name>_set_source_tests.cpp` and (if applicable) `tests/<name>_card_preview_source_tests.cpp` mirroring the Magic and Pokemon files. Add them to `tests/CMakeLists.txt`.
- After adding or changing `ICardPreviewSource` optional capabilities (for example `detectFirstPrint` / `detectPrintVariants`), you **must** extend `card_preview_service_tests.cpp` and the corresponding game source tests to cover both supported and unsupported paths.
## Commands
+3
View File
@@ -16,8 +16,11 @@ add_executable(ccm_core_tests
magic_set_source_tests.cpp
magic_card_preview_source_tests.cpp
card_preview_service_tests.cpp
local_preview_byte_cache_tests.cpp
pokemon_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp
yugioh_set_source_tests.cpp
yugioh_card_preview_source_tests.cpp
card_sorter_tests.cpp
card_filter_tests.cpp
+25
View File
@@ -8,6 +8,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/services/CardFilter.hpp"
#include <string>
@@ -52,6 +53,21 @@ PokemonCard pc(std::string name,
return c;
}
YuGiOhCard yc(std::string name,
std::string setName,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
YuGiOhCard c;
c.id = 1;
c.name = std::move(name);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
return c;
}
} // namespace
TEST_SUITE("CardFilter::matchesMagicFilter") {
@@ -158,3 +174,12 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
}
}
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
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"));
CHECK(matchesYuGiOhFilter(c, "ultra"));
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
}
}
+585 -5
View File
@@ -3,9 +3,13 @@
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/ports/IPreviewByteCache.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
using namespace ccm;
@@ -15,21 +19,54 @@ class FakeSource final : public ICardPreviewSource {
public:
std::string url = "https://example.com/preview.jpg";
bool ok = true;
// The kind of error returned when ok == false. Defaults to Transient
// because the historical tests assumed "errors don't get cached"; the
// negative-cache tests flip this to NotFound explicitly.
PreviewLookupError::Kind errKind = PreviewLookupError::Kind::Transient;
std::string err = "boom";
int calls{0};
// Capture inputs so tests can assert routing.
std::string lastName;
std::string lastSetId;
std::string lastSetNo;
std::string detectLastName;
std::string detectLastSetId;
AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"};
bool allowAutoDetect{true};
Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override {
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override {
++calls;
lastName = std::string(name);
lastSetId = std::string(setId);
lastSetNo = std::string(setNo);
return ok ? Result<std::string>::ok(url)
: Result<std::string>::err(err);
if (ok) {
return Result<std::string, PreviewLookupError>::ok(url);
}
return Result<std::string, PreviewLookupError>::err({errKind, err});
}
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override {
detectLastName = std::string(name);
detectLastSetId = std::string(setId);
return Result<AutoDetectedPrint>::ok(detectedPrint);
}
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override {
detectLastName = std::string(name);
detectLastSetId = std::string(setId);
std::vector<AutoDetectedPrint> v;
v.push_back(detectedPrint);
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
}
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return allowAutoDetect;
}
};
@@ -39,14 +76,48 @@ public:
std::string body;
bool ok = true;
std::string err = "offline";
int calls{0};
Result<std::string> get(std::string_view url) override {
++calls;
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err(err);
}
};
// In-memory persistent cache stand-in. Real implementation lives in
// LocalPreviewByteCache (covered by local_preview_byte_cache_tests.cpp); the
// fake here lets us pin down CardPreviewService's wiring without dragging in
// the filesystem.
class InMemoryByteCache final : public IPreviewByteCache {
public:
struct Entry {
bool negative{false};
std::string payload;
};
std::unordered_map<std::string, Entry> entries;
int loadCalls{0};
int storeCalls{0};
int storeNegativeCalls{0};
[[nodiscard]] LoadResult load(std::string_view key) override {
++loadCalls;
auto it = entries.find(std::string(key));
if (it == entries.end()) return {HitKind::Miss, {}};
if (it->second.negative) return {HitKind::NegativeHit, {}};
return {HitKind::Hit, it->second.payload};
}
void store(std::string_view key, const std::string& payload) override {
++storeCalls;
entries[std::string(key)] = Entry{false, payload};
}
void storeNegative(std::string_view key) override {
++storeNegativeCalls;
entries[std::string(key)] = Entry{true, {}};
}
};
// Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule {
public:
@@ -150,3 +221,512 @@ TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
CHECK(out.error() == "net down");
}
}
TEST_SUITE("CardPreviewService caching") {
TEST_CASE("repeat fetchPreviewBytes for the same key serves from cache") {
// Re-selecting the same row in the table is the hot path: the
// (game, name, setId, setNo) tuple uniquely identifies a printing,
// and the resolved image URL is a deterministic function of those
// inputs - so we can safely cache the bytes and skip HTTP entirely
// on repeat. Each cache hit spares us two HTTPS round trips
// (Yugipedia API + image GET) plus a TLS handshake on a cold pool.
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};
svc.registerModule(module);
const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(first.isOk());
CHECK(first.value() == "PNG-bytes");
CHECK(http.calls == 1);
// Mutate the source's response to prove the second call doesn't go
// through the source either: only the cache should be consulted.
source.url = "https://example.com/CHANGED.png";
http.body = "DIFFERENT-bytes";
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(second.isOk());
CHECK(second.value() == "PNG-bytes");
CHECK(http.calls == 1); // no new GET issued
}
TEST_CASE("different cards share the same source but get separate cache slots") {
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
source.url = "https://example.com/a.png";
http.body = "A-bytes";
const auto a = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", "");
REQUIRE(a.isOk());
CHECK(a.value() == "A-bytes");
source.url = "https://example.com/b.png";
http.body = "B-bytes";
const auto b = svc.fetchPreviewBytes(Game::Magic, "Card B", "lea", "");
REQUIRE(b.isOk());
CHECK(b.value() == "B-bytes");
CHECK(http.calls == 2);
// Re-fetching A returns the originally cached payload, not the most
// recent http.body; this pins down per-card cache scoping.
http.body = "stale";
const auto aAgain = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", "");
REQUIRE(aAgain.isOk());
CHECK(aAgain.value() == "A-bytes");
CHECK(http.calls == 2);
}
TEST_CASE("source errors are not cached so a transient failure can recover") {
// If the per-game source returns an error we should not poison the
// cache - the next selection must be free to retry, otherwise a
// single network blip would permanently disable previews for that
// card until the user restarts the app.
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "PNG-bytes";
CardPreviewService svc{http};
svc.registerModule(module);
source.ok = false;
source.err = "transient 503";
const auto firstErr = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
CHECK(firstErr.isErr());
CHECK(http.calls == 0); // source short-circuited before HTTP
source.ok = true;
source.url = "https://example.com/x.png";
const auto recovered = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
REQUIRE(recovered.isOk());
CHECK(recovered.value() == "PNG-bytes");
CHECK(http.calls == 1);
}
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
// sub-case exercises the write-through path.
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "PNG-bytes";
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(out.isOk());
CHECK(disk.storeCalls == 1);
CHECK(disk.entries.size() == 1);
// Key shape is service-internal so we don't assert it directly; only
// that *something* identifying this card was persisted with the right
// payload.
bool foundPayload = false;
for (const auto& [k, e] : disk.entries) {
if (!e.negative && e.payload == "PNG-bytes") foundPayload = true;
}
CHECK(foundPayload);
}
TEST_CASE("warm restart: a fresh service instance serves from disk without HTTP") {
// Simulates an app restart by constructing two CardPreviewService
// instances over the same persistent cache. The second instance
// must serve the previously-fetched bytes from disk - no source
// call, no HTTP call - which is the whole point of the persistent
// tier.
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::YuGiOh;
module.preview = &source;
FixedHttpClient http;
http.body = "DM-bytes";
InMemoryByteCache disk;
// Run 1: cold start, normal fetch path warms the persistent cache.
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(
Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE");
REQUIRE(out.isOk());
}
REQUIRE(http.calls == 1);
REQUIRE(disk.entries.size() == 1);
// Run 2: pretend the app restarted. Make HTTP and the source both
// return obviously-wrong data so any unexpected fall-through to
// them shows up loudly in the assertion.
http.body = "UNEXPECTED";
source.url = "https://example.com/UNEXPECTED.png";
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto warm = svc.fetchPreviewBytes(
Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE");
REQUIRE(warm.isOk());
CHECK(warm.value() == "DM-bytes");
}
// No second HTTP request - disk tier covered it.
CHECK(http.calls == 1);
CHECK(disk.loadCalls >= 1);
}
TEST_CASE("disk hit promotes into the in-memory tier so subsequent calls skip even disk I/O") {
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "X-bytes";
InMemoryByteCache disk;
// Pre-seed the disk cache with a value the service has never fetched
// this session. The first call should pull it off disk and copy it
// into memory; the second call should be served from memory without
// touching the disk cache at all.
// We can't compose the key from outside, so we go through the
// service once to learn the key shape via storeCalls.
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
(void)svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
}
REQUIRE(http.calls == 1);
REQUIRE(disk.entries.size() == 1);
const auto storedKey = disk.entries.begin()->first;
// Clear the in-memory tier by constructing a fresh service. Disk
// entry stays.
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const int loadsBefore = disk.loadCalls;
// Force HTTP to fail loudly so we can prove neither call hits it.
http.ok = false;
const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(first.isOk());
CHECK(first.value() == "X-bytes");
CHECK(disk.loadCalls == loadsBefore + 1);
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(second.isOk());
CHECK(second.value() == "X-bytes");
// No additional disk hit - in-memory tier served it.
CHECK(disk.loadCalls == loadsBefore + 1);
// Sanity: only the original storedKey ever made it to disk.
CHECK(disk.entries.count(storedKey) == 1);
}
TEST_CASE("transient source errors do not write through to the persistent cache") {
// A transient failure (HTTP 5xx, network drop, parse error) must
// never get cached - if it did, a single bad request would
// silently break this card's preview until the user restarts the
// app or edits the record.
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::Transient;
source.err = "scryfall 503";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
CHECK(out.isErr());
CHECK(disk.storeCalls == 0);
CHECK(disk.storeNegativeCalls == 0);
CHECK(disk.entries.empty());
}
TEST_CASE("NotFound source errors are negative-cached and short-circuit subsequent calls") {
// A NotFound result means the upstream cleanly answered "no image
// for this record". The next selection of the *same* record must
// return immediately without invoking the source again - that's
// the whole point of negative caching, and the user-visible win
// (no spinner, no per-click HTTP) on every selection of a card
// whose printing has no scan upstream.
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "no scan exists";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto first = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", "");
CHECK(first.isErr());
CHECK(source.calls == 1);
CHECK(disk.storeCalls == 0);
CHECK(disk.storeNegativeCalls == 1);
// Second call: the source must NOT be consulted again. The
// in-memory negative entry covers it.
const auto second = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", "");
CHECK(second.isErr());
CHECK(source.calls == 1); // still 1 - cache short-circuited
CHECK(http.calls == 0);
}
TEST_CASE("editing a lookup-relevant field invalidates the negative entry automatically") {
// The cache key is (game, name, setId, setNo). The user fixing a
// typo in the card's name (or any other lookup-relevant field)
// changes the key, so the previously-cached "no image" verdict no
// longer matches and a fresh resolution attempt runs.
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "no scan exists";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
// First lookup: the typo name produces a clean NotFound -> negative cache.
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "").isErr());
REQUIRE(source.calls == 1);
// Now flip the source to "found" and ask for the corrected name.
source.ok = true;
source.url = "https://example.com/lb.png";
http.body = "LB-bytes";
const auto fixed = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(fixed.isOk());
CHECK(fixed.value() == "LB-bytes");
CHECK(source.calls == 2);
// And the original (typo) record stays negative-cached.
source.ok = false; // belt-and-braces: prove the typo path doesn't re-call the source
const auto stillNegative =
svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "");
CHECK(stillNegative.isErr());
CHECK(source.calls == 2); // typo path never re-resolved
}
TEST_CASE("warm restart honors a previously-stored negative entry without HTTP") {
// Persistent negative caching is the strongest motivation for the
// disk tier carrying negatives at all: a card with no upstream
// image stays "instant card-back" across app restarts, instead of
// re-paying the round-trip to learn the same thing every launch.
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "no scan exists";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
// Run 1: negative outcome, persisted.
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Z", "set", "").isErr());
}
REQUIRE(disk.storeNegativeCalls == 1);
const int sourceCallsAfterRun1 = source.calls;
// Run 2: fresh service over the same disk fake. Source must not be
// consulted at all - the negative entry on disk short-circuits it.
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto warm = svc.fetchPreviewBytes(Game::Magic, "Z", "set", "");
CHECK(warm.isErr());
}
CHECK(source.calls == sourceCallsAfterRun1);
}
TEST_CASE("a later positive result for the same key replaces the negative entry") {
// If the upstream eventually grows a scan for a card that was
// previously NotFound, the very next successful lookup must
// overwrite the negative entry so it doesn't keep haunting the
// user. Realistic scenario: Yugipedia uploads a scan that didn't
// exist before; the user clicks the row again and we now serve
// the real image. (In production the cache key may also change
// due to a record edit, but that's a separate path; here we test
// the same-key recovery story.)
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
// First call: NotFound.
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "no scan yet";
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Card", "set", "").isErr());
// Imitate the upstream growing a scan, AND simulate the user
// retrying. To bypass the negative cache without a record edit,
// construct a fresh service: this models the next-launch case
// where the user reopens the app and the image now exists. (Within
// a single session, only an edit to the record clears the cache,
// which is the correct UX - we don't want a network round-trip on
// every click for cards we already know have no upstream image.)
source.ok = true;
source.url = "https://example.com/late.png";
http.body = "LATE-bytes";
// Drop the disk negative manually to simulate a scenario where
// the cache was cleared (e.g. the user manually wiped the cache
// directory). In production code paths, an edit to the record is
// the standard invalidation; both routes converge on the same
// expected behavior: a fresh successful lookup.
disk.entries.clear();
CardPreviewService svc2{http, &disk};
svc2.registerModule(module);
const auto out = svc2.fetchPreviewBytes(Game::Magic, "Card", "set", "");
REQUIRE(out.isOk());
CHECK(out.value() == "LATE-bytes");
}
TEST_CASE("fetchImageBytesByUrl caches by URL too (fallback card-back)") {
// The fallback card-back image is fetched via fetchImageBytesByUrl
// for every card without a preview; caching by URL makes the second
// fallback effectively free.
FixedHttpClient http;
http.body = "card-back-bytes";
CardPreviewService svc{http};
const auto first = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(first.isOk());
CHECK(http.calls == 1);
http.body = "stale";
const auto second = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(second.isOk());
CHECK(second.value() == "card-back-bytes");
CHECK(http.calls == 1);
}
}
TEST_SUITE("CardPreviewService::detectFirstPrint") {
TEST_CASE("routes auto-detect to registered source") {
FakeSource source;
source.detectedPrint = {"LOB-005", "Secret Rare"};
FakeGameModule module;
module.gameId = Game::YuGiOh;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "Legend of Blue Eyes White Dragon");
REQUIRE(out.isOk());
CHECK(out.value().setNo == "LOB-005");
CHECK(out.value().rarity == "Secret Rare");
CHECK(source.detectLastName == "Dark Magician");
CHECK(source.detectLastSetId == "Legend of Blue Eyes White Dragon");
}
TEST_CASE("returns explicit error when game does not enable auto-detect") {
FakeSource source;
source.allowAutoDetect = false;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectFirstPrint(Game::Magic, "Any", "Any Set");
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
}
TEST_SUITE("CardPreviewService::detectPrintVariants") {
TEST_CASE("routes variant listing to registered source") {
FakeSource source;
source.detectedPrint = {"LOB-005", "Secret Rare"};
FakeGameModule module;
module.gameId = Game::YuGiOh;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectPrintVariants(Game::YuGiOh, "Dark Magician",
"Legend of Blue Eyes White Dragon");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "LOB-005");
CHECK(out.value()[0].rarity == "Secret Rare");
}
TEST_CASE("detectPrintVariants returns error when game does not enable auto-detect") {
FakeSource source;
source.allowAutoDetect = false;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectPrintVariants(Game::Magic, "Any", "Any Set");
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
}
+36
View File
@@ -8,6 +8,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/services/CardSorter.hpp"
@@ -59,6 +60,22 @@ PokemonCard pc(std::uint32_t id, std::string name,
return c;
}
YuGiOhCard yc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
YuGiOhCard c;
c.id = id;
c.name = std::move(name);
c.set.name = std::move(setName);
c.set.releaseDate = std::move(releaseDate);
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
return c;
}
std::vector<std::uint32_t> ids(const std::vector<MagicCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
@@ -73,6 +90,13 @@ std::vector<std::uint32_t> ids(const std::vector<PokemonCard>& v) {
return out;
}
std::vector<std::uint32_t> ids(const std::vector<YuGiOhCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
for (const auto& c : v) out.push_back(c.id);
return out;
}
} // namespace
TEST_SUITE("CardSorter - Magic columns") {
@@ -246,3 +270,15 @@ TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
CHECK(v.front().id == 42);
}
}
TEST_SUITE("CardSorter - YuGiOh columns") {
TEST_CASE("Amount sorts numerically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 9),
yc(2, "b", "X", "2000/01/01", "", "", 11),
yc(3, "c", "X", "2000/01/01", "", "", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
}
+33
View File
@@ -4,6 +4,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
@@ -19,6 +20,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json j2 = "Pokemon";
CHECK(j2.get<Game>() == Game::Pokemon);
nlohmann::json jYgo = "YuGiOh";
CHECK(jYgo.get<Game>() == Game::YuGiOh);
nlohmann::json j3 = Theme::Dark;
CHECK(j3.get<std::string>() == "Dark");
CHECK(j3.get<Theme>() == Theme::Dark);
@@ -121,3 +125,32 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
CHECK(back == cfg);
}
}
TEST_SUITE("YuGiOhCard JSON") {
TEST_CASE("round-trips with setNo and rarity fields") {
YuGiOhCard c;
c.id = 77;
c.amount = 2;
c.name = "Blue-Eyes White Dragon";
c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"};
c.setNo = "SDK-001";
c.rarity = "Ultra Rare";
c.rarityCode = "(UR)";
c.note = "classic";
c.images = {"77+starter+blue-eyes+0.png"};
c.language = Language::English;
c.condition = Condition::NearMint;
c.firstEdition = true;
c.signed_ = false;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "SDK-001");
CHECK(j.at("rarity") == "Ultra Rare");
CHECK(j.at("rarityCode") == "(UR)");
CHECK(j.at("signed") == false);
const YuGiOhCard back = j.get<YuGiOhCard>();
CHECK(back == c);
}
}
+293
View File
@@ -0,0 +1,293 @@
#include <doctest/doctest.h>
// LocalPreviewByteCache is the only ccm_core test in this binary that
// exercises the real filesystem - by design. The adapter's reason for
// existing is to translate IPreviewByteCache calls into actual on-disk
// state (binary payloads, sidecar key files, mtime-driven LRU eviction
// via std::filesystem), and stubbing those primitives just to test the
// in-memory bookkeeping would defeat the whole point. We pin every test
// to a fresh, unique directory under the OS temp path and clean it up
// even on assertion failure.
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
#include <string>
#include <thread>
using namespace ccm;
namespace fs = std::filesystem;
namespace {
// Creates and removes a unique temp directory with strong cleanup
// guarantees. Doctest does not have RAII fixtures by default; this struct
// provides the same effect via destructor.
struct TempDir {
fs::path path;
TempDir() {
std::random_device rd;
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
path = fs::temp_directory_path() /
(std::string("ccm_preview_cache_test_") + std::to_string(stamp) +
"_" + std::to_string(rd()));
std::error_code ec;
fs::create_directories(path, ec);
}
~TempDir() {
std::error_code ec;
fs::remove_all(path, ec);
}
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
};
// Touch helper: nudges a file's mtime backwards so eviction-by-oldest is
// deterministic regardless of FS timestamp resolution.
void backdate(const fs::path& p, int seconds) {
std::error_code ec;
auto t = fs::last_write_time(p, ec);
if (ec) return;
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
}
} // namespace
TEST_SUITE("LocalPreviewByteCache") {
TEST_CASE("store then load round-trips bytes verbatim") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
const std::string key = "magic|Lightning Bolt|lea|161";
const std::string body = std::string("\x89PNG\r\n\x1a\n", 8) + std::string(2048, '\xab');
cache.store(key, body);
const auto loaded = cache.load(key);
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
CHECK(loaded.payload == body);
}
TEST_CASE("missing key is a clean miss, not an error") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
CHECK(cache.load("never-stored").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("empty payload is silently skipped") {
// Empty would be an indistinguishable miss anyway, and writing it
// would waste an inode. The contract is: we treat it as a no-op.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("k", "");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
CHECK(cache.currentSizeBytes() == 0);
}
TEST_CASE("hash collision check via sidecar mismatch is treated as a miss") {
// We fake a collision by writing the same hash file with a wrong
// sidecar key. Even though the .bin is present, load() must
// reject it - otherwise we'd serve the wrong card's bytes, which
// is much worse than re-fetching.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
// Find the .idx and corrupt the key inside.
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
StdFileSystem io;
(void)io.writeText(entry.path(), "different-key");
break;
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("survives an adapter restart over the same directory") {
// The whole point of the disk tier is persistence. Exercise it by
// dropping one cache instance and bringing up a new one against
// the same path - the previously-stored entry must come back.
TempDir td;
StdFileSystem fs;
const std::string key = "ygo|Dark Magician|SDY|6|1E";
const std::string body(4096, 'D');
{
LocalPreviewByteCache writer(fs, td.path);
writer.store(key, body);
}
{
LocalPreviewByteCache reader(fs, td.path);
const auto loaded = reader.load(key);
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
CHECK(loaded.payload == body);
}
}
TEST_CASE("storeNegative round-trips as NegativeHit, not a miss and not a payload") {
// Negative entries are the "we asked, the upstream said no image"
// marker. They must round-trip as their own load kind so the
// service can short-circuit without re-resolving the URL.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
const std::string key = "magic|Bogus Card|lea|";
cache.storeNegative(key);
const auto loaded = cache.load(key);
CHECK(loaded.kind == IPreviewByteCache::HitKind::NegativeHit);
CHECK(loaded.payload.empty());
CHECK(cache.currentSizeBytes() == 0); // negatives don't take real space
}
TEST_CASE("negative entries survive an adapter restart") {
// The "warm restart, same negative answer instantly" property is
// the whole reason negatives go to disk. Verify it explicitly so
// it can never silently regress.
TempDir td;
StdFileSystem fs;
const std::string key = "ygo|Truly Missing|SET|999||UE";
{
LocalPreviewByteCache writer(fs, td.path);
writer.storeNegative(key);
}
{
LocalPreviewByteCache reader(fs, td.path);
CHECK(reader.load(key).kind == IPreviewByteCache::HitKind::NegativeHit);
}
}
TEST_CASE("a later positive store overwrites an earlier negative entry") {
// Upstream eventually grows a scan: the next positive store has
// to flip the entry. If the .neg lingered we'd keep returning a
// NegativeHit even after a successful network resolution, which
// would defeat the recovery story.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
const std::string key = "magic|Late|lea|";
const std::string body = std::string(1024, 'L');
cache.storeNegative(key);
REQUIRE(cache.load(key).kind == IPreviewByteCache::HitKind::NegativeHit);
cache.store(key, body);
const auto loaded = cache.load(key);
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
CHECK(loaded.payload == body);
}
TEST_CASE("storeNegative for an existing positive entry replaces the bytes") {
// Symmetric to the previous case: an upstream that *had* a scan
// and now reports "no image" (e.g. file deleted) flips the entry
// back to negative. The .bin must go away so it doesn't keep
// serving stale bytes and doesn't keep wasting cap space.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
const std::string key = "magic|Recall|lea|";
cache.store(key, std::string(2048, 'R'));
REQUIRE(cache.currentSizeBytes() > 0);
cache.storeNegative(key);
const auto loaded = cache.load(key);
CHECK(loaded.kind == IPreviewByteCache::HitKind::NegativeHit);
CHECK(cache.currentSizeBytes() == 0);
}
TEST_CASE("collision check applies to negative entries too") {
// A hash collision must not let a negative entry stored under
// key A "leak" into a load of key B. We fake the collision by
// rewriting the .idx sidecar with a different key.
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);
StdFileSystem io;
(void)io.writeText(entry.path(), "different-key");
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;
// Cap is just big enough to hold 2 of the 1 KiB payloads but not 3.
constexpr std::size_t kCap = 2'400;
LocalPreviewByteCache cache(fs, td.path, kCap);
const std::string a(1024, 'a');
const std::string b(1024, 'b');
const std::string c(1024, 'c');
cache.store("k-a", a);
// Make 'a' look definitively older than 'b' regardless of the FS's
// mtime resolution (some filesystems round to whole seconds, which
// can otherwise make the test flaky).
for (const auto& entry : fs::directory_iterator(td.path)) {
backdate(entry.path(), /*seconds=*/10);
}
cache.store("k-b", b);
cache.store("k-c", c); // would push us to ~3 KiB; 'a' must go.
CHECK(cache.load("k-a").kind == IPreviewByteCache::HitKind::Miss);
CHECK(cache.load("k-b").kind == IPreviewByteCache::HitKind::Hit);
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
CHECK(cache.currentSizeBytes() <= kCap);
}
TEST_CASE("a hit refreshes mtime so the entry is not the next eviction victim") {
// Without the touch-on-load behavior, an entry that the user
// re-views constantly would still age out simply because newer
// entries piled on top. Verify we promote on hit.
TempDir td;
StdFileSystem fs;
constexpr std::size_t kCap = 2'400;
LocalPreviewByteCache cache(fs, td.path, kCap);
const std::string a(1024, 'a');
const std::string b(1024, 'b');
const std::string c(1024, 'c');
cache.store("k-a", a);
cache.store("k-b", b);
// Backdate both so the upcoming touch on 'a' is unambiguously newer.
for (const auto& entry : fs::directory_iterator(td.path)) {
backdate(entry.path(), /*seconds=*/10);
}
REQUIRE(cache.load("k-a").kind == IPreviewByteCache::HitKind::Hit); // touches 'a' to "now".
cache.store("k-c", c); // forces an eviction.
// 'a' was the youngest after the touch, so 'b' should be the victim.
CHECK(cache.load("k-b").kind == IPreviewByteCache::HitKind::Miss);
CHECK(cache.load("k-a").kind == IPreviewByteCache::HitKind::Hit);
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
}
}
+19 -10
View File
@@ -64,38 +64,47 @@ TEST_SUITE("MagicCardPreviewSource::parseResponse") {
CHECK(out.value() == "https://img.scryfall.io/normal.jpg");
}
TEST_CASE("empty data array returns an error") {
TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") {
const auto out = MagicCardPreviewSource::parseResponse(R"({"data":[]})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("missing data array returns an error") {
TEST_CASE("missing data array is classified as Transient (schema deviation)") {
// No `data` array at all means the API contract failed - this is
// not the user's record being weird, it's the upstream not
// talking to us right now.
const auto out = MagicCardPreviewSource::parseResponse(R"({"meta":{}})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("entry without image_uris returns an error (double-faced cards)") {
TEST_CASE("entry without image_uris is classified as NotFound (double-faced cards)") {
const std::string json = R"({
"data": [
{"name":"DoubleFace","card_faces":[{"image_uris":{"normal":"x"}}]}
]
})";
const auto out = MagicCardPreviewSource::parseResponse(json);
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("invalid JSON returns an error") {
TEST_CASE("invalid JSON is classified as Transient") {
const auto out = MagicCardPreviewSource::parseResponse("{not json");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("MagicCardPreviewSource::fetchImageUrl") {
TEST_CASE("network error is surfaced as a Result error") {
TEST_CASE("network error is surfaced as Transient") {
FixedHttpClient http;
http.ok = false;
MagicCardPreviewSource src{http};
CHECK(src.fetchImageUrl("Lightning Bolt", "lea", "").isErr());
const auto out = src.fetchImageUrl("Lightning Bolt", "lea", "");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("network success is parsed end-to-end and uses the encoded URL") {
+16 -10
View File
@@ -85,34 +85,40 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
CHECK(out.value() == "https://small.only/img.png");
}
TEST_CASE("empty data array returns an error") {
TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":[]})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("missing data array returns an error") {
TEST_CASE("missing data array is classified as Transient (schema deviation)") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("entry without images returns an error") {
TEST_CASE("entry without images is classified as NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(
R"({"data":[{"name":"Pikachu"}]})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("invalid JSON returns an error") {
TEST_CASE("invalid JSON is classified as Transient") {
const auto out = PokemonCardPreviewSource::parseResponse("{not json");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
TEST_CASE("network error is surfaced as a Result error") {
TEST_CASE("network error is surfaced as Transient") {
FixedHttpClient http;
http.ok = false;
PokemonCardPreviewSource src{http};
CHECK(src.fetchImageUrl("Pikachu", "base1", "").isErr());
const auto out = src.fetchImageUrl("Pikachu", "base1", "");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("network success is parsed end-to-end and uses the encoded URL") {
+30 -1
View File
@@ -26,7 +26,11 @@ public:
Game gameId;
explicit FakeGameModule(Game id) : gameId(id) {}
Game id() const noexcept override { return gameId; }
std::string dirName() const override { return gameId == Game::Magic ? "magic" : "pokemon"; }
std::string dirName() const override {
if (gameId == Game::Magic) return "magic";
if (gameId == Game::Pokemon) return "pokemon";
return "yugioh";
}
std::string displayName() const override { return dirName(); }
ISetSource& setSource() override { return source; }
};
@@ -116,4 +120,29 @@ TEST_SUITE("SetService") {
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
}
TEST_CASE("YuGiOh module routes independently when all games are registered") {
InMemSetRepo repo;
SetService svc{repo};
FakeGameModule magic{Game::Magic};
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
FakeGameModule pokemon{Game::Pokemon};
pokemon.source.result = Result<std::vector<Set>>::ok({{"base1", "Base", "1999/01/09"}});
FakeGameModule yugioh{Game::YuGiOh};
yugioh.source.result = Result<std::vector<Set>>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}});
svc.registerModule(&magic);
svc.registerModule(&pokemon);
svc.registerModule(&yugioh);
REQUIRE(svc.updateSets(Game::Magic).isOk());
REQUIRE(svc.updateSets(Game::Pokemon).isOk());
const auto ygo = svc.updateSets(Game::YuGiOh);
REQUIRE(ygo.isOk());
CHECK(ygo.value().front().id == "LOB");
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
}
}
+613
View File
@@ -0,0 +1,613 @@
#include <doctest/doctest.h>
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
using namespace ccm;
namespace {
// Single-shot HTTP fake matching the other game tests. Captures the last URL
// requested and returns a configurable body / failure flag.
class FixedHttpClient final : public IHttpClient {
public:
std::string lastUrl;
std::string body;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err("offline");
}
};
// Routing fake used to simulate the two-step Yugipedia -> YGOPRODeck flow.
// Both bodies are queued by URL substring so order-of-call assertions stay
// readable in the tests. Unmatched URLs return a 404-equivalent error so
// regressions are obvious instead of silently passing.
class RoutingHttpClient final : public IHttpClient {
public:
std::string yugipediaBody;
std::string ygoprodeckBody;
bool yugipediaOk = true;
bool ygoprodeckOk = true;
std::vector<std::string> calls;
Result<std::string> get(std::string_view url) override {
std::string s(url);
calls.push_back(s);
if (s.find("yugipedia.com") != std::string::npos) {
return yugipediaOk ? Result<std::string>::ok(yugipediaBody)
: Result<std::string>::err("yugipedia offline");
}
if (s.find("ygoprodeck.com") != std::string::npos) {
return ygoprodeckOk ? Result<std::string>::ok(ygoprodeckBody)
: Result<std::string>::err("ygoprodeck offline");
}
return Result<std::string>::err("RoutingHttpClient: unrouted URL");
}
};
} // namespace
TEST_SUITE("ygoPrintingSlotsMatch") {
TEST_CASE("matches CCM composed setNo to YGOPRODeck set_code with region infix") {
CHECK(ygoPrintingSlotsMatch("SOD-015", "SOD-EN015"));
CHECK(ygoPrintingSlotsMatch("sod-015", "SOD-EN015"));
CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "SOD-EN016"));
CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "IOC-EN015"));
}
TEST_CASE("matches German-style and digits-only suffix variants") {
CHECK(ygoPrintingSlotsMatch("LOB-005", "LOB-DE005"));
CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001"));
}
TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") {
CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
}
}
TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
TEST_CASE("strips whitespace and policy-banned punctuation") {
// Yugipedia's image policy: whitespace and a fixed punctuation set
// (#,.:'"?!&@%=[]<>/\- and friends) get dropped from the slug.
CHECK(YuGiOhCardPreviewSource::normalizeName("Blue-Eyes White Dragon")
== "BlueEyesWhiteDragon");
CHECK(YuGiOhCardPreviewSource::normalizeName("Dark Magician") == "DarkMagician");
CHECK(YuGiOhCardPreviewSource::normalizeName("Sasuke Samurai #4")
== "SasukeSamurai4");
CHECK(YuGiOhCardPreviewSource::normalizeName("Don't Talk to Me!")
== "DontTalktoMe");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
TEST_CASE("maps the dialog's rarity options to Yugipedia codes") {
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Common") == "C");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Rare") == "R");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Super Rare") == "SR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Ultra Rare") == "UR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
== "QCScR");
}
TEST_CASE("returns empty string for unknown rarity names") {
// Unknown rarity should fall through to the rarity-less filename
// pattern, not throw and not return a misleading code.
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty());
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") {
TEST_CASE("returns everything before the first dash") {
CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-005") == "LOB");
CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-DE005") == "LOB");
CHECK(YuGiOhCardPreviewSource::extractSetCode("RA04-EN001") == "RA04");
}
TEST_CASE("returns the input unchanged when no dash is present") {
CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB") == "LOB");
CHECK(YuGiOhCardPreviewSource::extractSetCode("").empty());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::buildCandidateFilenames") {
TEST_CASE("primary candidate uses the printed edition + EN region + .png") {
const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
"Blue-Eyes White Dragon", "LOB", "UR", /*firstEdition=*/false);
REQUIRE_FALSE(names.empty());
// Highest-priority filename: Yugipedia's modern English/UE/.png
// shape, which is what most LOB-era reprints actually use.
CHECK(names.front() == "BlueEyesWhiteDragon-LOB-EN-UR-UE.png");
}
TEST_CASE("includes 1E when the user marked the card as first edition") {
const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
"Dark Magician", "SDY", "UR", /*firstEdition=*/true);
REQUIRE_FALSE(names.empty());
CHECK(names.front() == "DarkMagician-SDY-EN-UR-1E.png");
}
TEST_CASE("covers EN/NA/EU/AU regions and both png/jpg extensions") {
const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
"Blue-Eyes White Dragon", "SDK", "UR", /*firstEdition=*/false);
// Sanity: the standard SDK Blue-Eyes scan is hosted at NA-UR-UE.png,
// so the candidate list must include that exact filename.
bool foundNaUe = false;
for (const auto& n : names) {
if (n == "BlueEyesWhiteDragon-SDK-NA-UR-UE.png") foundNaUe = true;
}
CHECK(foundNaUe);
}
TEST_CASE("falls back to a rarity-less pattern for unknown rarities") {
const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
"Token Card", "TKN", /*rarityCode=*/"", /*firstEdition=*/false);
REQUIRE_FALSE(names.empty());
// Rarity slot omitted -> filename has only one dash before edition.
CHECK(names.front() == "TokenCard-TKN-EN-UE.png");
}
TEST_CASE("returns empty list when the slug or set code is empty") {
// Don't waste an HTTP call on cards that haven't been filled in yet.
CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("", "LOB", "UR", false).empty());
CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("Dark Magician", "", "UR", false).empty());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::buildYugipediaQueryUrl") {
TEST_CASE("encodes filenames into a single MediaWiki batch query") {
const std::vector<std::string> names = {
"BlueEyesWhiteDragon-LOB-EN-UR-UE.png",
"BlueEyesWhiteDragon-LOB-NA-UR-UE.png",
};
const std::string url = YuGiOhCardPreviewSource::buildYugipediaQueryUrl(names);
CHECK(url.find("https://yugipedia.com/api.php") == 0);
CHECK(url.find("action=query") != std::string::npos);
CHECK(url.find("prop=imageinfo") != std::string::npos);
CHECK(url.find("iiprop=url") != std::string::npos);
// The two filenames are joined with `|` (URL-encoded as %7C) and
// each one is namespaced with `File:` (encoded `File%3A`).
CHECK(url.find("File%3ABlueEyesWhiteDragon-LOB-EN-UR-UE.png") != std::string::npos);
CHECK(url.find("%7CFile%3ABlueEyesWhiteDragon-LOB-NA-UR-UE.png") != std::string::npos);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
TEST_CASE("returns the URL of the highest-priority filename that exists") {
// MediaWiki returns one entry per requested title. Missing files come
// back with `"missing": ""` and no imageinfo; existing files carry an
// imageinfo[0].url. Order matters: we must walk filenameOrder and
// pick the first entry that resolved, not whichever MediaWiki listed
// first in its hash-keyed `pages` object.
const std::string body = R"({
"query":{"pages":{
"-1":{"title":"File:DarkMagician-SDY-EN-UR-1E.png","missing":""},
"96018":{"title":"File:DarkMagician-SDY-NA-UR-UE.png",
"imageinfo":[{"url":"https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png"}]},
"99999":{"title":"File:DarkMagician-SDY-EU-UR-UE.png",
"imageinfo":[{"url":"https://ms.yugipedia.com/eu/DarkMagician-SDY-EU-UR-UE.png"}]}
}}
})";
const std::vector<std::string> order = {
"DarkMagician-SDY-EN-UR-UE.png", // missing from response entirely
"DarkMagician-SDY-EN-UR-1E.png", // present but `missing`
"DarkMagician-SDY-NA-UR-UE.png", // first existing one
"DarkMagician-SDY-EU-UR-UE.png",
};
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(body, order);
REQUIRE(out.isOk());
CHECK(out.value() == "https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png");
}
TEST_CASE("pure-miss response is classified as NotFound") {
// Pure-miss response: every page is marked `missing`. The caller
// (fetchImageUrl) treats this as "no Yugipedia scan" and triggers
// the YGOPRODeck fallback. Yugipedia having explicitly answered
// "no such file" is a clean negative (the upstream contract was
// honored), not a transient failure.
const std::string body = R"({
"query":{"pages":{
"-1":{"title":"File:Whatever-XYZ-EN-UR-UE.png","missing":""},
"-2":{"title":"File:Whatever-XYZ-NA-UR-UE.png","missing":""}
}}
})";
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
body, {"Whatever-XYZ-EN-UR-UE.png", "Whatever-XYZ-NA-UR-UE.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("malformed JSON is classified as Transient") {
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
"{not json", {"Anything-XYZ-EN-UR-UE.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
TEST_CASE("returns first print for preferred set name") {
const std::string json = R"({
"data":[
{"card_sets":[
{"set_name":"Metal Raiders","set_code":"MRD-001","set_rarity":"Ultra Rare"},
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parseFirstPrint(
json, "Legend of Blue Eyes White Dragon");
REQUIRE(out.isOk());
CHECK(out.value().setNo == "LOB-001");
CHECK(out.value().rarity == "Ultra Rare");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
TEST_CASE("lists distinct set_code entries for an exact name in one display set") {
const std::string json = R"({
"data":[
{"name":"Test Goblin",
"card_sets":[
{"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"},
{"set_name":"Mega Pack","set_code":"MP21-EN002","set_rarity":"Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Mega Pack", "Test Goblin");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "MP21-EN001");
CHECK(out.value()[1].setNo == "MP21-EN002");
}
TEST_CASE("lists multiple rarities for one set_code") {
const std::string json = R"({
"data":[
{"name":"Odd-Eyes Pendulum Dragon",
"card_sets":[
{"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Super Rare"},
{"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Ultimate Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Duelist Alliance", "Odd-Eyes Pendulum Dragon");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "DUEA-EN004");
CHECK(out.value()[0].rarity == "Super Rare");
CHECK(out.value()[1].setNo == "DUEA-EN004");
CHECK(out.value()[1].rarity == "Ultimate Rare");
}
TEST_CASE("dedupes identical print pairs") {
const std::string json = R"({
"data":[
{"name":"Mirror Force",
"card_sets":[
{"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"},
{"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Starter Deck", "Mirror Force");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "SDY-043");
CHECK(out.value()[0].rarity == "Super Rare");
}
}
// Helpers aligned with external fixture `yugioh_same_card_set_variant_tests`
// (same_card_same_set: distinct collector numbers vs distinct rarities).
[[nodiscard]] std::size_t countDistinctSetCodes(const std::vector<AutoDetectedPrint>& v) {
std::unordered_set<std::string> codes;
for (const auto& p : v) {
if (!p.setNo.empty()) codes.insert(p.setNo);
}
return codes.size();
}
[[nodiscard]] std::size_t maxRarityVariantsPerCode(const std::vector<AutoDetectedPrint>& v) {
std::unordered_map<std::string, std::unordered_set<std::string>> byCode;
for (const auto& p : v) {
if (p.setNo.empty() || p.rarity.empty()) continue;
byCode[p.setNo].insert(p.rarity);
}
std::size_t mx = 0;
for (const auto& e : byCode) {
mx = std::max(mx, e.second.size());
}
return mx;
}
TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_variant_tests") {
TEST_CASE("S1-001 Armed Dragon LV7 Soul of the Duelist Ultra vs Ultimate") {
const std::string json = R"({
"data":[{
"name":"Armed Dragon LV7",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Soul of the Duelist", "Armed Dragon LV7");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 2);
}
TEST_CASE("S1-002 Horus LV8 Soul of the Duelist Ultra vs Ultimate") {
const std::string json = R"({
"data":[{
"name":"Horus the Black Flame Dragon LV8",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultra Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultimate Rare"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Soul of the Duelist", "Horus the Black Flame Dragon LV8");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 2);
}
TEST_CASE("S1-003 Mobius Soul of the Duelist Super vs Ultimate") {
const std::string json = R"({
"data":[{
"name":"Mobius the Frost Monarch",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Super Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Ultimate Rare"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Soul of the Duelist", "Mobius the Frost Monarch");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 2);
}
TEST_CASE("S2-001 Dark Magician Yugi's Legendary Decks three set codes") {
const std::string json = R"({
"data":[{
"name":"Dark Magician",
"card_sets":[
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"},
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENB02","set_rarity":"Common"},
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC09","set_rarity":"Common"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Yugi's Legendary Decks", "Dark Magician");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 3);
CHECK(maxRarityVariantsPerCode(out.value()) == 1);
}
TEST_CASE("S2-002 Dark Magician Girl Yugi's Legendary Decks two set codes") {
const std::string json = R"({
"data":[{
"name":"Dark Magician Girl",
"card_sets":[
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA04","set_rarity":"Common"},
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC10","set_rarity":"Common"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Yugi's Legendary Decks", "Dark Magician Girl");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 2);
CHECK(maxRarityVariantsPerCode(out.value()) == 1);
}
TEST_CASE("NEG-001 duplicate rarity lines collapse to one variant") {
const std::string json = R"({
"data":[{
"name":"Armed Dragon LV7",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Soul of the Duelist", "Armed Dragon LV7");
REQUIRE(out.isOk());
CHECK(out.value().size() == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 1);
}
TEST_CASE("NEG-002 duplicate set codes collapse to one collector slot") {
const std::string json = R"({
"data":[{
"name":"Dark Magician",
"card_sets":[
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"},
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Yugi's Legendary Decks", "Dark Magician");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 1);
}
TEST_CASE("NEG-003 no cross-product merge when display set matches nothing") {
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"},
{"set_name":"Starter Deck: Yugi","set_code":"SDY-006","set_rarity":"Ultra Rare"}
]
}]
})";
const auto bogus = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Different Sets", "Dark Magician");
CHECK(bogus.isErr());
const auto lob = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Legend of Blue Eyes White Dragon", "Dark Magician");
REQUIRE(lob.isOk());
REQUIRE(lob.value().size() == 1);
CHECK(lob.value()[0].setNo == "LOB-005");
const auto sdy = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Starter Deck: Yugi", "Dark Magician");
REQUIRE(sdy.isOk());
REQUIRE(sdy.value().size() == 1);
CHECK(sdy.value()[0].setNo == "SDY-006");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
TEST_CASE("queries Yugipedia first and uses the per-printing scan when found") {
// Two same-passcode reprints with genuinely different art (LOB vs
// SDK Blue-Eyes). Yugipedia hosts both, so we should always pick the
// SDK one for the SDK input - no leak of the LOB scan into the SDK
// entry, which was the user-reported regression on YGOPRODeck.
RoutingHttpClient http;
http.yugipediaBody = R"({"query":{"pages":{
"1":{"title":"File:BlueEyesWhiteDragon-SDK-NA-UR-UE.png",
"imageinfo":[{"url":"https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png"}]}
}}})";
// Make the YGOPRODeck fallback obviously wrong so we can assert it
// isn't being used when Yugipedia returns a hit.
http.ygoprodeckBody = R"({"data":[{"name":"Blue-Eyes White Dragon",
"card_images":[{"image_url":"https://example.invalid/wrong.png"}],
"card_sets":[{"set_code":"SDK-001","set_rarity":"Ultra Rare"}]}]})";
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Blue-Eyes White Dragon", "Starter Deck: Kaiba", "SDK-001||Ultra Rare||UE");
REQUIRE(out.isOk());
CHECK(out.value() == "https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png");
// Verified: only the Yugipedia call was made, not the YGOPRODeck one.
REQUIRE(http.calls.size() == 1);
CHECK(http.calls.front().find("yugipedia.com") != std::string::npos);
}
TEST_CASE("falls back to YGOPRODeck standard art when Yugipedia has no match") {
// Empty `pages` -> Yugipedia parser returns an error -> fallback
// path kicks in. This is the safety net for OCG-only or just-released
// cards that don't yet have an English scan uploaded to Yugipedia.
RoutingHttpClient http;
http.yugipediaBody = R"({"query":{"pages":{
"-1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png","missing":""}
}}})";
http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician",
"card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}],
"card_sets":[{"set_code":"LOB-005","set_rarity":"Ultra Rare"}]}]})";
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
// Two HTTP calls: Yugipedia first, YGOPRODeck second.
REQUIRE(http.calls.size() == 2);
CHECK(http.calls[0].find("yugipedia.com") != std::string::npos);
CHECK(http.calls[1].find("ygoprodeck.com") != std::string::npos);
}
TEST_CASE("falls back when Yugipedia errors out (transient HTTP failure)") {
// We don't want a Yugipedia outage to leave the user without any
// preview at all - YGOPRODeck's generic art is acceptable degraded
// behavior.
RoutingHttpClient http;
http.yugipediaOk = false;
http.ygoprodeckBody = 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", "LOB-005||Ultra Rare||UE");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
}
TEST_CASE("propagates the YGOPRODeck error as Transient when the fallback also fails") {
// Both upstreams unreachable -> the overall outcome is transient.
// CardPreviewService relies on this to avoid negative-caching when
// the user's connection is offline.
RoutingHttpClient http;
http.yugipediaOk = false;
http.ygoprodeckOk = false;
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("classifies as NotFound only when both upstreams answered cleanly with no match") {
// Yugipedia: clean miss (every candidate "missing").
// YGOPRODeck: clean miss (empty data array).
// -> Both upstreams agree -> NotFound, safe to remember.
RoutingHttpClient http;
http.yugipediaBody = R"({"query":{"pages":{
"-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""}
}}})";
http.ygoprodeckBody = R"({"data":[]})";
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::NotFound);
}
TEST_CASE("Yugipedia transient + YGOPRODeck clean-miss is overall Transient") {
// We can't conclude the card has no image when one upstream
// couldn't speak. This is the cautious path that keeps the offline
// user out of the "permanent no-preview" trap.
RoutingHttpClient http;
http.yugipediaOk = false; // network failure
http.ygoprodeckBody = R"({"data":[]})"; // clean miss
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Possibly Real", "Some Set", "ABC-001||Ultra Rare||UE");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
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.
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", "");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos);
CHECK(http.lastUrl.find("yugipedia.com") == std::string::npos);
}
}
+62
View File
@@ -0,0 +1,62 @@
#include <doctest/doctest.h>
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
using namespace ccm;
namespace {
class FixedHttpClient final : public IHttpClient {
public:
std::string lastUrl;
std::string body;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err("offline");
}
};
} // namespace
TEST_SUITE("YuGiOhSetSource::parseResponse") {
TEST_CASE("maps set_code/set_name/tcg_date") {
const std::string json = R"([
{"set_name":"Set A","set_code":"AAA","tcg_date":"2020-01-01"},
{"set_name":"Set B","set_code":"BBB","tcg_date":"2021-02-03"}
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].id == "AAA");
CHECK(out.value()[0].releaseDate == "2020/01/01");
}
TEST_CASE("sorts by release date ascending") {
const auto out = YuGiOhSetSource::parseResponse(R"([
{"set_name":"New","set_code":"N","tcg_date":"2024-01-01"},
{"set_name":"Old","set_code":"O","tcg_date":"2010-01-01"}
])");
REQUIRE(out.isOk());
CHECK(out.value().front().id == "O");
CHECK(out.value().back().id == "N");
}
TEST_CASE("missing array returns error") {
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
}
}
TEST_SUITE("YuGiOhSetSource::fetchAll") {
TEST_CASE("network success parses and hits endpoint") {
FixedHttpClient http;
http.body = R"([{"set_name":"Set X","set_code":"X","tcg_date":"2020-01-01"}])";
YuGiOhSetSource src{http};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
CHECK(out.value().front().id == "X");
CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php");
}
}