Fix: replace api.pokemontcg.io with api.tcgdex.net (#20)

This commit is contained in:
Sebastian Dine
2026-07-23 17:58:00 +02:00
committed by GitHub
parent 9917e364c1
commit ab0e3c5ae2
27 changed files with 1067 additions and 784 deletions
+4 -2
View File
@@ -19,8 +19,10 @@
- `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. `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`.
- `pokemon_west_set_id_tests.cpp``canonicalizeWestSetId` identity + legacy pokemontcg → TCGdex EN mappings (`sv1``sv01`, `pgo``swsh10.5`, …) and unknown passthrough.
- `pokemon_collection_set_sync_tests.cpp``syncPokemonCollectionSets` West id migration + name/date refresh; Asia metadata-only refresh.
- `pokemon_set_source_tests.cpp``PokemonSetSource::parseListResponse` (TCGdex EN `/v2/en/sets` top-level array) + `parseReleaseDate` / `parseCatalogPackFromSetDetail`. Drives `fetchAll` / `fetchAllWithCatalog` via routing HTTP fakes and asserts EN endpoints.
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` / `buildCardByIdUrl` (canonicalization + `localId` filters), `parseSearchResponse` / `parseCardByIdResponse` (`image` + `/high.png`), and `fetchImageUrl` / auto-detect 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`.
+2
View File
@@ -19,6 +19,8 @@ add_executable(ccm_core_tests
card_preview_service_tests.cpp
local_preview_byte_cache_tests.cpp
std_file_system_tests.cpp
pokemon_west_set_id_tests.cpp
pokemon_collection_set_sync_tests.cpp
pokemon_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp
+18
View File
@@ -236,4 +236,22 @@ TEST_SUITE("CollectionService<MagicCard>") {
CHECK(store.removed[0].second == "a.png");
CHECK(store.removed[1].second == "b.png");
}
TEST_CASE("saveAll replaces the collection map") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
REQUIRE(svc.add(Game::Magic, makeCard("A")).isOk());
REQUIRE(svc.add(Game::Magic, makeCard("B")).isOk());
MagicCard only = makeCard("Only");
only.id = 7;
REQUIRE(svc.saveAll(Game::Magic, {only}).isOk());
auto listed = svc.list(Game::Magic);
REQUIRE(listed.isOk());
REQUIRE(listed.value().size() == 1);
CHECK(listed.value()[0].id == 7);
CHECK(listed.value()[0].name == "Only");
}
}
+42
View File
@@ -218,6 +218,48 @@ TEST_SUITE("PokemonCard JSON") {
const PokemonCard legacy = j.get<PokemonCard>();
CHECK(legacy.region == PokemonRegion::West);
}
TEST_CASE("West load migrates legacy pokemontcg set ids to TCGdex EN") {
nlohmann::json j = {
{"id", 1},
{"amount", 1},
{"name", "Charizard"},
{"set", {{"id", "sv1"}, {"name", "Scarlet & Violet"}, {"releaseDate", "2023/03/31"}}},
{"setNo", "6"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", false},
{"holo", false},
{"signed", false},
{"altered", false},
{"region", "West"},
};
const PokemonCard back = j.get<PokemonCard>();
CHECK(back.set.id == "sv01");
}
TEST_CASE("Asia load does not rewrite set ids through West aliases") {
nlohmann::json j = {
{"id", 1},
{"amount", 1},
{"name", "Charmander"},
{"set", {{"id", "sv1"}, {"name", "Keep Asia id"}, {"releaseDate", "2023/01/01"}}},
{"setNo", "001"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "Japanese"},
{"condition", "NearMint"},
{"firstEdition", false},
{"holo", false},
{"signed", false},
{"altered", false},
{"region", "Asia"},
};
const PokemonCard back = j.get<PokemonCard>();
CHECK(back.set.id == "sv1");
}
}
TEST_SUITE("DigiBattle99Card JSON") {
+105 -306
View File
@@ -24,186 +24,113 @@ public:
} // namespace
TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
TEST_CASE("name and setId produce a percent-encoded query") {
const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Pikachu", "base1", "");
CHECK(url.find("https://api.pokemontcg.io/v2/cards?q=") == 0);
CHECK(url.find("%22Pikachu%22") != std::string::npos);
CHECK(url.find("set.id%3Abase1") != std::string::npos);
// No number term when setNo is empty.
CHECK(url.find("number") == std::string::npos);
}
TEST_CASE("setId plus setNo omits name to avoid Lucene name-number misses") {
TEST_CASE("setId plus setNo uses localId and set.id filters without name") {
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);
CHECK(url.find("https://api.tcgdex.net/v2/en/cards?") == 0);
CHECK(url.find("set.id=eq:base1") != std::string::npos);
CHECK(url.find("localId=eq:4") != std::string::npos);
CHECK(url.find("name=") == std::string::npos);
}
TEST_CASE("legacy swsh12tg is canonicalized to swsh12.5tg") {
const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Pikachu", "swsh12tg", "TG14");
CHECK(url.find("set.id=eq:swsh12.5tg") != std::string::npos);
CHECK(url.find("localId=eq:TG14") != std::string::npos);
}
TEST_CASE("setNo with a slash is normalized to the printed number") {
// Pokemon collection numbers are commonly stored as "4/102" — the
// Pokemon TCG search API only accepts the printed-number portion.
const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Charizard", "base1", "4/102");
CHECK(url.find("number%3A4") != std::string::npos);
CHECK(url.find("localId=eq:4") != 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") {
TEST_CASE("name-only search percent-encodes the name") {
const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Mr. Mime", "base1", "");
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos);
}
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);
CHECK(url.find("name=eq:Mr.%20Mime") != std::string::npos);
CHECK(url.find("set.id=eq:base1") != 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");
CHECK(url == "https://api.tcgdex.net/v2/en/cards/base1-4");
}
TEST_CASE("percent-encodes alphanumeric collector numbers") {
TEST_CASE("canonicalizes legacy set ids") {
const auto url =
PokemonCardPreviewSource::buildCardByIdUrl("swsh12tg", "TG14");
CHECK(url == "https://api.pokemontcg.io/v2/cards/swsh12tg-TG14");
CHECK(url == "https://api.tcgdex.net/v2/en/cards/swsh12.5tg-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");
CHECK(url == "https://api.tcgdex.net/v2/en/cards/base1-4");
}
}
TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
TEST_CASE("returns images.large when present") {
const std::string json = R"({
"data": [
{
"name": "Pikachu",
"images": {
"small": "https://images.pokemontcg.io/small.png",
"large": "https://images.pokemontcg.io/large.png"
}
}
]
})";
const auto out = PokemonCardPreviewSource::parseResponse(json);
TEST_SUITE("PokemonCardPreviewSource::parseSearchResponse") {
TEST_CASE("appends /high.png to the first card image base") {
const std::string json = R"([
{"id":"base1-25","localId":"25","name":"Pikachu",
"image":"https://assets.tcgdex.net/en/base/base1/25"}
])";
const auto out = PokemonCardPreviewSource::parseSearchResponse(json);
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.pokemontcg.io/large.png");
CHECK(out.value() ==
"https://assets.tcgdex.net/en/base/base1/25/high.png");
}
TEST_CASE("falls back to images.small when large is absent") {
const std::string json = R"({
"data": [
{"name":"Pikachu","images":{"small":"https://small.only/img.png"}}
]
})";
const auto out = PokemonCardPreviewSource::parseResponse(json);
REQUIRE(out.isOk());
CHECK(out.value() == "https://small.only/img.png");
}
TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":[]})");
TEST_CASE("empty array is NotFound") {
const auto out = PokemonCardPreviewSource::parseSearchResponse("[]");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("missing data array is classified as Transient (schema deviation)") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})");
TEST_CASE("object shape is Transient") {
const auto out = PokemonCardPreviewSource::parseSearchResponse(R"({"data":[]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("'data' present but not an array is Transient") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":{}})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("'images' present but not an object is NotFound") {
const auto out =
PokemonCardPreviewSource::parseResponse(R"({"data":[{"images":[]}]})");
TEST_CASE("cards without image are NotFound") {
const auto out = PokemonCardPreviewSource::parseSearchResponse(
R"([{"id":"base1-1","localId":"1","name":"X"}])");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("large unusable type falls back to small string") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({
"data":[{"images":{"large":123,"small":"https://only.small/img.png"}}]
})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://only.small/img.png");
}
TEST_CASE("no usable large or small string yields NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({
"data":[{"images":{"large":null,"small":false}}]
})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("entry without images is classified as NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(
R"({"data":[{"name":"Pikachu"}]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("invalid JSON is classified as Transient") {
const auto out = PokemonCardPreviewSource::parseResponse("{not json");
TEST_CASE("invalid JSON is Transient") {
const auto out = PokemonCardPreviewSource::parseSearchResponse("{not json");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("PokemonCardPreviewSource::parseCardByIdResponse") {
TEST_CASE("returns images.large from data object") {
TEST_CASE("returns image base with /high.png") {
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"
}
}
"id": "base1-4",
"image": "https://assets.tcgdex.net/en/base/base1/4"
})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.pokemontcg.io/large.png");
CHECK(out.value() == "https://assets.tcgdex.net/en/base/base1/4/high.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") {
TEST_CASE("null image is NotFound") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(
R"({"data":{"id":"base1-4","name":"Charizard"}})");
R"({"id":"base1-4","image":null})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("data array shape is Transient") {
const auto out =
PokemonCardPreviewSource::parseCardByIdResponse(R"({"data":[]})");
TEST_CASE("array shape is Transient") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse("[]");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
@@ -228,15 +155,16 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
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"}}})";
http.body = R"({"id":"base1-25","image":"https://assets.tcgdex.net/en/base/base1/25"})";
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");
CHECK(out.value() ==
"https://assets.tcgdex.net/en/base/base1/25/high.png");
CHECK(http.lastUrl == "https://api.tcgdex.net/v2/en/cards/base1-25");
}
TEST_CASE("falls back to name-less search when card-by-id HTTP fails") {
TEST_CASE("falls back to search when card-by-id HTTP fails") {
class RoutingHttp final : public IHttpClient {
public:
int calls = 0;
@@ -244,250 +172,130 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
++calls;
if (url.find("/v2/cards?") == std::string::npos) {
if (url.find("/v2/en/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"}}]})");
R"([{"id":"base1-4","localId":"4","name":"Charizard",
"image":"https://assets.tcgdex.net/en/base/base1/4"}])");
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Charizard", "base1", "4");
REQUIRE(out.isOk());
CHECK(out.value() == "https://l/search.png");
CHECK(out.value() ==
"https://assets.tcgdex.net/en/base/base1/4/high.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);
CHECK(http.lastUrl.find("set.id=eq:base1") != std::string::npos);
CHECK(http.lastUrl.find("localId=eq:4") != 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"}}]})";
http.body = R"([{"id":"base1-25","localId":"25","name":"Pikachu",
"image":"https://assets.tcgdex.net/en/base/base1/25"}])";
PokemonCardPreviewSource src{http};
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("/v2/cards/base1-") == std::string::npos);
CHECK(http.lastUrl.find("name=eq:Pikachu") != std::string::npos);
CHECK(http.lastUrl.find("/v2/en/cards/base1-") == std::string::npos);
}
}
namespace {
const char* kCharizardSwsh4 = R"({
"data": [
const char* kCharizardSwsh4Detail = R"({
"id": "swsh4",
"name": "Vivid Voltage",
"cards": [
{
"id": "swsh4-25",
"localId": "25",
"name": "Charizard",
"number": "25",
"rarity": "Rare",
"set": {
"id": "swsh4",
"name": "Vivid Voltage",
"printedTotal": 185
}
"image": "https://assets.tcgdex.net/en/swsh/swsh4/25"
}
]
})";
const char* kMultiVariantPayload = R"({
"data": [
{
"name": "Pikachu",
"number": "25",
"rarity": "Common",
"set": {"id": "base1", "printedTotal": 102}
},
{
"name": "Pikachu",
"number": "58",
"rarity": "Rare",
"set": {"id": "base1", "printedTotal": 102}
},
{
"name": "Pikachu",
"number": "25",
"rarity": "Common",
"set": {"id": "base2", "printedTotal": 64}
}
const char* kMultiVariantDetail = R"({
"id": "base1",
"cards": [
{"localId":"25","name":"Pikachu","rarity":"Common"},
{"localId":"58","name":"Pikachu","rarity":"Rare"},
{"localId":"1","name":"Alakazam","rarity":"Rare"}
]
})";
} // namespace
TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") {
TEST_CASE("maps API number into setNo without printedTotal suffix") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Charizard");
TEST_CASE("maps localId into setNo from set detail") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(
kCharizardSwsh4Detail, "swsh4", "Charizard");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25");
CHECK(out.value().front().rarity == "Rare");
}
TEST_CASE("filters by set id and keeps multiple numbers in the same set") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base1", "Pikachu");
TEST_CASE("filters by card name within the set") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(
kMultiVariantDetail, "base1", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "25");
CHECK(out.value()[1].setNo == "58");
}
TEST_CASE("wrong set id yields explicit error when name and set are supplied") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "base1", "Charizard");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("wrong card name is filtered out") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Blastoise");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("empty data array yields error") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(R"({"data":[]})", "base1", "Pikachu");
REQUIRE(out.isErr());
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
}
TEST_CASE("name-only payload still filters to requested set id") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base2", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "25");
}
TEST_CASE("keeps bare number when printedTotal is zero") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data": [
{
"name": "Promo",
"number": "7",
"rarity": "Promo",
"set": {"id": "promo1", "printedTotal": 0}
}
]
})",
"promo1", "Promo");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "7");
}
TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data":[
{"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"},
{"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}}
]
})",
"base1", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().setNo == "26");
}
TEST_CASE("empty setId skips set filter and collects prints across sets") {
const char* crossSet = R"({
"data": [
{"name":"Pikachu","number":"1","rarity":"Common","set":{"id":"base1"}},
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base2"}}
]
})";
const auto out = PokemonCardPreviewSource::parsePrintVariants(crossSet, "", "Pikachu");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2u);
}
TEST_CASE("empty wanted card name skips name filter within the set") {
const char* twoInSet = R"({
"data": [
{"name":"Electabuzz","number":"1","rarity":"Common","set":{"id":"base1"}},
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base1"}}
]
})";
const auto out = PokemonCardPreviewSource::parsePrintVariants(twoInSet, "base1", "");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2u);
}
TEST_CASE("cards with empty number and rarity are skipped for auto-detect metadata") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
"data": [
{"name":"Pikachu","number":"","rarity":"","set":{"id":"base1"}}
]
})",
"base1", "Pikachu");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("no matches with empty setId yields generic no matching cards message") {
TEST_CASE("wrong card name yields error") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(
R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})",
"",
"Nobody");
kCharizardSwsh4Detail, "swsh4", "Blastoise");
REQUIRE(out.isErr());
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
TEST_CASE("invalid JSON in parsePrintVariants yields parse error") {
const auto out =
PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu");
TEST_CASE("missing cards array is an error") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(
R"({"id":"base1"})", "base1", "Pikachu");
REQUIRE(out.isErr());
CHECK(out.error().find("Pokemon TCG JSON parse error:") == 0);
}
TEST_CASE("empty wanted name collects all prints in the set") {
const auto out = PokemonCardPreviewSource::parsePrintVariants(
kMultiVariantDetail, "base1", "");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 3u);
}
}
TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
TEST_CASE("supports auto-detect and returns first print") {
TEST_CASE("supports auto-detect and returns first print from set detail") {
FixedHttpClient http;
http.body = kCharizardSwsh4;
http.body = kCharizardSwsh4Detail;
PokemonCardPreviewSource src{http};
CHECK(src.supportsAutoDetectPrint());
const auto first = src.detectFirstPrint("Charizard", "swsh4");
REQUIRE(first.isOk());
CHECK(first.value().setNo == "25");
CHECK(http.lastUrl == "https://api.tcgdex.net/v2/en/sets/swsh4");
}
TEST_CASE("uses slim set-scoped search URL without number clause") {
FixedHttpClient http;
http.body = kCharizardSwsh4;
PokemonCardPreviewSource src{http};
const auto out = src.detectPrintVariants("Charizard", "swsh4");
REQUIRE(out.isOk());
CHECK(http.lastUrl.find("number%3A") == std::string::npos);
CHECK(http.lastUrl.find("set.id%3Aswsh4") != std::string::npos);
CHECK(http.lastUrl.find("select=name,number,rarity,set") != std::string::npos);
CHECK(http.lastUrl.find("pageSize=50") != std::string::npos);
}
TEST_CASE("buildDetectSearchUrl requests only parser fields") {
const auto url = PokemonCardPreviewSource::buildDetectSearchUrl("Charizard", "swsh4");
CHECK(url.find("select=name,number,rarity,set") != std::string::npos);
CHECK(url.find("pageSize=50") != std::string::npos);
}
TEST_CASE("retries name-only query when the set-scoped request fails") {
TEST_CASE("falls back to cards search when set detail fails") {
class FallbackHttpClient final : public IHttpClient {
public:
int calls = 0;
Result<std::string> get(std::string_view url) override {
++calls;
if (calls == 1) return Result<std::string>::err("offline");
if (url.find("set.id") != std::string::npos) {
return Result<std::string>::err("unexpected set-scoped retry");
if (std::string(url).find("/sets/") != std::string::npos) {
return Result<std::string>::err("offline");
}
return Result<std::string>::ok(kMultiVariantPayload);
return Result<std::string>::ok(R"([
{"id":"base1-25","localId":"25","name":"Pikachu","rarity":"Common"},
{"id":"base1-58","localId":"58","name":"Pikachu","rarity":"Rare"}
])");
}
} http;
@@ -498,7 +306,7 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
CHECK(http.calls == 2);
}
TEST_CASE("detectPrintVariants surfaces fallback HTTP error when both requests fail") {
TEST_CASE("surfaces search HTTP error when set detail and search fail") {
class AlwaysFailHttp final : public IHttpClient {
public:
int calls = 0;
@@ -514,13 +322,4 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
CHECK(out.error() == "offline");
CHECK(http.calls == 2);
}
TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") {
FixedHttpClient http;
http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})";
PokemonCardPreviewSource src{http};
const auto out = src.detectFirstPrint("Promo", "promo1");
REQUIRE(out.isErr());
CHECK(out.error() == "Could not auto-detect set print metadata.");
}
}
@@ -0,0 +1,74 @@
#include <doctest/doctest.h>
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
using namespace ccm;
namespace {
PokemonCard makeWest(std::string setId, std::string setName, std::string releaseDate) {
PokemonCard c;
c.id = 1;
c.name = "Card";
c.region = PokemonRegion::West;
c.set.id = std::move(setId);
c.set.name = std::move(setName);
c.set.releaseDate = std::move(releaseDate);
c.setNo = "1";
c.language = Language::English;
return c;
}
PokemonCard makeAsia(std::string setId, std::string setName, std::string releaseDate) {
PokemonCard c = makeWest(std::move(setId), std::move(setName), std::move(releaseDate));
c.region = PokemonRegion::Asia;
c.language = Language::Japanese;
return c;
}
} // namespace
TEST_SUITE("syncPokemonCollectionSets") {
TEST_CASE("migrates West legacy set id and refreshes name/date") {
std::vector<PokemonCard> cards{
makeWest("sv1", "Old Name", "2000/01/01"),
};
const std::vector<Set> west{Set{"sv01", "Scarlet & Violet", "2023/03/31"}};
const std::vector<Set> asia;
CHECK(syncPokemonCollectionSets(cards, west, asia) == 1);
CHECK(cards[0].set.id == "sv01");
CHECK(cards[0].set.name == "Scarlet & Violet");
CHECK(cards[0].set.releaseDate == "2023/03/31");
}
TEST_CASE("Asia cards refresh metadata without West id aliases") {
std::vector<PokemonCard> cards{
makeAsia("sv1", "Old", "2000/01/01"),
};
const std::vector<Set> west{Set{"sv01", "Scarlet & Violet", "2023/03/31"}};
const std::vector<Set> asia{Set{"sv1", "Asia Set", "2023/01/20"}};
CHECK(syncPokemonCollectionSets(cards, west, asia) == 1);
CHECK(cards[0].set.id == "sv1");
CHECK(cards[0].set.name == "Asia Set");
CHECK(cards[0].set.releaseDate == "2023/01/20");
}
TEST_CASE("unchanged cards are not counted") {
std::vector<PokemonCard> cards{
makeWest("base1", "Base Set", "1999/01/09"),
};
const std::vector<Set> west{Set{"base1", "Base Set", "1999/01/09"}};
CHECK(syncPokemonCollectionSets(cards, west, {}) == 0);
}
TEST_CASE("unknown set id still migrates when aliased") {
std::vector<PokemonCard> cards{makeWest("pgo", "GO", "")};
// No matching upstream set — id still migrates.
CHECK(syncPokemonCollectionSets(cards, {}, {}) == 1);
CHECK(cards[0].set.id == "swsh10.5");
CHECK(cards[0].set.name == "GO");
}
}
+22
View File
@@ -127,6 +127,28 @@ TEST_SUITE("computePokemonSetCompletion") {
CHECK(rows[0].ownedUnique == 1);
}
TEST_CASE("legacy pokemontcg West set id matches TCGdex catalog pack") {
PokemonSetCatalog west;
PokemonSetCatalogPack pack;
pack.setId = "sv01";
pack.setName = "Scarlet & Violet";
pack.cards = {{"6", "Charizard"}};
west.packs.push_back(std::move(pack));
PokemonSetCatalog emptyAsia;
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "sv1", "6"),
};
const auto rows = computePokemonSetCompletion(collection, west, emptyAsia);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "sv01");
CHECK(rows[0].ownedUnique == 1);
const auto checklist = pokemonChecklistForSet(
collection, west, emptyAsia, PokemonRegion::West, "sv01");
REQUIRE(checklist.size() == 1);
CHECK(checklist[0].owned);
}
TEST_CASE("amount does not inflate unique ownership") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
+100 -88
View File
@@ -3,6 +3,9 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <unordered_map>
using namespace ccm;
namespace {
@@ -19,59 +22,93 @@ public:
}
};
class RoutingHttpClient final : public IHttpClient {
public:
std::string listBody;
std::unordered_map<std::string, std::string> byUrl;
std::string lastUrl;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (lastUrl == PokemonSetSource::kListEndpoint) {
return Result<std::string>::ok(listBody);
}
const auto it = byUrl.find(lastUrl);
if (it == byUrl.end()) return Result<std::string>::err("missing route");
return Result<std::string>::ok(it->second);
}
};
} // namespace
TEST_SUITE("PokemonSetSource::parseResponse") {
TEST_CASE("happy path: maps id/name/releaseDate without rewriting separators") {
// The Pokemon TCG API returns releaseDate already in YYYY/MM/DD form,
// unlike Scryfall's released_at YYYY-MM-DD.
const std::string json = R"({
"data": [
{"id":"base1","name":"Base","releaseDate":"1999/01/09"},
{"id":"jungle","name":"Jungle","releaseDate":"1999/06/16"}
]
})";
TEST_SUITE("PokemonSetSource::parseListResponse") {
TEST_CASE("happy path: maps id/name from top-level array") {
const std::string json = R"([
{"id":"base1","name":"Base Set","cardCount":{"total":102,"official":102}},
{"id":"base2","name":"Jungle","cardCount":{"total":64,"official":64}}
])";
const auto out = PokemonSetSource::parseResponse(json);
const auto out = PokemonSetSource::parseListResponse(json);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].id == "base1");
CHECK(out.value()[0].name == "Base");
CHECK(out.value()[0].releaseDate == "1999/01/09");
CHECK(out.value()[1].id == "jungle");
CHECK(out.value()[1].releaseDate == "1999/06/16");
CHECK(out.value()[0].name == "Base Set");
CHECK(out.value()[0].releaseDate.empty());
CHECK(out.value()[1].id == "base2");
}
TEST_CASE("sorts by release date ascending") {
const std::string json = R"({
"data": [
{"id":"newer","name":"N","releaseDate":"2024/01/01"},
{"id":"older","name":"O","releaseDate":"2010/01/01"}
]
})";
const auto out = PokemonSetSource::parseResponse(json);
REQUIRE(out.isOk());
CHECK(out.value().front().id == "older");
CHECK(out.value().back().id == "newer");
}
TEST_CASE("empty data array returns an empty list (not an error)") {
const auto out = PokemonSetSource::parseResponse(R"({"data":[]})");
TEST_CASE("empty array returns an empty list (not an error)") {
const auto out = PokemonSetSource::parseListResponse("[]");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
TEST_CASE("missing data array returns an error") {
const auto out = PokemonSetSource::parseResponse(R"({"meta":{}})");
TEST_CASE("object shape returns an error") {
const auto out = PokemonSetSource::parseListResponse(R"({"data":[]})");
CHECK(out.isErr());
}
TEST_CASE("invalid JSON returns an error") {
const auto out = PokemonSetSource::parseResponse("{not json");
const auto out = PokemonSetSource::parseListResponse("{not json");
CHECK(out.isErr());
}
}
TEST_SUITE("PokemonSetSource::parseReleaseDate") {
TEST_CASE("rewrites YYYY-MM-DD to YYYY/MM/DD") {
const auto out = PokemonSetSource::parseReleaseDate(
R"({"id":"base1","releaseDate":"1999-01-09"})");
REQUIRE(out.isOk());
CHECK(out.value() == "1999/01/09");
}
TEST_CASE("missing releaseDate yields empty string") {
const auto out = PokemonSetSource::parseReleaseDate(R"({"id":"base1"})");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
}
TEST_SUITE("PokemonSetSource::parseCatalogPackFromSetDetail") {
TEST_CASE("builds checklist from cards localId/name and dedupes") {
const Set set{"base1", "Base Set", "1999/01/09"};
const std::string json = R"({
"id":"base1",
"name":"Base Set",
"cards":[
{"id":"base1-4","localId":"4","name":"Charizard"},
{"id":"base1-4","localId":"4/102","name":"Charizard"},
{"id":"base1-58","localId":"58","name":"Growlithe"}
]
})";
const auto pack = PokemonSetSource::parseCatalogPackFromSetDetail(json, set);
REQUIRE(pack.isOk());
CHECK(pack.value().setId == "base1");
REQUIRE(pack.value().cards.size() == 2);
CHECK(pack.value().cards[0].setNo == "4");
CHECK(pack.value().cards[1].setNo == "58");
}
}
TEST_SUITE("PokemonSetSource::fetchAll") {
TEST_CASE("network error is surfaced as a Result error") {
FixedHttpClient http;
@@ -80,67 +117,42 @@ TEST_SUITE("PokemonSetSource::fetchAll") {
CHECK(src.fetchAll().isErr());
}
TEST_CASE("network success is parsed end-to-end and hits the public endpoint") {
FixedHttpClient http;
http.ok = true;
http.body = R"({"data":[{"id":"x","name":"X","releaseDate":"2020/01/01"}]})";
TEST_CASE("list plus set detail fills release dates and hits EN endpoints") {
RoutingHttpClient http;
http.listBody = R"([{"id":"base1","name":"Base Set"}])";
http.byUrl[PokemonSetSource::buildSetDetailUrl("base1")] =
R"({"id":"base1","name":"Base Set","releaseDate":"1999-01-09","cards":[]})";
PokemonSetSource src{http};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
CHECK(out.value().front().id == "x");
CHECK(out.value().front().releaseDate == "2020/01/01");
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets");
REQUIRE(out.value().size() == 1);
CHECK(out.value().front().id == "base1");
CHECK(out.value().front().releaseDate == "1999/01/09");
CHECK(http.lastUrl == PokemonSetSource::buildSetDetailUrl("base1"));
}
}
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
TEST_SUITE("PokemonSetSource::fetchAllWithCatalog") {
TEST_CASE("builds catalog packs from set detail cards") {
RoutingHttpClient http;
http.listBody = R"([{"id":"base1","name":"Base Set"}])";
http.byUrl[PokemonSetSource::buildSetDetailUrl("base1")] = R"({
"id":"base1",
"name":"Base Set",
"releaseDate":"1999-01-09",
"cards":[
{"localId":"4","name":"Charizard"},
{"localId":"58","name":"Growlithe"}
]
})";
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");
PokemonSetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isOk());
REQUIRE(out.value().sets.size() == 1);
REQUIRE(out.value().catalog.packs.size() == 1);
const auto* pack = out.value().catalog.findPack("base1");
REQUIRE(pack != nullptr);
REQUIRE(pack->cards.size() == 2);
CHECK(pack->cards[0].setNo == "4");
}
}
+41
View File
@@ -0,0 +1,41 @@
#include <doctest/doctest.h>
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
using namespace ccm;
TEST_SUITE("canonicalizeWestSetId") {
TEST_CASE("identity for ids already on TCGdex") {
CHECK(canonicalizeWestSetId("base1") == "base1");
CHECK(canonicalizeWestSetId("swsh3") == "swsh3");
CHECK(canonicalizeWestSetId("sv01") == "sv01");
CHECK(canonicalizeWestSetId("sv10") == "sv10");
}
TEST_CASE("maps Scarlet & Violet divergences") {
CHECK(canonicalizeWestSetId("sv1") == "sv01");
CHECK(canonicalizeWestSetId("sv3") == "sv03");
CHECK(canonicalizeWestSetId("sv3pt5") == "sv03.5");
CHECK(canonicalizeWestSetId("sv8pt5") == "sv08.5");
CHECK(canonicalizeWestSetId("zsv10pt5") == "sv10.5b");
CHECK(canonicalizeWestSetId("rsv10pt5") == "sv10.5w");
}
TEST_CASE("maps SWSH galleries and specials") {
CHECK(canonicalizeWestSetId("pgo") == "swsh10.5");
CHECK(canonicalizeWestSetId("swsh12tg") == "swsh12.5tg");
CHECK(canonicalizeWestSetId("swsh12pt5") == "swsh12.5");
CHECK(canonicalizeWestSetId("swsh45") == "swsh4.5");
CHECK(canonicalizeWestSetId("cel25c") == "cel25cc");
}
TEST_CASE("maps McDonald's year codes") {
CHECK(canonicalizeWestSetId("mcd19") == "2019sm");
CHECK(canonicalizeWestSetId("mcd22") == "2022swsh");
}
TEST_CASE("unknown and empty pass through") {
CHECK(canonicalizeWestSetId("fut20") == "fut20");
CHECK(canonicalizeWestSetId("").empty());
}
}