mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 23:01:09 +00:00
patch: Patch/ygo set 25th (#13)
This commit is contained in:
@@ -24,11 +24,21 @@ namespace ccm {
|
||||
// only fires one outbound request at a time anyway.
|
||||
class CprHttpClient final : public IHttpClient {
|
||||
public:
|
||||
struct RawResponse {
|
||||
bool transportError{false};
|
||||
std::string transportMessage;
|
||||
int statusCode{0};
|
||||
std::string body;
|
||||
};
|
||||
|
||||
using GetExecutor = std::function<Result<std::string>(std::string_view)>;
|
||||
using RawGetExecutor = std::function<RawResponse(std::string_view)>;
|
||||
|
||||
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
CprHttpClient(GetExecutor executor,
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
CprHttpClient(RawGetExecutor rawExecutor,
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
~CprHttpClient() override;
|
||||
|
||||
Result<std::string> get(std::string_view url) override;
|
||||
@@ -37,6 +47,7 @@ private:
|
||||
std::chrono::milliseconds timeout_;
|
||||
std::unique_ptr<cpr::Session> session_;
|
||||
GetExecutor executor_;
|
||||
RawGetExecutor rawExecutor_;
|
||||
std::mutex sessionMutex_;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,42 @@
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
|
||||
struct YuGiOhSetAlias {
|
||||
const char* code;
|
||||
const char* name;
|
||||
const char* releaseDate;
|
||||
};
|
||||
|
||||
constexpr std::array<YuGiOhSetAlias, 6> kMissing25thAnniversaryReprints{{
|
||||
{"LOB-25TH", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"MRD-25TH", "Metal Raiders (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"SRL-25TH", "Spell Ruler (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"PSV-25TH", "Pharaoh's Servant (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"DCR-25TH", "Dark Crisis (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"IOC-25TH", "Invasion of Chaos (25th Anniversary Edition)", "2023/06/08"},
|
||||
}};
|
||||
|
||||
void appendMissingSetAliases(std::vector<Set>& sets) {
|
||||
for (const auto& alias : kMissing25thAnniversaryReprints) {
|
||||
const bool exists = std::any_of(
|
||||
sets.begin(), sets.end(), [&](const Set& s) { return s.name == alias.name; });
|
||||
if (exists) continue;
|
||||
|
||||
Set s;
|
||||
s.id = alias.code;
|
||||
s.name = alias.name;
|
||||
s.releaseDate = alias.releaseDate;
|
||||
sets.push_back(std::move(s));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
@@ -29,6 +62,7 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
|
||||
s.releaseDate = std::move(release);
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
appendMissingSetAliases(out);
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
|
||||
@@ -26,16 +26,15 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
|
||||
/*follow=*/true,
|
||||
/*cont_send_cred=*/false,
|
||||
cpr::PostRedirectFlags::POST_ALL});
|
||||
executor_ = [this](std::string_view url) -> Result<std::string> {
|
||||
rawExecutor_ = [this](std::string_view url) -> RawResponse {
|
||||
session_->SetUrl(cpr::Url{std::string(url)});
|
||||
cpr::Response r = session_->Get();
|
||||
|
||||
if (r.error) {
|
||||
return mapHttpGetResponse(true, r.error.message, r.status_code, {},
|
||||
url);
|
||||
}
|
||||
return mapHttpGetResponse(false, {}, r.status_code, std::move(r.text),
|
||||
url);
|
||||
return RawResponse{
|
||||
.transportError = static_cast<bool>(r.error),
|
||||
.transportMessage = r.error.message,
|
||||
.statusCode = static_cast<int>(r.status_code),
|
||||
.body = std::move(r.text),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +44,12 @@ CprHttpClient::CprHttpClient(GetExecutor executor,
|
||||
session_(nullptr),
|
||||
executor_(std::move(executor)) {}
|
||||
|
||||
CprHttpClient::CprHttpClient(RawGetExecutor rawExecutor,
|
||||
std::chrono::milliseconds timeout)
|
||||
: timeout_(timeout),
|
||||
session_(nullptr),
|
||||
rawExecutor_(std::move(rawExecutor)) {}
|
||||
|
||||
CprHttpClient::~CprHttpClient() = default;
|
||||
|
||||
Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
@@ -53,10 +58,18 @@ Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
// (one fetch per BaseSelectedCardPanel selection change), so contention
|
||||
// is negligible.
|
||||
std::lock_guard<std::mutex> lock(sessionMutex_);
|
||||
if (!executor_) {
|
||||
return Result<std::string>::err("HTTP error: no executor configured");
|
||||
}
|
||||
if (executor_) {
|
||||
return executor_(url);
|
||||
}
|
||||
if (rawExecutor_) {
|
||||
RawResponse raw = rawExecutor_(url);
|
||||
return mapHttpGetResponse(raw.transportError,
|
||||
raw.transportMessage,
|
||||
raw.statusCode,
|
||||
std::move(raw.body),
|
||||
url);
|
||||
}
|
||||
return Result<std::string>::err("HTTP error: no executor configured");
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -21,6 +21,7 @@ add_executable(ccm_core_tests
|
||||
std_file_system_tests.cpp
|
||||
pokemon_set_source_tests.cpp
|
||||
pokemon_card_preview_source_tests.cpp
|
||||
icard_preview_source_tests.cpp
|
||||
yugioh_set_source_tests.cpp
|
||||
yugioh_card_preview_source_tests.cpp
|
||||
game_module_tests.cpp
|
||||
|
||||
@@ -703,6 +703,29 @@ TEST_SUITE("CardPreviewService caching") {
|
||||
CHECK(http.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("fetchImageBytesByUrl serves from persistent cache hit without HTTP") {
|
||||
FixedHttpClient http;
|
||||
http.body = "warm-card-back";
|
||||
InMemoryByteCache disk;
|
||||
|
||||
// Seed persistent cache via first service instance.
|
||||
{
|
||||
CardPreviewService seed{http, &disk};
|
||||
const auto seeded = seed.fetchImageBytesByUrl("https://cdn.example/back.png");
|
||||
REQUIRE(seeded.isOk());
|
||||
CHECK(seeded.value() == "warm-card-back");
|
||||
}
|
||||
REQUIRE(http.calls == 1);
|
||||
|
||||
// Fresh service instance: force HTTP failure and ensure disk hit is used.
|
||||
CardPreviewService warm{http, &disk};
|
||||
http.ok = false;
|
||||
const auto warmHit = warm.fetchImageBytesByUrl("https://cdn.example/back.png");
|
||||
REQUIRE(warmHit.isOk());
|
||||
CHECK(warmHit.value() == "warm-card-back");
|
||||
CHECK(http.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("in-memory LRU evicts oldest entry after exceeding capacity") {
|
||||
FakeSource source;
|
||||
FakeGameModule module;
|
||||
|
||||
@@ -16,9 +16,15 @@ namespace {
|
||||
class InMemoryRepo final : public ICollectionRepository<MagicCard> {
|
||||
public:
|
||||
Map storage;
|
||||
bool failLoad{false};
|
||||
bool failSave{false};
|
||||
|
||||
Result<Map> load(Game) override { return Result<Map>::ok(storage); }
|
||||
Result<Map> load(Game) override {
|
||||
if (failLoad) return Result<Map>::err("load failed");
|
||||
return Result<Map>::ok(storage);
|
||||
}
|
||||
Result<void> save(Game, const Map& m) override {
|
||||
if (failSave) return Result<void>::err("save failed");
|
||||
storage = m;
|
||||
return Result<void>::ok();
|
||||
}
|
||||
@@ -27,12 +33,14 @@ public:
|
||||
class StubImageStore final : public IImageStore {
|
||||
public:
|
||||
std::vector<std::pair<Game, std::string>> removed;
|
||||
bool failRemove{false};
|
||||
|
||||
Result<std::string> copyIn(Game, const std::filesystem::path&, const std::string& n) override {
|
||||
return Result<std::string>::ok(n);
|
||||
}
|
||||
Result<void> remove(Game g, const std::string& n) override {
|
||||
removed.emplace_back(g, n);
|
||||
if (failRemove) return Result<void>::err("remove failed for " + n);
|
||||
return Result<void>::ok();
|
||||
}
|
||||
std::filesystem::path resolvePath(Game, const std::string& n) const override {
|
||||
@@ -117,4 +125,72 @@ TEST_SUITE("CollectionService<MagicCard>") {
|
||||
|
||||
CHECK(svc.remove(Game::Magic, 12345).isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("load errors are propagated by list and findById") {
|
||||
InMemoryRepo repo;
|
||||
repo.failLoad = true;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto listed = svc.list(Game::Magic);
|
||||
REQUIRE(listed.isErr());
|
||||
CHECK(listed.error() == "load failed");
|
||||
|
||||
const auto found = svc.findById(Game::Magic, 1);
|
||||
REQUIRE(found.isErr());
|
||||
CHECK(found.error() == "load failed");
|
||||
}
|
||||
|
||||
TEST_CASE("save errors are propagated by add and update") {
|
||||
InMemoryRepo repo;
|
||||
repo.failSave = true;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto addRes = svc.add(Game::Magic, makeCard("A"));
|
||||
REQUIRE(addRes.isErr());
|
||||
CHECK(addRes.error() == "save failed");
|
||||
|
||||
repo.failSave = false;
|
||||
const auto id = svc.add(Game::Magic, makeCard("B"));
|
||||
REQUIRE(id.isOk());
|
||||
|
||||
repo.failSave = true;
|
||||
MagicCard updated = makeCard("Renamed");
|
||||
updated.id = id.value();
|
||||
const auto updateRes = svc.update(Game::Magic, updated);
|
||||
REQUIRE(updateRes.isErr());
|
||||
CHECK(updateRes.error() == "save failed");
|
||||
}
|
||||
|
||||
TEST_CASE("findById returns nullopt for missing id") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto out = svc.findById(Game::Magic, 99);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK_FALSE(out.value().has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("remove reports image cleanup issues but still removes card") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
store.failRemove = true;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto id = svc.add(
|
||||
Game::Magic, makeCard("With Images", {"a.png", "b.png"}));
|
||||
REQUIRE(id.isOk());
|
||||
|
||||
const auto removed = svc.remove(Game::Magic, id.value());
|
||||
REQUIRE(removed.isErr());
|
||||
CHECK(removed.error().find("Card removed but image cleanup had issues:") != std::string::npos);
|
||||
CHECK(removed.error().find("remove failed for a.png") != std::string::npos);
|
||||
CHECK(removed.error().find("remove failed for b.png") != std::string::npos);
|
||||
|
||||
const auto listed = svc.list(Game::Magic);
|
||||
REQUIRE(listed.isOk());
|
||||
CHECK(listed.value().empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,4 +34,60 @@ TEST_SUITE("CprHttpClient injected executor") {
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "HTTP 503 from https://example.com");
|
||||
}
|
||||
|
||||
TEST_CASE("returns an explicit error when executor is empty") {
|
||||
CprHttpClient::GetExecutor empty;
|
||||
CprHttpClient client{empty};
|
||||
|
||||
const auto out = client.get("https://example.com");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "HTTP error: no executor configured");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CprHttpClient injected raw executor") {
|
||||
TEST_CASE("maps 2xx raw response to success body") {
|
||||
CprHttpClient client{
|
||||
[](std::string_view) -> CprHttpClient::RawResponse {
|
||||
return CprHttpClient::RawResponse{
|
||||
.transportError = false,
|
||||
.transportMessage = "",
|
||||
.statusCode = 200,
|
||||
.body = "ok-body",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const auto out = client.get("https://example.com/success");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "ok-body");
|
||||
}
|
||||
|
||||
TEST_CASE("maps transport error via shared http mapping") {
|
||||
CprHttpClient client{
|
||||
[](std::string_view) -> CprHttpClient::RawResponse {
|
||||
return CprHttpClient::RawResponse{
|
||||
.transportError = true,
|
||||
.transportMessage = "timeout",
|
||||
.statusCode = 0,
|
||||
.body = "",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const auto out = client.get("https://example.com/timeout");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("timeout") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CprHttpClient real session") {
|
||||
TEST_CASE("default constructor handles malformed URL without crashing") {
|
||||
// Exercise the real cpr::Session-backed constructor/lambda path
|
||||
// without depending on external network availability.
|
||||
CprHttpClient client{};
|
||||
const auto out = client.get("://not-a-valid-url");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("HTTP") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class MinimalPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view,
|
||||
std::string_view,
|
||||
std::string_view) override {
|
||||
return Result<std::string, PreviewLookupError>::err(
|
||||
PreviewLookupError{PreviewLookupError::Kind::NotFound, "not found"});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("ICardPreviewSource defaults") {
|
||||
TEST_CASE("auto-detect is disabled by default") {
|
||||
MinimalPreviewSource src;
|
||||
CHECK_FALSE(src.supportsAutoDetectPrint());
|
||||
}
|
||||
|
||||
TEST_CASE("default detectFirstPrint returns explicit unsupported error") {
|
||||
MinimalPreviewSource src;
|
||||
const auto out = src.detectFirstPrint("Card", "Set");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Auto-detect not supported by this game.");
|
||||
}
|
||||
|
||||
TEST_CASE("default detectPrintVariants returns explicit unsupported error") {
|
||||
MinimalPreviewSource src;
|
||||
const auto out = src.detectPrintVariants("Card", "Set");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Print variant listing not supported by this game.");
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,24 @@ public:
|
||||
std::vector<Call> copies;
|
||||
std::vector<std::pair<Game, std::string>> removes;
|
||||
std::string returnedExt = ".png";
|
||||
int failCopyAt = -1;
|
||||
int failRemoveAt = -1;
|
||||
|
||||
Result<std::string> copyIn(Game game,
|
||||
const std::filesystem::path& srcPath,
|
||||
const std::string& targetName) override {
|
||||
copies.push_back({game, srcPath, targetName});
|
||||
if (failCopyAt >= 0 && static_cast<int>(copies.size()) == failCopyAt) {
|
||||
return Result<std::string>::err("copy failed at " + std::to_string(failCopyAt));
|
||||
}
|
||||
return Result<std::string>::ok(targetName + returnedExt);
|
||||
}
|
||||
|
||||
Result<void> remove(Game game, const std::string& imageName) override {
|
||||
removes.emplace_back(game, imageName);
|
||||
if (failRemoveAt >= 0 && static_cast<int>(removes.size()) == failRemoveAt) {
|
||||
return Result<void>::err("remove failed at " + std::to_string(failRemoveAt));
|
||||
}
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
@@ -55,6 +63,11 @@ TEST_SUITE("ImageService::nextImageIndex") {
|
||||
std::vector<std::string> imgs2 = {"otherIMG_BACK.png"};
|
||||
CHECK(ImageService::nextImageIndex(imgs2) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("index increments from two-digit legacy cap") {
|
||||
std::vector<std::string> imgs = {"set+name+99.png"};
|
||||
CHECK(ImageService::nextImageIndex(imgs) == 100);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("ImageService::buildTargetName") {
|
||||
@@ -128,4 +141,38 @@ TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
|
||||
CHECK(store.copies.empty());
|
||||
CHECK(store.removes.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("copy failure rolls back already-created names and returns error") {
|
||||
RecordingImageStore store;
|
||||
store.failCopyAt = 2;
|
||||
ImageService svc{store};
|
||||
|
||||
const std::vector<std::string> images{
|
||||
"Beta+BlackLotus+0.png",
|
||||
"Beta+BlackLotus+1.jpg"
|
||||
};
|
||||
const auto out = svc.normalizeNamesForPersistedCard(
|
||||
Game::Magic, 42, "Beta", "Black Lotus", images);
|
||||
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("copy failed at 2") != std::string::npos);
|
||||
// Second copy failed, so first created file should be rolled back.
|
||||
REQUIRE(store.removes.size() == 1);
|
||||
CHECK(store.removes[0].second == "42+Beta+BlackLotus+0.png");
|
||||
}
|
||||
|
||||
TEST_CASE("remove failure after rename returns error") {
|
||||
RecordingImageStore store;
|
||||
store.failRemoveAt = 1;
|
||||
ImageService svc{store};
|
||||
|
||||
const std::vector<std::string> images{
|
||||
"Beta+BlackLotus+0.png"
|
||||
};
|
||||
const auto out = svc.normalizeNamesForPersistedCard(
|
||||
Game::Magic, 42, "Beta", "Black Lotus", images);
|
||||
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("remove failed at 1") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,41 @@ ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
|
||||
cfg.initialize();
|
||||
return cfg;
|
||||
}
|
||||
|
||||
class FailingSetFs final : public IFileSystem {
|
||||
public:
|
||||
bool ensureOk{true};
|
||||
bool writeOk{true};
|
||||
bool readOk{true};
|
||||
std::string readPayload{"[]"};
|
||||
std::filesystem::path lastWritePath;
|
||||
std::string lastWriteBody;
|
||||
|
||||
[[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; }
|
||||
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; }
|
||||
|
||||
Result<void> ensureDirectory(const std::filesystem::path&) override {
|
||||
if (!ensureOk) return Result<void>::err("ensure failed");
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<std::string> readText(const std::filesystem::path&) override {
|
||||
if (!readOk) return Result<std::string>::err("read failed");
|
||||
return Result<std::string>::ok(readPayload);
|
||||
}
|
||||
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override {
|
||||
if (!writeOk) return Result<void>::err("write failed");
|
||||
lastWritePath = p;
|
||||
lastWriteBody = std::string(contents);
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<void> copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override {
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<void> remove(const std::filesystem::path&) override { return Result<void>::ok(); }
|
||||
Result<std::vector<std::filesystem::path>> listDirectory(const std::filesystem::path&) override {
|
||||
return Result<std::vector<std::filesystem::path>>::ok({});
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("JsonSetRepository") {
|
||||
@@ -49,4 +84,48 @@ TEST_SUITE("JsonSetRepository") {
|
||||
const auto loaded = repo.load(Game::Pokemon);
|
||||
CHECK(loaded.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("load propagates read errors from filesystem") {
|
||||
InMemoryFileSystem configFs;
|
||||
auto cfg = makeConfig(configFs, "/data");
|
||||
FailingSetFs fs;
|
||||
fs.readOk = false;
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error() == "read failed");
|
||||
}
|
||||
|
||||
TEST_CASE("load reports parse error for malformed sets.json") {
|
||||
InMemoryFileSystem configFs;
|
||||
auto cfg = makeConfig(configFs, "/data");
|
||||
FailingSetFs fs;
|
||||
fs.readPayload = "{bad json";
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error().find("sets.json parse error:") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("save propagates ensureDirectory and writeText failures") {
|
||||
InMemoryFileSystem configFs;
|
||||
auto cfg = makeConfig(configFs, "/data");
|
||||
FailingSetFs fs;
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
|
||||
const std::vector<Set> sets = {{"lea", "Limited Edition Alpha", "1993/08/05"}};
|
||||
|
||||
fs.ensureOk = false;
|
||||
const auto ensureFail = repo.save(Game::Magic, sets);
|
||||
REQUIRE(ensureFail.isErr());
|
||||
CHECK(ensureFail.error() == "ensure failed");
|
||||
|
||||
fs.ensureOk = true;
|
||||
fs.writeOk = false;
|
||||
const auto writeFail = repo.save(Game::Magic, sets);
|
||||
REQUIRE(writeFail.isErr());
|
||||
CHECK(writeFail.error() == "write failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +642,12 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_var
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -29,9 +29,18 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
|
||||
])";
|
||||
const auto out = YuGiOhSetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].id == "AAA");
|
||||
CHECK(out.value()[0].releaseDate == "2020/01/01");
|
||||
bool foundA = false;
|
||||
bool foundB = false;
|
||||
for (const auto& set : out.value()) {
|
||||
if (set.id == "AAA" && set.name == "Set A" && set.releaseDate == "2020/01/01") {
|
||||
foundA = true;
|
||||
}
|
||||
if (set.id == "BBB" && set.name == "Set B" && set.releaseDate == "2021/02/03") {
|
||||
foundB = true;
|
||||
}
|
||||
}
|
||||
CHECK(foundA);
|
||||
CHECK(foundB);
|
||||
}
|
||||
|
||||
TEST_CASE("sorts by release date ascending") {
|
||||
@@ -47,6 +56,48 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
|
||||
TEST_CASE("missing array returns error") {
|
||||
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("adds 25th Anniversary aliases when upstream list misses them") {
|
||||
const auto out = YuGiOhSetSource::parseResponse(R"([
|
||||
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}
|
||||
])");
|
||||
REQUIRE(out.isOk());
|
||||
|
||||
bool foundLob25th = false;
|
||||
bool foundIoc25th = false;
|
||||
for (const auto& set : out.value()) {
|
||||
if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)"
|
||||
&& set.id == "LOB-25TH") {
|
||||
foundLob25th = true;
|
||||
}
|
||||
if (set.name == "Invasion of Chaos (25th Anniversary Edition)" && set.id == "IOC-25TH") {
|
||||
foundIoc25th = true;
|
||||
}
|
||||
}
|
||||
CHECK(foundLob25th);
|
||||
CHECK(foundIoc25th);
|
||||
}
|
||||
|
||||
TEST_CASE("does not duplicate aliases that already exist by name") {
|
||||
const auto out = YuGiOhSetSource::parseResponse(R"json([
|
||||
{"set_name":"Legend of Blue Eyes White Dragon (25th Anniversary Edition)","set_code":"LOB-25TH","tcg_date":"2023-04-20"}
|
||||
])json");
|
||||
REQUIRE(out.isOk());
|
||||
|
||||
int aliasCount = 0;
|
||||
for (const auto& set : out.value()) {
|
||||
if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)") {
|
||||
++aliasCount;
|
||||
}
|
||||
}
|
||||
CHECK(aliasCount == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("malformed json returns parse error") {
|
||||
const auto out = YuGiOhSetSource::parseResponse("{bad json");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("YGOPRODeck set parse error:") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhSetSource::fetchAll") {
|
||||
@@ -59,4 +110,13 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") {
|
||||
CHECK(out.value().front().id == "X");
|
||||
CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php");
|
||||
}
|
||||
|
||||
TEST_CASE("network error is propagated") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
YuGiOhSetSource src{http};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "offline");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user