mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
patch: Feature/ygo set selection (#16)
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
- `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_set_lookup_tests.cpp` — `lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous).
|
||||
- `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`.
|
||||
- `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.
|
||||
|
||||
@@ -23,6 +23,7 @@ add_executable(ccm_core_tests
|
||||
pokemon_card_preview_source_tests.cpp
|
||||
icard_preview_source_tests.cpp
|
||||
yugioh_set_source_tests.cpp
|
||||
yugioh_set_lookup_tests.cpp
|
||||
yugioh_card_preview_source_tests.cpp
|
||||
game_module_tests.cpp
|
||||
card_sorter_tests.cpp
|
||||
|
||||
@@ -17,4 +17,9 @@ TEST_SUITE("asciiLower") {
|
||||
const std::string input = "caf\u00e9";
|
||||
CHECK(asciiLower(input) == input);
|
||||
}
|
||||
|
||||
TEST_CASE("bytes above ASCII range are passed through tolower unchanged") {
|
||||
const std::string input(1, static_cast<char>('\x80'));
|
||||
CHECK(asciiLower(input) == input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,63 @@ TEST_SUITE("Domain JSON required fields") {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("MagicCard missing each required key throws") {
|
||||
const nlohmann::json full = {
|
||||
{"id", 10},
|
||||
{"amount", 1},
|
||||
{"name", "Lightning Bolt"},
|
||||
{"set", nlohmann::json{
|
||||
{"id", "lea"},
|
||||
{"name", "Limited Edition Alpha"},
|
||||
{"releaseDate", "1993/08/05"},
|
||||
}},
|
||||
{"note", ""},
|
||||
{"images", nlohmann::json::array()},
|
||||
{"language", "English"},
|
||||
{"condition", "NearMint"},
|
||||
{"foil", false},
|
||||
{"signed", false},
|
||||
{"altered", false},
|
||||
};
|
||||
|
||||
for (const char* key : {"id", "amount", "name", "set", "note", "images", "language",
|
||||
"condition", "foil", "signed", "altered"}) {
|
||||
nlohmann::json partial = full;
|
||||
partial.erase(key);
|
||||
CHECK_THROWS(partial.get<MagicCard>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PokemonCard missing each required key throws") {
|
||||
const nlohmann::json full = {
|
||||
{"id", 7},
|
||||
{"amount", 1},
|
||||
{"name", "Charizard"},
|
||||
{"set", nlohmann::json{
|
||||
{"id", "base1"},
|
||||
{"name", "Base Set"},
|
||||
{"releaseDate", "1999/01/09"},
|
||||
}},
|
||||
{"setNo", "4/102"},
|
||||
{"note", ""},
|
||||
{"images", nlohmann::json::array()},
|
||||
{"language", "English"},
|
||||
{"condition", "Excellent"},
|
||||
{"firstEdition", true},
|
||||
{"holo", true},
|
||||
{"signed", false},
|
||||
{"altered", false},
|
||||
};
|
||||
|
||||
for (const char* key :
|
||||
{"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition",
|
||||
"firstEdition", "holo", "signed", "altered"}) {
|
||||
nlohmann::json partial = full;
|
||||
partial.erase(key);
|
||||
CHECK_THROWS(partial.get<PokemonCard>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Configuration missing required key throws") {
|
||||
const nlohmann::json j = {
|
||||
{"defaultGame", "Magic"},
|
||||
|
||||
@@ -163,4 +163,28 @@ TEST_SUITE("JsonCollectionRepository<MagicCard>") {
|
||||
REQUIRE(loadCreateFail.isErr());
|
||||
CHECK(loadCreateFail.error() == "write failed");
|
||||
}
|
||||
|
||||
TEST_CASE("load returns read error when collection exists but read fails") {
|
||||
InMemoryFileSystem configFs;
|
||||
auto cfg = makeConfig(configFs, "/data");
|
||||
FailingCollectionFs fs;
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
fs.existsValue = true;
|
||||
fs.readOk = false;
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error() == "read failed");
|
||||
}
|
||||
|
||||
TEST_CASE("load returns parse error when card object does not deserialize") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonCollectionRepository<MagicCard> repo{fs, cfg, magicDir};
|
||||
fs.writeText("/data/magic/collection.json", R"({"0":{"id":"not-a-number"}})");
|
||||
|
||||
const auto loaded = repo.load(Game::Magic);
|
||||
REQUIRE(loaded.isErr());
|
||||
CHECK(loaded.error().find("JSON parse error:") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +277,70 @@ TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") {
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().setNo == "7");
|
||||
}
|
||||
|
||||
TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
|
||||
"data":[
|
||||
{"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"},
|
||||
{"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}}
|
||||
]
|
||||
})",
|
||||
"base1", "Pikachu");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().setNo == "26");
|
||||
}
|
||||
|
||||
TEST_CASE("empty setId skips set filter and collects prints across sets") {
|
||||
const char* crossSet = R"({
|
||||
"data": [
|
||||
{"name":"Pikachu","number":"1","rarity":"Common","set":{"id":"base1"}},
|
||||
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base2"}}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(crossSet, "", "Pikachu");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2u);
|
||||
}
|
||||
|
||||
TEST_CASE("empty wanted card name skips name filter within the set") {
|
||||
const char* twoInSet = R"({
|
||||
"data": [
|
||||
{"name":"Electabuzz","number":"1","rarity":"Common","set":{"id":"base1"}},
|
||||
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base1"}}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(twoInSet, "base1", "");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2u);
|
||||
}
|
||||
|
||||
TEST_CASE("cards with empty number and rarity are skipped for auto-detect metadata") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
|
||||
"data": [
|
||||
{"name":"Pikachu","number":"","rarity":"","set":{"id":"base1"}}
|
||||
]
|
||||
})",
|
||||
"base1", "Pikachu");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
||||
}
|
||||
|
||||
TEST_CASE("no matches with empty setId yields generic no matching cards message") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(
|
||||
R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})",
|
||||
"",
|
||||
"Nobody");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON in parsePrintVariants yields parse error") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("Pokemon TCG JSON parse error:") == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
|
||||
@@ -329,6 +393,23 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
|
||||
CHECK(http.calls == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("detectPrintVariants surfaces fallback HTTP error when both requests fail") {
|
||||
class AlwaysFailHttp final : public IHttpClient {
|
||||
public:
|
||||
int calls = 0;
|
||||
Result<std::string> get(std::string_view) override {
|
||||
++calls;
|
||||
return Result<std::string>::err("offline");
|
||||
}
|
||||
} http;
|
||||
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.detectPrintVariants("Pikachu", "base1");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "offline");
|
||||
CHECK(http.calls == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})";
|
||||
@@ -337,17 +418,4 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
||||
}
|
||||
|
||||
TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
|
||||
"data":[
|
||||
{"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"},
|
||||
{"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}}
|
||||
]
|
||||
})",
|
||||
"base1", "Pikachu");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().setNo == "26");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,4 +202,15 @@ TEST_SUITE("StdFileSystem") {
|
||||
REQUIRE(filled.isOk());
|
||||
CHECK(filled.value().size() == 2u);
|
||||
}
|
||||
|
||||
TEST_CASE("writeText fails when the path names an existing directory") {
|
||||
TempDir td;
|
||||
StdFileSystem fs;
|
||||
const auto dir = td.path / "is_dir";
|
||||
REQUIRE(fs.ensureDirectory(dir).isOk());
|
||||
|
||||
const auto r = fs.writeText(dir, "cannot-write-here");
|
||||
REQUIRE(r.isErr());
|
||||
CHECK(r.error().find("Unable to create file") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-E"));
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EX005"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +172,12 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty());
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("uses dialog synonym table when ygoRarityShortCode does not match") {
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mosaic Rare") == "MSR");
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Parallel Rare") == "PR");
|
||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Gold Rare") == "GUR");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/util/YuGiOhSetLookup.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<Set> sampleSets() {
|
||||
return {
|
||||
Set{.id = "LOB", .name = "Legend of Blue Eyes White Dragon", .releaseDate = "2002/03/08"},
|
||||
Set{.id = "MRD", .name = "Metal Raiders", .releaseDate = "2002/06/26"},
|
||||
Set{.id = "LOB-25TH", .name = "Legend of Blue Eyes White Dragon (25th Anniversary Edition)",
|
||||
.releaseDate = "2023/04/20"},
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("lookupYuGiOhSetByShorthand") {
|
||||
using Kind = YuGiOhSetShorthandLookup::Kind;
|
||||
|
||||
TEST_CASE("empty and whitespace-only query is NotFound") {
|
||||
const auto sets = sampleSets();
|
||||
CHECK(lookupYuGiOhSetByShorthand("", sets).kind == Kind::NotFound);
|
||||
CHECK(lookupYuGiOhSetByShorthand(" ", sets).kind == Kind::NotFound);
|
||||
CHECK(lookupYuGiOhSetByShorthand("\t\n", sets).kind == Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("case-insensitive exact id match is Unique") {
|
||||
const auto sets = sampleSets();
|
||||
auto r = lookupYuGiOhSetByShorthand("lob", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 0);
|
||||
CHECK(sets[r.index].id == "LOB");
|
||||
|
||||
r = lookupYuGiOhSetByShorthand("MRD", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("trim ASCII whitespace around query") {
|
||||
const auto sets = sampleSets();
|
||||
const auto r = lookupYuGiOhSetByShorthand(" LOB ", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("hyphenated set codes match") {
|
||||
const auto sets = sampleSets();
|
||||
const auto r = lookupYuGiOhSetByShorthand("lob-25th", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 2);
|
||||
CHECK(sets[r.index].id == "LOB-25TH");
|
||||
}
|
||||
|
||||
TEST_CASE("unknown code is NotFound") {
|
||||
const auto sets = sampleSets();
|
||||
CHECK(lookupYuGiOhSetByShorthand("NOPE", sets).kind == Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("Ambiguous when two sets share the same normalized id") {
|
||||
std::vector<Set> dup = {
|
||||
Set{.id = "X1", .name = "A", .releaseDate = "2000/01/01"},
|
||||
Set{.id = "x1", .name = "B", .releaseDate = "2000/01/02"},
|
||||
};
|
||||
CHECK(lookupYuGiOhSetByShorthand("X1", dup).kind == Kind::Ambiguous);
|
||||
}
|
||||
|
||||
TEST_CASE("first matching index is stable when Unique among similar prefixes") {
|
||||
const auto sets = sampleSets();
|
||||
const auto r = lookupYuGiOhSetByShorthand("LOB", sets);
|
||||
REQUIRE(r.kind == Kind::Unique);
|
||||
CHECK(r.index == 0);
|
||||
CHECK(sets[r.index].id == "LOB");
|
||||
}
|
||||
|
||||
TEST_CASE("normalizeYuGiOhSetIdForLookup lowercases ASCII") {
|
||||
CHECK(normalizeYuGiOhSetIdForLookup("Ra04-EN001") == "ra04-en001");
|
||||
}
|
||||
|
||||
TEST_CASE("trimAsciiWhitespace handles empty") {
|
||||
CHECK(trimAsciiWhitespace("") == "");
|
||||
CHECK(trimAsciiWhitespace("x") == "x");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user