Minor: New Game Yu-Gi-Oh! Bandai (#22)

This commit is contained in:
Sebastian Dine
2026-07-30 14:51:53 +02:00
committed by GitHub
parent 7cf25d671f
commit 2eb7c59f78
65 changed files with 4142 additions and 75 deletions
+3
View File
@@ -26,6 +26,9 @@ add_executable(ccm_core_tests
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
digibattle99_set_completion_tests.cpp
yugiohbandai_set_source_tests.cpp
yugiohbandai_card_preview_source_tests.cpp
yugiohbandai_set_completion_tests.cpp
yugioh_set_completion_tests.cpp
pokemon_set_completion_tests.cpp
set_no_natural_tests.cpp
+34
View File
@@ -10,6 +10,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/services/CardFilter.hpp"
@@ -287,6 +288,39 @@ TEST_SUITE("CardFilter::matchesDigiBattle99Filter") {
}
}
TEST_SUITE("CardFilter::matchesYuGiOhBandaiFilter") {
TEST_CASE("matches name set setNo rarity language") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
c.set.name = "1st Generation";
c.setNo = "14";
c.rarity = "Rare";
c.language = Language::Japanese;
CHECK(matchesYuGiOhBandaiFilter(c, "magician"));
CHECK(matchesYuGiOhBandaiFilter(c, "1st"));
CHECK(matchesYuGiOhBandaiFilter(c, "14"));
CHECK(matchesYuGiOhBandaiFilter(c, "rare"));
CHECK(matchesYuGiOhBandaiFilter(c, "japanese"));
CHECK_FALSE(matchesYuGiOhBandaiFilter(c, "blue-eyes"));
}
TEST_CASE("empty filter matches everything") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
CHECK(matchesYuGiOhBandaiFilter(c, ""));
}
TEST_CASE("boolean flag columns are not matched") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
c.holo = true;
c.signed_ = true;
c.altered = true;
CHECK_FALSE(matchesYuGiOhBandaiFilter(c, "true"));
CHECK(matchesYuGiOhBandaiFilter(c, "dark"));
}
}
TEST_SUITE("CardFilter::matchesJapanesePokemonFilter") {
TEST_CASE("matches by name and set.name") {
JapanesePokemonCard c;
+46
View File
@@ -10,6 +10,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/services/CardSorter.hpp"
@@ -548,6 +549,51 @@ TEST_SUITE("CardSorter - DigiBattle99 columns") {
}
}
TEST_SUITE("CardSorter - YuGiOhBandai columns") {
TEST_CASE("Holo sorts false before true; Rarity and SetNo sort") {
YuGiOhBandaiCard a;
a.id = 1;
a.name = "a";
a.set = Set{"ban1", "1st Generation", "1998/09/01"};
a.setNo = "14";
a.rarity = "Rare";
a.holo = true;
YuGiOhBandaiCard b;
b.id = 2;
b.name = "b";
b.set = Set{"ban1", "1st Generation", "1998/09/01"};
b.setNo = "9";
b.rarity = "Common";
b.holo = false;
std::vector<YuGiOhBandaiCard> v = {a, b};
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::Holo, /*ascending=*/true);
CHECK(v[0].id == 2);
CHECK(v[1].id == 1);
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::SetNo, /*ascending=*/true);
CHECK(v[0].setNo == "14");
CHECK(v[1].setNo == "9");
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::Rarity, /*ascending=*/true);
CHECK(v[0].rarity == "Common");
}
TEST_CASE("Set column sorts by release date") {
YuGiOhBandaiCard a;
a.id = 1;
a.set = Set{"ban3", "3rd Generation", "1999/03/06"};
YuGiOhBandaiCard b;
b.id = 2;
b.set = Set{"ban1", "1st Generation", "1998/09/01"};
std::vector<YuGiOhBandaiCard> v = {a, b};
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(v[0].id == 2);
CHECK(v[1].id == 1);
}
}
TEST_SUITE("CardSorter - JapanesePokemon columns") {
TEST_CASE("Holo and FirstEdition sort false before true") {
std::vector<JapanesePokemonCard> v = {
+87 -1
View File
@@ -9,6 +9,8 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
@@ -31,6 +33,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json jDigi = "DigiBattle99";
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
nlohmann::json jBandai = "YuGiOhBandai";
CHECK(jBandai.get<Game>() == Game::YuGiOhBandai);
nlohmann::json jJp = "JapanesePokemon";
CHECK(jJp.get<Game>() == Game::JapanesePokemon);
@@ -77,7 +82,7 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
for (const auto game : allGames()) {
CHECK(game != Game::JapanesePokemon);
}
CHECK(allGames().size() == 4);
CHECK(allGames().size() == 5);
CHECK(gameFromString("JapanesePokemon") == Game::JapanesePokemon);
CHECK(pokemonBackendGame(PokemonRegion::West) == Game::Pokemon);
CHECK(pokemonBackendGame(PokemonRegion::Asia) == Game::JapanesePokemon);
@@ -312,6 +317,87 @@ TEST_SUITE("DigiBattle99SetCatalog JSON") {
}
}
TEST_SUITE("YuGiOhBandaiCard JSON") {
TEST_CASE("round-trips with setNo, rarity, holo and signed alias") {
YuGiOhBandaiCard c;
c.id = 14;
c.amount = 2;
c.name = "Dark Magician";
c.set = Set{"ban1", "1st Generation", "1998/09/01"};
c.setNo = "14";
c.rarity = "Rare";
c.note = "classic";
c.images = {"a.png"};
c.language = Language::Japanese;
c.condition = Condition::NearMint;
c.holo = true;
c.signed_ = true;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "14");
CHECK(j.at("rarity") == "Rare");
CHECK(j.at("holo") == true);
CHECK(j.at("signed") == true);
CHECK_FALSE(j.contains("firstEdition"));
const YuGiOhBandaiCard back = j.get<YuGiOhBandaiCard>();
CHECK(back == c);
}
TEST_CASE("missing each required key throws") {
const nlohmann::json full = {
{"id", 14},
{"amount", 1},
{"name", "Dark Magician"},
{"set", nlohmann::json{
{"id", "ban1"},
{"name", "1st Generation"},
{"releaseDate", "1998/09/01"},
}},
{"setNo", "14"},
{"rarity", "Rare"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "Japanese"},
{"condition", "NearMint"},
{"holo", false},
{"signed", false},
{"altered", false},
};
for (const char* key : {
"id", "amount", "name", "set", "setNo", "rarity", "note", "images",
"language", "condition", "holo", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<YuGiOhBandaiCard>());
}
}
}
TEST_SUITE("YuGiOhBandaiSetCatalog JSON") {
TEST_CASE("round-trips packs with rarity") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back(YuGiOhBandaiCatalogCard{"14", "Dark Magician", "Rare"});
pack.cards.push_back(YuGiOhBandaiCatalogCard{"9", "Blue-Eyes White Dragon", "Super Rare"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").at(0).at("id") == "ban1");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "14");
CHECK(j.at("packs").at(0).at("cards").at(0).at("rarity") == "Rare");
const YuGiOhBandaiSetCatalog back = j.get<YuGiOhBandaiSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("ban1") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("YuGiOhSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
YuGiOhSetCatalog catalog;
+12
View File
@@ -5,6 +5,7 @@
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
using namespace ccm;
@@ -65,6 +66,17 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("YuGiOhBandai module reports canonical metadata") {
NoopHttpClient http;
YuGiOhBandaiGameModule module(http);
CHECK(module.id() == Game::YuGiOhBandai);
CHECK(module.dirName() == "yugiohbandai");
CHECK(module.displayName() == "Yu-Gi-Oh! (Bandai)");
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);
+38
View File
@@ -31,6 +31,7 @@ public:
if (gameId == Game::Pokemon) return "pokemon";
if (gameId == Game::YuGiOh) return "yugioh";
if (gameId == Game::DigiBattle99) return "digibattle99";
if (gameId == Game::YuGiOhBandai) return "yugiohbandai";
if (gameId == Game::JapanesePokemon) return "pokemon";
return "yugioh";
}
@@ -182,6 +183,43 @@ TEST_SUITE("SetService") {
CHECK(digi.source.calls == 1);
}
TEST_CASE("YuGiOhBandai 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 bandai{Game::YuGiOhBandai};
bandai.source.result = Result<std::vector<Set>>::ok(
{{"ban1", "1st Generation", "1998/09/01"}});
svc.registerModule(&magic);
svc.registerModule(&pokemon);
svc.registerModule(&yugioh);
svc.registerModule(&digi);
svc.registerModule(&bandai);
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::YuGiOhBandai);
REQUIRE(out.isOk());
CHECK(out.value().front().id == "ban1");
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
CHECK(digi.source.calls == 1);
CHECK(bandai.source.calls == 1);
}
TEST_CASE("JapanesePokemon module routes independently when all games are registered") {
InMemSetRepo repo;
SetService svc{repo};
@@ -0,0 +1,220 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
#include <doctest/doctest.h>
#include <string>
using namespace ccm;
namespace {
class FixedHttpClient final : public IHttpClient {
public:
std::string body;
std::string lastUrl;
bool fail{false};
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (fail) return Result<std::string>::err("http fail");
return Result<std::string>::ok(body);
}
};
} // namespace
TEST_SUITE("YuGiOhBandaiCardPreviewSource helpers") {
TEST_CASE("preferredPageTitle picks Bandai / English / Sealdass") {
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Dark Magician", "ban1", "14") ==
"Dark Magician (Bandai)");
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Blue-Eyes White Dragon", "ban3",
"118") ==
"Blue-Eyes White Dragon (English Bandai)");
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Dark Magician", "bansealdass",
"2") ==
"Dark Magician (Bandai Sealdass)");
}
TEST_CASE("buildPageImagesUrl encodes spaces as underscores then percent") {
const auto url =
YuGiOhBandaiCardPreviewSource::buildPageImagesUrl("Dark Magician (Bandai)");
CHECK(url.find("titles=Dark_Magician_%28Bandai%29") != std::string::npos);
}
TEST_CASE("buildAskByNameUrl includes English name constraint") {
const auto url = YuGiOhBandaiCardPreviewSource::buildAskByNameUrl("Dark Magician");
CHECK(url.find("action=ask") != std::string::npos);
CHECK(url.find("query=") != std::string::npos);
}
TEST_CASE("parsePageImagesResponse returns original source") {
const std::string body = R"JSON({
"query": {
"pages": {
"1": {
"title": "Dark Magician (Bandai)",
"original": {"source": "https://ms.yugipedia.com/d/d0/DarkMagician.png"}
}
}
}
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(body);
REQUIRE(out);
CHECK(out.value() == "https://ms.yugipedia.com/d/d0/DarkMagician.png");
}
TEST_CASE("parsePageImagesResponse missing page is NotFound") {
const std::string body = R"JSON({
"query": { "pages": { "-1": { "missing": true, "title": "Nope" } } }
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(body);
REQUIRE_FALSE(out);
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("parseAskResponse fills setNo rarity name and setId") {
const std::string body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1");
REQUIRE(out);
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Dark Magician");
CHECK(out.value()[0].setNo == "14");
CHECK(out.value()[0].rarity == "Rare");
CHECK(out.value()[0].setId == "ban1");
CHECK(out.value()[0].language == "Japanese");
}
TEST_CASE("parseAskResponse prefers Bandai over Sealdass when set is ban1") {
const std::string body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai Sealdass)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [2],
"Rarity": [{"fulltext": "Common"}]
}
},
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1");
REQUIRE(out);
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "14");
CHECK(out.value()[0].setId == "ban1");
}
TEST_CASE("fetchImageUrl uses pageimages URL") {
FixedHttpClient http;
http.body = R"JSON({
"query": {
"pages": {
"1": {
"title": "Dark Magician (Bandai)",
"original": {"source": "https://ms.yugipedia.com/x.png"}
}
}
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.fetchImageUrl("Dark Magician", "ban1", "14");
REQUIRE(out);
CHECK(out.value() == "https://ms.yugipedia.com/x.png");
CHECK(http.lastUrl.find("pageimages") != std::string::npos);
}
TEST_CASE("detectFirstPrint uses ask response") {
FixedHttpClient http;
http.body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.detectFirstPrint("Dark Magician", "ban1");
REQUIRE(out);
CHECK(out.value().setNo == "14");
CHECK(out.value().rarity == "Rare");
CHECK(out.value().setId == "ban1");
}
TEST_CASE("detectBySetNo uses ask-by-number URL") {
FixedHttpClient http;
http.body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.detectBySetNo("014");
REQUIRE(out);
CHECK(out.value().name == "Dark Magician");
CHECK(http.lastUrl.find("action=ask") != std::string::npos);
}
TEST_CASE("detectBySetNo resolves promo TA2 from gallery parse") {
FixedHttpClient http;
http.body = R"JSON({
"parse": {
"wikitext": "WickedChain-BAN1-JP-SR.png | [[TA1]] ([[SR]]) {{Gallery card names|Wicked Chain|ja}}\nBlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]])<br />{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.detectBySetNo("ta2");
REQUIRE(out);
CHECK(out.value().name == "Blue-Eyes White Dragon's 3-Body Connection");
CHECK(out.value().setNo == "TA2");
CHECK(out.value().setId == "banpromo-ta");
CHECK(out.value().rarity == "Super Rare");
CHECK(http.lastUrl.find("action=parse") != std::string::npos);
CHECK(http.lastUrl.find("Promotional") != std::string::npos);
}
TEST_CASE("isAlphanumericPromoNumber detects Jump and Toei codes") {
CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("TA2"));
CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("j1"));
CHECK_FALSE(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("14"));
}
TEST_CASE("preferredPageTitle omits Bandai suffix for promo sets") {
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle(
"Blue-Eyes White Dragon's 3-Body Connection", "banpromo-ta", "TA2") ==
"Blue-Eyes White Dragon's 3-Body Connection");
}
}
+153
View File
@@ -0,0 +1,153 @@
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include "ccm/services/YuGiOhBandaiSetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
#include <doctest/doctest.h>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
YuGiOhBandaiCard makeOwned(std::string setId, std::string setName, std::string setNo) {
YuGiOhBandaiCard c;
c.set = Set{std::move(setId), std::move(setName), "1998/09/01"};
c.setNo = std::move(setNo);
c.name = "Card";
c.language = Language::Japanese;
return c;
}
} // namespace
TEST_SUITE("YuGiOhBandaiSetCompletion") {
TEST_CASE("unique setNo within a pack; amount does not inflate") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back({"9", "Blue-Eyes", "Super Rare"});
pack.cards.push_back({"14", "Dark Magician", "Rare"});
catalog.packs.push_back(pack);
std::vector<YuGiOhBandaiCard> coll;
auto a = makeOwned("ban1", "1st Generation", "14");
a.amount = 5;
coll.push_back(a);
coll.push_back(makeOwned("ban1", "1st Generation", "014"));
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
REQUIRE(progress.size() == 1);
CHECK(progress[0].ownedUnique == 1);
CHECK(progress[0].total == 2);
CHECK(progress[0].percent() == 50);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack ban1;
ban1.setId = "ban1";
ban1.setName = "1st Generation";
ban1.cards.push_back({"14", "Dark Magician", "Rare"});
YuGiOhBandaiSetCatalogPack seal;
seal.setId = "bansealdass";
seal.setName = "Sealdass";
seal.cards.push_back({"14", "Other", "Common"});
catalog.packs.push_back(ban1);
catalog.packs.push_back(seal);
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban1", "1st Generation", "14")};
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
REQUIRE(progress.size() == 1);
CHECK(progress[0].setId == "ban1");
}
TEST_CASE("checklist marks owned rows") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back({"9", "Blue-Eyes", "Super Rare"});
pack.cards.push_back({"14", "Dark Magician", "Rare"});
catalog.packs.push_back(pack);
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban1", "1st Generation", "14")};
auto list = yuGiOhBandaiChecklistForSet(coll, catalog, "ban1");
REQUIRE(list.size() == 2);
CHECK(list[0].setNo == "14");
CHECK(list[0].owned);
CHECK(list[1].setNo == "9");
CHECK_FALSE(list[1].owned);
}
TEST_CASE("empty setNo produces no progress rows") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back({"14", "Dark Magician", "Rare"});
catalog.packs.push_back(pack);
YuGiOhBandaiCard missingNo = makeOwned("ban1", "1st Generation", "");
YuGiOhBandaiCard whitespaceNo = makeOwned("ban1", "1st Generation", " ");
std::vector<YuGiOhBandaiCard> coll{missingNo, whitespaceNo};
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
CHECK(progress.empty());
}
TEST_CASE("set.id plus setNo against catalog pack yields a tile") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban2";
pack.setName = "2nd Generation";
pack.cards.push_back({"47", "Time Wizard", "Super Rare"});
pack.cards.push_back({"48", "Polymerization", "Super Rare"});
catalog.packs.push_back(pack);
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban2", "2nd Generation", "047")};
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
REQUIRE(progress.size() == 1);
CHECK(progress[0].setId == "ban2");
CHECK(progress[0].setName == "2nd Generation");
CHECK(progress[0].ownedUnique == 1);
CHECK(progress[0].total == 2);
CHECK(progress[0].percent() == 50);
}
}
TEST_SUITE("YuGiOhBandaiSetCatalogService") {
TEST_CASE("catalog service round-trips against InMemoryFileSystem") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
YuGiOhBandaiSetCatalogService svc(fs, config, [](Game) { return "yugiohbandai"; });
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back({"14", "Dark Magician", "Rare"});
catalog.packs.push_back(pack);
REQUIRE(svc.save(catalog));
CHECK(svc.exists());
auto loaded = svc.load();
REQUIRE(loaded);
CHECK(loaded.value() == catalog);
}
}
+139
View File
@@ -0,0 +1,139 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <doctest/doctest.h>
#include <string>
using namespace ccm;
namespace {
class FixedHttpClient final : public IHttpClient {
public:
std::string body;
std::string lastUrl;
bool fail{false};
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (fail) return Result<std::string>::err("http fail");
return Result<std::string>::ok(body);
}
};
} // namespace
TEST_SUITE("YuGiOhBandaiSetSource") {
TEST_CASE("parseResponse returns stable manifest ordered by release date") {
auto sets = YuGiOhBandaiSetSource::parseResponse({});
REQUIRE(sets);
REQUIRE(sets.value().size() == 6);
CHECK(sets.value()[0].id == "ban1");
CHECK(sets.value()[0].name == "1st Generation");
CHECK(sets.value()[0].releaseDate == "1998/09/01");
CHECK(sets.value()[5].id == "bansealdass");
}
TEST_CASE("normalizeCardNumber strips leading zeros and uppercases prefixes") {
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("014") == "14");
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("#9") == "9");
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("j1") == "J1");
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("ta2") == "TA2");
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber(" ") == "");
}
TEST_CASE("expandRarityCode maps gallery abbreviations") {
CHECK(YuGiOhBandaiSetSource::expandRarityCode("C") == "Common");
CHECK(YuGiOhBandaiSetSource::expandRarityCode("R") == "Rare");
CHECK(YuGiOhBandaiSetSource::expandRarityCode("SR") == "Super Rare");
CHECK(YuGiOhBandaiSetSource::expandRarityCode("HFR") == "Holo Seal");
}
TEST_CASE("setIdForNumber maps ranges and promo prefixes") {
CHECK(YuGiOhBandaiSetSource::setIdForNumber("14") == "ban1");
CHECK(YuGiOhBandaiSetSource::setIdForNumber("50") == "ban2");
CHECK(YuGiOhBandaiSetSource::setIdForNumber("118") == "ban3");
CHECK(YuGiOhBandaiSetSource::setIdForNumber("J1") == "banpromo-j");
CHECK(YuGiOhBandaiSetSource::setIdForNumber("TA2") == "banpromo-ta");
}
TEST_CASE("parseGalleryWikitext extracts number rarity and English name") {
const std::string wiki =
"DarkMagician-BAN1-JP-R.png | {{pound}}014 ([[R]]) "
"{{Gallery card names|Dark Magician (Bandai)|ja}}\n"
"BlueEyesWhiteDragon-BAN1-JP-SR.png | {{pound}}009 ([[SR]]) "
"{{Gallery card names|Blue-Eyes White Dragon (Bandai)|ja}}\n";
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
REQUIRE(cards);
REQUIRE(cards.value().size() == 2);
CHECK(cards.value()[0].setNo == "14");
CHECK(cards.value()[0].name == "Dark Magician");
CHECK(cards.value()[0].rarity == "Rare");
CHECK(cards.value()[1].setNo == "9");
CHECK(cards.value()[1].rarity == "Super Rare");
}
TEST_CASE("parseGalleryWikitext accepts promo [[TA2]] number format") {
const std::string wiki =
"WickedChain-BAN1-JP-SR.png | [[TA1]] ([[SR]]) "
"{{Gallery card names|Wicked Chain|ja}}\n"
"BlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]]) "
"{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
"MirrorForce-BAN1-JP-SR.png | [[J1]] ([[SR]]) "
"{{Gallery card names|Mirror Force (Bandai)|ja}}\n";
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
REQUIRE(cards);
REQUIRE(cards.value().size() == 3);
CHECK(cards.value()[0].setNo == "TA1");
CHECK(cards.value()[0].name == "Wicked Chain");
CHECK(cards.value()[0].rarity == "Super Rare");
CHECK(cards.value()[1].setNo == "TA2");
CHECK(cards.value()[1].name == "Blue-Eyes White Dragon's 3-Body Connection");
CHECK(cards.value()[2].setNo == "J1");
CHECK(cards.value()[2].name == "Mirror Force");
}
TEST_CASE("parseGalleryWikitext tolerates <br /> between rarity and name template") {
// Live Yugipedia promo gallery captions insert <br /> after expansion.
const std::string wiki =
"<gallery mode=\"packed\">\n"
"BlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]])<br />"
"{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
"MirrorForce-BAN1-JP-SR.png | [[J1]] ([[SR]])<br />"
"{{Gallery card names|Mirror Force (Bandai)|ja}}\n"
"</gallery>\n";
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
REQUIRE(cards);
REQUIRE(cards.value().size() == 2);
CHECK(cards.value()[0].setNo == "TA2");
CHECK(cards.value()[0].name == "Blue-Eyes White Dragon's 3-Body Connection");
CHECK(cards.value()[0].rarity == "Super Rare");
CHECK(cards.value()[1].setNo == "J1");
}
TEST_CASE("parseGalleryWikitext empty body yields empty ok") {
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext("");
REQUIRE(cards);
CHECK(cards.value().empty());
}
TEST_CASE("fetchAll returns manifest without HTTP") {
FixedHttpClient http;
YuGiOhBandaiSetSource src(http);
auto sets = src.fetchAll();
REQUIRE(sets);
CHECK(sets.value().size() == 6);
CHECK(http.lastUrl.empty());
}
TEST_CASE("buildGalleryParseUrl percent-encodes page title") {
const auto url = YuGiOhBandaiSetSource::buildGalleryParseUrl(
"Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation");
CHECK(url.find("action=parse") != std::string::npos);
CHECK(url.find("prop=wikitext") != std::string::npos);
CHECK(url.find("page=") != std::string::npos);
}
}