mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
major: initial release
* initial development * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * pokemon * pokemon * pokemon * pokemon * pokemon * pokemon * improvements * improvements * ci/cd * ci/cd * improvements * improvements * improvements * improvements * improvements * improvements * improvements * improvements * improvements --------- Co-authored-by: sdine <sdine@sdine.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# tests/AGENTS.md
|
||||
|
||||
`ccm_core_tests` — doctest unit tests for `ccm_core`. Hermetic, fast, no real network or disk. Read the root `AGENTS.md` first.
|
||||
|
||||
## File pointers
|
||||
|
||||
- `main.cpp` — doctest entry point with `DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN`. Do not put tests here.
|
||||
- `fakes/InMemoryFileSystem.{hpp,cpp}` — `IFileSystem` implementation backed by `std::map`. Normalizes paths via `lexically_normal().generic_string()` (always `/` separators).
|
||||
- `fs_names_tests.cpp` — `formatTextForFs` + `parseIndexFromFilename`. Both are compatibility ports of the original Rust path rules.
|
||||
- `domain_json_tests.cpp` — JSON round-trip tests for every domain type. **Update this file whenever a domain type changes.**
|
||||
- `image_service_tests.cpp` — `ImageService` (uses inline `RecordingImageStore` fake).
|
||||
- `collection_service_tests.cpp` — `CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
|
||||
- `config_service_tests.cpp` — `ConfigService` against `InMemoryFileSystem`.
|
||||
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`.
|
||||
- `set_service_tests.cpp` — `SetService` with `FakeSetSource` + `InMemSetRepo`.
|
||||
- `magic_set_source_tests.cpp` — `MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
|
||||
- `magic_card_preview_source_tests.cpp` — `MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`.
|
||||
- `card_preview_service_tests.cpp` — `CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` and a `FixedHttpClient`. Pin-down for the "module returning nullptr is silently skipped" rule.
|
||||
- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
|
||||
- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
|
||||
- `card_sorter_tests.cpp` — `sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key.
|
||||
- `card_filter_tests.cpp` — `matchesMagicFilter` / `matchesPokemonFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`), boolean flag columns (foil/signed/altered/holo/firstEdition) intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
|
||||
- `CMakeLists.txt` — explicit list of every `.cpp` (no glob).
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`.
|
||||
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`.
|
||||
3. **Fakes for narrow concerns stay in the test file** as anonymous-namespace classes (e.g. `RecordingImageStore`, `InMemoryRepo`). Promote a fake to `tests/fakes/` only when more than one test file needs it.
|
||||
4. **Path strings** in expectations must use forward slashes. The fake normalizes everything to `generic_string()`. Do not hard-code `\` separators.
|
||||
5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file".
|
||||
6. **Add a `.cpp` to the `add_executable` call in `tests/CMakeLists.txt`.** No glob.
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
- After modifying any domain type field or alias you **must** extend the matching test in `domain_json_tests.cpp`.
|
||||
- After modifying `formatTextForFs` or `parseIndexFromFilename` you **must** extend `fs_names_tests.cpp` — these are byte-compatibility shims with the original Rust code.
|
||||
- After adding a new service in `core/` you **must** add a corresponding `<name>_service_tests.cpp` with at least the happy-path and one error-path test.
|
||||
- After adding a new game's set source / card preview source you **must** add `tests/<name>_set_source_tests.cpp` and (if applicable) `tests/<name>_card_preview_source_tests.cpp` mirroring the Magic and Pokemon files. Add them to `tests/CMakeLists.txt`.
|
||||
|
||||
## Commands
|
||||
|
||||
- Configure with tests on:
|
||||
`cmake -S . -B build -DCCM_BUILD_TESTS=ON`
|
||||
- Build the suite:
|
||||
`cmake --build build --target ccm_core_tests`
|
||||
- Run all tests:
|
||||
`ctest --test-dir build --output-on-failure`
|
||||
- Run a single test by name pattern:
|
||||
`./build/bin/ccm_core_tests --test-case="*nextImageIndex*"`
|
||||
- Run a single suite:
|
||||
`./build/bin/ccm_core_tests --test-suite="MagicSetSource::parseResponse"`
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't depend on `ccm_ui_wx` from tests. UI is out of scope here.
|
||||
- Don't rely on file paths existing on the host (no `/tmp`, no `C:\Users\...`). Use `InMemoryFileSystem`.
|
||||
- Don't add tests that require network access. The Pokemon stub test checks the message, not a real API call.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Unit tests for ccm_core. Wx and infra-bound tests are out of scope here -
|
||||
# everything in this target operates against the ccm_core ports/services using
|
||||
# in-memory fakes, so the suite is fast and hermetic.
|
||||
|
||||
add_executable(ccm_core_tests
|
||||
fakes/InMemoryFileSystem.cpp
|
||||
|
||||
fs_names_tests.cpp
|
||||
domain_json_tests.cpp
|
||||
image_service_tests.cpp
|
||||
collection_service_tests.cpp
|
||||
config_service_tests.cpp
|
||||
json_collection_repository_tests.cpp
|
||||
json_set_repository_tests.cpp
|
||||
set_service_tests.cpp
|
||||
magic_set_source_tests.cpp
|
||||
magic_card_preview_source_tests.cpp
|
||||
card_preview_service_tests.cpp
|
||||
pokemon_set_source_tests.cpp
|
||||
pokemon_card_preview_source_tests.cpp
|
||||
card_sorter_tests.cpp
|
||||
card_filter_tests.cpp
|
||||
|
||||
main.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ccm_core_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
target_link_libraries(ccm_core_tests
|
||||
PRIVATE
|
||||
ccm_core
|
||||
doctest::doctest
|
||||
ccm_warnings
|
||||
)
|
||||
|
||||
include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake)
|
||||
doctest_discover_tests(ccm_core_tests)
|
||||
@@ -0,0 +1,160 @@
|
||||
// CardFilter tests - exercises the per-row matcher ported from the table
|
||||
// TableTemplate.tsx::applyFilter. Each TEST_CASE pins down a behavior the
|
||||
// original UI relied on, so a regression here implies the C++ table no
|
||||
// longer filters like the TypeScript reference.
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
MagicCard mc(std::string name,
|
||||
std::string setName,
|
||||
std::uint8_t amount = 1,
|
||||
Language lang = Language::English,
|
||||
Condition cond = Condition::NearMint,
|
||||
std::string note = "",
|
||||
bool foil = false, bool sgnd = false, bool altered = false) {
|
||||
MagicCard c;
|
||||
c.id = 1;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.amount = amount;
|
||||
c.language = lang;
|
||||
c.condition = cond;
|
||||
c.note = std::move(note);
|
||||
c.foil = foil;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
return c;
|
||||
}
|
||||
|
||||
PokemonCard pc(std::string name,
|
||||
std::string setName,
|
||||
std::string setNo = "",
|
||||
std::uint8_t amount = 1,
|
||||
std::string note = "") {
|
||||
PokemonCard c;
|
||||
c.id = 1;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.setNo = std::move(setNo);
|
||||
c.amount = amount;
|
||||
c.note = std::move(note);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CardFilter::matchesMagicFilter") {
|
||||
TEST_CASE("empty filter matches every row (mirrors JS \"\".includes(\"\"))") {
|
||||
CHECK(matchesMagicFilter(mc("Brainstorm", "Alpha"), ""));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by name (case-insensitive substring)") {
|
||||
const MagicCard c = mc("Lightning Bolt", "Beta");
|
||||
CHECK(matchesMagicFilter(c, "lightning"));
|
||||
CHECK(matchesMagicFilter(c, "BOLT")); // both sides lowercased
|
||||
CHECK(matchesMagicFilter(c, "ning Bo")); // mid-string substring
|
||||
CHECK_FALSE(matchesMagicFilter(c, "fireball"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by set.name (valueKey 'set.name', not 'set')") {
|
||||
const MagicCard c = mc("Brainstorm", "Modern Horizons");
|
||||
CHECK(matchesMagicFilter(c, "Modern"));
|
||||
CHECK(matchesMagicFilter(c, "horizons"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "Zendikar"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by language label") {
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 1, Language::Japanese);
|
||||
CHECK(matchesMagicFilter(c, "japanese"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "german"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by condition label") {
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 1,
|
||||
Language::English, Condition::Played);
|
||||
CHECK(matchesMagicFilter(c, "played"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "mint"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by amount as decimal string (val.toString())") {
|
||||
// JS used `val.toString().toLowerCase().includes(filter)` so the
|
||||
// *integer* amount becomes searchable as its decimal representation.
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 17);
|
||||
CHECK(matchesMagicFilter(c, "17"));
|
||||
CHECK(matchesMagicFilter(c, "1")); // substring match: "1" in "17"
|
||||
CHECK_FALSE(matchesMagicFilter(c, "99"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by note (case-insensitive)") {
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 1,
|
||||
Language::English, Condition::NearMint,
|
||||
"Birthday gift");
|
||||
CHECK(matchesMagicFilter(c, "Birthday"));
|
||||
CHECK(matchesMagicFilter(c, "GIFT"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "trade"));
|
||||
}
|
||||
|
||||
TEST_CASE("boolean flag columns (foil / signed / altered) are NOT matched") {
|
||||
// Filtering checks only string- or number-typed cells (typeof check). The
|
||||
// bool flag columns therefore must not match the literal "true" /
|
||||
// "false" — even when the flag is set.
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 1,
|
||||
Language::English, Condition::NearMint, "",
|
||||
/*foil=*/true, /*sgnd=*/true, /*altered=*/true);
|
||||
CHECK_FALSE(matchesMagicFilter(c, "true"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "false"));
|
||||
// ...unless the literal happens to be a substring of an actual
|
||||
// string-typed column. Sanity-check that the matcher still fires on
|
||||
// the name field with the same haystack.
|
||||
CHECK(matchesMagicFilter(c, "brain"));
|
||||
}
|
||||
|
||||
TEST_CASE("filter is lowercased so uppercase input matches lowercased data") {
|
||||
// The original path only lowercased the cell value, so "Brainstorm" filter against
|
||||
// a card named "brainstorm" never matched. We lowercase both sides;
|
||||
// pin that down here so a regression to the literal JS behavior gets
|
||||
// caught.
|
||||
const MagicCard c = mc("brainstorm", "alpha");
|
||||
CHECK(matchesMagicFilter(c, "BRAIN"));
|
||||
CHECK(matchesMagicFilter(c, "Alpha"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardFilter::matchesPokemonFilter") {
|
||||
TEST_CASE("matches by name and set.name") {
|
||||
const PokemonCard c = pc("Charizard", "Base Set");
|
||||
CHECK(matchesPokemonFilter(c, "char"));
|
||||
CHECK(matchesPokemonFilter(c, "BASE"));
|
||||
CHECK_FALSE(matchesPokemonFilter(c, "pikachu"));
|
||||
}
|
||||
|
||||
TEST_CASE("Pokemon adds setNo to the searchable columns") {
|
||||
// PokemonTable.tsx tableFields includes a "Set No" column whose
|
||||
// valueKey is `setNo`. Pin that down — `setNo` is Pokemon-specific so
|
||||
// the Magic matcher does not see it.
|
||||
const PokemonCard c = pc("Charizard", "Base Set", "4/102");
|
||||
CHECK(matchesPokemonFilter(c, "4/102"));
|
||||
CHECK(matchesPokemonFilter(c, "/102"));
|
||||
}
|
||||
|
||||
TEST_CASE("amount is searchable as decimal string for Pokemon too") {
|
||||
const PokemonCard c = pc("Charizard", "Base Set", "4/102", 23);
|
||||
CHECK(matchesPokemonFilter(c, "23"));
|
||||
CHECK_FALSE(matchesPokemonFilter(c, "99"));
|
||||
}
|
||||
|
||||
TEST_CASE("empty filter matches everything") {
|
||||
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeSource final : public ICardPreviewSource {
|
||||
public:
|
||||
std::string url = "https://example.com/preview.jpg";
|
||||
bool ok = true;
|
||||
std::string err = "boom";
|
||||
|
||||
// Capture inputs so tests can assert routing.
|
||||
std::string lastName;
|
||||
std::string lastSetId;
|
||||
std::string lastSetNo;
|
||||
|
||||
Result<std::string> fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override {
|
||||
lastName = std::string(name);
|
||||
lastSetId = std::string(setId);
|
||||
lastSetNo = std::string(setNo);
|
||||
return ok ? Result<std::string>::ok(url)
|
||||
: Result<std::string>::err(err);
|
||||
}
|
||||
};
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
std::string err = "offline";
|
||||
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err(err);
|
||||
}
|
||||
};
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// CardSorter tests - exercises the per-column comparators ported from the
|
||||
// TableTemplate.tsx::byField. Each TEST_CASE pins down a specific behavior
|
||||
// the original UI relied on, so a regression here implies the C++ table no
|
||||
// longer behaves like the TypeScript reference.
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/services/CardSorter.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
MagicCard mc(std::uint32_t id, std::string name,
|
||||
std::string setName, std::string releaseDate,
|
||||
std::uint8_t amount = 1,
|
||||
bool foil = false, bool sgnd = false, bool altered = false,
|
||||
Language lang = Language::English,
|
||||
Condition cond = Condition::NearMint,
|
||||
std::string note = "") {
|
||||
MagicCard c;
|
||||
c.id = id;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.set.releaseDate = std::move(releaseDate);
|
||||
c.amount = amount;
|
||||
c.foil = foil;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
c.language = lang;
|
||||
c.condition = cond;
|
||||
c.note = std::move(note);
|
||||
return c;
|
||||
}
|
||||
|
||||
PokemonCard pc(std::uint32_t id, std::string name,
|
||||
std::string setName, std::string releaseDate,
|
||||
std::uint8_t amount = 1,
|
||||
bool holo = false, bool firstEdition = false,
|
||||
bool sgnd = false, bool altered = false) {
|
||||
PokemonCard c;
|
||||
c.id = id;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.set.releaseDate = std::move(releaseDate);
|
||||
c.amount = amount;
|
||||
c.holo = holo;
|
||||
c.firstEdition = firstEdition;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
return c;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> ids(const std::vector<MagicCard>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
for (const auto& c : v) out.push_back(c.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> ids(const std::vector<PokemonCard>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
for (const auto& c : v) out.push_back(c.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CardSorter - Magic columns") {
|
||||
TEST_CASE("Name sorts case-insensitively (matches String.toLowerCase())") {
|
||||
// Three cards whose names only differ in casing - if sort were a
|
||||
// plain `<` this would put "ABC" before "abc" and wedge "Brainstorm"
|
||||
// somewhere wrong. The JS path normalizes to lowercase first.
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "brainstorm", "X", "2000/01/01"),
|
||||
mc(2, "ABC", "X", "2000/01/01"),
|
||||
mc(3, "abc", "X", "2000/01/01"),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/true);
|
||||
// ABC and abc tie under case-insensitive compare; stable sort keeps
|
||||
// the input order (id 2 before id 3).
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/false);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2, 3});
|
||||
}
|
||||
|
||||
TEST_CASE("Set column sorts by release date (chronological), not by name") {
|
||||
// The whole point of having distinct valueKey/sortKey: the
|
||||
// table *displays* set.name, but ascending order is chronological.
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "x", "Zendikar", "2009/10/02"),
|
||||
mc(2, "y", "Alpha", "1993/08/05"),
|
||||
mc(3, "z", "Modern Horizons","2019/06/14"),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1, 3}); // 1993 < 2009 < 2019
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/false);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Amount sorts numerically (no string-compare 10 < 2 trap)") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", /*amount=*/10),
|
||||
mc(2, "b", "X", "2000/01/01", /*amount=*/2),
|
||||
mc(3, "c", "X", "2000/01/01", /*amount=*/4),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10
|
||||
}
|
||||
|
||||
TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", 1, /*foil=*/true),
|
||||
mc(2, "b", "X", "2000/01/01", 1, /*foil=*/false),
|
||||
mc(3, "c", "X", "2000/01/01", 1, /*foil=*/true),
|
||||
mc(4, "d", "X", "2000/01/01", 1, /*foil=*/false),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/true);
|
||||
// Stable: relative order within each bucket preserved.
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 4, 1, 3});
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/false);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2, 4});
|
||||
}
|
||||
|
||||
TEST_CASE("Signed and Altered booleans sort independently") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", 1, false, /*sgnd=*/false, /*alt=*/true),
|
||||
mc(2, "b", "X", "2000/01/01", 1, false, /*sgnd=*/true, /*alt=*/false),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Signed, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::Altered, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||
}
|
||||
|
||||
TEST_CASE("Language and Condition sort by their string label, lowercased") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::Japanese, Condition::Mint),
|
||||
mc(2, "b", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::English, Condition::Played),
|
||||
mc(3, "c", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::German, Condition::NearMint),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Language, /*ascending=*/true);
|
||||
// english < german < japanese (lowercased compare)
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::Condition, /*ascending=*/true);
|
||||
// mint < nearmint < played (lowercased compare)
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Note sorts case-insensitively") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::English, Condition::NearMint, "Zeta"),
|
||||
mc(2, "b", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::English, Condition::NearMint, "alpha"),
|
||||
mc(3, "c", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::English, Condition::NearMint, "Beta"),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Note, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // alpha, beta, zeta
|
||||
}
|
||||
|
||||
TEST_CASE("stable: sort by name then by set keeps name order within each set") {
|
||||
// Mirrors the UX expectation: a user clicks Name, then Set, and
|
||||
// sees rows grouped by set with names alphabetical inside each group.
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "Counterspell", "Beta", "1993/10/04"),
|
||||
mc(2, "Brainstorm", "Alpha", "1993/08/05"),
|
||||
mc(3, "Lightning Bolt","Beta", "1993/10/04"),
|
||||
mc(4, "Ancestral Recall","Alpha","1993/08/05"),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Name, /*asc=*/true);
|
||||
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*asc=*/true);
|
||||
// Alpha (1993/08/05) first: ancestral, brainstorm
|
||||
// Beta (1993/10/04) next: counterspell, lightning bolt
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{4, 2, 1, 3});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardSorter - Pokemon-specific columns") {
|
||||
TEST_CASE("Holo and FirstEdition each sort their own bool field") {
|
||||
std::vector<PokemonCard> v = {
|
||||
pc(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*1st=*/false),
|
||||
pc(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/true),
|
||||
pc(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/false),
|
||||
};
|
||||
sortPokemonCards(v, PokemonSortColumn::Holo, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
|
||||
sortPokemonCards(v, PokemonSortColumn::FirstEdition, /*ascending=*/true);
|
||||
// After previous sort: 2,3,1 (false=2, false=1 actually... let me think
|
||||
// -- with v in order {2,3,1} their firstEdition = {true,false,false}.
|
||||
// Stable asc on firstEdition keeps 3 before 1 in the false bucket.)
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Set column sorts by release date for Pokemon too") {
|
||||
std::vector<PokemonCard> v = {
|
||||
pc(1, "x", "Sun & Moon", "2017/02/03"),
|
||||
pc(2, "y", "Base Set", "1999/01/09"),
|
||||
pc(3, "z", "Sword & Shield","2020/02/07"),
|
||||
};
|
||||
sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1, 3});
|
||||
}
|
||||
|
||||
TEST_CASE("Amount sorts numerically") {
|
||||
std::vector<PokemonCard> v = {
|
||||
pc(1, "a", "X", "2000/01/01", 9),
|
||||
pc(2, "b", "X", "2000/01/01", 11),
|
||||
pc(3, "c", "X", "2000/01/01", 1),
|
||||
};
|
||||
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
|
||||
TEST_CASE("empty vector stays empty") {
|
||||
std::vector<MagicCard> v;
|
||||
sortMagicCards(v, MagicSortColumn::Name, true);
|
||||
CHECK(v.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("single element preserved") {
|
||||
std::vector<MagicCard> v = { mc(42, "Solo", "X", "2000/01/01") };
|
||||
sortMagicCards(v, MagicSortColumn::Amount, false);
|
||||
CHECK(v.size() == 1);
|
||||
CHECK(v.front().id == 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/ports/ICollectionRepository.hpp"
|
||||
#include "ccm/ports/IImageStore.hpp"
|
||||
#include "ccm/services/CollectionService.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class InMemoryRepo final : public ICollectionRepository<MagicCard> {
|
||||
public:
|
||||
Map storage;
|
||||
|
||||
Result<Map> load(Game) override { return Result<Map>::ok(storage); }
|
||||
Result<void> save(Game, const Map& m) override {
|
||||
storage = m;
|
||||
return Result<void>::ok();
|
||||
}
|
||||
};
|
||||
|
||||
class StubImageStore final : public IImageStore {
|
||||
public:
|
||||
std::vector<std::pair<Game, std::string>> removed;
|
||||
|
||||
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);
|
||||
return Result<void>::ok();
|
||||
}
|
||||
std::filesystem::path resolvePath(Game, const std::string& n) const override {
|
||||
return std::filesystem::path(n);
|
||||
}
|
||||
};
|
||||
|
||||
MagicCard makeCard(const std::string& name, std::vector<std::string> imgs = {}) {
|
||||
MagicCard c;
|
||||
c.name = name;
|
||||
c.set = Set{"alp", "Alpha", "1993/08/05"};
|
||||
c.images = std::move(imgs);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CollectionService<MagicCard>") {
|
||||
TEST_CASE("nextId on empty map is 0, then strictly increments") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto id0 = svc.add(Game::Magic, makeCard("A"));
|
||||
REQUIRE(id0.isOk());
|
||||
CHECK(id0.value() == 0);
|
||||
|
||||
const auto id1 = svc.add(Game::Magic, makeCard("B"));
|
||||
REQUIRE(id1.isOk());
|
||||
CHECK(id1.value() == 1);
|
||||
|
||||
const auto listed = svc.list(Game::Magic);
|
||||
REQUIRE(listed.isOk());
|
||||
CHECK(listed.value().size() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("update modifies an existing card and is rejected for unknown ids") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto id = svc.add(Game::Magic, makeCard("Initial")).value();
|
||||
|
||||
MagicCard updated = makeCard("Renamed");
|
||||
updated.id = id;
|
||||
CHECK(svc.update(Game::Magic, updated).isOk());
|
||||
|
||||
const auto found = svc.findById(Game::Magic, id);
|
||||
REQUIRE(found.isOk());
|
||||
REQUIRE(found.value().has_value());
|
||||
CHECK(found.value()->name == "Renamed");
|
||||
|
||||
MagicCard ghost = makeCard("Ghost");
|
||||
ghost.id = 999;
|
||||
CHECK(svc.update(Game::Magic, ghost).isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("remove deletes images and the entry") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto id = svc.add(
|
||||
Game::Magic, makeCard("With Images", {"a.png", "b.png"})).value();
|
||||
|
||||
REQUIRE(svc.remove(Game::Magic, id).isOk());
|
||||
|
||||
// Both images should have been requested for deletion.
|
||||
REQUIRE(store.removed.size() == 2);
|
||||
CHECK(store.removed[0].second == "a.png");
|
||||
CHECK(store.removed[1].second == "b.png");
|
||||
|
||||
const auto listed = svc.list(Game::Magic);
|
||||
REQUIRE(listed.isOk());
|
||||
CHECK(listed.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("remove of unknown id returns an error") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
CHECK(svc.remove(Game::Magic, 12345).isErr());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace ccm;
|
||||
using ccm::testing::InMemoryFileSystem;
|
||||
|
||||
TEST_SUITE("ConfigService") {
|
||||
TEST_CASE("missing file is created with defaults") {
|
||||
InMemoryFileSystem fs;
|
||||
ConfigService svc{fs, "/app/config.json", "/data"};
|
||||
|
||||
REQUIRE(svc.initialize().isOk());
|
||||
CHECK(svc.current().dataStorage == "/data");
|
||||
CHECK(svc.current().defaultGame == Game::Magic);
|
||||
CHECK(svc.current().theme == Theme::Light);
|
||||
|
||||
const auto& written = fs.files();
|
||||
REQUIRE(written.count("/app/config.json"));
|
||||
const auto j = nlohmann::json::parse(written.at("/app/config.json"));
|
||||
CHECK(j.at("dataStorage") == "/data");
|
||||
CHECK(j.at("defaultGame") == "Magic");
|
||||
CHECK(j.at("theme") == "Light");
|
||||
}
|
||||
|
||||
TEST_CASE("existing file is loaded verbatim") {
|
||||
InMemoryFileSystem fs;
|
||||
REQUIRE(fs.writeText("/app/config.json",
|
||||
R"({"dataStorage":"/somewhere","defaultGame":"Pokemon","theme":"Dark"})").isOk());
|
||||
|
||||
ConfigService svc{fs, "/app/config.json", "/default"};
|
||||
REQUIRE(svc.initialize().isOk());
|
||||
CHECK(svc.current().dataStorage == "/somewhere");
|
||||
CHECK(svc.current().defaultGame == Game::Pokemon);
|
||||
CHECK(svc.current().theme == Theme::Dark);
|
||||
}
|
||||
|
||||
TEST_CASE("store updates both the live config and the file") {
|
||||
InMemoryFileSystem fs;
|
||||
ConfigService svc{fs, "/app/config.json", "/data"};
|
||||
REQUIRE(svc.initialize().isOk());
|
||||
|
||||
Configuration next;
|
||||
next.dataStorage = "/new/place";
|
||||
next.defaultGame = Game::Pokemon;
|
||||
next.theme = Theme::Dark;
|
||||
REQUIRE(svc.store(next).isOk());
|
||||
|
||||
CHECK(svc.current() == next);
|
||||
const auto j = nlohmann::json::parse(fs.files().at("/app/config.json"));
|
||||
CHECK(j.at("dataStorage") == "/new/place");
|
||||
CHECK(j.at("defaultGame") == "Pokemon");
|
||||
CHECK(j.at("theme") == "Dark");
|
||||
}
|
||||
|
||||
TEST_CASE("missing theme field defaults to light for compatibility") {
|
||||
InMemoryFileSystem fs;
|
||||
REQUIRE(fs.writeText("/app/config.json",
|
||||
R"({"dataStorage":"/somewhere","defaultGame":"Pokemon"})").isOk());
|
||||
|
||||
ConfigService svc{fs, "/app/config.json", "/default"};
|
||||
REQUIRE(svc.initialize().isOk());
|
||||
CHECK(svc.current().theme == Theme::Light);
|
||||
}
|
||||
|
||||
TEST_CASE("malformed JSON surfaces a clear error") {
|
||||
InMemoryFileSystem fs;
|
||||
REQUIRE(fs.writeText("/app/config.json", "not-json").isOk());
|
||||
ConfigService svc{fs, "/app/config.json", "/default"};
|
||||
const auto r = svc.initialize();
|
||||
REQUIRE(r.isErr());
|
||||
CHECK(r.error().find("config.json parse error") != std::string::npos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
TEST_SUITE("domain enums round-trip JSON as strings") {
|
||||
TEST_CASE("Game") {
|
||||
nlohmann::json j = Game::Magic;
|
||||
CHECK(j.get<std::string>() == "Magic");
|
||||
CHECK(j.get<Game>() == Game::Magic);
|
||||
|
||||
nlohmann::json j2 = "Pokemon";
|
||||
CHECK(j2.get<Game>() == Game::Pokemon);
|
||||
|
||||
nlohmann::json j3 = Theme::Dark;
|
||||
CHECK(j3.get<std::string>() == "Dark");
|
||||
CHECK(j3.get<Theme>() == Theme::Dark);
|
||||
}
|
||||
|
||||
TEST_CASE("Language and Condition") {
|
||||
nlohmann::json l = Language::Japanese;
|
||||
CHECK(l.get<std::string>() == "Japanese");
|
||||
CHECK(l.get<Language>() == Language::Japanese);
|
||||
|
||||
nlohmann::json c = Condition::LightPlayed;
|
||||
CHECK(c.get<std::string>() == "LightPlayed");
|
||||
CHECK(c.get<Condition>() == Condition::LightPlayed);
|
||||
}
|
||||
|
||||
TEST_CASE("invalid enum string throws") {
|
||||
nlohmann::json bad = "Spanglish";
|
||||
CHECK_THROWS(bad.get<Language>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("Set JSON shape stays stable") {
|
||||
TEST_CASE("uses 'releaseDate' alias") {
|
||||
Set s{"abc", "Test Set", "2024/05/01"};
|
||||
const nlohmann::json j = s;
|
||||
CHECK(j.contains("releaseDate"));
|
||||
CHECK_FALSE(j.contains("release_date"));
|
||||
CHECK(j.at("releaseDate") == "2024/05/01");
|
||||
|
||||
const Set back = j.get<Set>();
|
||||
CHECK(back == s);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("MagicCard JSON") {
|
||||
TEST_CASE("round-trips with all original field names") {
|
||||
MagicCard c;
|
||||
c.id = 42;
|
||||
c.amount = 3;
|
||||
c.name = "Lightning Bolt";
|
||||
c.set = Set{"lea", "Limited Edition Alpha", "1993/08/05"};
|
||||
c.note = "rare promo";
|
||||
c.images = {"foo+bar+0.png"};
|
||||
c.language = Language::English;
|
||||
c.condition = Condition::NearMint;
|
||||
c.foil = true;
|
||||
c.signed_ = false;
|
||||
c.altered = false;
|
||||
|
||||
nlohmann::json j = c;
|
||||
CHECK(j.contains("signed"));
|
||||
CHECK(j.at("signed") == false);
|
||||
CHECK(j.at("foil") == true);
|
||||
|
||||
const MagicCard back = j.get<MagicCard>();
|
||||
CHECK(back == c);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCard JSON") {
|
||||
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
|
||||
PokemonCard c;
|
||||
c.id = 7;
|
||||
c.amount = 1;
|
||||
c.name = "Charizard";
|
||||
c.set = Set{"base1", "Base Set", "1999/01/09"};
|
||||
c.setNo = "4/102";
|
||||
c.note = "";
|
||||
c.images = {};
|
||||
c.language = Language::English;
|
||||
c.condition = Condition::Excellent;
|
||||
c.firstEdition = true;
|
||||
c.holo = true;
|
||||
c.signed_ = false;
|
||||
c.altered = false;
|
||||
|
||||
nlohmann::json j = c;
|
||||
CHECK(j.at("setNo") == "4/102");
|
||||
CHECK(j.at("firstEdition") == true);
|
||||
CHECK(j.at("signed") == false);
|
||||
|
||||
const PokemonCard back = j.get<PokemonCard>();
|
||||
CHECK(back == c);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("Configuration JSON matches Rust serde aliases") {
|
||||
TEST_CASE("dataStorage / defaultGame / theme keys are present") {
|
||||
Configuration cfg;
|
||||
cfg.dataStorage = "/some/path";
|
||||
cfg.defaultGame = Game::Pokemon;
|
||||
cfg.theme = Theme::Dark;
|
||||
|
||||
nlohmann::json j = cfg;
|
||||
CHECK(j.at("dataStorage") == "/some/path");
|
||||
CHECK(j.at("defaultGame") == "Pokemon");
|
||||
CHECK(j.at("theme") == "Dark");
|
||||
|
||||
const auto back = j.get<Configuration>();
|
||||
CHECK(back == cfg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
namespace ccm::testing {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
std::string InMemoryFileSystem::norm(const fs::path& p) {
|
||||
// Use forward slashes regardless of host so tests are portable.
|
||||
return p.lexically_normal().generic_string();
|
||||
}
|
||||
|
||||
bool InMemoryFileSystem::exists(const fs::path& p) const {
|
||||
const auto k = norm(p);
|
||||
return files_.count(k) || dirs_.count(k);
|
||||
}
|
||||
|
||||
bool InMemoryFileSystem::isDirectory(const fs::path& p) const {
|
||||
return dirs_.count(norm(p)) > 0;
|
||||
}
|
||||
|
||||
Result<void> InMemoryFileSystem::ensureDirectory(const fs::path& p) {
|
||||
auto k = norm(p);
|
||||
if (files_.count(k)) {
|
||||
return Result<void>::err("Path is a file, cannot become a directory: " + k);
|
||||
}
|
||||
// Walk parent chain so listDirectory() acts naturally.
|
||||
fs::path acc;
|
||||
for (const auto& part : p) {
|
||||
acc /= part;
|
||||
dirs_.insert(norm(acc));
|
||||
}
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<std::string> InMemoryFileSystem::readText(const fs::path& p) {
|
||||
auto it = files_.find(norm(p));
|
||||
if (it == files_.end()) {
|
||||
return Result<std::string>::err("Not found: " + norm(p));
|
||||
}
|
||||
return Result<std::string>::ok(it->second);
|
||||
}
|
||||
|
||||
Result<void> InMemoryFileSystem::writeText(const fs::path& p, std::string_view contents) {
|
||||
if (p.has_parent_path()) {
|
||||
auto r = ensureDirectory(p.parent_path());
|
||||
if (!r) return r;
|
||||
}
|
||||
files_[norm(p)] = std::string(contents);
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<void> InMemoryFileSystem::copyFile(const fs::path& from, const fs::path& to, bool overwrite) {
|
||||
auto src = files_.find(norm(from));
|
||||
if (src == files_.end()) {
|
||||
return Result<void>::err("copy_file source missing: " + norm(from));
|
||||
}
|
||||
if (!overwrite && files_.count(norm(to))) {
|
||||
return Result<void>::err("copy_file destination exists: " + norm(to));
|
||||
}
|
||||
if (to.has_parent_path()) {
|
||||
auto r = ensureDirectory(to.parent_path());
|
||||
if (!r) return r;
|
||||
}
|
||||
files_[norm(to)] = src->second;
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<void> InMemoryFileSystem::remove(const fs::path& p) {
|
||||
files_.erase(norm(p));
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<std::vector<fs::path>> InMemoryFileSystem::listDirectory(const fs::path& p) {
|
||||
const auto prefix = norm(p) + "/";
|
||||
std::vector<fs::path> out;
|
||||
for (const auto& [path, _] : files_) {
|
||||
if (path.rfind(prefix, 0) == 0 &&
|
||||
path.find('/', prefix.size()) == std::string::npos) {
|
||||
out.emplace_back(path);
|
||||
}
|
||||
}
|
||||
return Result<std::vector<fs::path>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm::testing
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
// In-memory IFileSystem fake used to drive the service tests with no real I/O.
|
||||
// Tracks regular files (string contents) and directories (just by presence).
|
||||
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace ccm::testing {
|
||||
|
||||
class InMemoryFileSystem final : public IFileSystem {
|
||||
public:
|
||||
[[nodiscard]] bool exists(const std::filesystem::path& p) const override;
|
||||
[[nodiscard]] bool isDirectory(const std::filesystem::path& p) const override;
|
||||
|
||||
Result<void> ensureDirectory(const std::filesystem::path& p) override;
|
||||
Result<std::string> readText(const std::filesystem::path& p) override;
|
||||
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override;
|
||||
Result<void> copyFile(const std::filesystem::path& from,
|
||||
const std::filesystem::path& to,
|
||||
bool overwrite) override;
|
||||
Result<void> remove(const std::filesystem::path& p) override;
|
||||
Result<std::vector<std::filesystem::path>> listDirectory(
|
||||
const std::filesystem::path& p) override;
|
||||
|
||||
// Test helpers.
|
||||
[[nodiscard]] const std::map<std::string, std::string>& files() const noexcept { return files_; }
|
||||
[[nodiscard]] const std::set<std::string>& dirs() const noexcept { return dirs_; }
|
||||
|
||||
private:
|
||||
static std::string norm(const std::filesystem::path& p);
|
||||
|
||||
std::map<std::string, std::string> files_;
|
||||
std::set<std::string> dirs_;
|
||||
};
|
||||
|
||||
} // namespace ccm::testing
|
||||
@@ -0,0 +1,53 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/util/FsNames.hpp"
|
||||
|
||||
using ccm::formatTextForFs;
|
||||
using ccm::parseIndexFromFilename;
|
||||
|
||||
TEST_SUITE("FsNames::formatTextForFs") {
|
||||
TEST_CASE("strips spaces, commas, apostrophes, backticks") {
|
||||
CHECK(formatTextForFs("Hello, World's Set") == "HelloWorldsSet");
|
||||
CHECK(formatTextForFs("`tick`") == "tick");
|
||||
}
|
||||
|
||||
TEST_CASE("colons become hyphens") {
|
||||
CHECK(formatTextForFs("Set: Subtitle") == "Set-Subtitle");
|
||||
}
|
||||
|
||||
TEST_CASE("ampersand becomes And, pipe becomes Or") {
|
||||
CHECK(formatTextForFs("Black & White") == "BlackAndWhite");
|
||||
CHECK(formatTextForFs("a|b") == "aOrb");
|
||||
}
|
||||
|
||||
TEST_CASE("flattens accented vowels listed in the original Rust source") {
|
||||
// UTF-8 sequences for the accented characters the Rust crate handles.
|
||||
CHECK(formatTextForFs("\xC3\xA1\xC3\xA9\xC3\xAD\xC3\xB3\xC3\xBA\xC3\xBB") == "aeiouu");
|
||||
}
|
||||
|
||||
TEST_CASE("idempotent on already-clean strings") {
|
||||
CHECK(formatTextForFs("AlreadyClean") == "AlreadyClean");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("FsNames::parseIndexFromFilename") {
|
||||
TEST_CASE("single digit") {
|
||||
CHECK(parseIndexFromFilename("Image1.png") == 1);
|
||||
CHECK(parseIndexFromFilename("foo+bar+0.jpg") == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("two digit") {
|
||||
CHECK(parseIndexFromFilename("Image22.jpeg") == 22);
|
||||
CHECK(parseIndexFromFilename("set+name+99.png") == 99);
|
||||
}
|
||||
|
||||
TEST_CASE("only the last two digits are taken (matches Rust source)") {
|
||||
CHECK(parseIndexFromFilename("foo+123+45.png") == 45);
|
||||
}
|
||||
|
||||
TEST_CASE("no digits or no extension returns 0") {
|
||||
CHECK(parseIndexFromFilename("noindex.png") == 0);
|
||||
CHECK(parseIndexFromFilename("nothing") == 0);
|
||||
CHECK(parseIndexFromFilename("") == 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/ports/IImageStore.hpp"
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
// Trivial image store that records calls without touching disk.
|
||||
class RecordingImageStore final : public IImageStore {
|
||||
public:
|
||||
struct Call { Game game; std::filesystem::path src; std::string target; };
|
||||
std::vector<Call> copies;
|
||||
std::vector<std::pair<Game, std::string>> removes;
|
||||
std::string returnedExt = ".png";
|
||||
|
||||
Result<std::string> copyIn(Game game,
|
||||
const std::filesystem::path& srcPath,
|
||||
const std::string& targetName) override {
|
||||
copies.push_back({game, srcPath, targetName});
|
||||
return Result<std::string>::ok(targetName + returnedExt);
|
||||
}
|
||||
|
||||
Result<void> remove(Game game, const std::string& imageName) override {
|
||||
removes.emplace_back(game, imageName);
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
std::filesystem::path resolvePath(Game, const std::string& imageName) const override {
|
||||
return std::filesystem::path("/fake") / imageName;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("ImageService::nextImageIndex") {
|
||||
TEST_CASE("empty list returns 0") {
|
||||
CHECK(ImageService::nextImageIndex({}) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("increments by one over the latest filename") {
|
||||
std::vector<std::string> imgs = {"set+name+0.png", "set+name+1.png"};
|
||||
CHECK(ImageService::nextImageIndex(imgs) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("CCM1-style filenames reset back to 0") {
|
||||
std::vector<std::string> imgs = {"someCardIMG_FRONT.png"};
|
||||
CHECK(ImageService::nextImageIndex(imgs) == 0);
|
||||
std::vector<std::string> imgs2 = {"otherIMG_BACK.png"};
|
||||
CHECK(ImageService::nextImageIndex(imgs2) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("ImageService::buildTargetName") {
|
||||
TEST_CASE("new entry omits id, uses sanitized set+name+idx") {
|
||||
const auto out = ImageService::buildTargetName(true, 99, "Limited: Alpha", "Lightning, Bolt", 3);
|
||||
CHECK(out == "Limited-Alpha+LightningBolt+3");
|
||||
}
|
||||
|
||||
TEST_CASE("existing entry prepends the card id") {
|
||||
const auto out = ImageService::buildTargetName(false, 42, "Beta", "Black Lotus", 0);
|
||||
CHECK(out == "42+Beta+BlackLotus+0");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("ImageService::addImage") {
|
||||
TEST_CASE("delegates to the store with the computed target name") {
|
||||
RecordingImageStore store;
|
||||
ImageService svc{store};
|
||||
|
||||
std::vector<std::string> existing;
|
||||
auto res = svc.addImage(Game::Magic, "/tmp/source.png",
|
||||
/*newEntry=*/true, /*cardId=*/0,
|
||||
"Beta", "Black Lotus", existing);
|
||||
|
||||
REQUIRE(res.isOk());
|
||||
CHECK(res.value() == "Beta+BlackLotus+0.png");
|
||||
REQUIRE(store.copies.size() == 1);
|
||||
CHECK(store.copies[0].game == Game::Magic);
|
||||
CHECK(store.copies[0].target == "Beta+BlackLotus+0");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("ImageService::normalizeNamesForPersistedCard") {
|
||||
TEST_CASE("renames non-prefixed images to include card id") {
|
||||
RecordingImageStore store;
|
||||
ImageService svc{store};
|
||||
|
||||
const std::vector<std::string> images{
|
||||
"Beta+BlackLotus+0.png",
|
||||
"Beta+BlackLotus+1.jpg"
|
||||
};
|
||||
auto normalized = svc.normalizeNamesForPersistedCard(
|
||||
Game::Magic, 42, "Beta", "Black Lotus", images);
|
||||
|
||||
REQUIRE(normalized.isOk());
|
||||
CHECK(normalized.value().size() == 2);
|
||||
CHECK(normalized.value()[0] == "42+Beta+BlackLotus+0.png");
|
||||
CHECK(normalized.value()[1] == "42+Beta+BlackLotus+1.jpg");
|
||||
|
||||
REQUIRE(store.copies.size() == 2);
|
||||
CHECK(store.copies[0].src == std::filesystem::path("/fake/Beta+BlackLotus+0.png"));
|
||||
CHECK(store.copies[0].target == "42+Beta+BlackLotus+0");
|
||||
CHECK(store.copies[1].src == std::filesystem::path("/fake/Beta+BlackLotus+1.jpg"));
|
||||
CHECK(store.copies[1].target == "42+Beta+BlackLotus+1");
|
||||
|
||||
REQUIRE(store.removes.size() == 2);
|
||||
CHECK(store.removes[0].second == "Beta+BlackLotus+0.png");
|
||||
CHECK(store.removes[1].second == "Beta+BlackLotus+1.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("keeps already-prefixed names untouched") {
|
||||
RecordingImageStore store;
|
||||
ImageService svc{store};
|
||||
|
||||
const std::vector<std::string> images{"42+Beta+BlackLotus+0.png"};
|
||||
auto normalized = svc.normalizeNamesForPersistedCard(
|
||||
Game::Magic, 42, "Beta", "Black Lotus", images);
|
||||
|
||||
REQUIRE(normalized.isOk());
|
||||
CHECK(normalized.value() == images);
|
||||
CHECK(store.copies.empty());
|
||||
CHECK(store.removes.empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/infra/JsonCollectionRepository.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace ccm;
|
||||
using ccm::testing::InMemoryFileSystem;
|
||||
|
||||
namespace {
|
||||
|
||||
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
|
||||
// Pre-write a config so initialize() doesn't reset our data directory.
|
||||
Configuration c;
|
||||
c.dataStorage = dataDir;
|
||||
c.defaultGame = Game::Magic;
|
||||
const nlohmann::json j = c;
|
||||
fs.writeText("/app/config.json", j.dump());
|
||||
ConfigService cfg{fs, "/app/config.json", dataDir};
|
||||
cfg.initialize();
|
||||
return cfg;
|
||||
}
|
||||
|
||||
std::string magicDir(Game g) { return g == Game::Magic ? "magic" : "pokemon"; }
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("JsonCollectionRepository<MagicCard>") {
|
||||
TEST_CASE("missing collection.json is created on first load") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isOk());
|
||||
CHECK(loaded.value().empty());
|
||||
CHECK(fs.files().count("/data/magic/collection.json") == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("save/load round-trip preserves all card fields") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
|
||||
MagicCard c;
|
||||
c.id = 5;
|
||||
c.amount = 2;
|
||||
c.name = "Black Lotus";
|
||||
c.set = Set{"lea", "Limited Edition Alpha", "1993/08/05"};
|
||||
c.note = "tournament-illegal";
|
||||
c.images = {"lea+BlackLotus+0.png"};
|
||||
c.language = Language::English;
|
||||
c.condition = Condition::Mint;
|
||||
c.foil = false;
|
||||
c.signed_ = true;
|
||||
c.altered = false;
|
||||
|
||||
std::map<std::uint32_t, MagicCard> m{{c.id, c}};
|
||||
REQUIRE(repo.save(Game::Magic, m).isOk());
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isOk());
|
||||
REQUIRE(loaded.value().count(5) == 1);
|
||||
CHECK(loaded.value().at(5) == c);
|
||||
}
|
||||
|
||||
TEST_CASE("on-disk JSON is keyed by stringified card id") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
|
||||
MagicCard c;
|
||||
c.id = 17;
|
||||
c.name = "X";
|
||||
c.set = Set{"x", "X", "2024/01/01"};
|
||||
std::map<std::uint32_t, MagicCard> m{{c.id, c}};
|
||||
REQUIRE(repo.save(Game::Magic, m).isOk());
|
||||
|
||||
const auto& contents = fs.files().at("/data/magic/collection.json");
|
||||
const auto j = nlohmann::json::parse(contents);
|
||||
REQUIRE(j.contains("17"));
|
||||
CHECK(j.at("17").at("id") == 17);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/infra/JsonSetRepository.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace ccm;
|
||||
using ccm::testing::InMemoryFileSystem;
|
||||
|
||||
namespace {
|
||||
std::string dirNameFn(Game g) { return g == Game::Magic ? "magic" : "pokemon"; }
|
||||
|
||||
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
|
||||
Configuration c;
|
||||
c.dataStorage = dataDir;
|
||||
c.defaultGame = Game::Magic;
|
||||
fs.writeText("/app/config.json", nlohmann::json(c).dump());
|
||||
ConfigService cfg{fs, "/app/config.json", dataDir};
|
||||
cfg.initialize();
|
||||
return cfg;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("JsonSetRepository") {
|
||||
TEST_CASE("save then load returns identical sets") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
|
||||
const std::vector<Set> sets = {
|
||||
{"lea", "Limited Edition Alpha", "1993/08/05"},
|
||||
{"leb", "Limited Edition Beta", "1993/10/04"},
|
||||
};
|
||||
REQUIRE(repo.save(Game::Magic, sets).isOk());
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isOk());
|
||||
CHECK(loaded.value() == sets);
|
||||
}
|
||||
|
||||
TEST_CASE("load before any save reports a clear error") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
|
||||
const auto loaded = repo.load(Game::Pokemon);
|
||||
CHECK(loaded.isErr());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err("offline");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("MagicCardPreviewSource::buildSearchUrl") {
|
||||
TEST_CASE("simple name and set produce a percent-encoded query") {
|
||||
const auto url = MagicCardPreviewSource::buildSearchUrl("Lightning Bolt", "lea");
|
||||
// Spaces -> %20, quotes -> %22, colons -> %3A. setId stays as-is when
|
||||
// it only contains unreserved chars.
|
||||
CHECK(url.find("https://api.scryfall.com/cards/search?q=") == 0);
|
||||
CHECK(url.find("%22Lightning%20Bolt%22") != std::string::npos);
|
||||
CHECK(url.find("set%3Alea") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("ampersand in card name is replaced with 'and' before encoding") {
|
||||
const auto url = MagicCardPreviewSource::buildSearchUrl("Fire & Ice", "abc");
|
||||
CHECK(url.find("Fire%20and%20Ice") != std::string::npos);
|
||||
CHECK(url.find("%26") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("unreserved characters in setId are preserved") {
|
||||
const auto url = MagicCardPreviewSource::buildSearchUrl("X", "swsh10");
|
||||
CHECK(url.find("set%3Aswsh10") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("MagicCardPreviewSource::parseResponse") {
|
||||
TEST_CASE("happy path returns image_uris.normal") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{
|
||||
"name": "Lightning Bolt",
|
||||
"image_uris": {
|
||||
"small": "https://img.scryfall.io/small.jpg",
|
||||
"normal": "https://img.scryfall.io/normal.jpg",
|
||||
"large": "https://img.scryfall.io/large.jpg"
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
const auto out = MagicCardPreviewSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://img.scryfall.io/normal.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("empty data array returns an error") {
|
||||
const auto out = MagicCardPreviewSource::parseResponse(R"({"data":[]})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("missing data array returns an error") {
|
||||
const auto out = MagicCardPreviewSource::parseResponse(R"({"meta":{}})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("entry without image_uris returns an error (double-faced cards)") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"name":"DoubleFace","card_faces":[{"image_uris":{"normal":"x"}}]}
|
||||
]
|
||||
})";
|
||||
const auto out = MagicCardPreviewSource::parseResponse(json);
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON returns an error") {
|
||||
const auto out = MagicCardPreviewSource::parseResponse("{not json");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("MagicCardPreviewSource::fetchImageUrl") {
|
||||
TEST_CASE("network error is surfaced as a Result error") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
MagicCardPreviewSource src{http};
|
||||
CHECK(src.fetchImageUrl("Lightning Bolt", "lea", "").isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("network success is parsed end-to-end and uses the encoded URL") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"({"data":[{"image_uris":{"normal":"https://img/normal.jpg"}}]})";
|
||||
MagicCardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Lightning Bolt", "lea", "");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://img/normal.jpg");
|
||||
CHECK(http.lastUrl.find("%22Lightning%20Bolt%22") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("set%3Alea") != std::string::npos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/magic/MagicSetSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view) override {
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err("offline");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("MagicSetSource::parseResponse") {
|
||||
TEST_CASE("filters digital sets and converts release date format") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"code":"lea","name":"Alpha","released_at":"1993-08-05","digital":false},
|
||||
{"code":"mtgo","name":"Online Promo","released_at":"2010-01-01","digital":true},
|
||||
{"code":"leb","name":"Beta","released_at":"1993-10-04","digital":false}
|
||||
]
|
||||
})";
|
||||
|
||||
const auto out = MagicSetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].id == "lea");
|
||||
CHECK(out.value()[0].releaseDate == "1993/08/05");
|
||||
CHECK(out.value()[1].id == "leb");
|
||||
CHECK(out.value()[1].releaseDate == "1993/10/04");
|
||||
}
|
||||
|
||||
TEST_CASE("sorts by release date ascending") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"code":"newer","name":"N","released_at":"2024-01-01","digital":false},
|
||||
{"code":"older","name":"O","released_at":"2010-01-01","digital":false}
|
||||
]
|
||||
})";
|
||||
const auto out = MagicSetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "older");
|
||||
CHECK(out.value().back().id == "newer");
|
||||
}
|
||||
|
||||
TEST_CASE("missing data array returns an error") {
|
||||
const auto out = MagicSetSource::parseResponse(R"({"meta":{}})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON returns an error") {
|
||||
const auto out = MagicSetSource::parseResponse("{not json");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("MagicSetSource::fetchAll") {
|
||||
TEST_CASE("network error is surfaced as a Result error") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
MagicSetSource src{http};
|
||||
CHECK(src.fetchAll().isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("network success is parsed end-to-end") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"({"data":[{"code":"x","name":"X","released_at":"2020-01-01","digital":false}]})";
|
||||
MagicSetSource src{http};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "x");
|
||||
CHECK(out.value().front().releaseDate == "2020/01/01");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
|
||||
#include <doctest/doctest.h>
|
||||
@@ -0,0 +1,130 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err("offline");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
|
||||
TEST_CASE("name and setId produce a percent-encoded query") {
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Pikachu", "base1", "");
|
||||
CHECK(url.find("https://api.pokemontcg.io/v2/cards?q=") == 0);
|
||||
CHECK(url.find("%22Pikachu%22") != std::string::npos);
|
||||
CHECK(url.find("set.id%3Abase1") != std::string::npos);
|
||||
// No number term when setNo is empty.
|
||||
CHECK(url.find("number") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("setNo is appended as a number: clause") {
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Charizard", "base1", "4");
|
||||
CHECK(url.find("number%3A4") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("setNo with a slash is normalized to the printed number") {
|
||||
// Pokemon collection numbers are commonly stored as "4/102" — the
|
||||
// Pokemon TCG search API only accepts the printed-number portion.
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Charizard", "base1", "4/102");
|
||||
CHECK(url.find("number%3A4") != std::string::npos);
|
||||
CHECK(url.find("102") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("name with spaces is percent-encoded") {
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Mr. Mime", "base1", "");
|
||||
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
|
||||
TEST_CASE("returns images.large when present") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{
|
||||
"name": "Pikachu",
|
||||
"images": {
|
||||
"small": "https://images.pokemontcg.io/small.png",
|
||||
"large": "https://images.pokemontcg.io/large.png"
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.pokemontcg.io/large.png");
|
||||
}
|
||||
|
||||
TEST_CASE("falls back to images.small when large is absent") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"name":"Pikachu","images":{"small":"https://small.only/img.png"}}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://small.only/img.png");
|
||||
}
|
||||
|
||||
TEST_CASE("empty data array returns an error") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":[]})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("missing data array returns an error") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("entry without images returns an error") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(
|
||||
R"({"data":[{"name":"Pikachu"}]})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON returns an error") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse("{not json");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
|
||||
TEST_CASE("network error is surfaced as a Result error") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
PokemonCardPreviewSource src{http};
|
||||
CHECK(src.fetchImageUrl("Pikachu", "base1", "").isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("network success is parsed end-to-end and uses the encoded URL") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"({"data":[{"images":{"large":"https://l/x.png"}}]})";
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Pikachu", "base1", "25");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://l/x.png");
|
||||
CHECK(http.lastUrl.find("%22Pikachu%22") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("number%3A25") != std::string::npos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/pokemon/PokemonSetSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err("offline");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("PokemonSetSource::parseResponse") {
|
||||
TEST_CASE("happy path: maps id/name/releaseDate without rewriting separators") {
|
||||
// The Pokemon TCG API returns releaseDate already in YYYY/MM/DD form,
|
||||
// unlike Scryfall's released_at YYYY-MM-DD.
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"id":"base1","name":"Base","releaseDate":"1999/01/09"},
|
||||
{"id":"jungle","name":"Jungle","releaseDate":"1999/06/16"}
|
||||
]
|
||||
})";
|
||||
|
||||
const auto out = PokemonSetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].id == "base1");
|
||||
CHECK(out.value()[0].name == "Base");
|
||||
CHECK(out.value()[0].releaseDate == "1999/01/09");
|
||||
CHECK(out.value()[1].id == "jungle");
|
||||
CHECK(out.value()[1].releaseDate == "1999/06/16");
|
||||
}
|
||||
|
||||
TEST_CASE("sorts by release date ascending") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"id":"newer","name":"N","releaseDate":"2024/01/01"},
|
||||
{"id":"older","name":"O","releaseDate":"2010/01/01"}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonSetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "older");
|
||||
CHECK(out.value().back().id == "newer");
|
||||
}
|
||||
|
||||
TEST_CASE("empty data array returns an empty list (not an error)") {
|
||||
const auto out = PokemonSetSource::parseResponse(R"({"data":[]})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("missing data array returns an error") {
|
||||
const auto out = PokemonSetSource::parseResponse(R"({"meta":{}})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON returns an error") {
|
||||
const auto out = PokemonSetSource::parseResponse("{not json");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonSetSource::fetchAll") {
|
||||
TEST_CASE("network error is surfaced as a Result error") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
PokemonSetSource src{http};
|
||||
CHECK(src.fetchAll().isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("network success is parsed end-to-end and hits the public endpoint") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"({"data":[{"id":"x","name":"X","releaseDate":"2020/01/01"}]})";
|
||||
PokemonSetSource src{http};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "x");
|
||||
CHECK(out.value().front().releaseDate == "2020/01/01");
|
||||
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ISetRepository.hpp"
|
||||
#include "ccm/services/SetService.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeSetSource final : public ISetSource {
|
||||
public:
|
||||
Result<std::vector<Set>> result = Result<std::vector<Set>>::err("not set");
|
||||
int calls = 0;
|
||||
Result<std::vector<Set>> fetchAll() override {
|
||||
++calls;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
class FakeGameModule final : public IGameModule {
|
||||
public:
|
||||
FakeSetSource source;
|
||||
Game gameId;
|
||||
explicit FakeGameModule(Game id) : gameId(id) {}
|
||||
Game id() const noexcept override { return gameId; }
|
||||
std::string dirName() const override { return gameId == Game::Magic ? "magic" : "pokemon"; }
|
||||
std::string displayName() const override { return dirName(); }
|
||||
ISetSource& setSource() override { return source; }
|
||||
};
|
||||
|
||||
class InMemSetRepo final : public ISetRepository {
|
||||
public:
|
||||
std::vector<Set> stored;
|
||||
bool hasStored = false;
|
||||
Result<std::vector<Set>> load(Game) override {
|
||||
if (!hasStored) return Result<std::vector<Set>>::err("no cache");
|
||||
return Result<std::vector<Set>>::ok(stored);
|
||||
}
|
||||
Result<void> save(Game, const std::vector<Set>& s) override {
|
||||
stored = s;
|
||||
hasStored = true;
|
||||
return Result<void>::ok();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("SetService") {
|
||||
TEST_CASE("updateSets fetches via the registered module and persists the result") {
|
||||
InMemSetRepo repo;
|
||||
SetService svc{repo};
|
||||
|
||||
FakeGameModule magic{Game::Magic};
|
||||
magic.source.result = Result<std::vector<Set>>::ok({
|
||||
{"lea", "Alpha", "1993/08/05"},
|
||||
});
|
||||
svc.registerModule(&magic);
|
||||
|
||||
const auto out = svc.updateSets(Game::Magic);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().size() == 1);
|
||||
CHECK(magic.source.calls == 1);
|
||||
|
||||
// Cache is now warm.
|
||||
const auto cached = svc.getSets(Game::Magic);
|
||||
REQUIRE(cached.isOk());
|
||||
CHECK(cached.value() == out.value());
|
||||
}
|
||||
|
||||
TEST_CASE("updateSets fails cleanly for unregistered games") {
|
||||
InMemSetRepo repo;
|
||||
SetService svc{repo};
|
||||
const auto out = svc.updateSets(Game::Pokemon);
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("propagates upstream fetch errors without persisting") {
|
||||
InMemSetRepo repo;
|
||||
SetService svc{repo};
|
||||
|
||||
FakeGameModule pokemon{Game::Pokemon};
|
||||
pokemon.source.result = Result<std::vector<Set>>::err("upstream is down");
|
||||
svc.registerModule(&pokemon);
|
||||
|
||||
const auto out = svc.updateSets(Game::Pokemon);
|
||||
CHECK(out.isErr());
|
||||
CHECK_FALSE(repo.hasStored);
|
||||
}
|
||||
|
||||
TEST_CASE("Pokemon module is routed independently of Magic") {
|
||||
// Both modules registered; updating Pokemon must hit the Pokemon
|
||||
// source and persist under the Pokemon Game key without disturbing
|
||||
// a previously cached Magic list.
|
||||
InMemSetRepo repo;
|
||||
SetService svc{repo};
|
||||
|
||||
FakeGameModule magic{Game::Magic};
|
||||
magic.source.result = Result<std::vector<Set>>::ok({
|
||||
{"lea", "Alpha", "1993/08/05"},
|
||||
});
|
||||
FakeGameModule pokemon{Game::Pokemon};
|
||||
pokemon.source.result = Result<std::vector<Set>>::ok({
|
||||
{"base1", "Base", "1999/01/09"},
|
||||
});
|
||||
svc.registerModule(&magic);
|
||||
svc.registerModule(&pokemon);
|
||||
|
||||
REQUIRE(svc.updateSets(Game::Magic).isOk());
|
||||
const auto poke = svc.updateSets(Game::Pokemon);
|
||||
REQUIRE(poke.isOk());
|
||||
REQUIRE(poke.value().size() == 1);
|
||||
CHECK(poke.value().front().id == "base1");
|
||||
CHECK(magic.source.calls == 1);
|
||||
CHECK(pokemon.source.calls == 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user