#include #include "ccm/games/yugioh/YuGiOhSetSource.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("YuGiOhSetSource::parseResponse") { TEST_CASE("maps set_code/set_name/tcg_date") { const std::string json = R"([ {"set_name":"Set A","set_code":"AAA","tcg_date":"2020-01-01"}, {"set_name":"Set B","set_code":"BBB","tcg_date":"2021-02-03"} ])"; const auto out = YuGiOhSetSource::parseResponse(json); REQUIRE(out.isOk()); bool foundA = false; bool foundB = false; for (const auto& set : out.value()) { if (set.id == "AAA" && set.name == "Set A" && set.releaseDate == "2020/01/01") { foundA = true; } if (set.id == "BBB" && set.name == "Set B" && set.releaseDate == "2021/02/03") { foundB = true; } } CHECK(foundA); CHECK(foundB); } TEST_CASE("sorts by release date ascending") { const auto out = YuGiOhSetSource::parseResponse(R"([ {"set_name":"New","set_code":"N","tcg_date":"2024-01-01"}, {"set_name":"Old","set_code":"O","tcg_date":"2010-01-01"} ])"); REQUIRE(out.isOk()); CHECK(out.value().front().id == "O"); CHECK(out.value().back().id == "N"); } TEST_CASE("missing array returns error") { CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr()); } TEST_CASE("empty upstream array still appends missing 25th aliases") { const auto out = YuGiOhSetSource::parseResponse("[]"); REQUIRE(out.isOk()); CHECK(out.value().size() == 6); bool foundLob25th = false; bool foundIoc25th = false; for (const auto& set : out.value()) { if (set.id == "LOB-25TH") foundLob25th = true; if (set.id == "IOC-25TH") foundIoc25th = true; } CHECK(foundLob25th); CHECK(foundIoc25th); } TEST_CASE("adds 25th Anniversary aliases when upstream list misses them") { const auto out = YuGiOhSetSource::parseResponse(R"([ {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"} ])"); REQUIRE(out.isOk()); bool foundLob25th = false; bool foundIoc25th = false; for (const auto& set : out.value()) { if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)" && set.id == "LOB-25TH") { foundLob25th = true; } if (set.name == "Invasion of Chaos (25th Anniversary Edition)" && set.id == "IOC-25TH") { foundIoc25th = true; } } CHECK(foundLob25th); CHECK(foundIoc25th); } TEST_CASE("does not duplicate aliases that already exist by name") { const auto out = YuGiOhSetSource::parseResponse(R"json([ {"set_name":"Legend of Blue Eyes White Dragon (25th Anniversary Edition)","set_code":"LOB-25TH","tcg_date":"2023-04-20"} ])json"); REQUIRE(out.isOk()); int aliasCount = 0; for (const auto& set : out.value()) { if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)") { ++aliasCount; } } CHECK(aliasCount == 1); } TEST_CASE("malformed json returns parse error") { const auto out = YuGiOhSetSource::parseResponse("{bad json"); REQUIRE(out.isErr()); CHECK(out.error().find("YGOPRODeck set parse error:") != std::string::npos); } TEST_CASE("missing fields fall back to empty strings and keep parsing") { const std::string json = R"([ {"set_name":"Set A"}, {"set_code":"BBB","tcg_date":"2021-02-03"} ])"; const auto out = YuGiOhSetSource::parseResponse(json); REQUIRE(out.isOk()); bool foundMissingCode = false; bool foundMissingName = false; for (const auto& set : out.value()) { if (set.name == "Set A" && set.id.empty() && set.releaseDate.empty()) { foundMissingCode = true; } if (set.id == "BBB" && set.name.empty() && set.releaseDate == "2021/02/03") { foundMissingName = true; } } CHECK(foundMissingCode); CHECK(foundMissingName); } TEST_CASE("preserves slash-formatted dates and normalizes hyphen dates") { const std::string json = R"([ {"set_name":"Slash Date","set_code":"S","tcg_date":"2024/01/01"}, {"set_name":"Hyphen Date","set_code":"H","tcg_date":"2024-01-02"} ])"; const auto out = YuGiOhSetSource::parseResponse(json); REQUIRE(out.isOk()); bool sawSlash = false; bool sawHyphenNormalized = false; for (const auto& set : out.value()) { if (set.id == "S" && set.releaseDate == "2024/01/01") sawSlash = true; if (set.id == "H" && set.releaseDate == "2024/01/02") sawHyphenNormalized = true; } CHECK(sawSlash); CHECK(sawHyphenNormalized); } } TEST_SUITE("YuGiOhSetSource::fetchAll") { TEST_CASE("network success parses and hits endpoint") { FixedHttpClient http; http.body = R"([{"set_name":"Set X","set_code":"X","tcg_date":"2020-01-01"}])"; YuGiOhSetSource src{http}; const auto out = src.fetchAll(); REQUIRE(out.isOk()); CHECK(out.value().front().id == "X"); CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php"); } TEST_CASE("network error is propagated") { FixedHttpClient http; http.ok = false; YuGiOhSetSource src{http}; const auto out = src.fetchAll(); REQUIRE(out.isErr()); 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 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); for (const auto& c : lob->cards) { if (c.name == "Blue-Eyes White Dragon") CHECK(c.rarity == "Ultra Rare"); if (c.name == "Dark Magician") CHECK(c.rarity == "Ultra Rare"); } const auto* mrd = out.value().findPack("MRD"); REQUIRE(mrd != nullptr); REQUIRE(mrd->cards.size() == 1); CHECK(mrd->cards[0].setNo == "MRD-010"); CHECK(mrd->cards[0].rarity == "Ultra Rare"); } 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 urls; Result get(std::string_view url) override { urls.emplace_back(url); if (url == YuGiOhSetSource::kEndpoint) { return setsOk ? Result::ok(setsBody) : Result::err("sets offline"); } if (url == YuGiOhSetSource::kCardInfoEndpoint) { return infoOk ? Result::ok(infoBody) : Result::err("info offline"); } return Result::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"); } }