#include #include "ccm/domain/YuGiOhSetCatalog.hpp" #include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp" #include "ccm/ports/IHttpClient.hpp" #include "ccm/util/YuGiOhPrintingSlot.hpp" #include #include #include #include #include using namespace ccm; namespace { // Single-shot HTTP fake matching the other game tests. Captures the last URL // requested and returns a configurable body / failure flag. 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"); } }; // Routing fake used to simulate the two-step Yugipedia -> YGOPRODeck flow. // Both bodies are queued by URL substring so order-of-call assertions stay // readable in the tests. Unmatched URLs return a 404-equivalent error so // regressions are obvious instead of silently passing. class RoutingHttpClient final : public IHttpClient { public: std::string yugipediaBody; std::string ygoprodeckBody; bool yugipediaOk = true; bool ygoprodeckOk = true; std::vector calls; Result get(std::string_view url) override { std::string s(url); calls.push_back(s); if (s.find("yugipedia.com") != std::string::npos) { return yugipediaOk ? Result::ok(yugipediaBody) : Result::err("yugipedia offline"); } if (s.find("ygoprodeck.com") != std::string::npos) { return ygoprodeckOk ? Result::ok(ygoprodeckBody) : Result::err("ygoprodeck offline"); } return Result::err("RoutingHttpClient: unrouted URL"); } }; // First GET (filtered `cardset=` URL) fails; second GET (unfiltered) succeeds. // Exercises `YuGiOhCardPreviewSource::detectPrintVariants` narrow-query fallback. class FailFilteredThenOkHttpClient final : public IHttpClient { public: std::string unfilteredBody; int calls{0}; Result get(std::string_view url) override { ++calls; std::string u(url); if (u.find("cardset=") != std::string::npos) { return Result::err("filtered endpoint unavailable"); } return Result::ok(unfilteredBody); } }; } // namespace TEST_SUITE("ygoPrintingSlotsMatch") { TEST_CASE("matches CCM composed setNo to YGOPRODeck set_code with region infix") { CHECK(ygoPrintingSlotsMatch("SOD-015", "SOD-EN015")); CHECK(ygoPrintingSlotsMatch("sod-015", "SOD-EN015")); CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "SOD-EN016")); CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "IOC-EN015")); } TEST_CASE("matches German-style and digits-only suffix variants") { CHECK(ygoPrintingSlotsMatch("LOB-005", "LOB-DE005")); CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001")); } TEST_CASE("matches collector numbers ignoring leading zeros") { CHECK(ygoPrintingSlotsMatch("LOB-5", "LOB-005")); CHECK(ygoPrintingSlotsMatch("LOB-EN005", "LOB-5")); CHECK(ygoCollectorDigitsEqual("005", "5")); CHECK(ygoCollectorDigitsEqual("LOB-EN005", "5")); CHECK(ygoCollectorDigitsFromInput("005") == "005"); CHECK(ygoCollectorDigitsFromInput("LOB-EN005") == "005"); } TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") { CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-E")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EX005")); } } TEST_SUITE("YuGiOhPrintingSlot helpers") { TEST_CASE("trimAsciiSpaces handles empty and surrounding whitespace") { CHECK(trimAsciiSpaces("").empty()); CHECK(trimAsciiSpaces(" ").empty()); CHECK(trimAsciiSpaces(" LOB-005 ") == "LOB-005"); } TEST_CASE("ygoAbbrevBeforeDash and ygoCollectorDigitsOnly cover no-dash and mixed tails") { CHECK(ygoAbbrevBeforeDash("lob") == "lob"); CHECK(ygoAbbrevBeforeDash(" SOD-015 ") == "sod"); CHECK(ygoCollectorDigitsOnly("SOD").empty()); CHECK(ygoCollectorDigitsOnly("SOD-EN015") == "015"); CHECK(ygoCollectorDigitsOnly("SOD-ABC") == ""); } } TEST_SUITE("ygoRarityShortCode") { TEST_CASE("maps supported Yu-Gi-Oh rarity names to short form") { CHECK(ygoRarityShortCode("Common") == "C"); CHECK(ygoRarityShortCode("Rare") == "R"); CHECK(ygoRarityShortCode("Super Rare") == "SR"); CHECK(ygoRarityShortCode("Ultra Rare") == "UR"); CHECK(ygoRarityShortCode("Secret Rare") == "ScR"); CHECK(ygoRarityShortCode("Quarter Century Secret Rare") == "QCScR"); CHECK(ygoRarityShortCode("Starlight Rare") == "StR"); CHECK(ygoRarityShortCode("Collector's Rare") == "CR"); CHECK(ygoRarityShortCode("Ghost Rare") == "GR"); CHECK(ygoRarityShortCode("Ultimate Rare") == "UtR"); CHECK(ygoRarityShortCode("Platinum Secret Rare") == "PlScR"); CHECK(ygoRarityShortCode("Prismatic Secret Rare") == "PScR"); } TEST_CASE("normalizes punctuation and spacing and accepts QCSR alias") { CHECK(ygoRarityShortCode("Ultra-Rare") == "UR"); CHECK(ygoRarityShortCode("Collector`s Rare") == "CR"); CHECK(ygoRarityShortCode("QCSR") == "QCScR"); } TEST_CASE("unknown rarity returns empty") { CHECK(ygoRarityShortCode("Mythic Cosmic Rare").empty()); } } TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") { TEST_CASE("strips whitespace and policy-banned punctuation") { // Yugipedia's image policy: whitespace and a fixed punctuation set // (#,.:'"?!&@%=[]<>/\- and friends) get dropped from the slug. CHECK(YuGiOhCardPreviewSource::normalizeName("Blue-Eyes White Dragon") == "BlueEyesWhiteDragon"); CHECK(YuGiOhCardPreviewSource::normalizeName("Dark Magician") == "DarkMagician"); CHECK(YuGiOhCardPreviewSource::normalizeName("Sasuke Samurai #4") == "SasukeSamurai4"); CHECK(YuGiOhCardPreviewSource::normalizeName("Don't Talk to Me!") == "DontTalktoMe"); } } TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") { TEST_CASE("maps the dialog's rarity options to Yugipedia codes") { CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Common") == "C"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Rare") == "R"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Super Rare") == "SR"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Ultra Rare") == "UR"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare") == "QCScR"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Collector's Rare") == "CR"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Platinum Secret Rare") == "PlScR"); } TEST_CASE("returns empty string for unknown rarity names") { // Unknown rarity should fall through to the rarity-less filename // pattern, not throw and not return a misleading code. CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty()); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty()); } TEST_CASE("uses dialog synonym table when ygoRarityShortCode does not match") { CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mosaic Rare") == "MSR"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Parallel Rare") == "PR"); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Gold Rare") == "GUR"); } } TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") { TEST_CASE("returns everything before the first dash") { CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-005") == "LOB"); CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-DE005") == "LOB"); CHECK(YuGiOhCardPreviewSource::extractSetCode("RA04-EN001") == "RA04"); } TEST_CASE("returns the input unchanged when no dash is present") { CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB") == "LOB"); CHECK(YuGiOhCardPreviewSource::extractSetCode("").empty()); } } TEST_SUITE("YuGiOhCardPreviewSource::buildCandidateFilenames") { TEST_CASE("primary candidate uses the printed edition + EN region + .png") { const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames( "Blue-Eyes White Dragon", "LOB", "UR", /*firstEdition=*/false); REQUIRE_FALSE(names.empty()); // Highest-priority filename: Yugipedia's modern English/UE/.png // shape, which is what most LOB-era reprints actually use. CHECK(names.front() == "BlueEyesWhiteDragon-LOB-EN-UR-UE.png"); } TEST_CASE("includes 1E when the user marked the card as first edition") { const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames( "Dark Magician", "SDY", "UR", /*firstEdition=*/true); REQUIRE_FALSE(names.empty()); CHECK(names.front() == "DarkMagician-SDY-EN-UR-1E.png"); } TEST_CASE("covers EN/NA/EU/AU regions and both png/jpg extensions") { const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames( "Blue-Eyes White Dragon", "SDK", "UR", /*firstEdition=*/false); // Sanity: the standard SDK Blue-Eyes scan is hosted at NA-UR-UE.png, // so the candidate list must include that exact filename. bool foundNaUe = false; for (const auto& n : names) { if (n == "BlueEyesWhiteDragon-SDK-NA-UR-UE.png") foundNaUe = true; } CHECK(foundNaUe); } TEST_CASE("falls back to a rarity-less pattern for unknown rarities") { const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames( "Token Card", "TKN", /*rarityCode=*/"", /*firstEdition=*/false); REQUIRE_FALSE(names.empty()); // Rarity slot omitted -> filename has only one dash before edition. CHECK(names.front() == "TokenCard-TKN-EN-UE.png"); } TEST_CASE("returns empty list when the slug or set code is empty") { // Don't waste an HTTP call on cards that haven't been filled in yet. CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("", "LOB", "UR", false).empty()); CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("Dark Magician", "", "UR", false).empty()); } } TEST_SUITE("YuGiOhCardPreviewSource::buildYugipediaQueryUrl") { TEST_CASE("encodes filenames into a single MediaWiki batch query") { const std::vector names = { "BlueEyesWhiteDragon-LOB-EN-UR-UE.png", "BlueEyesWhiteDragon-LOB-NA-UR-UE.png", }; const std::string url = YuGiOhCardPreviewSource::buildYugipediaQueryUrl(names); CHECK(url.find("https://yugipedia.com/api.php") == 0); CHECK(url.find("action=query") != std::string::npos); CHECK(url.find("prop=imageinfo") != std::string::npos); CHECK(url.find("iiprop=url") != std::string::npos); // The two filenames are joined with `|` (URL-encoded as %7C) and // each one is namespaced with `File:` (encoded `File%3A`). CHECK(url.find("File%3ABlueEyesWhiteDragon-LOB-EN-UR-UE.png") != std::string::npos); CHECK(url.find("%7CFile%3ABlueEyesWhiteDragon-LOB-NA-UR-UE.png") != std::string::npos); } } TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") { TEST_CASE("returns the URL of the highest-priority filename that exists") { // MediaWiki returns one entry per requested title. Missing files come // back with `"missing": ""` and no imageinfo; existing files carry an // imageinfo[0].url. Order matters: we must walk filenameOrder and // pick the first entry that resolved, not whichever MediaWiki listed // first in its hash-keyed `pages` object. const std::string body = R"({ "query":{"pages":{ "-1":{"title":"File:DarkMagician-SDY-EN-UR-1E.png","missing":""}, "96018":{"title":"File:DarkMagician-SDY-NA-UR-UE.png", "imageinfo":[{"url":"https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png"}]}, "99999":{"title":"File:DarkMagician-SDY-EU-UR-UE.png", "imageinfo":[{"url":"https://ms.yugipedia.com/eu/DarkMagician-SDY-EU-UR-UE.png"}]} }} })"; const std::vector order = { "DarkMagician-SDY-EN-UR-UE.png", // missing from response entirely "DarkMagician-SDY-EN-UR-1E.png", // present but `missing` "DarkMagician-SDY-NA-UR-UE.png", // first existing one "DarkMagician-SDY-EU-UR-UE.png", }; const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(body, order); REQUIRE(out.isOk()); CHECK(out.value() == "https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png"); } TEST_CASE("pure-miss response is classified as NotFound") { // Pure-miss response: every page is marked `missing`. The caller // (fetchImageUrl) treats this as "no Yugipedia scan" and triggers // the YGOPRODeck fallback. Yugipedia having explicitly answered // "no such file" is a clean negative (the upstream contract was // honored), not a transient failure. const std::string body = R"({ "query":{"pages":{ "-1":{"title":"File:Whatever-XYZ-EN-UR-UE.png","missing":""}, "-2":{"title":"File:Whatever-XYZ-NA-UR-UE.png","missing":""} }} })"; const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse( body, {"Whatever-XYZ-EN-UR-UE.png", "Whatever-XYZ-NA-UR-UE.png"}); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } TEST_CASE("malformed JSON is classified as Transient") { const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse( "{not json", {"Anything-XYZ-EN-UR-UE.png"}); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("missing top-level query object is Transient") { const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse( R"({"not_query":{}})", {"File.png"}); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("query.pages not an object is Transient") { const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse( R"({"query":{"pages":[]}})", {"X.png"}); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("page without imageinfo is treated as missing") { const std::string body = R"({ "query":{"pages":{ "1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png"} }} })"; const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse( body, {"DarkMagician-LOB-EN-UR-UE.png"}); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } } TEST_SUITE("YuGiOhCardPreviewSource::buildSearchUrl") { TEST_CASE("percent-encodes name and optional set name filter") { const auto url = YuGiOhCardPreviewSource::buildSearchUrl( "Dark Magician", "Legend of Blue Eyes White Dragon"); CHECK(url.find("https://db.ygoprodeck.com/api/v7/cardinfo.php") == 0); CHECK(url.find("fname=Dark%20Magician") != std::string::npos); CHECK(url.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon") != std::string::npos); } TEST_CASE("omits cardset when set name is empty") { const auto url = YuGiOhCardPreviewSource::buildSearchUrl("Dark Magician", ""); CHECK(url.find("cardset=") == std::string::npos); } } TEST_SUITE("YuGiOhCardPreviewSource::parseFallbackImageUrl") { TEST_CASE("missing data array is Transient") { const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"meta":{}})", "Dark Magician"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("data present but not an array is Transient") { const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"data":{}})", "X"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("empty data array is NotFound") { const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"data":[]})", "X"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } TEST_CASE("prefers exact-name row with image_url_small when image_url absent") { const std::string json = R"({"data":[ {"name":"Dark Magician Girl","card_images":[{"image_url_small":"https://small.only/a.jpg"}]}, {"name":"Dark Magician","card_images":[{"image_url":"https://ignored/wrong.jpg"}]} ]})"; const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Dark Magician Girl"); REQUIRE(out.isOk()); CHECK(out.value() == "https://small.only/a.jpg"); } TEST_CASE("exact-name match uses image_url_cropped when earlier slots absent") { const std::string json = R"({"data":[{ "name":"Slifer", "card_images":[{"image_url_cropped":"https://crop/z.jpg"}] }]})"; const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Slifer"); REQUIRE(out.isOk()); CHECK(out.value() == "https://crop/z.jpg"); } TEST_CASE("no exact name match falls back to first ranked card_images row") { const std::string json = R"({"data":[{ "name":"Dark Magician Girl", "card_images":[{"image_url":"https://images/std-from-ranked-first.jpg"}] }]})"; const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Dark Magician"); REQUIRE(out.isOk()); CHECK(out.value() == "https://images/std-from-ranked-first.jpg"); } TEST_CASE("matching cards without usable images is NotFound") { const std::string json = R"({"data":[{ "name":"Empty Card", "card_images":[{}] }]})"; const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Empty Card"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } TEST_CASE("malformed JSON is Transient") { const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl("{not json", "Any"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } } TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") { TEST_CASE("returns first print for preferred set name") { const std::string json = R"({ "data":[ {"card_sets":[ {"set_name":"Metal Raiders","set_code":"MRD-001","set_rarity":"Ultra Rare"}, {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"} ]} ] })"; const auto out = YuGiOhCardPreviewSource::parseFirstPrint( json, "Legend of Blue Eyes White Dragon"); REQUIRE(out.isOk()); CHECK(out.value().setNo == "LOB-001"); CHECK(out.value().rarity == "Ultra Rare"); } TEST_CASE("empty data array yields error") { const auto out = YuGiOhCardPreviewSource::parseFirstPrint(R"({"data":[]})", "Any Set"); CHECK(out.isErr()); } TEST_CASE("card row without card_sets yields error") { const auto out = YuGiOhCardPreviewSource::parseFirstPrint( R"({"data":[{"name":"Solo"}]})", "Any Display Set"); CHECK(out.isErr()); } } TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") { TEST_CASE("lists distinct set_code entries for an exact name in one display set") { const std::string json = R"({ "data":[ {"name":"Test Goblin", "card_sets":[ {"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"}, {"set_name":"Mega Pack","set_code":"MP21-EN002","set_rarity":"Rare"} ]} ] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Mega Pack", "Test Goblin"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2); CHECK(out.value()[0].setNo == "MP21-EN001"); CHECK(out.value()[1].setNo == "MP21-EN002"); } TEST_CASE("lists multiple rarities for one set_code") { const std::string json = R"({ "data":[ {"name":"Odd-Eyes Pendulum Dragon", "card_sets":[ {"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Super Rare"}, {"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Ultimate Rare"} ]} ] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Duelist Alliance", "Odd-Eyes Pendulum Dragon"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2); CHECK(out.value()[0].setNo == "DUEA-EN004"); CHECK(out.value()[0].rarity == "Super Rare"); CHECK(out.value()[1].setNo == "DUEA-EN004"); CHECK(out.value()[1].rarity == "Ultimate Rare"); } TEST_CASE("dedupes identical print pairs") { const std::string json = R"({ "data":[ {"name":"Mirror Force", "card_sets":[ {"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"}, {"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"} ]} ] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Starter Deck", "Mirror Force"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value()[0].setNo == "SDY-043"); CHECK(out.value()[0].rarity == "Super Rare"); } TEST_CASE("malformed JSON surfaces as parse error") { const auto out = YuGiOhCardPreviewSource::parsePrintVariants("{bad json", "Mega Pack", "X"); REQUIRE(out.isErr()); CHECK(out.error().find("YGOPRODeck JSON parse error") != std::string::npos); } TEST_CASE("maps 25th Anniversary display-set alias to original set name") { const std::string json = R"({ "data":[ {"name":"Dark Magician", "card_sets":[ {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"} ]} ] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "Dark Magician"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value()[0].setNo == "LOB-005"); } } TEST_SUITE("YuGiOhCardPreviewSource::detectPrintVariants HTTP fallback") { TEST_CASE("retries without cardset when the filtered request fails") { FailFilteredThenOkHttpClient http; http.unfilteredBody = R"({ "data":[{ "name":"Test Goblin", "card_sets":[ {"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"} ] }] })"; YuGiOhCardPreviewSource src{http}; const auto out = src.detectPrintVariants("Test Goblin", "Mega Pack"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value()[0].setNo == "MP21-EN001"); REQUIRE(http.calls == 2); } TEST_CASE("uses original set name in cardset query for 25th alias") { FixedHttpClient http; http.body = R"({ "data":[{ "name":"Dark Magician", "card_sets":[ {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"} ] }] })"; YuGiOhCardPreviewSource src{http}; const auto out = src.detectPrintVariants( "Dark Magician", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)"); REQUIRE(out.isOk()); CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon") != std::string::npos); CHECK(http.lastUrl.find("25th") == std::string::npos); } } // Helpers aligned with external fixture `yugioh_same_card_set_variant_tests` // (same_card_same_set: distinct collector numbers vs distinct rarities). [[nodiscard]] std::size_t countDistinctSetCodes(const std::vector& v) { std::unordered_set codes; for (const auto& p : v) { if (!p.setNo.empty()) codes.insert(p.setNo); } return codes.size(); } [[nodiscard]] std::size_t maxRarityVariantsPerCode(const std::vector& v) { std::unordered_map> byCode; for (const auto& p : v) { if (p.setNo.empty() || p.rarity.empty()) continue; byCode[p.setNo].insert(p.rarity); } std::size_t mx = 0; for (const auto& e : byCode) { mx = std::max(mx, e.second.size()); } return mx; } TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_variant_tests") { TEST_CASE("S1-001 Armed Dragon LV7 Soul of the Duelist Ultra vs Ultimate") { const std::string json = R"({ "data":[{ "name":"Armed Dragon LV7", "card_sets":[ {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}, {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"} ] }] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Soul of the Duelist", "Armed Dragon LV7"); REQUIRE(out.isOk()); CHECK(countDistinctSetCodes(out.value()) == 1); CHECK(maxRarityVariantsPerCode(out.value()) == 2); } TEST_CASE("S1-002 Horus LV8 Soul of the Duelist Ultra vs Ultimate") { const std::string json = R"({ "data":[{ "name":"Horus the Black Flame Dragon LV8", "card_sets":[ {"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultra Rare"}, {"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultimate Rare"} ] }] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Soul of the Duelist", "Horus the Black Flame Dragon LV8"); REQUIRE(out.isOk()); CHECK(countDistinctSetCodes(out.value()) == 1); CHECK(maxRarityVariantsPerCode(out.value()) == 2); } TEST_CASE("S1-003 Mobius Soul of the Duelist Super vs Ultimate") { const std::string json = R"({ "data":[{ "name":"Mobius the Frost Monarch", "card_sets":[ {"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Super Rare"}, {"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Ultimate Rare"} ] }] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Soul of the Duelist", "Mobius the Frost Monarch"); REQUIRE(out.isOk()); CHECK(countDistinctSetCodes(out.value()) == 1); CHECK(maxRarityVariantsPerCode(out.value()) == 2); } TEST_CASE("S2-001 Dark Magician Yugi's Legendary Decks three set codes") { const std::string json = R"({ "data":[{ "name":"Dark Magician", "card_sets":[ {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"}, {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENB02","set_rarity":"Common"}, {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC09","set_rarity":"Common"} ] }] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Yugi's Legendary Decks", "Dark Magician"); REQUIRE(out.isOk()); CHECK(countDistinctSetCodes(out.value()) == 3); CHECK(maxRarityVariantsPerCode(out.value()) == 1); } TEST_CASE("S2-002 Dark Magician Girl Yugi's Legendary Decks two set codes") { const std::string json = R"({ "data":[{ "name":"Dark Magician Girl", "card_sets":[ {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA04","set_rarity":"Common"}, {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC10","set_rarity":"Common"} ] }] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Yugi's Legendary Decks", "Dark Magician Girl"); REQUIRE(out.isOk()); CHECK(countDistinctSetCodes(out.value()) == 2); CHECK(maxRarityVariantsPerCode(out.value()) == 1); } TEST_CASE("NEG-001 duplicate rarity lines collapse to one variant") { const std::string json = R"({ "data":[{ "name":"Armed Dragon LV7", "card_sets":[ {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}, {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"} ] }] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Soul of the Duelist", "Armed Dragon LV7"); REQUIRE(out.isOk()); CHECK(out.value().size() == 1); CHECK(maxRarityVariantsPerCode(out.value()) == 1); } TEST_CASE("NEG-002 duplicate set codes collapse to one collector slot") { const std::string json = R"({ "data":[{ "name":"Dark Magician", "card_sets":[ {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"}, {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"} ] }] })"; const auto out = YuGiOhCardPreviewSource::parsePrintVariants( json, "Yugi's Legendary Decks", "Dark Magician"); REQUIRE(out.isOk()); CHECK(countDistinctSetCodes(out.value()) == 1); CHECK(maxRarityVariantsPerCode(out.value()) == 1); } TEST_CASE("NEG-003 no cross-product merge when display set matches nothing") { const std::string json = R"({ "data":[{ "name":"Dark Magician", "card_sets":[ {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}, {"set_name":"Starter Deck: Yugi","set_code":"SDY-006","set_rarity":"Ultra Rare"} ] }] })"; const auto bogus = YuGiOhCardPreviewSource::parsePrintVariants( json, "Different Sets", "Dark Magician"); CHECK(bogus.isErr()); const auto lob = YuGiOhCardPreviewSource::parsePrintVariants( json, "Legend of Blue Eyes White Dragon", "Dark Magician"); REQUIRE(lob.isOk()); REQUIRE(lob.value().size() == 1); CHECK(lob.value()[0].setNo == "LOB-005"); const auto sdy = YuGiOhCardPreviewSource::parsePrintVariants( json, "Starter Deck: Yugi", "Dark Magician"); REQUIRE(sdy.isOk()); REQUIRE(sdy.value().size() == 1); CHECK(sdy.value()[0].setNo == "SDY-006"); } } TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") { TEST_CASE("supports auto-detect print metadata") { FixedHttpClient http; YuGiOhCardPreviewSource src{http}; CHECK(src.supportsAutoDetectPrint()); } TEST_CASE("queries Yugipedia first and uses the per-printing scan when found") { // Two same-passcode reprints with genuinely different art (LOB vs // SDK Blue-Eyes). Yugipedia hosts both, so we should always pick the // SDK one for the SDK input - no leak of the LOB scan into the SDK // entry, which was the user-reported regression on YGOPRODeck. RoutingHttpClient http; http.yugipediaBody = R"({"query":{"pages":{ "1":{"title":"File:BlueEyesWhiteDragon-SDK-NA-UR-UE.png", "imageinfo":[{"url":"https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png"}]} }}})"; // Make the YGOPRODeck fallback obviously wrong so we can assert it // isn't being used when Yugipedia returns a hit. http.ygoprodeckBody = R"({"data":[{"name":"Blue-Eyes White Dragon", "card_images":[{"image_url":"https://example.invalid/wrong.png"}], "card_sets":[{"set_code":"SDK-001","set_rarity":"Ultra Rare"}]}]})"; YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "Blue-Eyes White Dragon", "Starter Deck: Kaiba", "SDK-001||Ultra Rare||UE"); REQUIRE(out.isOk()); CHECK(out.value() == "https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png"); // Verified: only the Yugipedia call was made, not the YGOPRODeck one. REQUIRE(http.calls.size() == 1); CHECK(http.calls.front().find("yugipedia.com") != std::string::npos); } TEST_CASE("falls back to YGOPRODeck standard art when Yugipedia has no match") { // Empty `pages` -> Yugipedia parser returns an error -> fallback // path kicks in. This is the safety net for OCG-only or just-released // cards that don't yet have an English scan uploaded to Yugipedia. RoutingHttpClient http; http.yugipediaBody = R"({"query":{"pages":{ "-1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png","missing":""} }}})"; http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician", "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}], "card_sets":[{"set_code":"LOB-005","set_rarity":"Ultra Rare"}]}]})"; YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE"); REQUIRE(out.isOk()); CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg"); // Two HTTP calls: Yugipedia first, YGOPRODeck second. REQUIRE(http.calls.size() == 2); CHECK(http.calls[0].find("yugipedia.com") != std::string::npos); CHECK(http.calls[1].find("ygoprodeck.com") != std::string::npos); } TEST_CASE("falls back when Yugipedia errors out (transient HTTP failure)") { // We don't want a Yugipedia outage to leave the user without any // preview at all - YGOPRODeck's generic art is acceptable degraded // behavior. RoutingHttpClient http; http.yugipediaOk = false; http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician", "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})"; YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE"); REQUIRE(out.isOk()); CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg"); } TEST_CASE("propagates the YGOPRODeck error as Transient when the fallback also fails") { // Both upstreams unreachable -> the overall outcome is transient. // CardPreviewService relies on this to avoid negative-caching when // the user's connection is offline. RoutingHttpClient http; http.yugipediaOk = false; http.ygoprodeckOk = false; YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("classifies as NotFound only when both upstreams answered cleanly with no match") { // Yugipedia: clean miss (every candidate "missing"). // YGOPRODeck: clean miss (empty data array). // -> Both upstreams agree -> NotFound, safe to remember. RoutingHttpClient http; http.yugipediaBody = R"({"query":{"pages":{ "-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""} }}})"; http.ygoprodeckBody = R"({"data":[]})"; YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "No Such Card", "Legend of Blue Eyes White Dragon", "LOB-999||Ultra Rare||UE"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); } TEST_CASE("Yugipedia transient + YGOPRODeck clean-miss is overall Transient") { // We can't conclude the card has no image when one upstream // couldn't speak. This is the cautious path that keeps the offline // user out of the "permanent no-preview" trap. RoutingHttpClient http; http.yugipediaOk = false; // network failure http.ygoprodeckBody = R"({"data":[]})"; // clean miss YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "Possibly Real", "Some Set", "ABC-001||Ultra Rare||UE"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("Yugipedia clean-miss + YGOPRODeck transient is overall Transient") { RoutingHttpClient http; http.yugipediaBody = R"({"query":{"pages":{ "-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""} }}})"; http.ygoprodeckOk = false; YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "No Such Card", "Legend of Blue Eyes White Dragon", "LOB-999||Ultra Rare||UE"); REQUIRE(out.isErr()); CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } TEST_CASE("tuple-style setNo parsing trims fields and supports empty set code") { FixedHttpClient http; http.ok = true; http.body = R"({"data":[{"name":"Dark Magician", "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})"; YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "Dark Magician", "Legend of Blue Eyes White Dragon", " || Ultra Rare || 1E "); REQUIRE(out.isOk()); CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg"); CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos); } TEST_CASE("skips Yugipedia entirely when the set code is missing") { // Without a set code we can't construct any candidate filename - go // straight to the YGOPRODeck fallback to avoid wasting an HTTP call. FixedHttpClient http; http.ok = true; http.body = R"({"data":[{"name":"Dark Magician", "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})"; YuGiOhCardPreviewSource src{http}; const auto out = src.fetchImageUrl( "Dark Magician", "Legend of Blue Eyes White Dragon", ""); REQUIRE(out.isOk()); CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg"); CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos); CHECK(http.lastUrl.find("yugipedia.com") == std::string::npos); } } TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") { TEST_CASE("returns first variant from filtered request") { FixedHttpClient http; http.body = R"({ "data":[ {"name":"Dark Magician", "card_sets":[ {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"} ]} ] })"; YuGiOhCardPreviewSource src{http}; const auto out = src.detectFirstPrint( "Dark Magician", "Legend of Blue Eyes White Dragon"); REQUIRE(out.isOk()); CHECK(out.value().setNo == "LOB-005"); CHECK(out.value().rarity == "Ultra Rare"); CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon") != std::string::npos); } TEST_CASE("propagates unfiltered fallback errors when both requests fail") { FixedHttpClient http; http.ok = false; YuGiOhCardPreviewSource src{http}; const auto out = src.detectFirstPrint("Any", "Any Set"); REQUIRE(out.isErr()); CHECK(out.error() == "offline"); } } TEST_SUITE("YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog") { TEST_CASE("matches digits within pack to card name") { YuGiOhSetCatalog catalog; YuGiOhSetCatalogPack pack; pack.setId = "LOB"; pack.setName = "Legend of Blue Eyes White Dragon"; pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician", "Ultra Rare"}); pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN001", "Blue-Eyes White Dragon", "Ultra Rare"}); catalog.packs.push_back(std::move(pack)); const auto byDigits = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "005"); REQUIRE(byDigits.isOk()); REQUIRE(byDigits.value().size() == 1); CHECK(byDigits.value()[0].name == "Dark Magician"); CHECK(byDigits.value()[0].setNo == "LOB-005"); CHECK(byDigits.value()[0].rarity == "Ultra Rare"); const auto byUnpadded = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "5"); REQUIRE(byUnpadded.isOk()); REQUIRE(byUnpadded.value().size() == 1); CHECK(byUnpadded.value()[0].name == "Dark Magician"); const auto byRegionCode = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "LOB-001"); REQUIRE(byRegionCode.isOk()); REQUIRE(byRegionCode.value().size() == 1); CHECK(byRegionCode.value()[0].name == "Blue-Eyes White Dragon"); } TEST_CASE("digits-only 1 does not match collector 011") { YuGiOhSetCatalog catalog; YuGiOhSetCatalogPack pack; pack.setId = "LOB"; pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"}); pack.cards.push_back(YuGiOhCatalogCard{"LOB-011", "Hitotsu-Me Giant"}); catalog.packs.push_back(std::move(pack)); CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "1") .isErr()); const auto byEleven = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "11"); REQUIRE(byEleven.isOk()); REQUIRE(byEleven.value().size() == 1); CHECK(byEleven.value()[0].name == "Hitotsu-Me Giant"); } TEST_CASE("unknown pack or number returns error") { YuGiOhSetCatalog catalog; YuGiOhSetCatalogPack pack; pack.setId = "LOB"; pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"}); catalog.packs.push_back(std::move(pack)); CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "SDK", "001") .isErr()); CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "999") .isErr()); } TEST_CASE("detectVariantsBySetNo falls back to cardset HTTP without catalog") { FixedHttpClient http; http.body = R"({ "data":[ {"name":"Dark Magician", "card_sets":[ {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"} ]} ] })"; YuGiOhCardPreviewSource src{http}; const auto out = src.detectVariantsBySetNo("Legend of Blue Eyes White Dragon", "5"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 1); CHECK(out.value()[0].name == "Dark Magician"); CHECK(out.value()[0].setNo == "LOB-EN005"); CHECK(out.value()[0].rarity == "Ultra Rare"); CHECK(http.lastUrl.find("cardset=") != std::string::npos); CHECK(http.lastUrl.find("fname=") == std::string::npos); } TEST_CASE("cardset reverse keeps distinct rarities for the same set code") { const std::string body = R"({ "data":[ {"name":"Elemental HERO Bubbleman", "card_sets":[ {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}, {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"} ]} ] })"; const auto out = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset( body, "Soul of the Duelist", "15"); REQUIRE(out.isOk()); REQUIRE(out.value().size() == 2); CHECK(out.value()[0].name == "Elemental HERO Bubbleman"); CHECK(out.value()[0].rarity == "Ultra Rare"); CHECK(out.value()[1].rarity == "Ultimate Rare"); } }