Minor: Set completion tracking (#19)

This commit is contained in:
Sebastian Dine
2026-07-23 10:29:56 +02:00
committed by GitHub
parent c9e6bc2b6b
commit 9917e364c1
66 changed files with 5786 additions and 224 deletions
+7 -4
View File
@@ -19,11 +19,14 @@
- `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` (real disk under a unique `temp_directory_path()/ccm_preview_cache_test_*` per case, RAII `TempDir` cleanup; see also `std_file_system_tests.cpp`). 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.
- `std_file_system_tests.cpp``StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
- `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`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. Drives `fetchAll` via `FixedHttpClient`.
- `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. `parseCatalog` / `mergeCardsPage` for set-completion checklists. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (name-less `set.id`+`number` when both present; `name:` when Set # empty; collector-number `4/102` -> `4` normalization), `buildCardByIdUrl`, `parseResponse` / `parseCardByIdResponse`, and `fetchImageUrl` (card-by-id first, search fallback) via `FixedHttpClient`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`.
- `digibattle99_set_completion_tests.cpp``computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`.
- `yugioh_set_completion_tests.cpp``computeYuGiOhSetCompletion` / `yuGiOhChecklistForSet` ownership rules (printing-slot match) + `YuGiOhSetCatalogService` round-trip against `InMemoryFileSystem`.
- `pokemon_set_completion_tests.cpp``computePokemonSetCompletion` / `pokemonChecklistForSet` West/Asia ownership isolation + region/language filters + `PokemonSetCatalogService` dual-path FS round-trip.
- `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` 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_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. Also `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist from `cardinfo.php`.
- `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 / DigiBattle99) 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).
+3
View File
@@ -23,6 +23,9 @@ add_executable(ccm_core_tests
pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
digibattle99_set_completion_tests.cpp
yugioh_set_completion_tests.cpp
pokemon_set_completion_tests.cpp
japanese_pokemon_en_catalog_tests.cpp
japanese_pokemon_set_source_tests.cpp
japanese_pokemon_card_preview_source_tests.cpp
+263
View File
@@ -0,0 +1,263 @@
#include <doctest/doctest.h>
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/DigiBattle99SetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
DigiBattle99Card makeOwned(std::string setId, std::string setName, std::string setNo) {
DigiBattle99Card c;
c.id = 1;
c.name = "Owned";
c.set.id = std::move(setId);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
return c;
}
DigiBattle99SetCatalog sampleCatalog() {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack starter;
starter.setId = "series-1-starter-set";
starter.setName = "Series 1 Starter Set";
starter.cards = {
{"ST-01", "Agumon"},
{"ST-02", "Greymon"},
{"ST-03", "Gabumon"},
};
DigiBattle99SetCatalogPack booster;
booster.setId = "series-1-booster-pack";
booster.setName = "Series 1 Booster Pack";
booster.cards = {
{"ST-01", "Agumon"},
{"BO-01", "MetalGreymon"},
};
catalog.packs.push_back(std::move(booster));
catalog.packs.push_back(std::move(starter));
return catalog;
}
} // namespace
TEST_SUITE("computeDigiBattle99SetCompletion") {
TEST_CASE("only packs with owned cards appear") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "series-1-starter-set");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("unique setNo within a pack; amount does not inflate") {
const auto catalog = sampleCatalog();
DigiBattle99Card a = makeOwned("series-1-starter-set", "Series 1 Starter Set", "st-01");
a.amount = 4;
DigiBattle99Card b = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
b.id = 2;
DigiBattle99Card c = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
c.id = 3;
const auto rows =
computeDigiBattle99SetCompletion({a, b, c}, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 66);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "series-1-starter-set");
}
TEST_CASE("empty catalog yields no rows") {
DigiBattle99SetCatalog empty;
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
CHECK(computeDigiBattle99SetCompletion(collection, empty).empty());
}
TEST_CASE("owned set missing from catalog is skipped") {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack onlyBooster;
onlyBooster.setId = "series-1-booster-pack";
onlyBooster.setName = "Series 1 Booster Pack";
onlyBooster.cards = {{"BO-01", "MetalGreymon"}};
catalog.packs.push_back(std::move(onlyBooster));
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
CHECK(computeDigiBattle99SetCompletion(collection, catalog).empty());
}
TEST_CASE("language filter hides packs with no cards in that language") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
en.language = Language::English;
const auto allRows = computeDigiBattle99SetCompletion({en}, catalog);
REQUIRE(allRows.size() == 1);
const auto deRows =
computeDigiBattle99SetCompletion({en}, catalog, Language::German);
CHECK(deRows.empty());
const auto enRows =
computeDigiBattle99SetCompletion({en}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
}
TEST_CASE("same setNo in two languages counts once aggregated; filter is exclusive") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
en.language = Language::English;
DigiBattle99Card de =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
de.id = 2;
de.language = Language::German;
const auto allRows = computeDigiBattle99SetCompletion({en, de}, catalog);
REQUIRE(allRows.size() == 1);
CHECK(allRows[0].ownedUnique == 1);
const auto enRows =
computeDigiBattle99SetCompletion({en, de}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
DigiBattle99Card deOnly =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
deOnly.id = 3;
deOnly.language = Language::German;
const auto deRows = computeDigiBattle99SetCompletion({en, de, deOnly}, catalog,
Language::German);
REQUIRE(deRows.size() == 1);
CHECK(deRows[0].ownedUnique == 2);
}
}
TEST_SUITE("digiBattle99ChecklistForSet") {
TEST_CASE("greys missing cards and marks owned ones") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"),
};
const auto list =
digiBattle99ChecklistForSet(collection, catalog, "series-1-starter-set");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "ST-01");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "ST-02");
CHECK(list[1].owned == true);
CHECK(list[2].setNo == "ST-03");
CHECK(list[2].owned == false);
}
TEST_CASE("unknown set returns empty") {
const auto catalog = sampleCatalog();
CHECK(digiBattle99ChecklistForSet({}, catalog, "missing").empty());
}
TEST_CASE("owned flags respect language filter") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
en.language = Language::English;
const auto filtered =
digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set",
Language::German);
REQUIRE(filtered.size() == 3);
CHECK(filtered[0].owned == false);
CHECK(filtered[1].owned == false);
CHECK(filtered[2].owned == false);
const auto english =
digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set",
Language::English);
REQUIRE(english.size() == 3);
CHECK(english[1].owned == true);
}
}
TEST_SUITE("digiBattle99LanguagesInCollection") {
TEST_CASE("empty collection yields empty") {
CHECK(digiBattle99LanguagesInCollection({}).empty());
}
TEST_CASE("returns distinct languages in allLanguages order") {
DigiBattle99Card jp =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
jp.language = Language::Japanese;
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
en.id = 2;
en.language = Language::English;
DigiBattle99Card enDup =
makeOwned("series-1-booster-pack", "Series 1 Booster Pack", "BO-01");
enDup.id = 3;
enDup.language = Language::English;
DigiBattle99Card de =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-03");
de.id = 4;
de.language = Language::German;
const auto langs = digiBattle99LanguagesInCollection({jp, en, enDup, de});
REQUIRE(langs.size() == 3);
CHECK(langs[0] == Language::English);
CHECK(langs[1] == Language::German);
CHECK(langs[2] == Language::Japanese);
}
}
TEST_SUITE("DigiBattle99SetCatalogService") {
TEST_CASE("save then load round-trips") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
DigiBattle99SetCatalogService store{fs, config, [](Game) { return "digibattle99"; }};
CHECK_FALSE(store.exists());
CHECK(store.load().isErr());
const auto catalog = sampleCatalog();
REQUIRE(store.save(catalog).isOk());
CHECK(store.exists());
const auto loaded = store.load();
REQUIRE(loaded.isOk());
CHECK(loaded.value() == catalog);
}
}
+71
View File
@@ -109,4 +109,75 @@ TEST_SUITE("DigiBattle99SetSource::fetchAll") {
CHECK(out.value().front().id == "series-1-starter-set");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
TEST_CASE("fetchAllWithCatalog returns sets and pack cards in one GET") {
FixedHttpClient http;
http.ok = true;
http.body = R"([
{"name":"Agumon","id":"st-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99SetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isOk());
CHECK(out.value().sets.size() == 2);
const auto* starter = out.value().catalog.findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 2);
CHECK(starter->cards[0].setNo == "ST-01");
CHECK(starter->cards[0].name == "Agumon");
const auto* booster = out.value().catalog.findPack("series-1-booster-pack");
REQUIRE(booster != nullptr);
REQUIRE(booster->cards.size() == 1);
CHECK(booster->cards[0].setNo == "ST-01");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
}
TEST_SUITE("DigiBattle99SetSource::parseCatalog") {
TEST_CASE("lists a card under every pack in set_name") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]}
])";
const auto out = DigiBattle99SetSource::parseCatalog(json);
REQUIRE(out.isOk());
REQUIRE(out.value().packs.size() == 2);
const auto* booster = out.value().findPack("series-1-booster-pack");
REQUIRE(booster != nullptr);
REQUIRE(booster->cards.size() == 2);
CHECK(booster->cards[0].setNo == "BO-01");
CHECK(booster->cards[1].setNo == "ST-01");
const auto* starter = out.value().findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 1);
CHECK(starter->cards[0].setNo == "ST-01");
}
TEST_CASE("dedupes the same setNo within one pack") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Agumon Alt","id":"ST-01","set_name":["Series 1 Starter Set"]}
])";
const auto out = DigiBattle99SetSource::parseCatalog(json);
REQUIRE(out.isOk());
const auto* starter = out.value().findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 1);
CHECK(starter->cards[0].name == "Agumon");
}
TEST_CASE("empty array returns an empty catalog") {
const auto out = DigiBattle99SetSource::parseCatalog("[]");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
TEST_CASE("error object is an error") {
const auto out = DigiBattle99SetSource::parseCatalog(
R"({"error":"No cards found for this search."})");
CHECK(out.isErr());
}
}
+69
View File
@@ -2,6 +2,9 @@
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
@@ -245,6 +248,72 @@ TEST_SUITE("DigiBattle99Card JSON") {
}
}
TEST_SUITE("DigiBattle99SetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack pack;
pack.setId = "series-1-starter-set";
pack.setName = "Series 1 Starter Set";
pack.cards.push_back(DigiBattle99CatalogCard{"ST-01", "Agumon"});
pack.cards.push_back(DigiBattle99CatalogCard{"ST-126", "Agumon"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "series-1-starter-set");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "ST-01");
const DigiBattle99SetCatalog back = j.get<DigiBattle99SetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("series-1-starter-set") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("YuGiOhSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.setName = "Legend of Blue Eyes White Dragon";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-001", "Blue-Eyes White Dragon"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "LOB");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "LOB-001");
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("LOB") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("PokemonSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
PokemonSetCatalog catalog;
PokemonSetCatalogPack pack;
pack.setId = "base1";
pack.setName = "Base";
pack.cards.push_back(PokemonCatalogCard{"4", "Charizard"});
pack.cards.push_back(PokemonCatalogCard{"58", "Growlithe"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "base1");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "4");
const PokemonSetCatalog back = j.get<PokemonSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("base1") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("JapanesePokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
JapanesePokemonCard c;
@@ -139,6 +139,21 @@ TEST_SUITE("JapanesePokemonEnCatalog") {
CHECK_FALSE(catalog.value().hasPrintsForSet("PMCG1"));
}
TEST_CASE("printsForSet returns all prints for a set id") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"A","local_id":"1","name_en":"One"},
{"set_id":"A","local_id":"2","name_en":"Two"},
{"set_id":"B","local_id":"1","name_en":"Other"}
]
})");
REQUIRE(catalog.isOk());
const auto prints = catalog.value().printsForSet("A");
REQUIRE(prints.size() == 2);
CHECK(catalog.value().printsForSet("missing").empty());
}
TEST_CASE("missing set/print returns nullopt") {
JapanesePokemonEnCatalog empty;
CHECK_FALSE(empty.findSet("X").has_value());
@@ -315,3 +315,56 @@ TEST_SUITE("JapanesePokemonSetSource::fetchAll") {
CHECK(jungle->name == "Pokémon Jungle");
}
}
TEST_SUITE("JapanesePokemonSetSource::parseCatalogPackFromSetDetail") {
TEST_CASE("builds checklist from cards[] and prefers EN catalog names") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {"PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック"}},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"},
{"set_id":"PMCG1","local_id":"099","name_en":"Catalog Only","name_ja":""}
]
})");
REQUIRE(catalog.isOk());
Set set;
set.id = "PMCG1";
set.name = "Expansion Pack";
const std::string detail = R"({
"id":"PMCG1",
"name":"拡張パック",
"cards":[
{"localId":"001","name":"ヒトカゲ"},
{"localId":"002","name":"リザード"}
]
})";
const auto pack = JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
detail, set, catalog.value());
REQUIRE(pack.isOk());
REQUIRE(pack.value().cards.size() == 3);
CHECK(pack.value().cards[0].setNo == "001");
CHECK(pack.value().cards[0].name == "Charmander");
CHECK(pack.value().cards[1].setNo == "002");
CHECK(pack.value().cards[1].name == "リザード");
CHECK(pack.value().cards[2].setNo == "099");
CHECK(pack.value().cards[2].name == "Catalog Only");
}
TEST_CASE("catalogPackFromEnCatalog covers classic-only products") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu"},
{"set_id":"UnnumberedPromo","local_id":"002","name_en":"Mewtwo"}
]
})");
REQUIRE(catalog.isOk());
Set set;
set.id = "UnnumberedPromo";
set.name = "Unnumbered Promotional cards";
const auto pack =
JapanesePokemonSetSource::catalogPackFromEnCatalog(set, catalog.value());
REQUIRE(pack.cards.size() == 2);
CHECK(pack.cards[0].setNo == "001");
CHECK(pack.cards[1].setNo == "002");
}
}
+110 -5
View File
@@ -34,10 +34,13 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
CHECK(url.find("number") == std::string::npos);
}
TEST_CASE("setNo is appended as a number: clause") {
TEST_CASE("setId plus setNo omits name to avoid Lucene name-number misses") {
const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Charizard", "base1", "4");
CHECK(url.find("number%3A4") != std::string::npos);
CHECK(url.find("set.id%3Abase1") != std::string::npos);
CHECK(url.find("name") == std::string::npos);
CHECK(url.find("Charizard") == std::string::npos);
}
TEST_CASE("setNo with a slash is normalized to the printed number") {
@@ -47,6 +50,7 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
"Charizard", "base1", "4/102");
CHECK(url.find("number%3A4") != std::string::npos);
CHECK(url.find("102") == std::string::npos);
CHECK(url.find("name") == std::string::npos);
}
TEST_CASE("name with spaces is percent-encoded") {
@@ -55,14 +59,34 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos);
}
TEST_CASE("empty setId omits the set.id clause") {
TEST_CASE("empty setId keeps name and appends number") {
const auto url =
PokemonCardPreviewSource::buildSearchUrl("Pikachu", "", "25");
CHECK(url.find("set.id") == std::string::npos);
CHECK(url.find("%22Pikachu%22") != std::string::npos);
CHECK(url.find("number%3A25") != std::string::npos);
}
}
TEST_SUITE("PokemonCardPreviewSource::buildCardByIdUrl") {
TEST_CASE("joins setId and normalized number with a hyphen") {
const auto url = PokemonCardPreviewSource::buildCardByIdUrl("base1", "4");
CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4");
}
TEST_CASE("percent-encodes alphanumeric collector numbers") {
const auto url =
PokemonCardPreviewSource::buildCardByIdUrl("swsh12tg", "TG14");
CHECK(url == "https://api.pokemontcg.io/v2/cards/swsh12tg-TG14");
}
TEST_CASE("strips slash form before building the id") {
const auto url =
PokemonCardPreviewSource::buildCardByIdUrl("base1", "4/102");
CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4");
}
}
TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
TEST_CASE("returns images.large when present") {
const std::string json = R"({
@@ -147,6 +171,50 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
}
}
TEST_SUITE("PokemonCardPreviewSource::parseCardByIdResponse") {
TEST_CASE("returns images.large from data object") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({
"data": {
"id": "base1-4",
"images": {
"small": "https://images.pokemontcg.io/small.png",
"large": "https://images.pokemontcg.io/large.png"
}
}
})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.pokemontcg.io/large.png");
}
TEST_CASE("falls back to images.small when large is absent") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({
"data": {"images":{"small":"https://small.only/img.png"}}
})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://small.only/img.png");
}
TEST_CASE("missing images is NotFound") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(
R"({"data":{"id":"base1-4","name":"Charizard"}})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("data array shape is Transient") {
const auto out =
PokemonCardPreviewSource::parseCardByIdResponse(R"({"data":[]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("invalid JSON is Transient") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse("{not json");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
TEST_CASE("network error is surfaced as Transient") {
FixedHttpClient http;
@@ -157,17 +225,54 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("network success is parsed end-to-end and uses the encoded URL") {
TEST_CASE("with setNo prefers card-by-id endpoint") {
FixedHttpClient http;
http.ok = true;
http.body = R"({"data":{"images":{"large":"https://l/by-id.png"}}})";
PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Pikachu", "base1", "25");
REQUIRE(out.isOk());
CHECK(out.value() == "https://l/by-id.png");
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/cards/base1-25");
}
TEST_CASE("falls back to name-less search when card-by-id HTTP fails") {
class RoutingHttp final : public IHttpClient {
public:
int calls = 0;
std::string lastUrl;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
++calls;
if (url.find("/v2/cards?") == std::string::npos) {
return Result<std::string>::err("HTTP 404 from card id");
}
return Result<std::string>::ok(
R"({"data":[{"images":{"large":"https://l/search.png"}}]})");
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Charizard", "base1", "4");
REQUIRE(out.isOk());
CHECK(out.value() == "https://l/search.png");
CHECK(http.calls == 2);
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos);
CHECK(http.lastUrl.find("number%3A4") != std::string::npos);
CHECK(http.lastUrl.find("name") == std::string::npos);
}
TEST_CASE("empty setNo uses name search without card-by-id") {
FixedHttpClient http;
http.ok = true;
http.body = R"({"data":[{"images":{"large":"https://l/x.png"}}]})";
PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Pikachu", "base1", "25");
const auto out = src.fetchImageUrl("Pikachu", "base1", "");
REQUIRE(out.isOk());
CHECK(out.value() == "https://l/x.png");
CHECK(http.lastUrl.find("%22Pikachu%22") != std::string::npos);
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos);
CHECK(http.lastUrl.find("number%3A25") != std::string::npos);
CHECK(http.lastUrl.find("/v2/cards/base1-") == std::string::npos);
}
}
+263
View File
@@ -0,0 +1,263 @@
#include <doctest/doctest.h>
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/PokemonSetCatalogService.hpp"
#include "ccm/services/PokemonSetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
PokemonCard makeOwned(PokemonRegion region, std::string setId, std::string setNo) {
PokemonCard c;
c.id = 1;
c.name = "Owned";
c.region = region;
c.set.id = std::move(setId);
c.set.name = "Set";
c.setNo = std::move(setNo);
c.language = Language::English;
return c;
}
PokemonSetCatalog westCatalog() {
PokemonSetCatalog catalog;
PokemonSetCatalogPack base;
base.setId = "base1";
base.setName = "Base";
base.cards = {
{"4", "Charizard"},
{"58", "Growlithe"},
{"59", "Arcanine"},
};
catalog.packs.push_back(std::move(base));
return catalog;
}
PokemonSetCatalog asiaCatalog() {
PokemonSetCatalog catalog;
PokemonSetCatalogPack pmcg1;
pmcg1.setId = "PMCG1";
pmcg1.setName = "Expansion Pack";
pmcg1.cards = {
{"001", "Charmander"},
{"002", "Charmeleon"},
{"006", "Charizard"},
};
catalog.packs.push_back(std::move(pmcg1));
return catalog;
}
} // namespace
TEST_SUITE("computePokemonSetCompletion") {
TEST_CASE("west pack with owned card appears") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "base1", "4"),
};
const auto rows = computePokemonSetCompletion(collection, west, asia);
REQUIRE(rows.size() == 1);
CHECK(rows[0].region == PokemonRegion::West);
CHECK(rows[0].setId == "base1");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("asia and west do not cross-count") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
// Same collector-looking number, different region/set.
PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4");
PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "006");
asiaCard.id = 2;
const auto rows = computePokemonSetCompletion({westCard, asiaCard}, west, asia);
REQUIRE(rows.size() == 2);
CHECK(rows[0].setId == "base1");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[1].setId == "PMCG1");
CHECK(rows[1].ownedUnique == 1);
}
TEST_CASE("region filter isolates catalogs") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4");
PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "001");
asiaCard.id = 2;
const auto westOnly = computePokemonSetCompletion(
{westCard, asiaCard}, west, asia, PokemonRegion::West);
REQUIRE(westOnly.size() == 1);
CHECK(westOnly[0].setId == "base1");
const auto asiaOnly = computePokemonSetCompletion(
{westCard, asiaCard}, west, asia, PokemonRegion::Asia);
REQUIRE(asiaOnly.size() == 1);
CHECK(asiaOnly[0].setId == "PMCG1");
}
TEST_CASE("normalizes west 4/102 to 4") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "base1", "4/102"),
};
const auto rows = computePokemonSetCompletion(collection, west, emptyAsia);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 1);
}
TEST_CASE("amount does not inflate unique ownership") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
PokemonCard a = makeOwned(PokemonRegion::West, "base1", "4");
a.amount = 5;
PokemonCard b = makeOwned(PokemonRegion::West, "base1", "4");
b.id = 2;
PokemonCard c = makeOwned(PokemonRegion::West, "base1", "58");
c.id = 3;
const auto rows = computePokemonSetCompletion({a, b, c}, west, emptyAsia);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
}
TEST_CASE("language filter hides packs with no matching language") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
PokemonCard en = makeOwned(PokemonRegion::West, "base1", "4");
en.language = Language::English;
CHECK(computePokemonSetCompletion({en}, west, emptyAsia, std::nullopt,
Language::German)
.empty());
REQUIRE(computePokemonSetCompletion({en}, west, emptyAsia, std::nullopt,
Language::English)
.size() == 1);
}
TEST_CASE("empty catalog yields no rows") {
PokemonSetCatalog empty;
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "base1", "4"),
};
CHECK(computePokemonSetCompletion(collection, empty, empty).empty());
}
}
TEST_SUITE("pokemonChecklistForSet") {
TEST_CASE("marks owned west cards") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "base1", "58"),
};
const auto list = pokemonChecklistForSet(collection, west, emptyAsia,
PokemonRegion::West, "base1");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "4");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "58");
CHECK(list[1].owned == true);
CHECK(list[2].setNo == "59");
CHECK(list[2].owned == false);
}
TEST_CASE("asia card does not mark west checklist") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::Asia, "PMCG1", "006"),
};
const auto list = pokemonChecklistForSet(collection, west, asia,
PokemonRegion::West, "base1");
REQUIRE(list.size() == 3);
CHECK(list[0].owned == false);
CHECK(list[1].owned == false);
CHECK(list[2].owned == false);
}
}
TEST_SUITE("pokemonLanguagesInCollection") {
TEST_CASE("region filter scopes languages") {
PokemonCard westEn = makeOwned(PokemonRegion::West, "base1", "4");
westEn.language = Language::English;
PokemonCard asiaJp = makeOwned(PokemonRegion::Asia, "PMCG1", "001");
asiaJp.id = 2;
asiaJp.language = Language::Japanese;
const auto all = pokemonLanguagesInCollection({westEn, asiaJp});
REQUIRE(all.size() == 2);
CHECK(all[0] == Language::English);
CHECK(all[1] == Language::Japanese);
const auto westOnly =
pokemonLanguagesInCollection({westEn, asiaJp}, PokemonRegion::West);
REQUIRE(westOnly.size() == 1);
CHECK(westOnly[0] == Language::English);
}
}
TEST_SUITE("pokemonRegionsInCollection") {
TEST_CASE("reports regions with matching catalog packs") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4");
PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "001");
asiaCard.id = 2;
const auto regions =
pokemonRegionsInCollection({westCard, asiaCard}, west, asia);
REQUIRE(regions.size() == 2);
CHECK(regions[0] == PokemonRegion::West);
CHECK(regions[1] == PokemonRegion::Asia);
}
}
TEST_SUITE("PokemonSetCatalogService") {
TEST_CASE("save then load round-trips for west and asia paths") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
PokemonSetCatalogService store{fs, config, [](Game) { return "pokemon"; }};
CHECK_FALSE(store.exists(PokemonRegion::West));
CHECK_FALSE(store.exists(PokemonRegion::Asia));
CHECK(store.load(PokemonRegion::West).isErr());
const auto west = westCatalog();
const auto asia = asiaCatalog();
REQUIRE(store.save(PokemonRegion::West, west).isOk());
REQUIRE(store.save(PokemonRegion::Asia, asia).isOk());
CHECK(store.exists(PokemonRegion::West));
CHECK(store.exists(PokemonRegion::Asia));
const auto loadedWest = store.load(PokemonRegion::West);
REQUIRE(loadedWest.isOk());
CHECK(loadedWest.value() == west);
const auto loadedAsia = store.load(PokemonRegion::Asia);
REQUIRE(loadedAsia.isOk());
CHECK(loadedAsia.value() == asia);
CHECK(fs.exists("/data/pokemon/set-catalog-west.json"));
CHECK(fs.exists("/data/pokemon/set-catalog-asia.json"));
}
}
+52
View File
@@ -92,3 +92,55 @@ TEST_SUITE("PokemonSetSource::fetchAll") {
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets");
}
}
TEST_SUITE("PokemonSetSource::parseCatalog") {
TEST_CASE("groups cards by set.id and dedupes collector numbers") {
const std::vector<Set> sets{
Set{"base1", "Base", "1999/01/09"},
Set{"jungle", "Jungle", "1999/06/16"},
};
const std::string json = R"({
"data": [
{"name":"Charizard","number":"4","set":{"id":"base1","name":"Base"}},
{"name":"Charizard","number":"4/102","set":{"id":"base1","name":"Base"}},
{"name":"Growlithe","number":"58","set":{"id":"base1","name":"Base"}},
{"name":"Pikachu","number":"60","set":{"id":"jungle","name":"Jungle"}}
],
"page":1,"pageSize":250,"count":4,"totalCount":4
})";
const auto catalog = PokemonSetSource::parseCatalog(json, sets);
REQUIRE(catalog.isOk());
REQUIRE(catalog.value().packs.size() == 2);
const auto* base = catalog.value().findPack("base1");
REQUIRE(base != nullptr);
REQUIRE(base->cards.size() == 2);
CHECK(base->cards[0].setNo == "4");
CHECK(base->cards[1].setNo == "58");
const auto* jungle = catalog.value().findPack("jungle");
REQUIRE(jungle != nullptr);
REQUIRE(jungle->cards.size() == 1);
CHECK(jungle->cards[0].setNo == "60");
}
TEST_CASE("mergeCardsPage accumulates across pages") {
const std::vector<Set> sets{Set{"base1", "Base", "1999/01/09"}};
PokemonSetCatalog catalog;
const std::string page1 = R"({
"data":[{"name":"A","number":"1","set":{"id":"base1","name":"Base"}}],
"page":1,"pageSize":1,"count":1,"totalCount":2
})";
const std::string page2 = R"({
"data":[{"name":"B","number":"2","set":{"id":"base1","name":"Base"}}],
"page":2,"pageSize":1,"count":1,"totalCount":2
})";
REQUIRE(PokemonSetSource::mergeCardsPage(page1, catalog, sets).isOk());
REQUIRE(PokemonSetSource::mergeCardsPage(page2, catalog, sets).isOk());
REQUIRE(catalog.packs.size() == 1);
REQUIRE(catalog.packs[0].cards.size() == 2);
}
TEST_CASE("buildCardsPageUrl includes select and pagination") {
CHECK(PokemonSetSource::buildCardsPageUrl(2) ==
"https://api.pokemontcg.io/v2/cards?select=name,number,set&pageSize=250&page=2");
}
}
+250
View File
@@ -0,0 +1,250 @@
#include <doctest/doctest.h>
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/services/YuGiOhSetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
YuGiOhCard makeOwned(std::string setId, std::string setName, std::string setNo) {
YuGiOhCard c;
c.id = 1;
c.name = "Owned";
c.set.id = std::move(setId);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
return c;
}
YuGiOhSetCatalog sampleCatalog() {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack lob;
lob.setId = "LOB";
lob.setName = "Legend of Blue Eyes White Dragon";
lob.cards = {
{"LOB-001", "Blue-Eyes White Dragon"},
{"LOB-EN005", "Dark Magician"},
{"LOB-007", "Gaia The Fierce Knight"},
};
YuGiOhSetCatalogPack mrd;
mrd.setId = "MRD";
mrd.setName = "Metal Raiders";
mrd.cards = {
{"MRD-001", "Summoned Skull"},
{"LOB-001", "Blue-Eyes White Dragon"},
};
catalog.packs.push_back(std::move(mrd));
catalog.packs.push_back(std::move(lob));
return catalog;
}
} // namespace
TEST_SUITE("computeYuGiOhSetCompletion") {
TEST_CASE("only packs with owned cards appear") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
const auto rows = computeYuGiOhSetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "LOB");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("printing slot match treats LOB-005 and LOB-EN005 as one slot") {
const auto catalog = sampleCatalog();
YuGiOhCard a = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
a.amount = 4;
YuGiOhCard b = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-EN005");
b.id = 2;
YuGiOhCard c = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
c.id = 3;
const auto rows = computeYuGiOhSetCompletion({a, b, c}, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 66);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
const auto rows = computeYuGiOhSetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "LOB");
}
TEST_CASE("empty catalog yields no rows") {
YuGiOhSetCatalog empty;
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
CHECK(computeYuGiOhSetCompletion(collection, empty).empty());
}
TEST_CASE("owned set missing from catalog is skipped") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack onlyMrd;
onlyMrd.setId = "MRD";
onlyMrd.setName = "Metal Raiders";
onlyMrd.cards = {{"MRD-001", "Summoned Skull"}};
catalog.packs.push_back(std::move(onlyMrd));
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
CHECK(computeYuGiOhSetCompletion(collection, catalog).empty());
}
TEST_CASE("language filter hides packs with no cards in that language") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
en.language = Language::English;
const auto allRows = computeYuGiOhSetCompletion({en}, catalog);
REQUIRE(allRows.size() == 1);
const auto deRows =
computeYuGiOhSetCompletion({en}, catalog, Language::German);
CHECK(deRows.empty());
const auto enRows =
computeYuGiOhSetCompletion({en}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
}
TEST_CASE("same slot in two languages counts once aggregated; filter is exclusive") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
en.language = Language::English;
YuGiOhCard de = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
de.id = 2;
de.language = Language::German;
const auto allRows = computeYuGiOhSetCompletion({en, de}, catalog);
REQUIRE(allRows.size() == 1);
CHECK(allRows[0].ownedUnique == 1);
const auto enRows =
computeYuGiOhSetCompletion({en, de}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
YuGiOhCard deOnly = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
deOnly.id = 3;
deOnly.language = Language::German;
const auto deRows =
computeYuGiOhSetCompletion({en, de, deOnly}, catalog, Language::German);
REQUIRE(deRows.size() == 1);
CHECK(deRows[0].ownedUnique == 2);
}
}
TEST_SUITE("yuGiOhChecklistForSet") {
TEST_CASE("greys missing cards and marks owned ones") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005"),
};
const auto list = yuGiOhChecklistForSet(collection, catalog, "LOB");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "LOB-001");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "LOB-007");
CHECK(list[1].owned == false);
CHECK(list[2].setNo == "LOB-EN005");
CHECK(list[2].owned == true);
}
TEST_CASE("unknown set returns empty") {
const auto catalog = sampleCatalog();
CHECK(yuGiOhChecklistForSet({}, catalog, "missing").empty());
}
TEST_CASE("owned flags respect language filter") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
en.language = Language::English;
const auto filtered =
yuGiOhChecklistForSet({en}, catalog, "LOB", Language::German);
REQUIRE(filtered.size() == 3);
CHECK(filtered[0].owned == false);
CHECK(filtered[1].owned == false);
CHECK(filtered[2].owned == false);
const auto english =
yuGiOhChecklistForSet({en}, catalog, "LOB", Language::English);
REQUIRE(english.size() == 3);
CHECK(english[2].owned == true);
}
}
TEST_SUITE("yuGiOhLanguagesInCollection") {
TEST_CASE("empty collection yields empty") {
CHECK(yuGiOhLanguagesInCollection({}).empty());
}
TEST_CASE("returns distinct languages in allLanguages order") {
YuGiOhCard jp = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
jp.language = Language::Japanese;
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
en.id = 2;
en.language = Language::English;
YuGiOhCard enDup = makeOwned("MRD", "Metal Raiders", "MRD-001");
enDup.id = 3;
enDup.language = Language::English;
YuGiOhCard de = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-007");
de.id = 4;
de.language = Language::German;
const auto langs = yuGiOhLanguagesInCollection({jp, en, enDup, de});
REQUIRE(langs.size() == 3);
CHECK(langs[0] == Language::English);
CHECK(langs[1] == Language::German);
CHECK(langs[2] == Language::Japanese);
}
}
TEST_SUITE("YuGiOhSetCatalogService") {
TEST_CASE("save then load round-trips") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
YuGiOhSetCatalogService store{fs, config, [](Game) { return "yugioh"; }};
CHECK_FALSE(store.exists());
CHECK(store.load().isErr());
const auto catalog = sampleCatalog();
REQUIRE(store.save(catalog).isOk());
CHECK(store.exists());
const auto loaded = store.load();
REQUIRE(loaded.isOk());
CHECK(loaded.value() == catalog);
}
}
+113
View File
@@ -3,6 +3,8 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <vector>
using namespace ccm;
namespace {
@@ -172,3 +174,114 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") {
CHECK(out.error() == "offline");
}
}
TEST_SUITE("YuGiOhSetSource::parseCatalog") {
TEST_CASE("groups by set_name resolved to Set.id and dedupes printing slots") {
const std::vector<Set> sets{
Set{"LOB", "Legend of Blue Eyes White Dragon", "2002/03/08"},
Set{"MRD", "Metal Raiders", "2002/06/26"},
};
const std::string json = R"({
"data": [
{
"name": "Blue-Eyes White Dragon",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"},
{"set_name":"Metal Raiders","set_code":"MRD-010","set_rarity":"Ultra Rare"}
]
},
{
"name": "Dark Magician",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"},
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"},
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-E003","set_rarity":"Ultra Rare"}
]
}
]
})";
const auto out = YuGiOhSetSource::parseCatalog(json, sets);
REQUIRE(out.isOk());
const auto* lob = out.value().findPack("LOB");
REQUIRE(lob != nullptr);
REQUIRE(lob->cards.size() == 2);
bool sawBe = false;
bool sawDm = false;
for (const auto& c : lob->cards) {
if (c.name == "Blue-Eyes White Dragon" && c.setNo == "LOB-001") sawBe = true;
if (c.name == "Dark Magician" && c.setNo == "LOB-EN005") sawDm = true;
}
CHECK(sawBe);
CHECK(sawDm);
const auto* mrd = out.value().findPack("MRD");
REQUIRE(mrd != nullptr);
REQUIRE(mrd->cards.size() == 1);
CHECK(mrd->cards[0].setNo == "MRD-010");
}
TEST_CASE("missing data array returns error") {
CHECK(YuGiOhSetSource::parseCatalog(R"([])", {}).isErr());
CHECK(YuGiOhSetSource::parseCatalog(R"({"data":{}})", {}).isErr());
}
}
namespace {
class RoutingHttpClient final : public IHttpClient {
public:
std::string setsBody;
std::string infoBody;
bool setsOk = true;
bool infoOk = true;
std::vector<std::string> urls;
Result<std::string> get(std::string_view url) override {
urls.emplace_back(url);
if (url == YuGiOhSetSource::kEndpoint) {
return setsOk ? Result<std::string>::ok(setsBody)
: Result<std::string>::err("sets offline");
}
if (url == YuGiOhSetSource::kCardInfoEndpoint) {
return infoOk ? Result<std::string>::ok(infoBody)
: Result<std::string>::err("info offline");
}
return Result<std::string>::err("unexpected url");
}
};
} // namespace
TEST_SUITE("YuGiOhSetSource::fetchAllWithCatalog") {
TEST_CASE("fetches sets then cardinfo and returns both") {
RoutingHttpClient http;
http.setsBody = R"([{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}])";
http.infoBody = R"({
"data": [{
"name": "Blue-Eyes White Dragon",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"}
]
}]
})";
YuGiOhSetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isOk());
REQUIRE(http.urls.size() == 2);
CHECK(http.urls[0] == YuGiOhSetSource::kEndpoint);
CHECK(http.urls[1] == YuGiOhSetSource::kCardInfoEndpoint);
CHECK(out.value().sets.front().id == "LOB");
REQUIRE(out.value().catalog.findPack("LOB") != nullptr);
CHECK(out.value().catalog.findPack("LOB")->cards.size() == 1);
}
TEST_CASE("cardinfo failure propagates after sets succeed") {
RoutingHttpClient http;
http.setsBody = R"([{"set_name":"Set X","set_code":"X","tcg_date":"2020-01-01"}])";
http.infoOk = false;
YuGiOhSetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isErr());
CHECK(out.error() == "info offline");
}
}