start of set completion tracking

This commit is contained in:
sdine
2026-07-22 20:03:54 +02:00
parent c9e6bc2b6b
commit 8a89e79e43
29 changed files with 1337 additions and 78 deletions
+2 -1
View File
@@ -21,7 +21,8 @@
- `std_file_system_tests.cpp``StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
- `pokemon_set_source_tests.cpp``PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. Drives `fetchAll` via `FixedHttpClient`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`.
- `digibattle99_set_completion_tests.cpp``computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`.
- `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`.
- `yugioh_set_source_tests.cpp``YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
- `yugioh_set_lookup_tests.cpp``lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous).
+1
View File
@@ -23,6 +23,7 @@ add_executable(ccm_core_tests
pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
digibattle99_set_completion_tests.cpp
japanese_pokemon_en_catalog_tests.cpp
japanese_pokemon_set_source_tests.cpp
japanese_pokemon_card_preview_source_tests.cpp
+164
View File
@@ -0,0 +1,164 @@
#include <doctest/doctest.h>
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/DigiBattle99SetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
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;
}
DigiBattle99Card makeOwned(std::string setId, std::string setName, std::string setNo) {
DigiBattle99Card c;
c.id = 1;
c.name = "Owned";
c.set.id = std::move(setId);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
return c;
}
DigiBattle99SetCatalog sampleCatalog() {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack starter;
starter.setId = "series-1-starter-set";
starter.setName = "Series 1 Starter Set";
starter.cards = {
{"ST-01", "Agumon"},
{"ST-02", "Greymon"},
{"ST-03", "Gabumon"},
};
DigiBattle99SetCatalogPack booster;
booster.setId = "series-1-booster-pack";
booster.setName = "Series 1 Booster Pack";
booster.cards = {
{"ST-01", "Agumon"},
{"BO-01", "MetalGreymon"},
};
catalog.packs.push_back(std::move(booster));
catalog.packs.push_back(std::move(starter));
return catalog;
}
} // namespace
TEST_SUITE("computeDigiBattle99SetCompletion") {
TEST_CASE("only packs with owned cards appear") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "series-1-starter-set");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("unique setNo within a pack; amount does not inflate") {
const auto catalog = sampleCatalog();
DigiBattle99Card a = makeOwned("series-1-starter-set", "Series 1 Starter Set", "st-01");
a.amount = 4;
DigiBattle99Card b = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
b.id = 2;
DigiBattle99Card c = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
c.id = 3;
const auto rows =
computeDigiBattle99SetCompletion({a, b, c}, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 66);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "series-1-starter-set");
}
TEST_CASE("empty catalog yields no rows") {
DigiBattle99SetCatalog empty;
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
CHECK(computeDigiBattle99SetCompletion(collection, empty).empty());
}
TEST_CASE("owned set missing from catalog is skipped") {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack onlyBooster;
onlyBooster.setId = "series-1-booster-pack";
onlyBooster.setName = "Series 1 Booster Pack";
onlyBooster.cards = {{"BO-01", "MetalGreymon"}};
catalog.packs.push_back(std::move(onlyBooster));
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
CHECK(computeDigiBattle99SetCompletion(collection, catalog).empty());
}
}
TEST_SUITE("digiBattle99ChecklistForSet") {
TEST_CASE("greys missing cards and marks owned ones") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"),
};
const auto list =
digiBattle99ChecklistForSet(collection, catalog, "series-1-starter-set");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "ST-01");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "ST-02");
CHECK(list[1].owned == true);
CHECK(list[2].setNo == "ST-03");
CHECK(list[2].owned == false);
}
TEST_CASE("unknown set returns empty") {
const auto catalog = sampleCatalog();
CHECK(digiBattle99ChecklistForSet({}, catalog, "missing").empty());
}
}
TEST_SUITE("DigiBattle99SetCatalogService") {
TEST_CASE("save then load round-trips") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
DigiBattle99SetCatalogService store{fs, config, [](Game) { return "digibattle99"; }};
CHECK_FALSE(store.exists());
CHECK(store.load().isErr());
const auto catalog = sampleCatalog();
REQUIRE(store.save(catalog).isOk());
CHECK(store.exists());
const auto loaded = store.load();
REQUIRE(loaded.isOk());
CHECK(loaded.value() == catalog);
}
}
+71
View File
@@ -109,4 +109,75 @@ TEST_SUITE("DigiBattle99SetSource::fetchAll") {
CHECK(out.value().front().id == "series-1-starter-set");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
TEST_CASE("fetchAllWithCatalog returns sets and pack cards in one GET") {
FixedHttpClient http;
http.ok = true;
http.body = R"([
{"name":"Agumon","id":"st-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99SetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isOk());
CHECK(out.value().sets.size() == 2);
const auto* starter = out.value().catalog.findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 2);
CHECK(starter->cards[0].setNo == "ST-01");
CHECK(starter->cards[0].name == "Agumon");
const auto* booster = out.value().catalog.findPack("series-1-booster-pack");
REQUIRE(booster != nullptr);
REQUIRE(booster->cards.size() == 1);
CHECK(booster->cards[0].setNo == "ST-01");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
}
TEST_SUITE("DigiBattle99SetSource::parseCatalog") {
TEST_CASE("lists a card under every pack in set_name") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]}
])";
const auto out = DigiBattle99SetSource::parseCatalog(json);
REQUIRE(out.isOk());
REQUIRE(out.value().packs.size() == 2);
const auto* booster = out.value().findPack("series-1-booster-pack");
REQUIRE(booster != nullptr);
REQUIRE(booster->cards.size() == 2);
CHECK(booster->cards[0].setNo == "BO-01");
CHECK(booster->cards[1].setNo == "ST-01");
const auto* starter = out.value().findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 1);
CHECK(starter->cards[0].setNo == "ST-01");
}
TEST_CASE("dedupes the same setNo within one pack") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Agumon Alt","id":"ST-01","set_name":["Series 1 Starter Set"]}
])";
const auto out = DigiBattle99SetSource::parseCatalog(json);
REQUIRE(out.isOk());
const auto* starter = out.value().findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 1);
CHECK(starter->cards[0].name == "Agumon");
}
TEST_CASE("empty array returns an empty catalog") {
const auto out = DigiBattle99SetSource::parseCatalog("[]");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
TEST_CASE("error object is an error") {
const auto out = DigiBattle99SetSource::parseCatalog(
R"({"error":"No cards found for this search."})");
CHECK(out.isErr());
}
}
+23
View File
@@ -2,6 +2,7 @@
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
@@ -245,6 +246,28 @@ TEST_SUITE("DigiBattle99Card JSON") {
}
}
TEST_SUITE("DigiBattle99SetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack pack;
pack.setId = "series-1-starter-set";
pack.setName = "Series 1 Starter Set";
pack.cards.push_back(DigiBattle99CatalogCard{"ST-01", "Agumon"});
pack.cards.push_back(DigiBattle99CatalogCard{"ST-126", "Agumon"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "series-1-starter-set");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "ST-01");
const DigiBattle99SetCatalog back = j.get<DigiBattle99SetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("series-1-starter-set") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("JapanesePokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
JapanesePokemonCard c;