patch: Feature/pkm autodetect (#15)

This commit is contained in:
Sebastian Dine
2026-05-12 15:49:37 +02:00
committed by GitHub
parent 98f2575b5a
commit 8a50e8daba
21 changed files with 1052 additions and 18 deletions
+1
View File
@@ -27,6 +27,7 @@ add_executable(ccm_core_tests
game_module_tests.cpp
card_sorter_tests.cpp
card_filter_tests.cpp
ascii_utils_tests.cpp
http_get_mapping_tests.cpp
cpr_http_client_tests.cpp
+20
View File
@@ -0,0 +1,20 @@
#include <doctest/doctest.h>
#include "ccm/util/AsciiUtils.hpp"
using namespace ccm;
TEST_SUITE("asciiLower") {
TEST_CASE("empty string stays empty") {
CHECK(asciiLower("").empty());
}
TEST_CASE("lowercases ASCII letters and leaves other ASCII bytes unchanged") {
CHECK(asciiLower("AbC123!@#") == "abc123!@#");
}
TEST_CASE("non-ASCII UTF-8 bytes pass through unchanged") {
const std::string input = "caf\u00e9";
CHECK(asciiLower(input) == input);
}
}
+55 -1
View File
@@ -57,7 +57,13 @@ YuGiOhCard yc(std::string name,
std::string setName,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "",
bool firstEdition = false,
bool sgnd = false,
bool altered = false) {
YuGiOhCard c;
c.id = 1;
c.name = std::move(name);
@@ -65,6 +71,12 @@ YuGiOhCard yc(std::string name,
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
return c;
}
@@ -176,6 +188,10 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
}
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
TEST_CASE("empty filter matches every row") {
CHECK(matchesYuGiOhFilter(yc("Dark Magician", "Legend of Blue Eyes"), ""));
}
TEST_CASE("matches by set number and rarity") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "lob-005"));
@@ -183,4 +199,42 @@ TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
CHECK(matchesYuGiOhFilter(c, "ur"));
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
}
TEST_CASE("matches by name and set.name") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "dark"));
CHECK(matchesYuGiOhFilter(c, "blue eyes"));
CHECK_FALSE(matchesYuGiOhFilter(c, "spell"));
}
TEST_CASE("matches by language, condition, amount, and note") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare",
12, Language::German, Condition::Played, "binder copy");
CHECK(matchesYuGiOhFilter(c, "german"));
CHECK(matchesYuGiOhFilter(c, "played"));
CHECK(matchesYuGiOhFilter(c, "12"));
CHECK(matchesYuGiOhFilter(c, "binder"));
CHECK_FALSE(matchesYuGiOhFilter(c, "english"));
}
TEST_CASE("matches rarity shorthand when the long rarity string does not") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005",
"Quarter Century Secret Rare");
CHECK(matchesYuGiOhFilter(c, "qcscr"));
CHECK_FALSE(matchesYuGiOhFilter(c, "mythic"));
}
TEST_CASE("boolean flag columns are not matched") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare",
1, Language::English, Condition::NearMint, "",
/*firstEdition=*/true, /*sgnd=*/true, /*altered=*/true);
CHECK_FALSE(matchesYuGiOhFilter(c, "true"));
CHECK_FALSE(matchesYuGiOhFilter(c, "false"));
CHECK(matchesYuGiOhFilter(c, "dark"));
}
TEST_CASE("no column hit returns false") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK_FALSE(matchesYuGiOhFilter(c, "zzznomatch"));
}
}
+72
View File
@@ -334,6 +334,78 @@ TEST_SUITE("CardPreviewService caching") {
CHECK(http.calls == 1);
}
TEST_CASE("memory-only caching works when no persistent cache is configured") {
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "PNG-bytes";
CardPreviewService svc{http, nullptr};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "").isOk());
http.body = "OTHER";
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(second.isOk());
CHECK(second.value() == "PNG-bytes");
CHECK(http.calls == 1);
}
TEST_CASE("registerModule replaces the preview source for the same game") {
FakeSource firstSource;
firstSource.url = "https://example.com/first.png";
FakeGameModule firstModule;
firstModule.gameId = Game::Magic;
firstModule.preview = &firstSource;
FakeSource secondSource;
secondSource.url = "https://example.com/second.png";
FakeGameModule secondModule;
secondModule.gameId = Game::Magic;
secondModule.preview = &secondSource;
FixedHttpClient http;
http.body = "SECOND";
CardPreviewService svc{http};
svc.registerModule(firstModule);
svc.registerModule(secondModule);
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(out.isOk());
CHECK(out.value() == "SECOND");
CHECK(http.lastUrl == "https://example.com/second.png");
CHECK(firstSource.calls == 0);
CHECK(secondSource.calls == 1);
}
TEST_CASE("NotFound without persistent cache still negative-caches in memory") {
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "not found";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http, nullptr};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "X", "abc", "").isErr());
CHECK(source.calls == 1);
const auto second = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
REQUIRE(second.isErr());
CHECK(second.error() == "No preview available for this card.");
CHECK(source.calls == 1);
CHECK(http.calls == 0);
}
TEST_CASE("HTTP success writes through to the persistent cache") {
// The persistent tier is fire-and-forget on the way down (HTTP -> disk)
// and consulted on the way up (cache miss -> disk -> HTTP). This first
+11
View File
@@ -430,6 +430,17 @@ TEST_SUITE("CardSorter - YuGiOh columns") {
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // C, ScR, UR
}
TEST_CASE("Rarity treats unknown labels as equal empty shorthand") {
std::vector<YuGiOhCard> v = {
yc(1, "alpha", "X", "2000/01/01", "", "Mythic Cosmic Rare", 1),
yc(2, "beta", "X", "2000/01/01", "", "Other Unknown", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
}
TEST_CASE("Amount sorts numerically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 9),
+127
View File
@@ -235,6 +235,103 @@ TEST_SUITE("YuGiOhCard JSON") {
CHECK(card.setNo == "SDY-006");
CHECK(card.set.id == "SDY");
}
TEST_CASE("serializes non-default flags and metadata fields") {
YuGiOhCard c;
c.id = 3;
c.amount = 4;
c.name = "Red-Eyes Black Dragon";
c.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
c.setNo = "LOB-070";
c.rarity = "Secret Rare";
c.note = "graded";
c.images = {};
c.language = Language::German;
c.condition = Condition::Played;
c.firstEdition = false;
c.signed_ = true;
c.altered = true;
const nlohmann::json j = c;
CHECK(j.at("firstEdition") == false);
CHECK(j.at("signed") == true);
CHECK(j.at("altered") == true);
CHECK(j.at("language") == "German");
CHECK(j.at("condition") == "Played");
CHECK(j.at("images") == nlohmann::json::array());
const YuGiOhCard back = j.get<YuGiOhCard>();
CHECK(back == c);
}
TEST_CASE("operator== distinguishes each field") {
YuGiOhCard base;
base.id = 10;
base.amount = 2;
base.name = "Dark Magician";
base.set = Set{"lob", "Legend of Blue Eyes", "2002/03/08"};
base.setNo = "LOB-005";
base.rarity = "Ultra Rare";
base.note = "note";
base.images = {"a.png"};
base.language = Language::English;
base.condition = Condition::NearMint;
base.firstEdition = true;
base.signed_ = false;
base.altered = false;
auto changed = base;
changed.id = 11;
CHECK_FALSE(changed == base);
changed = base;
changed.amount = 3;
CHECK_FALSE(changed == base);
changed = base;
changed.name = "Other";
CHECK_FALSE(changed == base);
changed = base;
changed.set.name = "Other Set";
CHECK_FALSE(changed == base);
changed = base;
changed.setNo = "LOB-006";
CHECK_FALSE(changed == base);
changed = base;
changed.rarity = "Rare";
CHECK_FALSE(changed == base);
changed = base;
changed.note = "other";
CHECK_FALSE(changed == base);
changed = base;
changed.images = {};
CHECK_FALSE(changed == base);
changed = base;
changed.language = Language::Japanese;
CHECK_FALSE(changed == base);
changed = base;
changed.condition = Condition::Played;
CHECK_FALSE(changed == base);
changed = base;
changed.firstEdition = false;
CHECK_FALSE(changed == base);
changed = base;
changed.signed_ = true;
CHECK_FALSE(changed == base);
changed = base;
changed.altered = true;
CHECK_FALSE(changed == base);
}
}
TEST_SUITE("Domain JSON required fields") {
@@ -313,6 +410,36 @@ TEST_SUITE("Domain JSON required fields") {
CHECK_THROWS(j.get<YuGiOhCard>());
}
TEST_CASE("YuGiOhCard missing each required key throws") {
const nlohmann::json full = {
{"id", 7},
{"amount", 1},
{"name", "Blue-Eyes White Dragon"},
{"set", nlohmann::json{
{"id", "sdk"},
{"name", "Starter Deck Kaiba"},
{"releaseDate", "2002/03/29"},
}},
{"setNo", "SDK-001"},
{"rarity", "Ultra Rare"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", true},
{"signed", false},
{"altered", false},
};
for (const char* key : {
"id", "amount", "name", "set", "setNo", "note", "images",
"language", "condition", "firstEdition", "rarity", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<YuGiOhCard>());
}
}
TEST_CASE("Configuration missing required key throws") {
const nlohmann::json j = {
{"defaultGame", "Magic"},
+3
View File
@@ -27,6 +27,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(module.dirName() == "magic");
CHECK(module.displayName() == "Magic");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("Pokemon module reports canonical metadata") {
@@ -37,6 +38,7 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(module.dirName() == "pokemon");
CHECK(module.displayName() == "Pokemon");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("YuGiOh module reports canonical metadata") {
@@ -47,5 +49,6 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(module.dirName() == "yugioh");
CHECK(module.displayName() == "Yu-Gi-Oh!");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
}
+7
View File
@@ -46,4 +46,11 @@ TEST_SUITE("mapHttpGetResponse") {
REQUIRE(out.isErr());
CHECK(out.error() == "HTTP 404 from https://api.example/r");
}
TEST_CASE("HTTP 200 with empty body still maps to success") {
const auto out =
mapHttpGetResponse(false, {}, 200, "", "https://api.example/empty");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
}
+19
View File
@@ -17,6 +17,20 @@ public:
}
};
class AutoDetectPreviewSource final : public ICardPreviewSource {
public:
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return true;
}
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view,
std::string_view,
std::string_view) override {
return Result<std::string, PreviewLookupError>::ok("https://example.test/card.png");
}
};
} // namespace
TEST_SUITE("ICardPreviewSource defaults") {
@@ -25,6 +39,11 @@ TEST_SUITE("ICardPreviewSource defaults") {
CHECK_FALSE(src.supportsAutoDetectPrint());
}
TEST_CASE("implementations may override supportsAutoDetectPrint") {
AutoDetectPreviewSource src;
CHECK(src.supportsAutoDetectPrint());
}
TEST_CASE("default detectFirstPrint returns explicit unsupported error") {
MinimalPreviewSource src;
const auto out = src.detectFirstPrint("Card", "Set");
+84
View File
@@ -12,6 +12,8 @@
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
@@ -56,6 +58,59 @@ void backdate(const fs::path& p, int seconds) {
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
}
class FailingEnsureDirFs final : public IFileSystem {
public:
explicit FailingEnsureDirFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override {
(void)p;
return Result<void>::err("ensure failed");
}
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
class FailingIndexWriteFs final : public IFileSystem {
public:
explicit FailingIndexWriteFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
Result<void> ensureDirectory(const fs::path& p) override { return inner_.ensureDirectory(p); }
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
Result<void> writeText(const fs::path& p, std::string_view contents) override {
const auto path = p.generic_string();
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".idx") == 0) {
return Result<void>::err("idx write failed");
}
return inner_.writeText(p, contents);
}
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
return inner_.copyFile(from, to, overwrite);
}
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
return inner_.listDirectory(p);
}
private:
ccm::testing::InMemoryFileSystem& inner_;
};
} // namespace
TEST_SUITE("LocalPreviewByteCache") {
@@ -340,3 +395,32 @@ TEST_SUITE("LocalPreviewByteCache") {
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
}
}
TEST_SUITE("LocalPreviewByteCache in-memory filesystem failures") {
TEST_CASE("store is a silent no-op when ensureDirectory fails") {
ccm::testing::InMemoryFileSystem inner;
FailingEnsureDirFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("store rolls back payload when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.store("k", "payload");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("storeNegative rolls back marker when sidecar write fails") {
ccm::testing::InMemoryFileSystem inner;
FailingIndexWriteFs fs{inner};
LocalPreviewByteCache cache(fs, "/cache");
cache.storeNegative("k");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
}
}
@@ -43,6 +43,12 @@ TEST_SUITE("MagicCardPreviewSource::buildSearchUrl") {
const auto url = MagicCardPreviewSource::buildSearchUrl("X", "swsh10");
CHECK(url.find("set%3Aswsh10") != std::string::npos);
}
TEST_CASE("replaces every ampersand in the card name") {
const auto url = MagicCardPreviewSource::buildSearchUrl("A & B & C", "abc");
CHECK(url.find("A%20and%20B%20and%20C") != std::string::npos);
CHECK(url.find("%26") == std::string::npos);
}
}
TEST_SUITE("MagicCardPreviewSource::parseResponse") {
+181
View File
@@ -170,3 +170,184 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
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_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("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.");
}
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");
}
}
@@ -92,6 +92,23 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-E"));
}
}
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") == "");
}
}
@@ -110,6 +127,16 @@ TEST_SUITE("ygoRarityShortCode") {
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") {
@@ -281,6 +308,33 @@ TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
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") {
+14
View File
@@ -57,6 +57,20 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
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"}