mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 23:01:09 +00:00
d3b4762b76
* several functions. fixes #1 and #2 * multi selection functionality
1051 lines
39 KiB
C++
1051 lines
39 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include "ccm/games/IGameModule.hpp"
|
|
#include "ccm/ports/ICardPreviewSource.hpp"
|
|
#include "ccm/ports/IFileSystem.hpp"
|
|
#include "ccm/ports/IHttpClient.hpp"
|
|
#include "ccm/ports/IPreviewByteCache.hpp"
|
|
#include "ccm/services/CardPreviewService.hpp"
|
|
|
|
#include <filesystem>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
using namespace ccm;
|
|
|
|
namespace {
|
|
|
|
class FakeSource final : public ICardPreviewSource {
|
|
public:
|
|
std::string url = "https://example.com/preview.jpg";
|
|
bool ok = true;
|
|
// The kind of error returned when ok == false. Defaults to Transient
|
|
// because the historical tests assumed "errors don't get cached"; the
|
|
// negative-cache tests flip this to NotFound explicitly.
|
|
PreviewLookupError::Kind errKind = PreviewLookupError::Kind::Transient;
|
|
std::string err = "boom";
|
|
int calls{0};
|
|
|
|
// Capture inputs so tests can assert routing.
|
|
std::string lastName;
|
|
std::string lastSetId;
|
|
std::string lastSetNo;
|
|
std::string detectLastName;
|
|
std::string detectLastSetId;
|
|
std::string detectLastSetNo;
|
|
AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"};
|
|
bool allowAutoDetect{true};
|
|
|
|
Result<std::string, PreviewLookupError>
|
|
fetchImageUrl(std::string_view name,
|
|
std::string_view setId,
|
|
std::string_view setNo) override {
|
|
++calls;
|
|
lastName = std::string(name);
|
|
lastSetId = std::string(setId);
|
|
lastSetNo = std::string(setNo);
|
|
if (ok) {
|
|
return Result<std::string, PreviewLookupError>::ok(url);
|
|
}
|
|
return Result<std::string, PreviewLookupError>::err({errKind, err});
|
|
}
|
|
|
|
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
|
std::string_view setId) override {
|
|
detectLastName = std::string(name);
|
|
detectLastSetId = std::string(setId);
|
|
return Result<AutoDetectedPrint>::ok(detectedPrint);
|
|
}
|
|
|
|
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
|
std::string_view setId) override {
|
|
detectLastName = std::string(name);
|
|
detectLastSetId = std::string(setId);
|
|
std::vector<AutoDetectedPrint> v;
|
|
v.push_back(detectedPrint);
|
|
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
|
|
}
|
|
|
|
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
|
std::string_view setNo) override {
|
|
detectLastSetId = std::string(setId);
|
|
detectLastSetNo = std::string(setNo);
|
|
return Result<AutoDetectedPrint>::ok(detectedPrint);
|
|
}
|
|
|
|
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
|
std::string_view setId,
|
|
std::string_view setNo) override {
|
|
detectLastSetId = std::string(setId);
|
|
detectLastSetNo = std::string(setNo);
|
|
std::vector<AutoDetectedPrint> v;
|
|
v.push_back(detectedPrint);
|
|
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
|
|
}
|
|
|
|
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
|
|
return allowAutoDetect;
|
|
}
|
|
};
|
|
|
|
class FixedHttpClient final : public IHttpClient {
|
|
public:
|
|
std::string lastUrl;
|
|
std::string body;
|
|
bool ok = true;
|
|
std::string err = "offline";
|
|
int calls{0};
|
|
|
|
Result<std::string> get(std::string_view url) override {
|
|
++calls;
|
|
lastUrl = std::string(url);
|
|
return ok ? Result<std::string>::ok(body)
|
|
: Result<std::string>::err(err);
|
|
}
|
|
};
|
|
|
|
// In-memory persistent cache stand-in. Real implementation lives in
|
|
// LocalPreviewByteCache (covered by local_preview_byte_cache_tests.cpp); the
|
|
// fake here lets us pin down CardPreviewService's wiring without dragging in
|
|
// the filesystem.
|
|
class InMemoryByteCache final : public IPreviewByteCache {
|
|
public:
|
|
struct Entry {
|
|
bool negative{false};
|
|
std::string payload;
|
|
};
|
|
std::unordered_map<std::string, Entry> entries;
|
|
int loadCalls{0};
|
|
int storeCalls{0};
|
|
int storeNegativeCalls{0};
|
|
|
|
[[nodiscard]] LoadResult load(std::string_view key) override {
|
|
++loadCalls;
|
|
auto it = entries.find(std::string(key));
|
|
if (it == entries.end()) return {HitKind::Miss, {}};
|
|
if (it->second.negative) return {HitKind::NegativeHit, {}};
|
|
return {HitKind::Hit, it->second.payload};
|
|
}
|
|
void store(std::string_view key, const std::string& payload) override {
|
|
++storeCalls;
|
|
entries[std::string(key)] = Entry{false, payload};
|
|
}
|
|
void storeNegative(std::string_view key) override {
|
|
++storeNegativeCalls;
|
|
entries[std::string(key)] = Entry{true, {}};
|
|
}
|
|
};
|
|
|
|
class AlwaysNegativeUrlCache final : public IPreviewByteCache {
|
|
public:
|
|
[[nodiscard]] LoadResult load(std::string_view key) override {
|
|
if (!key.empty() && key.front() == 'u') {
|
|
return {HitKind::NegativeHit, {}};
|
|
}
|
|
return {HitKind::Miss, {}};
|
|
}
|
|
void store(std::string_view, const std::string&) override {}
|
|
void storeNegative(std::string_view) override {}
|
|
};
|
|
|
|
class MemoryFileSystem final : public IFileSystem {
|
|
public:
|
|
std::unordered_map<std::string, std::string> files;
|
|
|
|
[[nodiscard]] bool exists(const std::filesystem::path& p) const override {
|
|
return files.contains(p.generic_string());
|
|
}
|
|
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override {
|
|
return false;
|
|
}
|
|
Result<void> ensureDirectory(const std::filesystem::path&) override {
|
|
return Result<void>::ok();
|
|
}
|
|
Result<std::string> readText(const std::filesystem::path& p) override {
|
|
auto it = files.find(p.generic_string());
|
|
if (it == files.end()) {
|
|
return Result<std::string>::err("Unable to open " + p.generic_string());
|
|
}
|
|
return Result<std::string>::ok(it->second);
|
|
}
|
|
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override {
|
|
files[p.generic_string()] = std::string(contents);
|
|
return Result<void>::ok();
|
|
}
|
|
Result<void> copyFile(const std::filesystem::path& from,
|
|
const std::filesystem::path& to,
|
|
bool) override {
|
|
auto it = files.find(from.generic_string());
|
|
if (it == files.end()) {
|
|
return Result<void>::err("missing source");
|
|
}
|
|
files[to.generic_string()] = it->second;
|
|
return Result<void>::ok();
|
|
}
|
|
Result<void> remove(const std::filesystem::path& p) override {
|
|
files.erase(p.generic_string());
|
|
return Result<void>::ok();
|
|
}
|
|
Result<std::vector<std::filesystem::path>> listDirectory(
|
|
const std::filesystem::path&) override {
|
|
return Result<std::vector<std::filesystem::path>>::ok({});
|
|
}
|
|
};
|
|
|
|
// Minimal IGameModule fake that exposes a configurable preview source.
|
|
class FakeGameModule final : public IGameModule {
|
|
public:
|
|
Game gameId = Game::Magic;
|
|
ICardPreviewSource* preview = nullptr;
|
|
|
|
[[nodiscard]] Game id() const noexcept override { return gameId; }
|
|
[[nodiscard]] std::string dirName() const override { return "fake"; }
|
|
[[nodiscard]] std::string displayName() const override { return "Fake"; }
|
|
|
|
ISetSource& setSource() override {
|
|
// Tests in this file never call setSource(); a never-returned helper
|
|
// would clutter the fake. Throwing keeps misuse loud.
|
|
throw std::logic_error("FakeGameModule::setSource() not used in this test");
|
|
}
|
|
ICardPreviewSource* cardPreviewSource() noexcept override { return preview; }
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
|
|
TEST_CASE("happy path: routes through registered module then http GET") {
|
|
FakeSource source;
|
|
source.url = "https://example.com/img.png";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.body = std::string("\x89PNG\r\n\x1a\n", 8); // arbitrary binary
|
|
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
|
REQUIRE(out.isOk());
|
|
CHECK(out.value() == http.body);
|
|
CHECK(http.lastUrl == "https://example.com/img.png");
|
|
CHECK(source.lastName == "Lightning Bolt");
|
|
CHECK(source.lastSetId == "lea");
|
|
CHECK(source.lastSetNo.empty());
|
|
}
|
|
|
|
TEST_CASE("module returning nullptr preview source is skipped silently") {
|
|
FakeGameModule module;
|
|
module.gameId = Game::Pokemon;
|
|
module.preview = nullptr;
|
|
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module); // no-op
|
|
|
|
const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error().find("No preview source registered") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("unregistered game returns an explicit error") {
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error().find("No preview source registered") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("source error propagates and http is not called") {
|
|
FakeSource source;
|
|
source.ok = false;
|
|
source.err = "scryfall 404";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.lastUrl = "<unset>";
|
|
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error() == "scryfall 404");
|
|
CHECK(http.lastUrl == "<unset>");
|
|
}
|
|
|
|
TEST_CASE("http GET error propagates") {
|
|
FakeSource source;
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.ok = false;
|
|
http.err = "net down";
|
|
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error() == "net down");
|
|
}
|
|
|
|
TEST_CASE("asset: preview loads bytes from configured asset root") {
|
|
FakeSource source;
|
|
source.url = "asset:pokemon_jp_classic/TamamushiCG/016.jpg";
|
|
FakeGameModule module;
|
|
module.gameId = Game::JapanesePokemon;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
MemoryFileSystem fs;
|
|
fs.files["assets/pokemon_jp_classic/TamamushiCG/016.jpg"] = "JPEG-bytes";
|
|
|
|
CardPreviewService svc{http, nullptr, &fs, "assets"};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.fetchPreviewBytes(Game::JapanesePokemon, "Erika", "TamamushiCG", "016");
|
|
REQUIRE(out.isOk());
|
|
CHECK(out.value() == "JPEG-bytes");
|
|
CHECK(http.calls == 0);
|
|
}
|
|
}
|
|
|
|
TEST_SUITE("CardPreviewService caching") {
|
|
TEST_CASE("repeat fetchPreviewBytes for the same key serves from cache") {
|
|
// Re-selecting the same row in the table is the hot path: the
|
|
// (game, name, setId, setNo) tuple uniquely identifies a printing,
|
|
// and the resolved image URL is a deterministic function of those
|
|
// inputs - so we can safely cache the bytes and skip HTTP entirely
|
|
// on repeat. Each cache hit spares us two HTTPS round trips
|
|
// (Yugipedia API + image GET) plus a TLS handshake on a cold pool.
|
|
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};
|
|
svc.registerModule(module);
|
|
|
|
const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
|
REQUIRE(first.isOk());
|
|
CHECK(first.value() == "PNG-bytes");
|
|
CHECK(http.calls == 1);
|
|
|
|
// Mutate the source's response to prove the second call doesn't go
|
|
// through the source either: only the cache should be consulted.
|
|
source.url = "https://example.com/CHANGED.png";
|
|
http.body = "DIFFERENT-bytes";
|
|
|
|
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
|
REQUIRE(second.isOk());
|
|
CHECK(second.value() == "PNG-bytes");
|
|
CHECK(http.calls == 1); // no new GET issued
|
|
}
|
|
|
|
TEST_CASE("different cards share the same source but get separate cache slots") {
|
|
FakeSource source;
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
source.url = "https://example.com/a.png";
|
|
http.body = "A-bytes";
|
|
const auto a = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", "");
|
|
REQUIRE(a.isOk());
|
|
CHECK(a.value() == "A-bytes");
|
|
|
|
source.url = "https://example.com/b.png";
|
|
http.body = "B-bytes";
|
|
const auto b = svc.fetchPreviewBytes(Game::Magic, "Card B", "lea", "");
|
|
REQUIRE(b.isOk());
|
|
CHECK(b.value() == "B-bytes");
|
|
|
|
CHECK(http.calls == 2);
|
|
|
|
// Re-fetching A returns the originally cached payload, not the most
|
|
// recent http.body; this pins down per-card cache scoping.
|
|
http.body = "stale";
|
|
const auto aAgain = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", "");
|
|
REQUIRE(aAgain.isOk());
|
|
CHECK(aAgain.value() == "A-bytes");
|
|
CHECK(http.calls == 2);
|
|
}
|
|
|
|
TEST_CASE("source errors are not cached so a transient failure can recover") {
|
|
// If the per-game source returns an error we should not poison the
|
|
// cache - the next selection must be free to retry, otherwise a
|
|
// single network blip would permanently disable previews for that
|
|
// card until the user restarts the app.
|
|
FakeSource source;
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.body = "PNG-bytes";
|
|
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
source.ok = false;
|
|
source.err = "transient 503";
|
|
const auto firstErr = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
|
|
CHECK(firstErr.isErr());
|
|
CHECK(http.calls == 0); // source short-circuited before HTTP
|
|
|
|
source.ok = true;
|
|
source.url = "https://example.com/x.png";
|
|
const auto recovered = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
|
|
REQUIRE(recovered.isOk());
|
|
CHECK(recovered.value() == "PNG-bytes");
|
|
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
|
|
// sub-case exercises the write-through path.
|
|
FakeSource source;
|
|
source.url = "https://example.com/img.png";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.body = "PNG-bytes";
|
|
|
|
InMemoryByteCache disk;
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
|
REQUIRE(out.isOk());
|
|
CHECK(disk.storeCalls == 1);
|
|
CHECK(disk.entries.size() == 1);
|
|
// Key shape is service-internal so we don't assert it directly; only
|
|
// that *something* identifying this card was persisted with the right
|
|
// payload.
|
|
bool foundPayload = false;
|
|
for (const auto& [k, e] : disk.entries) {
|
|
if (!e.negative && e.payload == "PNG-bytes") foundPayload = true;
|
|
}
|
|
CHECK(foundPayload);
|
|
}
|
|
|
|
TEST_CASE("warm restart: a fresh service instance serves from disk without HTTP") {
|
|
// Simulates an app restart by constructing two CardPreviewService
|
|
// instances over the same persistent cache. The second instance
|
|
// must serve the previously-fetched bytes from disk - no source
|
|
// call, no HTTP call - which is the whole point of the persistent
|
|
// tier.
|
|
FakeSource source;
|
|
source.url = "https://example.com/img.png";
|
|
FakeGameModule module;
|
|
module.gameId = Game::YuGiOh;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.body = "DM-bytes";
|
|
|
|
InMemoryByteCache disk;
|
|
|
|
// Run 1: cold start, normal fetch path warms the persistent cache.
|
|
{
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
const auto out = svc.fetchPreviewBytes(
|
|
Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE");
|
|
REQUIRE(out.isOk());
|
|
}
|
|
REQUIRE(http.calls == 1);
|
|
REQUIRE(disk.entries.size() == 1);
|
|
|
|
// Run 2: pretend the app restarted. Make HTTP and the source both
|
|
// return obviously-wrong data so any unexpected fall-through to
|
|
// them shows up loudly in the assertion.
|
|
http.body = "UNEXPECTED";
|
|
source.url = "https://example.com/UNEXPECTED.png";
|
|
{
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
const auto warm = svc.fetchPreviewBytes(
|
|
Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE");
|
|
REQUIRE(warm.isOk());
|
|
CHECK(warm.value() == "DM-bytes");
|
|
}
|
|
// No second HTTP request - disk tier covered it.
|
|
CHECK(http.calls == 1);
|
|
CHECK(disk.loadCalls >= 1);
|
|
}
|
|
|
|
TEST_CASE("disk hit promotes into the in-memory tier so subsequent calls skip even disk I/O") {
|
|
FakeSource source;
|
|
source.url = "https://example.com/img.png";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.body = "X-bytes";
|
|
|
|
InMemoryByteCache disk;
|
|
// Pre-seed the disk cache with a value the service has never fetched
|
|
// this session. The first call should pull it off disk and copy it
|
|
// into memory; the second call should be served from memory without
|
|
// touching the disk cache at all.
|
|
// We can't compose the key from outside, so we go through the
|
|
// service once to learn the key shape via storeCalls.
|
|
{
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
(void)svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
|
}
|
|
REQUIRE(http.calls == 1);
|
|
REQUIRE(disk.entries.size() == 1);
|
|
const auto storedKey = disk.entries.begin()->first;
|
|
|
|
// Clear the in-memory tier by constructing a fresh service. Disk
|
|
// entry stays.
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
|
|
const int loadsBefore = disk.loadCalls;
|
|
// Force HTTP to fail loudly so we can prove neither call hits it.
|
|
http.ok = false;
|
|
|
|
const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
|
REQUIRE(first.isOk());
|
|
CHECK(first.value() == "X-bytes");
|
|
CHECK(disk.loadCalls == loadsBefore + 1);
|
|
|
|
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
|
REQUIRE(second.isOk());
|
|
CHECK(second.value() == "X-bytes");
|
|
// No additional disk hit - in-memory tier served it.
|
|
CHECK(disk.loadCalls == loadsBefore + 1);
|
|
// Sanity: only the original storedKey ever made it to disk.
|
|
CHECK(disk.entries.count(storedKey) == 1);
|
|
}
|
|
|
|
TEST_CASE("transient source errors do not write through to the persistent cache") {
|
|
// A transient failure (HTTP 5xx, network drop, parse error) must
|
|
// never get cached - if it did, a single bad request would
|
|
// silently break this card's preview until the user restarts the
|
|
// app or edits the record.
|
|
FakeSource source;
|
|
source.ok = false;
|
|
source.errKind = PreviewLookupError::Kind::Transient;
|
|
source.err = "scryfall 503";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
InMemoryByteCache disk;
|
|
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
|
|
CHECK(out.isErr());
|
|
CHECK(disk.storeCalls == 0);
|
|
CHECK(disk.storeNegativeCalls == 0);
|
|
CHECK(disk.entries.empty());
|
|
}
|
|
|
|
TEST_CASE("NotFound source errors are negative-cached and short-circuit subsequent calls") {
|
|
// A NotFound result means the upstream cleanly answered "no image
|
|
// for this record". The next selection of the *same* record must
|
|
// return immediately without invoking the source again - that's
|
|
// the whole point of negative caching, and the user-visible win
|
|
// (no spinner, no per-click HTTP) on every selection of a card
|
|
// whose printing has no scan upstream.
|
|
FakeSource source;
|
|
source.ok = false;
|
|
source.errKind = PreviewLookupError::Kind::NotFound;
|
|
source.err = "no scan exists";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
InMemoryByteCache disk;
|
|
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
|
|
const auto first = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", "");
|
|
CHECK(first.isErr());
|
|
CHECK(source.calls == 1);
|
|
CHECK(disk.storeCalls == 0);
|
|
CHECK(disk.storeNegativeCalls == 1);
|
|
|
|
// Second call: the source must NOT be consulted again. The
|
|
// in-memory negative entry covers it.
|
|
const auto second = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", "");
|
|
CHECK(second.isErr());
|
|
CHECK(source.calls == 1); // still 1 - cache short-circuited
|
|
CHECK(http.calls == 0);
|
|
}
|
|
|
|
TEST_CASE("editing a lookup-relevant field invalidates the negative entry automatically") {
|
|
// The cache key is (game, name, setId, setNo). The user fixing a
|
|
// typo in the card's name (or any other lookup-relevant field)
|
|
// changes the key, so the previously-cached "no image" verdict no
|
|
// longer matches and a fresh resolution attempt runs.
|
|
FakeSource source;
|
|
source.ok = false;
|
|
source.errKind = PreviewLookupError::Kind::NotFound;
|
|
source.err = "no scan exists";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
InMemoryByteCache disk;
|
|
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
|
|
// First lookup: the typo name produces a clean NotFound -> negative cache.
|
|
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "").isErr());
|
|
REQUIRE(source.calls == 1);
|
|
|
|
// Now flip the source to "found" and ask for the corrected name.
|
|
source.ok = true;
|
|
source.url = "https://example.com/lb.png";
|
|
http.body = "LB-bytes";
|
|
|
|
const auto fixed = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
|
REQUIRE(fixed.isOk());
|
|
CHECK(fixed.value() == "LB-bytes");
|
|
CHECK(source.calls == 2);
|
|
|
|
// And the original (typo) record stays negative-cached.
|
|
source.ok = false; // belt-and-braces: prove the typo path doesn't re-call the source
|
|
const auto stillNegative =
|
|
svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "");
|
|
CHECK(stillNegative.isErr());
|
|
CHECK(source.calls == 2); // typo path never re-resolved
|
|
}
|
|
|
|
TEST_CASE("warm restart honors a previously-stored negative entry without HTTP") {
|
|
// Persistent negative caching is the strongest motivation for the
|
|
// disk tier carrying negatives at all: a card with no upstream
|
|
// image stays "instant card-back" across app restarts, instead of
|
|
// re-paying the round-trip to learn the same thing every launch.
|
|
FakeSource source;
|
|
source.ok = false;
|
|
source.errKind = PreviewLookupError::Kind::NotFound;
|
|
source.err = "no scan exists";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
InMemoryByteCache disk;
|
|
|
|
// Run 1: negative outcome, persisted.
|
|
{
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Z", "set", "").isErr());
|
|
}
|
|
REQUIRE(disk.storeNegativeCalls == 1);
|
|
const int sourceCallsAfterRun1 = source.calls;
|
|
|
|
// Run 2: fresh service over the same disk fake. Source must not be
|
|
// consulted at all - the negative entry on disk short-circuits it.
|
|
{
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
const auto warm = svc.fetchPreviewBytes(Game::Magic, "Z", "set", "");
|
|
CHECK(warm.isErr());
|
|
}
|
|
CHECK(source.calls == sourceCallsAfterRun1);
|
|
}
|
|
|
|
TEST_CASE("a later positive result for the same key replaces the negative entry") {
|
|
// If the upstream eventually grows a scan for a card that was
|
|
// previously NotFound, the very next successful lookup must
|
|
// overwrite the negative entry so it doesn't keep haunting the
|
|
// user. Realistic scenario: Yugipedia uploads a scan that didn't
|
|
// exist before; the user clicks the row again and we now serve
|
|
// the real image. (In production the cache key may also change
|
|
// due to a record edit, but that's a separate path; here we test
|
|
// the same-key recovery story.)
|
|
FakeSource source;
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
InMemoryByteCache disk;
|
|
|
|
CardPreviewService svc{http, &disk};
|
|
svc.registerModule(module);
|
|
|
|
// First call: NotFound.
|
|
source.ok = false;
|
|
source.errKind = PreviewLookupError::Kind::NotFound;
|
|
source.err = "no scan yet";
|
|
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Card", "set", "").isErr());
|
|
|
|
// Imitate the upstream growing a scan, AND simulate the user
|
|
// retrying. To bypass the negative cache without a record edit,
|
|
// construct a fresh service: this models the next-launch case
|
|
// where the user reopens the app and the image now exists. (Within
|
|
// a single session, only an edit to the record clears the cache,
|
|
// which is the correct UX - we don't want a network round-trip on
|
|
// every click for cards we already know have no upstream image.)
|
|
source.ok = true;
|
|
source.url = "https://example.com/late.png";
|
|
http.body = "LATE-bytes";
|
|
|
|
// Drop the disk negative manually to simulate a scenario where
|
|
// the cache was cleared (e.g. the user manually wiped the cache
|
|
// directory). In production code paths, an edit to the record is
|
|
// the standard invalidation; both routes converge on the same
|
|
// expected behavior: a fresh successful lookup.
|
|
disk.entries.clear();
|
|
|
|
CardPreviewService svc2{http, &disk};
|
|
svc2.registerModule(module);
|
|
const auto out = svc2.fetchPreviewBytes(Game::Magic, "Card", "set", "");
|
|
REQUIRE(out.isOk());
|
|
CHECK(out.value() == "LATE-bytes");
|
|
}
|
|
|
|
TEST_CASE("fetchImageBytesByUrl caches by URL too (fallback card-back)") {
|
|
// The fallback card-back image is fetched via fetchImageBytesByUrl
|
|
// for every card without a preview; caching by URL makes the second
|
|
// fallback effectively free.
|
|
FixedHttpClient http;
|
|
http.body = "card-back-bytes";
|
|
CardPreviewService svc{http};
|
|
|
|
const auto first = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
|
|
REQUIRE(first.isOk());
|
|
CHECK(http.calls == 1);
|
|
|
|
http.body = "stale";
|
|
const auto second = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
|
|
REQUIRE(second.isOk());
|
|
CHECK(second.value() == "card-back-bytes");
|
|
CHECK(http.calls == 1);
|
|
}
|
|
|
|
TEST_CASE("empty HTTP body is rejected and not cached") {
|
|
FakeSource source;
|
|
source.url = "https://example.com/empty.png";
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.body = "";
|
|
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto first = svc.fetchPreviewBytes(Game::Magic, "Any", "set", "1");
|
|
CHECK(first.isErr());
|
|
CHECK(first.error().find("Empty response body") != std::string::npos);
|
|
CHECK(http.calls == 1);
|
|
|
|
const auto second = svc.fetchPreviewBytes(Game::Magic, "Any", "set", "1");
|
|
CHECK(second.isErr());
|
|
CHECK(http.calls == 2);
|
|
}
|
|
|
|
TEST_CASE("url negative entry on disk is treated as miss and refetched") {
|
|
FixedHttpClient http;
|
|
http.body = "card-back";
|
|
AlwaysNegativeUrlCache disk;
|
|
CardPreviewService svc{http, &disk};
|
|
|
|
const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
|
|
REQUIRE(out.isOk());
|
|
CHECK(out.value() == "card-back");
|
|
CHECK(http.calls == 1);
|
|
}
|
|
|
|
TEST_CASE("fetchImageBytesByUrl propagates HTTP errors when uncached") {
|
|
FixedHttpClient http;
|
|
http.ok = false;
|
|
http.err = "url fetch failed";
|
|
CardPreviewService svc{http};
|
|
|
|
const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
|
|
REQUIRE(out.isErr());
|
|
CHECK(out.error() == "url fetch failed");
|
|
}
|
|
|
|
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;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
http.body = "x";
|
|
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto cap = CardPreviewService::kCacheCapacity;
|
|
for (std::size_t i = 0; i < cap + 1; ++i) {
|
|
const std::string name = std::string("LRU-") + std::to_string(i);
|
|
REQUIRE(svc.fetchPreviewBytes(Game::Magic, name, "lea", "").isOk());
|
|
}
|
|
REQUIRE(http.calls == cap + 1);
|
|
|
|
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "LRU-0", "lea", "").isOk());
|
|
CHECK(http.calls == cap + 2);
|
|
}
|
|
}
|
|
|
|
TEST_SUITE("CardPreviewService::detectFirstPrint") {
|
|
TEST_CASE("routes auto-detect to registered source") {
|
|
FakeSource source;
|
|
source.detectedPrint = {"LOB-005", "Secret Rare"};
|
|
FakeGameModule module;
|
|
module.gameId = Game::YuGiOh;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "Legend of Blue Eyes White Dragon");
|
|
REQUIRE(out.isOk());
|
|
CHECK(out.value().setNo == "LOB-005");
|
|
CHECK(out.value().rarity == "Secret Rare");
|
|
CHECK(source.detectLastName == "Dark Magician");
|
|
CHECK(source.detectLastSetId == "Legend of Blue Eyes White Dragon");
|
|
}
|
|
|
|
TEST_CASE("returns explicit error when game does not enable auto-detect") {
|
|
FakeSource source;
|
|
source.allowAutoDetect = false;
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.detectFirstPrint(Game::Magic, "Any", "Any Set");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error().find("not enabled") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("unregistered game returns explicit error") {
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
const auto out =
|
|
svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "LOB");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error().find("No preview source registered") !=
|
|
std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_SUITE("CardPreviewService::detectPrintVariants") {
|
|
TEST_CASE("routes variant listing to registered source") {
|
|
FakeSource source;
|
|
source.detectedPrint = {"LOB-005", "Secret Rare"};
|
|
FakeGameModule module;
|
|
module.gameId = Game::YuGiOh;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.detectPrintVariants(Game::YuGiOh, "Dark Magician",
|
|
"Legend of Blue Eyes White Dragon");
|
|
REQUIRE(out.isOk());
|
|
REQUIRE(out.value().size() == 1);
|
|
CHECK(out.value()[0].setNo == "LOB-005");
|
|
CHECK(out.value()[0].rarity == "Secret Rare");
|
|
}
|
|
|
|
TEST_CASE("detectPrintVariants returns error when game does not enable auto-detect") {
|
|
FakeSource source;
|
|
source.allowAutoDetect = false;
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.detectPrintVariants(Game::Magic, "Any", "Any Set");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error().find("not enabled") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("unregistered game returns explicit error") {
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
const auto out =
|
|
svc.detectPrintVariants(Game::YuGiOh, "Dark Magician", "LOB");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error().find("No preview source registered") !=
|
|
std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_SUITE("CardPreviewService::detectVariantsBySetNo") {
|
|
TEST_CASE("routes set-scoped reverse lookup to registered source") {
|
|
FakeSource source;
|
|
source.detectedPrint = AutoDetectedPrint{"4", "Rare", "Pikachu"};
|
|
FakeGameModule module;
|
|
module.gameId = Game::Pokemon;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.detectVariantsBySetNo(Game::Pokemon, "base1", "4");
|
|
REQUIRE(out.isOk());
|
|
REQUIRE(out.value().size() == 1);
|
|
CHECK(out.value()[0].name == "Pikachu");
|
|
CHECK(out.value()[0].setNo == "4");
|
|
CHECK(source.detectLastSetId == "base1");
|
|
CHECK(source.detectLastSetNo == "4");
|
|
}
|
|
|
|
TEST_CASE("returns error when game does not enable auto-detect") {
|
|
FakeSource source;
|
|
source.allowAutoDetect = false;
|
|
FakeGameModule module;
|
|
module.gameId = Game::Magic;
|
|
module.preview = &source;
|
|
|
|
FixedHttpClient http;
|
|
CardPreviewService svc{http};
|
|
svc.registerModule(module);
|
|
|
|
const auto out = svc.detectVariantsBySetNo(Game::Magic, "lea", "1");
|
|
CHECK(out.isErr());
|
|
CHECK(out.error().find("not enabled") != std::string::npos);
|
|
}
|
|
}
|