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:
Sebastian Dine
2026-05-09 11:05:47 +02:00
committed by GitHub
parent 13262fa015
commit 55ace147bc
149 changed files with 12611 additions and 0 deletions
+58
View File
@@ -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.
+37
View File
@@ -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)
+160
View File
@@ -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"), ""));
}
}
+152
View File
@@ -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");
}
}
+248
View File
@@ -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);
}
}
+120
View File
@@ -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());
}
}
+78
View File
@@ -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);
}
}
+123
View File
@@ -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);
}
}
+85
View File
@@ -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
+40
View File
@@ -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
+53
View File
@@ -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);
}
}
+131
View File
@@ -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);
}
}
+52
View File
@@ -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());
}
}
+112
View File
@@ -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);
}
}
+83
View File
@@ -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");
}
}
+2
View File
@@ -0,0 +1,2 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+130
View File
@@ -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);
}
}
+94
View File
@@ -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");
}
}
+119
View File
@@ -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);
}
}