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.
|
// only fires one outbound request at a time anyway.
|
||||||
class CprHttpClient final : public IHttpClient {
|
class CprHttpClient final : public IHttpClient {
|
||||||
public:
|
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 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});
|
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||||
CprHttpClient(GetExecutor executor,
|
CprHttpClient(GetExecutor executor,
|
||||||
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||||
|
CprHttpClient(RawGetExecutor rawExecutor,
|
||||||
|
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||||
~CprHttpClient() override;
|
~CprHttpClient() override;
|
||||||
|
|
||||||
Result<std::string> get(std::string_view url) override;
|
Result<std::string> get(std::string_view url) override;
|
||||||
@@ -37,6 +47,7 @@ private:
|
|||||||
std::chrono::milliseconds timeout_;
|
std::chrono::milliseconds timeout_;
|
||||||
std::unique_ptr<cpr::Session> session_;
|
std::unique_ptr<cpr::Session> session_;
|
||||||
GetExecutor executor_;
|
GetExecutor executor_;
|
||||||
|
RawGetExecutor rawExecutor_;
|
||||||
std::mutex sessionMutex_;
|
std::mutex sessionMutex_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,42 @@
|
|||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace ccm {
|
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) {}
|
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);
|
s.releaseDate = std::move(release);
|
||||||
out.push_back(std::move(s));
|
out.push_back(std::move(s));
|
||||||
}
|
}
|
||||||
|
appendMissingSetAliases(out);
|
||||||
std::sort(out.begin(), out.end(),
|
std::sort(out.begin(), out.end(),
|
||||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||||
return Result<std::vector<Set>>::ok(std::move(out));
|
return Result<std::vector<Set>>::ok(std::move(out));
|
||||||
|
|||||||
@@ -26,16 +26,15 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
|
|||||||
/*follow=*/true,
|
/*follow=*/true,
|
||||||
/*cont_send_cred=*/false,
|
/*cont_send_cred=*/false,
|
||||||
cpr::PostRedirectFlags::POST_ALL});
|
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)});
|
session_->SetUrl(cpr::Url{std::string(url)});
|
||||||
cpr::Response r = session_->Get();
|
cpr::Response r = session_->Get();
|
||||||
|
return RawResponse{
|
||||||
if (r.error) {
|
.transportError = static_cast<bool>(r.error),
|
||||||
return mapHttpGetResponse(true, r.error.message, r.status_code, {},
|
.transportMessage = r.error.message,
|
||||||
url);
|
.statusCode = static_cast<int>(r.status_code),
|
||||||
}
|
.body = std::move(r.text),
|
||||||
return mapHttpGetResponse(false, {}, r.status_code, std::move(r.text),
|
};
|
||||||
url);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +44,12 @@ CprHttpClient::CprHttpClient(GetExecutor executor,
|
|||||||
session_(nullptr),
|
session_(nullptr),
|
||||||
executor_(std::move(executor)) {}
|
executor_(std::move(executor)) {}
|
||||||
|
|
||||||
|
CprHttpClient::CprHttpClient(RawGetExecutor rawExecutor,
|
||||||
|
std::chrono::milliseconds timeout)
|
||||||
|
: timeout_(timeout),
|
||||||
|
session_(nullptr),
|
||||||
|
rawExecutor_(std::move(rawExecutor)) {}
|
||||||
|
|
||||||
CprHttpClient::~CprHttpClient() = default;
|
CprHttpClient::~CprHttpClient() = default;
|
||||||
|
|
||||||
Result<std::string> CprHttpClient::get(std::string_view url) {
|
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
|
// (one fetch per BaseSelectedCardPanel selection change), so contention
|
||||||
// is negligible.
|
// is negligible.
|
||||||
std::lock_guard<std::mutex> lock(sessionMutex_);
|
std::lock_guard<std::mutex> lock(sessionMutex_);
|
||||||
if (!executor_) {
|
if (executor_) {
|
||||||
return Result<std::string>::err("HTTP error: no executor configured");
|
return executor_(url);
|
||||||
}
|
}
|
||||||
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
|
} // namespace ccm
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ add_executable(ccm_core_tests
|
|||||||
std_file_system_tests.cpp
|
std_file_system_tests.cpp
|
||||||
pokemon_set_source_tests.cpp
|
pokemon_set_source_tests.cpp
|
||||||
pokemon_card_preview_source_tests.cpp
|
pokemon_card_preview_source_tests.cpp
|
||||||
|
icard_preview_source_tests.cpp
|
||||||
yugioh_set_source_tests.cpp
|
yugioh_set_source_tests.cpp
|
||||||
yugioh_card_preview_source_tests.cpp
|
yugioh_card_preview_source_tests.cpp
|
||||||
game_module_tests.cpp
|
game_module_tests.cpp
|
||||||
|
|||||||
@@ -703,6 +703,29 @@ TEST_SUITE("CardPreviewService caching") {
|
|||||||
CHECK(http.calls == 1);
|
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") {
|
TEST_CASE("in-memory LRU evicts oldest entry after exceeding capacity") {
|
||||||
FakeSource source;
|
FakeSource source;
|
||||||
FakeGameModule module;
|
FakeGameModule module;
|
||||||
|
|||||||
@@ -16,9 +16,15 @@ namespace {
|
|||||||
class InMemoryRepo final : public ICollectionRepository<MagicCard> {
|
class InMemoryRepo final : public ICollectionRepository<MagicCard> {
|
||||||
public:
|
public:
|
||||||
Map storage;
|
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 {
|
Result<void> save(Game, const Map& m) override {
|
||||||
|
if (failSave) return Result<void>::err("save failed");
|
||||||
storage = m;
|
storage = m;
|
||||||
return Result<void>::ok();
|
return Result<void>::ok();
|
||||||
}
|
}
|
||||||
@@ -27,12 +33,14 @@ public:
|
|||||||
class StubImageStore final : public IImageStore {
|
class StubImageStore final : public IImageStore {
|
||||||
public:
|
public:
|
||||||
std::vector<std::pair<Game, std::string>> removed;
|
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 {
|
Result<std::string> copyIn(Game, const std::filesystem::path&, const std::string& n) override {
|
||||||
return Result<std::string>::ok(n);
|
return Result<std::string>::ok(n);
|
||||||
}
|
}
|
||||||
Result<void> remove(Game g, const std::string& n) override {
|
Result<void> remove(Game g, const std::string& n) override {
|
||||||
removed.emplace_back(g, n);
|
removed.emplace_back(g, n);
|
||||||
|
if (failRemove) return Result<void>::err("remove failed for " + n);
|
||||||
return Result<void>::ok();
|
return Result<void>::ok();
|
||||||
}
|
}
|
||||||
std::filesystem::path resolvePath(Game, const std::string& n) const override {
|
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());
|
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());
|
REQUIRE(out.isErr());
|
||||||
CHECK(out.error() == "HTTP 503 from https://example.com");
|
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<Call> copies;
|
||||||
std::vector<std::pair<Game, std::string>> removes;
|
std::vector<std::pair<Game, std::string>> removes;
|
||||||
std::string returnedExt = ".png";
|
std::string returnedExt = ".png";
|
||||||
|
int failCopyAt = -1;
|
||||||
|
int failRemoveAt = -1;
|
||||||
|
|
||||||
Result<std::string> copyIn(Game game,
|
Result<std::string> copyIn(Game game,
|
||||||
const std::filesystem::path& srcPath,
|
const std::filesystem::path& srcPath,
|
||||||
const std::string& targetName) override {
|
const std::string& targetName) override {
|
||||||
copies.push_back({game, srcPath, targetName});
|
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);
|
return Result<std::string>::ok(targetName + returnedExt);
|
||||||
}
|
}
|
||||||
|
|
||||||
Result<void> remove(Game game, const std::string& imageName) override {
|
Result<void> remove(Game game, const std::string& imageName) override {
|
||||||
removes.emplace_back(game, imageName);
|
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();
|
return Result<void>::ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +63,11 @@ TEST_SUITE("ImageService::nextImageIndex") {
|
|||||||
std::vector<std::string> imgs2 = {"otherIMG_BACK.png"};
|
std::vector<std::string> imgs2 = {"otherIMG_BACK.png"};
|
||||||
CHECK(ImageService::nextImageIndex(imgs2) == 0);
|
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") {
|
TEST_SUITE("ImageService::buildTargetName") {
|
||||||
@@ -128,4 +141,38 @@ TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
|
|||||||
CHECK(store.copies.empty());
|
CHECK(store.copies.empty());
|
||||||
CHECK(store.removes.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();
|
cfg.initialize();
|
||||||
return cfg;
|
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
|
} // namespace
|
||||||
|
|
||||||
TEST_SUITE("JsonSetRepository") {
|
TEST_SUITE("JsonSetRepository") {
|
||||||
@@ -49,4 +84,48 @@ TEST_SUITE("JsonSetRepository") {
|
|||||||
const auto loaded = repo.load(Game::Pokemon);
|
const auto loaded = repo.load(Game::Pokemon);
|
||||||
CHECK(loaded.isErr());
|
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_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") {
|
TEST_CASE("queries Yugipedia first and uses the per-printing scan when found") {
|
||||||
// Two same-passcode reprints with genuinely different art (LOB vs
|
// Two same-passcode reprints with genuinely different art (LOB vs
|
||||||
// SDK Blue-Eyes). Yugipedia hosts both, so we should always pick the
|
// 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);
|
const auto out = YuGiOhSetSource::parseResponse(json);
|
||||||
REQUIRE(out.isOk());
|
REQUIRE(out.isOk());
|
||||||
REQUIRE(out.value().size() == 2);
|
bool foundA = false;
|
||||||
CHECK(out.value()[0].id == "AAA");
|
bool foundB = false;
|
||||||
CHECK(out.value()[0].releaseDate == "2020/01/01");
|
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") {
|
TEST_CASE("sorts by release date ascending") {
|
||||||
@@ -47,6 +56,48 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") {
|
|||||||
TEST_CASE("missing array returns error") {
|
TEST_CASE("missing array returns error") {
|
||||||
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
|
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") {
|
TEST_SUITE("YuGiOhSetSource::fetchAll") {
|
||||||
@@ -59,4 +110,13 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") {
|
|||||||
CHECK(out.value().front().id == "X");
|
CHECK(out.value().front().id == "X");
|
||||||
CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php");
|
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