mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 23:01:09 +00:00
d3b4762b76
* several functions. fixes #1 and #2 * multi selection functionality
1051 lines
45 KiB
C++
1051 lines
45 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
|
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
|
#include "ccm/ports/IHttpClient.hpp"
|
|
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
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<std::string> get(std::string_view url) override {
|
|
lastUrl = std::string(url);
|
|
return ok ? Result<std::string>::ok(body)
|
|
: Result<std::string>::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<std::string> calls;
|
|
Result<std::string> 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<std::string>::ok(yugipediaBody)
|
|
: Result<std::string>::err("yugipedia offline");
|
|
}
|
|
if (s.find("ygoprodeck.com") != std::string::npos) {
|
|
return ygoprodeckOk ? Result<std::string>::ok(ygoprodeckBody)
|
|
: Result<std::string>::err("ygoprodeck offline");
|
|
}
|
|
return Result<std::string>::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<std::string> get(std::string_view url) override {
|
|
++calls;
|
|
std::string u(url);
|
|
if (u.find("cardset=") != std::string::npos) {
|
|
return Result<std::string>::err("filtered endpoint unavailable");
|
|
}
|
|
return Result<std::string>::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<std::string> 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<std::string> 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<AutoDetectedPrint>& v) {
|
|
std::unordered_set<std::string> 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<AutoDetectedPrint>& v) {
|
|
std::unordered_map<std::string, std::unordered_set<std::string>> 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");
|
|
}
|
|
}
|