Minor: Add Asian Pokemon Card Support (#18)

This commit is contained in:
Sebastian Dine
2026-07-22 11:13:42 +02:00
committed by GitHub
parent e5c830e945
commit c9e6bc2b6b
87 changed files with 78068 additions and 162 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
- `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`.
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`. Set-repo cases also pin Pokemon `sets-west.json` / `sets-asia.json` paths and migrate-on-load from legacy `pokemon/sets.json` / `pokemonjp/sets.json`.
- `local_image_store_tests.cpp``LocalImageStore` against `InMemoryFileSystem` + `ConfigService`: `copyIn` (extension preserved, missing source errors), `remove` (existing file deleted; absent path is a no-op), `resolvePath` layout under `dataStorage/<game>/images/`.
- `set_service_tests.cpp``SetService` with `FakeSetSource` + `InMemSetRepo`.
- `magic_set_source_tests.cpp``MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
+3
View File
@@ -23,6 +23,9 @@ add_executable(ccm_core_tests
pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
japanese_pokemon_en_catalog_tests.cpp
japanese_pokemon_set_source_tests.cpp
japanese_pokemon_card_preview_source_tests.cpp
icard_preview_source_tests.cpp
yugioh_set_source_tests.cpp
yugioh_set_lookup_tests.cpp
+47
View File
@@ -7,6 +7,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -186,6 +187,15 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
TEST_CASE("empty filter matches everything") {
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
}
TEST_CASE("region is searchable") {
PokemonCard c = pc("Charizard", "Base Set");
c.region = PokemonRegion::Asia;
CHECK(matchesPokemonFilter(c, "asia"));
CHECK_FALSE(matchesPokemonFilter(c, "west"));
c.region = PokemonRegion::West;
CHECK(matchesPokemonFilter(c, "west"));
}
}
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
@@ -276,3 +286,40 @@ TEST_SUITE("CardFilter::matchesDigiBattle99Filter") {
CHECK(matchesDigiBattle99Filter(c, "agu"));
}
}
TEST_SUITE("CardFilter::matchesJapanesePokemonFilter") {
TEST_CASE("matches by name and set.name") {
JapanesePokemonCard c;
c.name = "Charmander";
c.set.name = "Expansion Pack";
CHECK(matchesJapanesePokemonFilter(c, "char"));
CHECK(matchesJapanesePokemonFilter(c, "EXPANSION"));
CHECK_FALSE(matchesJapanesePokemonFilter(c, "pikachu"));
}
TEST_CASE("includes setNo in searchable columns") {
JapanesePokemonCard c;
c.name = "Charmander";
c.set.name = "Expansion Pack";
c.setNo = "001";
CHECK(matchesJapanesePokemonFilter(c, "001"));
CHECK(matchesJapanesePokemonFilter(c, "00"));
}
TEST_CASE("empty filter matches everything") {
JapanesePokemonCard c;
c.name = "Charmander";
CHECK(matchesJapanesePokemonFilter(c, ""));
}
TEST_CASE("boolean flag columns are not matched") {
JapanesePokemonCard c;
c.name = "Charmander";
c.holo = true;
c.firstEdition = true;
c.signed_ = true;
c.altered = true;
CHECK_FALSE(matchesJapanesePokemonFilter(c, "true"));
CHECK(matchesJapanesePokemonFilter(c, "char"));
}
}
+66
View File
@@ -2,10 +2,12 @@
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/ports/IPreviewByteCache.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include <filesystem>
#include <optional>
#include <string>
#include <unordered_map>
@@ -130,6 +132,50 @@ public:
void storeNegative(std::string_view) override {}
};
class MemoryFileSystem final : public IFileSystem {
public:
std::unordered_map<std::string, std::string> files;
[[nodiscard]] bool exists(const std::filesystem::path& p) const override {
return files.contains(p.generic_string());
}
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override {
return false;
}
Result<void> ensureDirectory(const std::filesystem::path&) override {
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path& p) override {
auto it = files.find(p.generic_string());
if (it == files.end()) {
return Result<std::string>::err("Unable to open " + p.generic_string());
}
return Result<std::string>::ok(it->second);
}
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override {
files[p.generic_string()] = std::string(contents);
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path& from,
const std::filesystem::path& to,
bool) override {
auto it = files.find(from.generic_string());
if (it == files.end()) {
return Result<void>::err("missing source");
}
files[to.generic_string()] = it->second;
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path& p) override {
files.erase(p.generic_string());
return Result<void>::ok();
}
Result<std::vector<std::filesystem::path>> listDirectory(
const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
// Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule {
public:
@@ -232,6 +278,26 @@ TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
CHECK(out.isErr());
CHECK(out.error() == "net down");
}
TEST_CASE("asset: preview loads bytes from configured asset root") {
FakeSource source;
source.url = "asset:pokemon_jp_classic/TamamushiCG/016.jpg";
FakeGameModule module;
module.gameId = Game::JapanesePokemon;
module.preview = &source;
FixedHttpClient http;
MemoryFileSystem fs;
fs.files["assets/pokemon_jp_classic/TamamushiCG/016.jpg"] = "JPEG-bytes";
CardPreviewService svc{http, nullptr, &fs, "assets"};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::JapanesePokemon, "Erika", "TamamushiCG", "016");
REQUIRE(out.isOk());
CHECK(out.value() == "JPEG-bytes");
CHECK(http.calls == 0);
}
}
TEST_SUITE("CardPreviewService caching") {
+64
View File
@@ -7,6 +7,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -119,6 +120,30 @@ DigiBattle99Card db(std::uint32_t id, std::string name,
return c;
}
JapanesePokemonCard jp(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,
Language lang = Language::Japanese,
Condition cond = Condition::NearMint,
std::string note = "") {
JapanesePokemonCard 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;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
return c;
}
std::vector<std::uint32_t> ids(const std::vector<MagicCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
@@ -147,6 +172,13 @@ std::vector<std::uint32_t> ids(const std::vector<DigiBattle99Card>& v) {
return out;
}
std::vector<std::uint32_t> ids(const std::vector<JapanesePokemonCard>& 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") {
@@ -515,3 +547,35 @@ TEST_SUITE("CardSorter - DigiBattle99 columns") {
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
}
TEST_SUITE("CardSorter - JapanesePokemon columns") {
TEST_CASE("Holo and FirstEdition sort false before true") {
std::vector<JapanesePokemonCard> v = {
jp(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*first=*/false),
jp(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/true),
jp(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/false),
};
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::Holo, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::FirstEdition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
TEST_CASE("Set column sorts by release date") {
std::vector<JapanesePokemonCard> v = {
jp(1, "x", "Late", "2023/03/10"),
jp(2, "y", "Early", "1996/10/20"),
};
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
TEST_CASE("Name sorts case-insensitively") {
std::vector<JapanesePokemonCard> v = {
jp(1, "charmander", "X", "1996/10/20"),
jp(2, "Bulbasaur", "X", "1996/10/20"),
};
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
}
+128
View File
@@ -3,6 +3,7 @@
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -27,6 +28,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json jDigi = "DigiBattle99";
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
nlohmann::json jJp = "JapanesePokemon";
CHECK(jJp.get<Game>() == Game::JapanesePokemon);
nlohmann::json j3 = Theme::Dark;
CHECK(j3.get<std::string>() == "Dark");
CHECK(j3.get<Theme>() == Theme::Dark);
@@ -37,11 +41,45 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
CHECK(l.get<std::string>() == "Japanese");
CHECK(l.get<Language>() == Language::Japanese);
nlohmann::json k = Language::Korean;
CHECK(k.get<std::string>() == "Korean");
CHECK(k.get<Language>() == Language::Korean);
nlohmann::json sc = Language::SimplifiedChinese;
CHECK(sc.get<std::string>() == "S-Chinese");
CHECK(sc.get<Language>() == Language::SimplifiedChinese);
nlohmann::json tc = Language::TraditionalChinese;
CHECK(tc.get<std::string>() == "T-Chinese");
CHECK(tc.get<Language>() == Language::TraditionalChinese);
nlohmann::json legacyChinese = "Chinese";
CHECK(legacyChinese.get<Language>() == Language::SimplifiedChinese);
nlohmann::json c = Condition::LightPlayed;
CHECK(c.get<std::string>() == "LightPlayed");
CHECK(c.get<Condition>() == Condition::LightPlayed);
}
TEST_CASE("PokemonRegion") {
nlohmann::json j = PokemonRegion::Asia;
CHECK(j.get<std::string>() == "Asia");
CHECK(j.get<PokemonRegion>() == PokemonRegion::Asia);
nlohmann::json w = "West";
CHECK(w.get<PokemonRegion>() == PokemonRegion::West);
}
TEST_CASE("allGames excludes JapanesePokemon but string mapping remains") {
for (const auto game : allGames()) {
CHECK(game != Game::JapanesePokemon);
}
CHECK(allGames().size() == 4);
CHECK(gameFromString("JapanesePokemon") == Game::JapanesePokemon);
CHECK(pokemonBackendGame(PokemonRegion::West) == Game::Pokemon);
CHECK(pokemonBackendGame(PokemonRegion::Asia) == Game::JapanesePokemon);
}
TEST_CASE("invalid enum string throws") {
nlohmann::json bad = "Spanglish";
CHECK_THROWS(bad.get<Language>());
@@ -146,15 +184,37 @@ TEST_SUITE("PokemonCard JSON") {
c.holo = true;
c.signed_ = false;
c.altered = false;
c.region = PokemonRegion::West;
nlohmann::json j = c;
CHECK(j.at("setNo") == "4/102");
CHECK(j.at("firstEdition") == true);
CHECK(j.at("signed") == false);
CHECK(j.at("region") == "West");
const PokemonCard back = j.get<PokemonCard>();
CHECK(back == c);
}
TEST_CASE("region Asia round-trips and missing region defaults to West") {
PokemonCard c;
c.id = 1;
c.amount = 1;
c.name = "Charmander";
c.set = Set{"PMCG1", "Expansion Pack", "1996/10/20"};
c.setNo = "001";
c.language = Language::Japanese;
c.condition = Condition::NearMint;
c.region = PokemonRegion::Asia;
nlohmann::json j = c;
CHECK(j.at("region") == "Asia");
CHECK(j.get<PokemonCard>().region == PokemonRegion::Asia);
j.erase("region");
const PokemonCard legacy = j.get<PokemonCard>();
CHECK(legacy.region == PokemonRegion::West);
}
}
TEST_SUITE("DigiBattle99Card JSON") {
@@ -185,6 +245,34 @@ TEST_SUITE("DigiBattle99Card JSON") {
}
}
TEST_SUITE("JapanesePokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
JapanesePokemonCard c;
c.id = 9;
c.amount = 1;
c.name = "Charmander";
c.set = Set{"PMCG1", "Expansion Pack", "1996/10/20"};
c.setNo = "001";
c.note = "";
c.images = {};
c.language = Language::Japanese;
c.condition = Condition::NearMint;
c.firstEdition = true;
c.holo = false;
c.signed_ = false;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "001");
CHECK(j.at("firstEdition") == true);
CHECK(j.at("signed") == false);
CHECK(j.at("language") == "Japanese");
const JapanesePokemonCard back = j.get<JapanesePokemonCard>();
CHECK(back == c);
}
}
TEST_SUITE("Configuration JSON matches Rust serde aliases") {
TEST_CASE("dataStorage / defaultGame / theme keys are present") {
Configuration cfg;
@@ -201,6 +289,16 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
CHECK(back == cfg);
}
TEST_CASE("legacy defaultGame JapanesePokemon coerces to Pokemon") {
nlohmann::json j = {
{"dataStorage", "/data"},
{"defaultGame", "JapanesePokemon"},
{"theme", "Light"},
};
const auto cfg = j.get<Configuration>();
CHECK(cfg.defaultGame == Game::Pokemon);
}
TEST_CASE("missing theme key defaults to Light") {
const nlohmann::json j = {
{"dataStorage", "/portable/data"},
@@ -559,6 +657,36 @@ TEST_SUITE("Domain JSON required fields") {
}
}
TEST_CASE("JapanesePokemonCard missing each required key throws") {
const nlohmann::json full = {
{"id", 9},
{"amount", 1},
{"name", "Charmander"},
{"set", nlohmann::json{
{"id", "PMCG1"},
{"name", "Expansion Pack"},
{"releaseDate", "1996/10/20"},
}},
{"setNo", "001"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "Japanese"},
{"condition", "NearMint"},
{"firstEdition", true},
{"holo", false},
{"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<JapanesePokemonCard>());
}
}
TEST_CASE("Configuration missing required key throws") {
const nlohmann::json j = {
{"defaultGame", "Magic"},
+12
View File
@@ -3,6 +3,7 @@
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
@@ -63,4 +64,15 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("JapanesePokemon module reports canonical metadata") {
NoopHttpClient http;
JapanesePokemonGameModule module(http);
CHECK(module.id() == Game::JapanesePokemon);
CHECK(module.dirName() == "pokemon");
CHECK(module.displayName() == "Pokemon (Japan)");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
}
@@ -0,0 +1,593 @@
#include <doctest/doctest.h>
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <unordered_map>
using namespace ccm;
namespace {
class RoutingHttpClient final : public IHttpClient {
public:
std::unordered_map<std::string, std::string> bodies;
std::string lastUrl;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (!ok) return Result<std::string>::err("offline");
const auto it = bodies.find(lastUrl);
if (it == bodies.end()) return Result<std::string>::err("unknown url: " + lastUrl);
return Result<std::string>::ok(it->second);
}
};
JapanesePokemonEnCatalog sampleCatalog() {
auto c = JapanesePokemonEnCatalog::parse(R"({
"sets": {
"SV1a": {"name_en":"Triplet Beat","name_ja":"トリプレットビート"}
},
"prints": [
{"set_id":"SV1a","local_id":"001","name_en":"Tropius","name_ja":"トロピウス","name_en_source":"bulbapedia"}
]
})");
REQUIRE(c.isOk());
return std::move(c).value();
}
} // namespace
TEST_SUITE("JapanesePokemonCardPreviewSource helpers") {
TEST_CASE("normalizeLocalId strips slash and whitespace") {
CHECK(JapanesePokemonCardPreviewSource::normalizeLocalId(" 001/102 ") == "001");
CHECK(JapanesePokemonCardPreviewSource::normalizeLocalId("4/102") == "4");
}
TEST_CASE("imageUrlFromBase appends high.png") {
CHECK(JapanesePokemonCardPreviewSource::imageUrlFromBase(
"https://assets.tcgdex.net/ja/SV/SV1a/001") ==
"https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
}
TEST_CASE("buildCardUrl encodes set-local id") {
CHECK(JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001") ==
"https://api.tcgdex.net/v2/ja/cards/SV1a-001");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::parseSetCards") {
TEST_CASE("parses localId name and image") {
const std::string json = R"({
"id":"SV1a",
"cards":[
{"id":"SV1a-001","localId":"001","name":"トロピウス",
"image":"https://assets.tcgdex.net/ja/SV/SV1a/001","rarity":"Common"}
]
})";
const auto out = JapanesePokemonCardPreviewSource::parseSetCards(json);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].localId == "001");
CHECK(out.value()[0].nameJa == "トロピウス");
CHECK(out.value()[0].imageBase == "https://assets.tcgdex.net/ja/SV/SV1a/001");
}
TEST_CASE("missing cards array is Transient") {
const auto out = JapanesePokemonCardPreviewSource::parseSetCards(R"({"id":"X"})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::parseCardImageUrl") {
TEST_CASE("returns high.png URL") {
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl(
R"({"id":"SV1a-001","image":"https://assets.tcgdex.net/ja/SV/SV1a/001"})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
}
TEST_CASE("null image is NotFound") {
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl(
R"({"id":"PMCG1-001","image":null})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("malformed JSON is Transient") {
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl("{bad");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::parsePrintVariants") {
TEST_CASE("matches English catalog name") {
const std::string body = R"({
"id":"SV1a",
"cards":[
{"localId":"001","name":"トロピウス","rarity":"Common"},
{"localId":"002","name":"other","rarity":"Common"}
]
})";
const auto catalog = sampleCatalog();
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "SV1a", "Tropius", catalog);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "001");
}
TEST_CASE("matches Japanese name without catalog") {
const std::string body = R"({
"id":"SV1a",
"cards":[{"localId":"001","name":"トロピウス"}]
})";
JapanesePokemonEnCatalog empty;
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "SV1a", "トロピウス", empty);
REQUIRE(out.isOk());
CHECK(out.value().front().setNo == "001");
}
TEST_CASE("rejects stale catalog localId when name_ja disagrees with TCGdex") {
// Historical seed bug: Charmander mapped to 001 (actually Bulbasaur).
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
const std::string body = R"({
"id":"PMCG1",
"cards":[
{"localId":"001","name":"フシギダネ","rarity":"Common"},
{"localId":"014","name":"ヒトカゲ","rarity":"Common"}
]
})";
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Charmander", catalog.value());
REQUIRE(out.isErr());
}
TEST_CASE("accepts corrected catalog localId for Charmander") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"014","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
const std::string body = R"({
"id":"PMCG1",
"cards":[
{"localId":"001","name":"フシギダネ"},
{"localId":"014","name":"ヒトカゲ","rarity":"Common"}
]
})";
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Charmander", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "014");
}
TEST_CASE("English Blastoise and Mewtwo resolve Expansion Pack localIds") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"032","name_en":"Blastoise","name_ja":"カメックス"},
{"set_id":"PMCG1","local_id":"050","name_en":"Mewtwo","name_ja":"ミュウツー"}
]
})");
REQUIRE(catalog.isOk());
const std::string body = R"({
"id":"PMCG1",
"cards":[
{"localId":"032","name":"カメックス","rarity":"Holo Rare"},
{"localId":"050","name":"ミュウツー","rarity":"Holo Rare"}
]
})";
auto blast = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Blastoise", catalog.value());
REQUIRE(blast.isOk());
REQUIRE(blast.value().size() == 1);
CHECK(blast.value()[0].setNo == "032");
auto mew = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Mewtwo", catalog.value());
REQUIRE(mew.isOk());
REQUIRE(mew.value().size() == 1);
CHECK(mew.value()[0].setNo == "050");
}
TEST_CASE("English Switch resolves Expansion Pack localId 073") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"073","name_en":"Switch","name_ja":"ポケモンいれかえ"}
]
})");
REQUIRE(catalog.isOk());
const std::string body = R"({
"id":"PMCG1",
"cards":[
{"localId":"071","name":"きずぐすり","rarity":"Common"},
{"localId":"073","name":"ポケモンいれかえ","rarity":"Common"}
]
})";
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Switch", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "073");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::fetchImageUrl") {
TEST_CASE("resolves via card endpoint when localId present") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001")] =
R"({"id":"SV1a-001","image":"https://assets.tcgdex.net/ja/SV/SV1a/001"})";
auto catalog = sampleCatalog();
JapanesePokemonCardPreviewSource src{http, catalog};
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
REQUIRE(out.isOk());
CHECK(out.value() == "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
}
TEST_CASE("resolves via set detail when card has no image but set row does") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001")] =
R"({"id":"SV1a-001","image":null})";
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("SV1a")] = R"({
"id":"SV1a",
"cards":[{"localId":"001","name":"トロピウス",
"image":"https://assets.tcgdex.net/ja/SV/SV1a/001"}]
})";
auto catalog = sampleCatalog();
JapanesePokemonCardPreviewSource src{http, catalog};
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
REQUIRE(out.isOk());
CHECK(out.value().find("/high.png") != std::string::npos);
}
TEST_CASE("stale catalog does not bind English name to wrong localId image") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
"id":"PMCG1",
"cards":[
{"localId":"001","name":"フシギダネ",
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/001"},
{"localId":"014","name":"ヒトカゲ",
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/014"}
]
})";
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
// Empty setNo forces name match; stale catalog must not pick Bulbasaur's art.
const auto out = src.fetchImageUrl("Charmander", "PMCG1", "");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("English name resolves correct localId image via catalog") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
"id":"PMCG1",
"cards":[
{"localId":"001","name":"フシギダネ",
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/001"},
{"localId":"014","name":"ヒトカゲ",
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/014"}
]
})";
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"014","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Charmander", "PMCG1", "");
REQUIRE(out.isOk());
CHECK(out.value() == "https://assets.tcgdex.net/ja/PMCG/PMCG1/014/high.png");
}
TEST_CASE("null image on set-specific card is NotFound without other-printing fallback") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("PMCG1", "021")] =
R"({"id":"PMCG1-021","name":"","image":null})";
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
"id":"PMCG1",
"cards":[{"localId":"021","name":"リザードン"}]
})";
JapanesePokemonEnCatalog empty;
JapanesePokemonCardPreviewSource src{http, empty};
const auto out = src.fetchImageUrl("Charizard", "PMCG1", "021");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("catalog tcgplayer_id gap-fills when TCGdex image is null") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("PMCG1", "021")] =
R"({"id":"PMCG1-021","name":"","image":null})";
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
"id":"PMCG1",
"cards":[{"localId":"021","name":"リザードン"}]
})";
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"021","name_en":"Charizard",
"name_ja":"リザードン","tcgplayer_id":"575604"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Charizard", "PMCG1", "021");
REQUIRE(out.isOk());
CHECK(out.value() ==
"https://product-images.tcgplayer.com/fit-in/437x437/575604.jpg");
}
TEST_CASE("HTTP failure is Transient") {
RoutingHttpClient http;
http.ok = false;
JapanesePokemonEnCatalog empty;
JapanesePokemonCardPreviewSource src{http, empty};
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("catalog-only theme deck resolves preview from tcgplayer_id") {
RoutingHttpClient http;
// No TCGdex bodies: card + set detail both miss.
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
"prints": [
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym",
"name_ja":"タマムシシティジム","tcgplayer_id":"12345"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Celadon City Gym", "TamamushiCG", "021");
REQUIRE(out.isOk());
CHECK(out.value() ==
"https://product-images.tcgplayer.com/fit-in/437x437/12345.jpg");
}
TEST_CASE("Tamamushi City Gym Erika uses catalog tcgplayer gap-fill") {
RoutingHttpClient http;
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
"prints": [
{"set_id":"TamamushiCG","local_id":"016","name_en":"Erika",
"name_ja":"エリカ","name_en_source":"trainer-table",
"tcgplayer_id":"576776"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Erika", "TamamushiCG", "016");
REQUIRE(out.isOk());
CHECK(out.value() ==
"https://product-images.tcgplayer.com/fit-in/437x437/576776.jpg");
}
TEST_CASE("neo catalog image_url gap-fills when TCGdex image is null") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("neo4", "106")] =
R"({"id":"neo4-106","name":"","image":null})";
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("neo4")] = R"({
"id":"neo4",
"cards":[{"localId":"106","name":"ラッキースタジアム"}]
})";
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"neo4","local_id":"106","name_en":"Shining Celebi",
"name_ja":"輝くセレビ","name_en_source":"species-table-variant",
"image_url":"https://images.pokemontcg.io/neo4/106_hires.png"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Shining Celebi", "neo4", "106");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.pokemontcg.io/neo4/106_hires.png");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::detectPrintVariants catalog-only") {
TEST_CASE("English trainer name resolves when TCGdex set detail is unavailable") {
RoutingHttpClient http;
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
"prints": [
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym",
"name_ja":"タマムシシティジム","name_en_source":"trainer-table",
"image_url":"https://example.com/celadon.jpg"},
{"set_id":"TamamushiCG","local_id":"001","name_en":"Erika's Oddish",
"name_ja":"エリカのナゾノクサ","name_en_source":"manual",
"image_url":"https://example.com/oddish.jpg"}
]
})json");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.detectPrintVariants("Celadon City Gym", "TamamushiCG");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "021");
}
TEST_CASE("detectPrintVariantsFromCatalog includes UnnumberedPromo prints without image_url") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)",
"name_ja":"ミュウツー","name_en_source":"manual"},
{"set_id":"UnnumberedPromo","local_id":"008","name_en":"Mewtwo (Fan Book promo)",
"name_ja":"ミュウツー","name_en_source":"manual"},
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
"name_ja":"ミュウツー","name_en_source":"manual",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"},
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (Jumbo)",
"name_ja":"","name_en_source":"manual"}
]
})json");
REQUIRE(catalog.isOk());
const auto mew = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"UnnumberedPromo", "Mewtwo", catalog.value());
REQUIRE(mew.isOk());
REQUIRE(mew.value().size() == 4);
// Imaged prints first, then empty-URL identity rows.
CHECK(mew.value()[0].setNo == "030");
CHECK(mew.value()[1].setNo == "007");
CHECK(mew.value()[2].setNo == "008");
CHECK(mew.value()[3].setNo == "045");
RoutingHttpClient http;
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto img = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "030");
REQUIRE(img.isOk());
CHECK(img.value() ==
"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg");
}
TEST_CASE("fetchImageUrl does not borrow sibling UnnumberedPromo image") {
RoutingHttpClient http;
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)",
"name_ja":"ミュウツー","name_en_source":"manual"},
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
"name_ja":"ミュウツー","name_en_source":"manual",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"}
]
})json");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto empty = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "007");
REQUIRE(empty.isErr());
CHECK(empty.error().kind == PreviewLookupError::Kind::NotFound);
const auto whf = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "030");
REQUIRE(whf.isOk());
CHECK(whf.value() ==
"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg");
}
TEST_CASE("detectPrintVariantsFromCatalog dedupes shared preview URLs") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"},
{"set_id":"UnnumberedPromo","local_id":"073","name_en":"Mewtwo (Song Best Collection promo)",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"},
{"set_id":"UnnumberedPromo","local_id":"197","name_en":"Mewtwo (Wizards Promo 12)",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"}
]
})json");
REQUIRE(catalog.isOk());
const auto mew = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"UnnumberedPromo", "Mewtwo", catalog.value());
REQUIRE(mew.isOk());
REQUIRE(mew.value().size() == 1);
CHECK(mew.value()[0].setNo == "030");
}
TEST_CASE("detectPrintVariantsFromCatalog matches owner Pokemon English title") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"TamamushiCG","local_id":"001","name_en":"Erika's Oddish",
"name_ja":"エリカのナゾノクサ",
"image_url":"https://example.com/oddish.jpg"}
]
})json");
REQUIRE(catalog.isOk());
const auto out = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"TamamushiCG", "Erika's Oddish", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "001");
}
TEST_CASE("detectPrintVariantsFromCatalog matches Dark Rocket and Owner PMCG titles") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"PMCG4","local_id":"017","name_en":"Dark Charizard",
"name_ja":"わるいリザードン","name_en_source":"species-table-variant",
"tcgplayer_id":"575744"},
{"set_id":"PMCG6","local_id":"042","name_en":"Rocket's Zapdos",
"name_ja":"R団のサンダー","name_en_source":"species-table-variant",
"tcgplayer_id":"1"},
{"set_id":"PMCG5","local_id":"002","name_en":"Erika's Oddish",
"name_ja":"エリカのナゾノクサ","name_en_source":"species-table-variant",
"tcgplayer_id":"2"}
]
})json");
REQUIRE(catalog.isOk());
const auto dark = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"PMCG4", "Dark Charizard", catalog.value());
REQUIRE(dark.isOk());
REQUIRE(dark.value().size() == 1);
CHECK(dark.value()[0].setNo == "017");
const auto rocket = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"PMCG6", "Rocket's Zapdos", catalog.value());
REQUIRE(rocket.isOk());
REQUIRE(rocket.value().size() == 1);
CHECK(rocket.value()[0].setNo == "042");
const auto owner = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"PMCG5", "Erika's Oddish", catalog.value());
REQUIRE(owner.isOk());
REQUIRE(owner.value().size() == 1);
CHECK(owner.value()[0].setNo == "002");
}
TEST_CASE("detectPrintVariantsFromCatalog matches Light and Shining neo titles") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"neo4","local_id":"004","name_en":"Light Sunflora",
"name_ja":"軽いサンフロラ","name_en_source":"species-table-variant",
"image_url":"https://example.com/sunflora.png"},
{"set_id":"neo4","local_id":"013","name_en":"Shining Celebi",
"name_ja":"輝くセレビ","name_en_source":"species-table-variant",
"image_url":"https://example.com/celebi.png"}
]
})json");
REQUIRE(catalog.isOk());
const auto light = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"neo4", "Light Sunflora", catalog.value());
REQUIRE(light.isOk());
REQUIRE(light.value().size() == 1);
CHECK(light.value()[0].setNo == "004");
const auto shining = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"neo4", "Shining Celebi", catalog.value());
REQUIRE(shining.isOk());
REQUIRE(shining.value().size() == 1);
CHECK(shining.value()[0].setNo == "013");
}
}
+152
View File
@@ -0,0 +1,152 @@
#include <doctest/doctest.h>
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
using namespace ccm;
TEST_SUITE("JapanesePokemonEnCatalog") {
TEST_CASE("parses sets and prints") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {
"PMCG1": {
"name_en": "Expansion Pack",
"name_ja": "拡張パック",
"releaseDate": "1996/10/20"
}
},
"prints": [
{
"set_id": "PMCG1",
"local_id": "001",
"name_en": "Charmander",
"name_ja": "ヒトカゲ",
"name_en_source": "bulbapedia"
}
]
})");
REQUIRE(catalog.isOk());
CHECK_FALSE(catalog.value().empty());
auto set = catalog.value().findSet("PMCG1");
REQUIRE(set.has_value());
CHECK(set->nameEn == "Expansion Pack");
CHECK(set->releaseDate == "1996/10/20");
auto print = catalog.value().findPrint("PMCG1", "001");
REQUIRE(print.has_value());
CHECK(print->nameEn == "Charmander");
CHECK(print->nameEnSource == "bulbapedia");
}
TEST_CASE("parses optional tcgplayer_id and image_url") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{
"set_id": "PMCG1",
"local_id": "021",
"name_en": "Charizard",
"name_ja": "リザードン",
"tcgplayer_id": 575604
},
{
"set_id": "SV1a",
"local_id": "001",
"name_en": "Tropius",
"image_url": "https://example.com/tropius.png"
}
]
})");
REQUIRE(catalog.isOk());
auto charizard = catalog.value().findPrint("PMCG1", "021");
REQUIRE(charizard.has_value());
CHECK(charizard->tcgplayerId == "575604");
CHECK(JapanesePokemonEnCatalog::previewImageUrlFromPrint(*charizard) ==
"https://product-images.tcgplayer.com/fit-in/437x437/575604.jpg");
auto tropius = catalog.value().findPrint("SV1a", "001");
REQUIRE(tropius.has_value());
CHECK(JapanesePokemonEnCatalog::previewImageUrlFromPrint(*tropius) ==
"https://example.com/tropius.png");
}
TEST_CASE("findPrintsByName is case-insensitive on English") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
const auto hits = catalog.value().findPrintsByName("PMCG1", "charmander");
REQUIRE(hits.size() == 1);
CHECK(hits[0].localId == "001");
}
TEST_CASE("findPrintsByName matches qualified English titles by bare prefix") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)"},
{"set_id":"UnnumberedPromo","local_id":"008","name_en":"Mewtwo (Fan Book promo)"},
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (Jumbo)"},
{"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu (CoroCoro promo)"}
]
})json");
REQUIRE(catalog.isOk());
const auto hits = catalog.value().findPrintsByName("UnnumberedPromo", "Mewtwo");
REQUIRE(hits.size() == 3);
CHECK(hits[0].localId == "007");
CHECK(hits[1].localId == "008");
CHECK(hits[2].localId == "045");
// Exact full title still works.
const auto exact = catalog.value().findPrintsByName(
"UnnumberedPromo", "Mewtwo Strikes Back (Jumbo)");
REQUIRE(exact.size() == 1);
CHECK(exact[0].localId == "045");
}
TEST_CASE("findPrintsByName whole-token matches owner and GR titles") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"227","name_en":"Team GR's Mewtwo (Pokémon Card GB2 promo)"},
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (CoroCoro promo) (Jumbo)"},
{"set_id":"UnnumberedPromo","local_id":"016","name_en":"Mew (CoroCoro promo)"}
]
})json");
REQUIRE(catalog.isOk());
const auto mewtwo = catalog.value().findPrintsByName("UnnumberedPromo", "Mewtwo");
REQUIRE(mewtwo.size() == 2);
CHECK(mewtwo[0].localId == "227");
CHECK(mewtwo[1].localId == "045");
// "Mew" must not match "Mewtwo …" rows.
const auto mew = catalog.value().findPrintsByName("UnnumberedPromo", "Mew");
REQUIRE(mew.size() == 1);
CHECK(mew[0].localId == "016");
}
TEST_CASE("hasPrintsForSet reports curated classic products") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym"}
]
})");
REQUIRE(catalog.isOk());
CHECK(catalog.value().hasPrintsForSet("TamamushiCG"));
CHECK_FALSE(catalog.value().hasPrintsForSet("PMCG1"));
}
TEST_CASE("missing set/print returns nullopt") {
JapanesePokemonEnCatalog empty;
CHECK_FALSE(empty.findSet("X").has_value());
CHECK_FALSE(empty.findPrint("X", "1").has_value());
CHECK(empty.empty());
}
TEST_CASE("malformed JSON is an error") {
CHECK(JapanesePokemonEnCatalog::parse("{not json").isErr());
}
}
+317
View File
@@ -0,0 +1,317 @@
#include <doctest/doctest.h>
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <algorithm>
#include <string>
#include <unordered_map>
using namespace ccm;
namespace {
class RoutingHttpClient final : public IHttpClient {
public:
std::unordered_map<std::string, std::string> bodies;
std::string lastUrl;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (!ok) return Result<std::string>::err("offline");
const auto it = bodies.find(lastUrl);
if (it == bodies.end()) return Result<std::string>::err("unknown url");
return Result<std::string>::ok(it->second);
}
};
} // namespace
TEST_SUITE("JapanesePokemonSetSource helpers") {
TEST_CASE("excludes CS* set ids") {
CHECK(JapanesePokemonSetSource::shouldExcludeSetId("CS1a"));
CHECK(JapanesePokemonSetSource::shouldExcludeSetId("CS4a"));
CHECK_FALSE(JapanesePokemonSetSource::shouldExcludeSetId("PMCG1"));
CHECK_FALSE(JapanesePokemonSetSource::shouldExcludeSetId("SV1a"));
}
TEST_CASE("applies SV4a name override") {
CHECK(JapanesePokemonSetSource::applySetNameOverride("SV4a", "wrong") ==
"シャイニートレジャーex");
CHECK(JapanesePokemonSetSource::applySetNameOverride("PMCG1", "拡張パック") ==
"拡張パック");
}
TEST_CASE("rewrites release date separators") {
CHECK(JapanesePokemonSetSource::rewriteReleaseDate("1996-10-20") == "1996/10/20");
}
TEST_CASE("buildSetDetailUrl percent-encodes id") {
CHECK(JapanesePokemonSetSource::buildSetDetailUrl("SV1a") ==
"https://api.tcgdex.net/v2/ja/sets/SV1a");
}
}
TEST_SUITE("JapanesePokemonSetSource::parseListResponse") {
TEST_CASE("maps id/name and drops CS* entries") {
const std::string json = R"([
{"id":"PMCG1","name":"拡張パック","cardCount":{"total":102,"official":102}},
{"id":"CS1a","name":"トリプレットビート","cardCount":{"total":1,"official":1}},
{"id":"SV4a","name":"レイジングサーフ","cardCount":{"total":320,"official":190}}
])";
const auto out = JapanesePokemonSetSource::parseListResponse(json);
REQUIRE(out.isOk());
// 2 from TCGdex + 11 curated products omitted by TCGdex.
REQUIRE(out.value().size() == 13);
CHECK(out.value()[0].id == "PMCG1");
CHECK(out.value()[0].name == "拡張パック");
CHECK(out.value()[1].id == "SV4a");
CHECK(out.value()[1].name == "シャイニートレジャーex");
}
TEST_CASE("injects classic City Gym and Expansion Sheet products") {
const auto out = JapanesePokemonSetSource::parseListResponse("[]");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 11);
const auto hasId = [&](const char* id) {
return std::any_of(out.value().begin(), out.value().end(),
[&](const Set& s) { return s.id == id; });
};
CHECK(hasId("UnnumberedPromo"));
CHECK(hasId("TamamushiCG"));
CHECK(hasId("NiviCG"));
CHECK(hasId("HanadaCG"));
CHECK(hasId("KuchibaCG"));
CHECK(hasId("YamabukiCG"));
CHECK(hasId("GurenTG"));
CHECK(hasId("ExpSheet1"));
CHECK(hasId("ExpSheet2"));
CHECK(hasId("ExpSheet3"));
CHECK(hasId("SouthernIslands"));
const Set* unnumbered = nullptr;
const Set* tama = nullptr;
for (const auto& s : out.value()) {
if (s.id == "UnnumberedPromo") unnumbered = &s;
if (s.id == "TamamushiCG") tama = &s;
}
REQUIRE(unnumbered != nullptr);
CHECK(unnumbered->name == "Unnumbered Promotional cards");
CHECK(unnumbered->releaseDate == "1997/03/06");
REQUIRE(tama != nullptr);
CHECK(tama->name == "Tamamushi City Gym");
CHECK(tama->releaseDate == "1998/07/25");
}
TEST_CASE("does not duplicate classic products already in the list") {
const std::string json = R"([
{"id":"TamamushiCG","name":"already-present"}
])";
const auto out = JapanesePokemonSetSource::parseListResponse(json);
REQUIRE(out.isOk());
int tamaCount = 0;
for (const auto& s : out.value()) {
if (s.id == "TamamushiCG") ++tamaCount;
}
CHECK(tamaCount == 1);
// Curated EN name / release date overwrite a stale upstream label.
CHECK(out.value().front().name == "Tamamushi City Gym");
CHECK(out.value().front().releaseDate == "1998/07/25");
}
TEST_CASE("empty array still injects classic products") {
const auto out = JapanesePokemonSetSource::parseListResponse("[]");
REQUIRE(out.isOk());
CHECK_FALSE(out.value().empty());
}
TEST_CASE("non-array is an error") {
CHECK(JapanesePokemonSetSource::parseListResponse(R"({"data":[]})").isErr());
}
TEST_CASE("invalid JSON is an error") {
CHECK(JapanesePokemonSetSource::parseListResponse("{not json").isErr());
}
}
TEST_SUITE("JapanesePokemonSetSource::parseReleaseDate") {
TEST_CASE("extracts and rewrites releaseDate") {
const auto out = JapanesePokemonSetSource::parseReleaseDate(
R"({"id":"PMCG1","releaseDate":"1996-10-20"})");
REQUIRE(out.isOk());
CHECK(out.value() == "1996/10/20");
}
TEST_CASE("missing releaseDate yields empty string") {
const auto out = JapanesePokemonSetSource::parseReleaseDate(R"({"id":"X"})");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
}
TEST_SUITE("JapanesePokemonSetSource::fetchAll") {
TEST_CASE("enriches from catalog and sorts by release date") {
RoutingHttpClient http;
http.bodies[JapanesePokemonSetSource::kListEndpoint] = R"([
{"id":"SV1a","name":"トリプレットビート"},
{"id":"PMCG1","name":"拡張パック"},
{"id":"PMCG2","name":"ポケモンジャングル"},
{"id":"CS1a","name":"junk"}
])";
// Catalog supplies dates so detail GETs are skipped.
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {
"PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック","releaseDate":"1996/10/20"},
"PMCG2": {"name_en":"Pokémon Jungle","name_ja":"ポケモンジャングル","releaseDate":"1997/03/05"},
"SV1a": {"name_en":"Triplet Beat","name_ja":"トリプレットビート","releaseDate":"2023/03/10"}
},
"prints": []
})");
REQUIRE(catalog.isOk());
JapanesePokemonSetSource src{http, catalog.value()};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
// CS* dropped; 3 TCGdex + 11 curated injections.
REQUIRE(out.value().size() == 14);
// Expansion Pack → Jungle → UnnumberedPromo (day after Jungle).
CHECK(out.value()[0].id == "PMCG1");
CHECK(out.value()[1].id == "PMCG2");
CHECK(out.value()[1].name == "Pokémon Jungle");
CHECK(out.value()[2].id == "UnnumberedPromo");
CHECK(out.value()[2].name == "Unnumbered Promotional cards");
CHECK(out.value()[2].releaseDate == "1997/03/06");
const Set* pmcg1 = nullptr;
bool foundSv = false;
bool foundTama = false;
for (const auto& s : out.value()) {
if (s.id == "PMCG1") {
pmcg1 = &s;
CHECK(s.name == "Expansion Pack");
CHECK(s.releaseDate == "1996/10/20");
}
if (s.id == "SV1a") {
foundSv = true;
CHECK(s.name == "Triplet Beat");
}
if (s.id == "TamamushiCG") {
foundTama = true;
CHECK(s.name == "Tamamushi City Gym");
}
}
REQUIRE(pmcg1 != nullptr);
CHECK(foundSv);
CHECK(foundTama);
}
TEST_CASE("fetches set detail when catalog lacks release date") {
RoutingHttpClient http;
http.bodies[JapanesePokemonSetSource::kListEndpoint] =
R"([{"id":"PMCG1","name":""}])";
http.bodies[JapanesePokemonSetSource::buildSetDetailUrl("PMCG1")] =
R"({"id":"PMCG1","releaseDate":"1996-10-20","cards":[]})";
JapanesePokemonEnCatalog empty;
JapanesePokemonSetSource src{http, empty};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 12); // PMCG1 + 11 curated
const Set* pmcg1 = nullptr;
for (const auto& s : out.value()) {
if (s.id == "PMCG1") {
pmcg1 = &s;
break;
}
}
REQUIRE(pmcg1 != nullptr);
CHECK(pmcg1->releaseDate == "1996/10/20");
// Without catalog EN, never keep Japanese TCGdex names in Set.name.
CHECK(pmcg1->name == "PMCG1");
}
TEST_CASE("CJK set names fall back to set id even without catalog") {
RoutingHttpClient http;
http.bodies[JapanesePokemonSetSource::kListEndpoint] =
R"([{"id":"PMCG3","name":""}])";
http.bodies[JapanesePokemonSetSource::buildSetDetailUrl("PMCG3")] =
R"({"id":"PMCG3","releaseDate":"1997-06-21"})";
JapanesePokemonEnCatalog empty;
JapanesePokemonSetSource src{http, empty};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
const Set* pmcg3 = nullptr;
for (const auto& s : out.value()) {
if (s.id == "PMCG3") {
pmcg3 = &s;
break;
}
}
REQUIRE(pmcg3 != nullptr);
CHECK(pmcg3->name == "PMCG3");
}
TEST_CASE("network error on list is surfaced") {
RoutingHttpClient http;
http.ok = false;
JapanesePokemonEnCatalog empty;
JapanesePokemonSetSource src{http, empty};
CHECK(src.fetchAll().isErr());
}
TEST_CASE("augmentCachedSets injects classic products into a cached list") {
RoutingHttpClient http;
JapanesePokemonEnCatalog empty;
JapanesePokemonSetSource src{http, empty};
std::vector<Set> cached;
Set pmcg2;
pmcg2.id = "PMCG2";
pmcg2.name = "Pokémon Jungle";
pmcg2.releaseDate = "1997/03/05";
cached.push_back(std::move(pmcg2));
src.augmentCachedSets(cached);
REQUIRE(cached.size() == 12);
// Jungle stays first; UnnumberedPromo (1997/03/06) is immediately after.
CHECK(cached[0].id == "PMCG2");
CHECK(cached[1].id == "UnnumberedPromo");
CHECK(cached[1].releaseDate == "1997/03/06");
bool foundTama = false;
bool foundUnnumbered = false;
for (const auto& s : cached) {
if (s.id == "TamamushiCG") {
foundTama = true;
CHECK(s.name == "Tamamushi City Gym");
}
if (s.id == "UnnumberedPromo") {
foundUnnumbered = true;
CHECK(s.name == "Unnumbered Promotional cards");
}
}
CHECK(foundTama);
CHECK(foundUnnumbered);
}
TEST_CASE("augmentCachedSets restores English names from catalog") {
RoutingHttpClient http;
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {
"PMCG2": {"name_en":"Pokémon Jungle","name_ja":"ポケモンジャングル","releaseDate":"1997/03/05"}
},
"prints": []
})");
REQUIRE(catalog.isOk());
JapanesePokemonSetSource src{http, catalog.value()};
std::vector<Set> cached;
Set pmcg2;
pmcg2.id = "PMCG2";
pmcg2.name = "PMCG2"; // stale cache stored the id as the display name
pmcg2.releaseDate = "1997/03/05";
cached.push_back(std::move(pmcg2));
src.augmentCachedSets(cached);
const Set* jungle = nullptr;
for (const auto& s : cached) {
if (s.id == "PMCG2") {
jungle = &s;
break;
}
}
REQUIRE(jungle != nullptr);
CHECK(jungle->name == "Pokémon Jungle");
}
}
+48 -4
View File
@@ -143,13 +143,57 @@ TEST_SUITE("JsonSetRepository") {
CHECK(writeFail.error() == "write failed");
}
TEST_CASE("paths are composed from dataStorage and game dir") {
TEST_CASE("paths use region-specific filenames for Pokemon West and Asia") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonSetRepository repo{fs, cfg, dirNameFn};
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
const std::vector<Set> west = {{"base1", "Base Set", "1999/01/09"}};
const std::vector<Set> asia = {{"SV1a", "Triplet Beat", "2023/01/20"}};
REQUIRE(repo.save(Game::Pokemon, sets).isOk());
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
REQUIRE(repo.save(Game::Pokemon, west).isOk());
REQUIRE(repo.save(Game::JapanesePokemon, asia).isOk());
CHECK(fs.files().count("/data/pokemon/sets-west.json") == 1);
CHECK(fs.files().count("/data/pokemon/sets-asia.json") == 1);
CHECK(fs.files().count("/data/pokemon/sets.json") == 0);
}
TEST_CASE("load migrates legacy pokemon/sets.json to sets-west.json") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
REQUIRE(fs.writeText("/data/pokemon/sets.json", nlohmann::json(sets).dump(2)).isOk());
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Pokemon);
REQUIRE(loaded.isOk());
CHECK(loaded.value() == sets);
CHECK(fs.files().count("/data/pokemon/sets-west.json") == 1);
}
TEST_CASE("load migrates legacy pokemonjp/sets.json to sets-asia.json") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
const std::vector<Set> sets = {{"SV1a", "Triplet Beat", "2023/01/20"}};
REQUIRE(fs.writeText("/data/pokemonjp/sets.json", nlohmann::json(sets).dump(2)).isOk());
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::JapanesePokemon);
REQUIRE(loaded.isOk());
CHECK(loaded.value() == sets);
CHECK(fs.files().count("/data/pokemon/sets-asia.json") == 1);
}
TEST_CASE("load prefers new path over legacy when both exist") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
const std::vector<Set> legacy = {{"old", "Old", "1999/01/01"}};
const std::vector<Set> neu = {{"new", "New", "2024/01/01"}};
REQUIRE(fs.writeText("/data/pokemon/sets.json", nlohmann::json(legacy).dump(2)).isOk());
REQUIRE(fs.writeText("/data/pokemon/sets-west.json", nlohmann::json(neu).dump(2)).isOk());
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Pokemon);
REQUIRE(loaded.isOk());
CHECK(loaded.value() == neu);
}
}
+1
View File
@@ -18,6 +18,7 @@ std::string dirNameForGame(Game g) {
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
case Game::DigiBattle99: return "digibattle99";
case Game::JapanesePokemon: return "pokemon";
}
return "magic";
}
+38
View File
@@ -29,6 +29,9 @@ public:
std::string dirName() const override {
if (gameId == Game::Magic) return "magic";
if (gameId == Game::Pokemon) return "pokemon";
if (gameId == Game::YuGiOh) return "yugioh";
if (gameId == Game::DigiBattle99) return "digibattle99";
if (gameId == Game::JapanesePokemon) return "pokemon";
return "yugioh";
}
std::string displayName() const override { return dirName(); }
@@ -179,6 +182,41 @@ TEST_SUITE("SetService") {
CHECK(digi.source.calls == 1);
}
TEST_CASE("JapanesePokemon module routes independently when all games are registered") {
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"}});
FakeGameModule yugioh{Game::YuGiOh};
yugioh.source.result = Result<std::vector<Set>>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}});
FakeGameModule digi{Game::DigiBattle99};
digi.source.result = Result<std::vector<Set>>::ok(
{{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"}});
FakeGameModule jp{Game::JapanesePokemon};
jp.source.result = Result<std::vector<Set>>::ok(
{{"PMCG1", "Expansion Pack", "1996/10/20"}});
svc.registerModule(&magic);
svc.registerModule(&pokemon);
svc.registerModule(&yugioh);
svc.registerModule(&digi);
svc.registerModule(&jp);
REQUIRE(svc.updateSets(Game::Magic).isOk());
REQUIRE(svc.updateSets(Game::Pokemon).isOk());
REQUIRE(svc.updateSets(Game::YuGiOh).isOk());
REQUIRE(svc.updateSets(Game::DigiBattle99).isOk());
const auto out = svc.updateSets(Game::JapanesePokemon);
REQUIRE(out.isOk());
CHECK(out.value().front().id == "PMCG1");
CHECK(jp.source.calls == 1);
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
}
TEST_CASE("updateSets propagates repository save failures") {
InMemSetRepo repo;
repo.failSave = true;