mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 23:01:09 +00:00
422 lines
16 KiB
C++
422 lines
16 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
|
#include "ccm/ports/IHttpClient.hpp"
|
|
|
|
#include <string>
|
|
|
|
using namespace ccm;
|
|
|
|
namespace {
|
|
|
|
class FixedHttpClient final : public IHttpClient {
|
|
public:
|
|
std::string lastUrl;
|
|
std::string body;
|
|
bool ok = true;
|
|
Result<std::string> get(std::string_view url) override {
|
|
lastUrl = std::string(url);
|
|
return ok ? Result<std::string>::ok(body)
|
|
: Result<std::string>::err("offline");
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_SUITE("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<std::string> get(std::string_view url) override {
|
|
++calls;
|
|
if (calls == 1) return Result<std::string>::err("offline");
|
|
if (url.find("set.id") != std::string::npos) {
|
|
return Result<std::string>::err("unexpected set-scoped retry");
|
|
}
|
|
return Result<std::string>::ok(kMultiVariantPayload);
|
|
}
|
|
} http;
|
|
|
|
PokemonCardPreviewSource src{http};
|
|
const auto out = src.detectPrintVariants("Pikachu", "base1");
|
|
REQUIRE(out.isOk());
|
|
REQUIRE(out.value().size() == 2);
|
|
CHECK(http.calls == 2);
|
|
}
|
|
|
|
TEST_CASE("detectPrintVariants surfaces fallback HTTP error when both requests fail") {
|
|
class AlwaysFailHttp final : public IHttpClient {
|
|
public:
|
|
int calls = 0;
|
|
Result<std::string> get(std::string_view) override {
|
|
++calls;
|
|
return Result<std::string>::err("offline");
|
|
}
|
|
} http;
|
|
|
|
PokemonCardPreviewSource src{http};
|
|
const auto out = src.detectPrintVariants("Pikachu", "base1");
|
|
REQUIRE(out.isErr());
|
|
CHECK(out.error() == "offline");
|
|
CHECK(http.calls == 2);
|
|
}
|
|
|
|
TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") {
|
|
FixedHttpClient http;
|
|
http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})";
|
|
PokemonCardPreviewSource src{http};
|
|
const auto out = src.detectFirstPrint("Promo", "promo1");
|
|
REQUIRE(out.isErr());
|
|
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
|
}
|
|
}
|