mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
patch: Feature/pkm autodetect (#15)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user