mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-09-03 17:01:03 +00:00
Minor: Add Asian Pokemon Card Support (#18)
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user