#include #include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp" #include "ccm/ports/IHttpClient.hpp" #include #include #include using namespace ccm; namespace { class RoutingHttpClient final : public IHttpClient { public: std::unordered_map bodies; std::string lastUrl; bool ok = true; Result get(std::string_view url) override { lastUrl = std::string(url); if (!ok) return Result::err("offline"); const auto it = bodies.find(lastUrl); if (it == bodies.end()) return Result::err("unknown url"); return Result::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 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 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"); } } TEST_SUITE("JapanesePokemonSetSource::parseCatalogPackFromSetDetail") { TEST_CASE("builds checklist from cards[] and prefers EN catalog names") { const auto catalog = JapanesePokemonEnCatalog::parse(R"({ "sets": {"PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック"}}, "prints": [ {"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}, {"set_id":"PMCG1","local_id":"099","name_en":"Catalog Only","name_ja":""} ] })"); REQUIRE(catalog.isOk()); Set set; set.id = "PMCG1"; set.name = "Expansion Pack"; const std::string detail = R"({ "id":"PMCG1", "name":"拡張パック", "cards":[ {"localId":"001","name":"ヒトカゲ"}, {"localId":"002","name":"リザード"} ] })"; const auto pack = JapanesePokemonSetSource::parseCatalogPackFromSetDetail( detail, set, catalog.value()); REQUIRE(pack.isOk()); REQUIRE(pack.value().cards.size() == 3); CHECK(pack.value().cards[0].setNo == "001"); CHECK(pack.value().cards[0].name == "Charmander"); CHECK(pack.value().cards[1].setNo == "002"); CHECK(pack.value().cards[1].name == "リザード"); CHECK(pack.value().cards[2].setNo == "099"); CHECK(pack.value().cards[2].name == "Catalog Only"); } TEST_CASE("catalogPackFromEnCatalog covers classic-only products") { const auto catalog = JapanesePokemonEnCatalog::parse(R"({ "sets": {}, "prints": [ {"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu"}, {"set_id":"UnnumberedPromo","local_id":"002","name_en":"Mewtwo"} ] })"); REQUIRE(catalog.isOk()); Set set; set.id = "UnnumberedPromo"; set.name = "Unnumbered Promotional cards"; const auto pack = JapanesePokemonSetSource::catalogPackFromEnCatalog(set, catalog.value()); REQUIRE(pack.cards.size() == 2); CHECK(pack.cards[0].setNo == "001"); CHECK(pack.cards[1].setNo == "002"); } }