#include #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" #include "ccm/ports/IHttpClient.hpp" #include using namespace ccm; namespace { class FixedHttpClient final : public IHttpClient { public: std::string lastUrl; std::string body; bool ok = true; Result get(std::string_view url) override { lastUrl = std::string(url); return ok ? Result::ok(body) : Result::err("offline"); } }; } // 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("setNo is appended as a number: clause") { const auto url = PokemonCardPreviewSource::buildSearchUrl( "Charizard", "base1", "4"); CHECK(url.find("number%3A4") != 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("102") == std::string::npos); } TEST_CASE("name with spaces is percent-encoded") { const auto url = PokemonCardPreviewSource::buildSearchUrl( "Mr. Mime", "base1", ""); CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos); } TEST_CASE("empty setId omits the set.id clause") { const auto url = PokemonCardPreviewSource::buildSearchUrl("Pikachu", "", "25"); CHECK(url.find("set.id") == std::string::npos); CHECK(url.find("number%3A25") != std::string::npos); } } 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); REQUIRE(out.isOk()); CHECK(out.value() == "https://images.pokemontcg.io/large.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":[]})"); 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":{}})"); 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":[]}]})"); 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"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } } TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") { TEST_CASE("network error is surfaced as Transient") { FixedHttpClient http; http.ok = false; PokemonCardPreviewSource src{http}; const auto out = src.fetchImageUrl("Pikachu", "base1", ""); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("network success is parsed end-to-end and uses the encoded URL") { 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"); 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); } } namespace { const char* kCharizardSwsh4 = R"({ "data": [ { "name": "Charizard", "number": "25", "rarity": "Rare", "set": { "id": "swsh4", "name": "Vivid Voltage", "printedTotal": 185 } } ] })"; const char* kMultiVariantPayload = R"({ "data": [ { "name": "Pikachu", "number": "25", "rarity": "Common", "set": {"id": "base1", "printedTotal": 102} }, { "name": "Pikachu", "number": "58", "rarity": "Rare", "set": {"id": "base1", "printedTotal": 102} }, { "name": "Pikachu", "number": "25", "rarity": "Common", "set": {"id": "base2", "printedTotal": 64} } ] })"; } // namespace TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") { TEST_CASE("maps API number into setNo without printedTotal suffix") { const auto out = PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Charizard"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value().front().setNo == "25"); CHECK(out.value().front().rarity == "Rare"); } TEST_CASE("filters by set id and keeps multiple numbers in the same set") { const auto out = PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base1", "Pikachu"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2); CHECK(out.value()[0].setNo == "25"); CHECK(out.value()[1].setNo == "58"); } TEST_CASE("wrong set id yields explicit error when name and set are supplied") { const auto out = PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "base1", "Charizard"); REQUIRE(out.isErr()); CHECK(out.error() == "Could not auto-detect set print metadata."); } TEST_CASE("wrong card name is filtered out") { const auto out = PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Blastoise"); REQUIRE(out.isErr()); CHECK(out.error() == "Could not auto-detect set print metadata."); } TEST_CASE("empty data array yields error") { const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({"data":[]})", "base1", "Pikachu"); REQUIRE(out.isErr()); CHECK(out.error() == "Pokemon TCG returned no matching cards."); } TEST_CASE("name-only payload still filters to requested set id") { const auto out = PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base2", "Pikachu"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value().front().setNo == "25"); } TEST_CASE("keeps bare number when printedTotal is zero") { const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ "data": [ { "name": "Promo", "number": "7", "rarity": "Promo", "set": {"id": "promo1", "printedTotal": 0} } ] })", "promo1", "Promo"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value().front().setNo == "7"); } TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") { const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ "data":[ {"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"}, {"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}} ] })", "base1", "Pikachu"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value().front().setNo == "26"); } TEST_CASE("empty setId skips set filter and collects prints across sets") { const char* crossSet = R"({ "data": [ {"name":"Pikachu","number":"1","rarity":"Common","set":{"id":"base1"}}, {"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base2"}} ] })"; const auto out = PokemonCardPreviewSource::parsePrintVariants(crossSet, "", "Pikachu"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2u); } TEST_CASE("empty wanted card name skips name filter within the set") { const char* twoInSet = R"({ "data": [ {"name":"Electabuzz","number":"1","rarity":"Common","set":{"id":"base1"}}, {"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base1"}} ] })"; const auto out = PokemonCardPreviewSource::parsePrintVariants(twoInSet, "base1", ""); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2u); } TEST_CASE("cards with empty number and rarity are skipped for auto-detect metadata") { const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ "data": [ {"name":"Pikachu","number":"","rarity":"","set":{"id":"base1"}} ] })", "base1", "Pikachu"); REQUIRE(out.isErr()); CHECK(out.error() == "Could not auto-detect set print metadata."); } TEST_CASE("no matches with empty setId yields generic no matching cards message") { const auto out = PokemonCardPreviewSource::parsePrintVariants( R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})", "", "Nobody"); REQUIRE(out.isErr()); CHECK(out.error() == "Pokemon TCG returned no matching cards."); } TEST_CASE("invalid JSON in parsePrintVariants yields parse error") { const auto out = PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu"); REQUIRE(out.isErr()); CHECK(out.error().find("Pokemon TCG JSON parse error:") == 0); } } TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") { TEST_CASE("supports auto-detect and returns first print") { FixedHttpClient http; http.body = kCharizardSwsh4; PokemonCardPreviewSource src{http}; CHECK(src.supportsAutoDetectPrint()); const auto first = src.detectFirstPrint("Charizard", "swsh4"); REQUIRE(first.isOk()); CHECK(first.value().setNo == "25"); } TEST_CASE("uses slim set-scoped search URL without number clause") { FixedHttpClient http; http.body = kCharizardSwsh4; PokemonCardPreviewSource src{http}; const auto out = src.detectPrintVariants("Charizard", "swsh4"); REQUIRE(out.isOk()); CHECK(http.lastUrl.find("number%3A") == std::string::npos); CHECK(http.lastUrl.find("set.id%3Aswsh4") != std::string::npos); CHECK(http.lastUrl.find("select=name,number,rarity,set") != std::string::npos); CHECK(http.lastUrl.find("pageSize=50") != std::string::npos); } TEST_CASE("buildDetectSearchUrl requests only parser fields") { const auto url = PokemonCardPreviewSource::buildDetectSearchUrl("Charizard", "swsh4"); CHECK(url.find("select=name,number,rarity,set") != std::string::npos); CHECK(url.find("pageSize=50") != std::string::npos); } TEST_CASE("retries name-only query when the set-scoped request fails") { class FallbackHttpClient final : public IHttpClient { public: int calls = 0; Result get(std::string_view url) override { ++calls; if (calls == 1) return Result::err("offline"); if (url.find("set.id") != std::string::npos) { return Result::err("unexpected set-scoped retry"); } return Result::ok(kMultiVariantPayload); } } http; PokemonCardPreviewSource src{http}; const auto out = src.detectPrintVariants("Pikachu", "base1"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2); CHECK(http.calls == 2); } TEST_CASE("detectPrintVariants surfaces fallback HTTP error when both requests fail") { class AlwaysFailHttp final : public IHttpClient { public: int calls = 0; Result get(std::string_view) override { ++calls; return Result::err("offline"); } } http; PokemonCardPreviewSource src{http}; const auto out = src.detectPrintVariants("Pikachu", "base1"); REQUIRE(out.isErr()); CHECK(out.error() == "offline"); CHECK(http.calls == 2); } TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") { FixedHttpClient http; http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})"; PokemonCardPreviewSource src{http}; const auto out = src.detectFirstPrint("Promo", "promo1"); REQUIRE(out.isErr()); CHECK(out.error() == "Could not auto-detect set print metadata."); } }