patch: Patch/misc (#10)

* yugioh adjustments

* test coverage
This commit is contained in:
Sebastian Dine
2026-05-10 11:43:25 +02:00
committed by GitHub
parent c1d42bdadd
commit 8b7d45fdac
23 changed files with 564 additions and 26 deletions
+3 -2
View File
@@ -18,10 +18,11 @@
- `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` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly.
- `local_preview_byte_cache_tests.cpp``LocalPreviewByteCache` adapter against `StdFileSystem` (the only test in the suite that touches real disk; each case scopes itself to a unique `temp_directory_path()/ccm_preview_cache_test_*` directory and cleans up via an RAII `TempDir`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
- `std_file_system_tests.cpp``StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
- `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`.
- `yugioh_set_source_tests.cpp``YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
- `yugioh_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `rarityCodeFor` (CCM3 dialog rarity names → Yugipedia codes, unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005``LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
- `yugioh_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005``LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
- `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` / `matchesYuGiOhFilter` 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`; Yu-Gi-Oh adds `setNo` + `rarity`), boolean flag columns 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).
@@ -29,7 +30,7 @@
## 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`. **One narrow exception**: `local_preview_byte_cache_tests.cpp` exercises the real filesystem because `LocalPreviewByteCache` uses `std::filesystem` directly for size + mtime queries that the `IFileSystem` port deliberately does not expose. Those tests scope themselves to a unique temp directory and clean up unconditionally — do not extend the exception to other test files.
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`. **Narrow exceptions** (unique temp dirs + RAII cleanup): `local_preview_byte_cache_tests.cpp` (mtime/size semantics tied to real `std::filesystem`) and `std_file_system_tests.cpp` (`StdFileSystem` integration). Do not add further real-disk suites without the same cleanup guarantees.
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".
+2
View File
@@ -24,6 +24,8 @@ add_executable(ccm_core_tests
yugioh_card_preview_source_tests.cpp
card_sorter_tests.cpp
card_filter_tests.cpp
game_module_tests.cpp
std_file_system_tests.cpp
main.cpp
)
+1
View File
@@ -180,6 +180,7 @@ TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "lob-005"));
CHECK(matchesYuGiOhFilter(c, "ultra"));
CHECK(matchesYuGiOhFilter(c, "ur"));
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
}
}
+160 -2
View File
@@ -46,7 +46,10 @@ 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) {
bool sgnd = false, bool altered = false,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "") {
PokemonCard c;
c.id = id;
c.name = std::move(name);
@@ -57,6 +60,9 @@ PokemonCard pc(std::uint32_t id, std::string name,
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
return c;
}
@@ -64,7 +70,13 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
bool firstEdition = false,
bool sgnd = false,
bool altered = false,
std::string note = "") {
YuGiOhCard c;
c.id = id;
c.name = std::move(name);
@@ -73,6 +85,12 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
c.note = std::move(note);
return c;
}
@@ -141,6 +159,9 @@ TEST_SUITE("CardSorter - Magic columns") {
};
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
@@ -254,6 +275,67 @@ TEST_SUITE("CardSorter - Pokemon-specific columns") {
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
TEST_CASE("Name sorts case-insensitively") {
std::vector<PokemonCard> v = {
pc(1, "piKAchu", "X", "2000/01/01"),
pc(2, "abra", "X", "2000/01/01"),
pc(3, "CHARMANDER", "X", "2000/01/01"),
};
sortPokemonCards(v, PokemonSortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
}
TEST_CASE("Language and Condition sort by lowercased labels") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, false, false,
Language::Japanese, Condition::Mint),
pc(2, "b", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::Played),
pc(3, "c", "X", "2000/01/01", 1, false, false, false, false,
Language::German, Condition::NearMint),
};
sortPokemonCards(v, PokemonSortColumn::Language, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortPokemonCards(v, PokemonSortColumn::Condition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("Signed and Altered sort like Magic booleans") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, /*sgnd=*/false, /*alt=*/true),
pc(2, "b", "X", "2000/01/01", 1, false, false, /*sgnd=*/true, /*alt=*/false),
};
sortPokemonCards(v, PokemonSortColumn::Signed, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
sortPokemonCards(v, PokemonSortColumn::Altered, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
TEST_CASE("Note sorts case-insensitively") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "ZETA"),
pc(2, "b", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "alpha"),
pc(3, "c", "X", "2000/01/01", 1, false, false, false, false,
Language::English, Condition::NearMint, "Beta"),
};
sortPokemonCards(v, PokemonSortColumn::Note, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
}
TEST_CASE("descending SetReleaseDate reverses chronological order") {
std::vector<PokemonCard> v = {
pc(1, "x", "Late", "2020/01/01"),
pc(2, "y", "Early", "1999/01/01"),
pc(3, "z", "Mid", "2015/06/01"),
};
sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
}
TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
@@ -272,6 +354,82 @@ TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
}
TEST_SUITE("CardSorter - YuGiOh columns") {
TEST_CASE("Name sorts case-insensitively") {
std::vector<YuGiOhCard> v = {
yc(1, "BLUE-EYES", "X", "2000/01/01"),
yc(2, "dark magician", "X", "2000/01/01"),
yc(3, "CELTIC_GUARDIAN", "X", "2000/01/01"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("SetReleaseDate sorts chronologically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "Late", "2020/01/01"),
yc(2, "b", "Early", "1999/01/01"),
yc(3, "c", "Mid", "2015/06/01"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("Language and Condition sort by lowercased labels") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 1,
Language::Japanese, Condition::Mint),
yc(2, "b", "X", "2000/01/01", "", "", 1,
Language::English, Condition::Played),
yc(3, "c", "X", "2000/01/01", "", "", 1,
Language::German, Condition::NearMint),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Language, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Condition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("FirstEdition Signed Altered and Note columns sort consistently") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/true, /*sgnd=*/false, /*alt=*/true, "Z"),
yc(2, "b", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/false, /*sgnd=*/true, /*alt=*/false, "a"),
yc(3, "c", "X", "2000/01/01", "", "", 1,
Language::English, Condition::NearMint,
/*firstEdition=*/false, /*sgnd=*/false, /*alt=*/false, "m"),
};
sortYuGiOhCards(v, YuGiOhSortColumn::FirstEdition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Signed, /*ascending=*/true);
// After FirstEdition sort order is {2,3,1}: signed=false wins first (stable: 3 then 1).
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
sortYuGiOhCards(v, YuGiOhSortColumn::Altered, /*ascending=*/true);
// Previous order {3,1,2}: altered=false entries are 3 and 2 before true (1).
CHECK(ids(v) == std::vector<std::uint32_t>{3, 2, 1});
sortYuGiOhCards(v, YuGiOhSortColumn::Note, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // a, m, Z (case-insensitive)
}
TEST_CASE("Rarity sorts by rarity shorthand") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "Ultra Rare", 1),
yc(2, "b", "X", "2000/01/01", "", "Common", 1),
yc(3, "c", "X", "2000/01/01", "", "Secret Rare", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // C, ScR, UR
}
TEST_CASE("Amount sorts numerically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 9),
+64 -2
View File
@@ -42,6 +42,50 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json bad = "Spanglish";
CHECK_THROWS(bad.get<Language>());
}
TEST_CASE("all enum values round-trip through string helpers") {
for (const auto game : allGames()) {
const auto name = to_string(game);
CHECK(gameFromString(name).has_value());
CHECK(*gameFromString(name) == game);
}
for (const auto language : allLanguages()) {
const auto name = to_string(language);
CHECK(languageFromString(name).has_value());
CHECK(*languageFromString(name) == language);
}
for (const auto condition : allConditions()) {
const auto name = to_string(condition);
CHECK(conditionFromString(name).has_value());
CHECK(*conditionFromString(name) == condition);
}
for (const auto theme : allThemes()) {
const auto name = to_string(theme);
CHECK(themeFromString(name).has_value());
CHECK(*themeFromString(name) == theme);
}
}
TEST_CASE("invalid enum helper inputs return nullopt") {
CHECK_FALSE(gameFromString("magic").has_value());
CHECK_FALSE(languageFromString("EN").has_value());
CHECK_FALSE(conditionFromString("Near Mint").has_value());
CHECK_FALSE(themeFromString("OLED").has_value());
}
TEST_CASE("invalid game, condition and theme JSON values throw") {
nlohmann::json badGame = "YGO";
CHECK_THROWS(badGame.get<Game>());
nlohmann::json badCondition = "Pristine";
CHECK_THROWS(badCondition.get<Condition>());
nlohmann::json badTheme = "Midnight";
CHECK_THROWS(badTheme.get<Theme>());
}
}
TEST_SUITE("Set JSON shape stays stable") {
@@ -124,6 +168,25 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
const auto back = j.get<Configuration>();
CHECK(back == cfg);
}
TEST_CASE("theme defaults to Light when missing from payload") {
const nlohmann::json j = {
{"dataStorage", "/storage"},
{"defaultGame", "Magic"},
};
const auto cfg = j.get<Configuration>();
CHECK(cfg.dataStorage == "/storage");
CHECK(cfg.defaultGame == Game::Magic);
CHECK(cfg.theme == Theme::Light);
}
TEST_CASE("missing required keys still throws") {
const nlohmann::json j = {
{"theme", "Dark"},
};
CHECK_THROWS(j.get<Configuration>());
}
}
TEST_SUITE("YuGiOhCard JSON") {
@@ -135,7 +198,6 @@ TEST_SUITE("YuGiOhCard JSON") {
c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"};
c.setNo = "SDK-001";
c.rarity = "Ultra Rare";
c.rarityCode = "(UR)";
c.note = "classic";
c.images = {"77+starter+blue-eyes+0.png"};
c.language = Language::English;
@@ -147,7 +209,7 @@ TEST_SUITE("YuGiOhCard JSON") {
nlohmann::json j = c;
CHECK(j.at("setNo") == "SDK-001");
CHECK(j.at("rarity") == "Ultra Rare");
CHECK(j.at("rarityCode") == "(UR)");
CHECK_FALSE(j.contains("rarityCode"));
CHECK(j.at("signed") == false);
const YuGiOhCard back = j.get<YuGiOhCard>();
+51
View File
@@ -0,0 +1,51 @@
#include <doctest/doctest.h>
#include "ccm/games/magic/MagicGameModule.hpp"
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
using namespace ccm;
namespace {
class NoopHttpClient final : public IHttpClient {
public:
Result<std::string> get(std::string_view) override {
return Result<std::string>::ok("{}");
}
};
} // namespace
TEST_SUITE("game modules expose stable identity and wiring") {
TEST_CASE("Magic module reports canonical metadata") {
NoopHttpClient http;
MagicGameModule module(http);
CHECK(module.id() == Game::Magic);
CHECK(module.dirName() == "magic");
CHECK(module.displayName() == "Magic");
CHECK(module.cardPreviewSource() != nullptr);
}
TEST_CASE("Pokemon module reports canonical metadata") {
NoopHttpClient http;
PokemonGameModule module(http);
CHECK(module.id() == Game::Pokemon);
CHECK(module.dirName() == "pokemon");
CHECK(module.displayName() == "Pokemon");
CHECK(module.cardPreviewSource() != nullptr);
}
TEST_CASE("YuGiOh module reports canonical metadata") {
NoopHttpClient http;
YuGiOhGameModule module(http);
CHECK(module.id() == Game::YuGiOh);
CHECK(module.dirName() == "yugioh");
CHECK(module.displayName() == "Yu-Gi-Oh!");
CHECK(module.cardPreviewSource() != nullptr);
}
}
+189
View File
@@ -0,0 +1,189 @@
#include <doctest/doctest.h>
// StdFileSystem translates IFileSystem onto std::filesystem. Like
// LocalPreviewByteCache tests, these exercise real disk under a dedicated
// temp directory so permission errors and path normalization behave like production.
#include "ccm/infra/StdFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
#include <string>
using namespace ccm;
namespace fs = std::filesystem;
namespace {
struct TempDir {
fs::path path;
TempDir() {
std::random_device rd;
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
path = fs::temp_directory_path() /
(std::string("ccm_std_fs_test_") + std::to_string(stamp) + "_" +
std::to_string(rd()));
std::error_code ec;
fs::create_directories(path, ec);
}
~TempDir() {
std::error_code ec;
fs::remove_all(path, ec);
}
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
};
} // namespace
TEST_SUITE("StdFileSystem") {
TEST_CASE("exists and isDirectory reflect real paths") {
TempDir td;
StdFileSystem fs;
const auto nested = td.path / "a" / "b";
CHECK_FALSE(fs.exists(nested));
REQUIRE(fs.ensureDirectory(nested).isOk());
CHECK(fs.exists(nested));
CHECK(fs.isDirectory(nested));
const auto filePath = td.path / "file.bin";
REQUIRE(fs.writeText(filePath, "x").isOk());
CHECK(fs.exists(filePath));
CHECK_FALSE(fs.isDirectory(filePath));
}
TEST_CASE("ensureDirectory succeeds when directory already exists") {
TempDir td;
StdFileSystem fs;
const auto dir = td.path / "existing";
REQUIRE(fs.ensureDirectory(dir).isOk());
REQUIRE(fs.ensureDirectory(dir).isOk());
CHECK(fs.isDirectory(dir));
}
TEST_CASE("ensureDirectory errors when path is a regular file") {
TempDir td;
StdFileSystem fs;
const auto clash = td.path / "notadir";
REQUIRE(fs.writeText(clash, "block").isOk());
const auto r = fs.ensureDirectory(clash);
REQUIRE(r.isErr());
CHECK(r.error().find("not a directory") != std::string::npos);
}
TEST_CASE("readText round-trips bytes written by writeText") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "sub" / "cfg.json";
const std::string payload(std::string("{\"x\":") + std::string(4, '\0') + "}");
REQUIRE(fs.writeText(p, payload).isOk());
const auto readBack = fs.readText(p);
REQUIRE(readBack.isOk());
CHECK(readBack.value() == payload);
}
TEST_CASE("readText errors when file does not exist") {
TempDir td;
StdFileSystem fs;
const auto missing = td.path / "missing.txt";
const auto r = fs.readText(missing);
REQUIRE(r.isErr());
CHECK(r.error().find("Unable to open") != std::string::npos);
}
TEST_CASE("writeText truncates an existing file") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "t.txt";
REQUIRE(fs.writeText(p, "aaaaaaaaaa").isOk());
REQUIRE(fs.writeText(p, "hi").isOk());
const auto r = fs.readText(p);
REQUIRE(r.isOk());
CHECK(r.value() == "hi");
}
TEST_CASE("copyFile copies bytes and respects overwrite flag") {
TempDir td;
StdFileSystem fs;
const auto src = td.path / "src.bin";
const auto dst = td.path / "nested" / "dst.bin";
REQUIRE(fs.writeText(src, "alpha").isOk());
REQUIRE(fs.copyFile(src, dst, /*overwrite=*/false).isOk());
auto rd = fs.readText(dst);
REQUIRE(rd.isOk());
CHECK(rd.value() == "alpha");
REQUIRE(fs.writeText(src, "beta").isOk());
const auto noOverwrite = fs.copyFile(src, dst, /*overwrite=*/false);
REQUIRE(noOverwrite.isErr());
REQUIRE(fs.copyFile(src, dst, /*overwrite=*/true).isOk());
rd = fs.readText(dst);
REQUIRE(rd.isOk());
CHECK(rd.value() == "beta");
}
TEST_CASE("copyFile fails cleanly when source is missing") {
TempDir td;
StdFileSystem fs;
const auto src = td.path / "ghost.dat";
const auto dst = td.path / "out.dat";
const auto r = fs.copyFile(src, dst, /*overwrite=*/false);
REQUIRE(r.isErr());
CHECK(r.error().find("copy_file failed") != std::string::npos);
}
TEST_CASE("remove deletes a file and tolerates repeated removes") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "gone.txt";
REQUIRE(fs.writeText(p, "body").isOk());
REQUIRE(fs.remove(p).isOk());
CHECK_FALSE(fs.exists(p));
REQUIRE(fs.remove(p).isOk());
}
TEST_CASE("listDirectory errors when path is not a directory") {
TempDir td;
StdFileSystem fs;
const auto p = td.path / "single.dat";
REQUIRE(fs.writeText(p, "").isOk());
const auto r = fs.listDirectory(p);
REQUIRE(r.isErr());
CHECK(r.error().find("Not a directory") != std::string::npos);
}
TEST_CASE("listDirectory returns entries for an empty and populated folder") {
TempDir td;
StdFileSystem fs;
const auto dir = td.path / "list_me";
REQUIRE(fs.ensureDirectory(dir).isOk());
auto empty = fs.listDirectory(dir);
REQUIRE(empty.isOk());
CHECK(empty.value().empty());
REQUIRE(fs.writeText(dir / "a.txt", "a").isOk());
REQUIRE(fs.writeText(dir / "b.txt", "b").isOk());
auto filled = fs.listDirectory(dir);
REQUIRE(filled.isOk());
CHECK(filled.value().size() == 2u);
}
}
@@ -78,6 +78,23 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
}
}
TEST_SUITE("ygoRarityShortCode") {
TEST_CASE("maps supported Yu-Gi-Oh rarity names to short form") {
CHECK(ygoRarityShortCode("Common") == "C");
CHECK(ygoRarityShortCode("Rare") == "R");
CHECK(ygoRarityShortCode("Super Rare") == "SR");
CHECK(ygoRarityShortCode("Ultra Rare") == "UR");
CHECK(ygoRarityShortCode("Secret Rare") == "ScR");
CHECK(ygoRarityShortCode("Quarter Century Secret Rare") == "QCScR");
CHECK(ygoRarityShortCode("Starlight Rare") == "StR");
CHECK(ygoRarityShortCode("Collector's Rare") == "CR");
CHECK(ygoRarityShortCode("Ghost Rare") == "GR");
CHECK(ygoRarityShortCode("Ultimate Rare") == "UtR");
CHECK(ygoRarityShortCode("Platinum Secret Rare") == "PlScR");
CHECK(ygoRarityShortCode("Prismatic Secret Rare") == "PScR");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
TEST_CASE("strips whitespace and policy-banned punctuation") {
// Yugipedia's image policy: whitespace and a fixed punctuation set
@@ -101,6 +118,8 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
== "QCScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Collector's Rare") == "CR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Platinum Secret Rare") == "PlScR");
}
TEST_CASE("returns empty string for unknown rarity names") {
// Unknown rarity should fall through to the rarity-less filename