// CardSorter tests - exercises the per-column comparators ported from the // TableTemplate.tsx::byField. Each TEST_CASE pins down a specific behavior // the original UI relied on, so a regression here implies the C++ table no // longer behaves like the TypeScript reference. #include #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/YuGiOhBandaiCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" #include "ccm/domain/Set.hpp" #include "ccm/services/CardSorter.hpp" #include #include #include using namespace ccm; namespace { MagicCard mc(std::uint32_t id, std::string name, std::string setName, std::string releaseDate, std::uint8_t amount = 1, bool foil = false, bool sgnd = false, bool altered = false, Language lang = Language::English, Condition cond = Condition::NearMint, std::string note = "") { MagicCard 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.foil = foil; c.signed_ = sgnd; c.altered = altered; c.language = lang; c.condition = cond; c.note = std::move(note); return c; } PokemonCard pc(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::English, Condition cond = Condition::NearMint, std::string note = "") { PokemonCard 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; } YuGiOhCard yc(std::uint32_t id, std::string name, std::string setName, std::string releaseDate, std::string setNo = "", std::string rarity = "", std::uint8_t amount = 1, Language lang = Language::English, Condition cond = Condition::NearMint, bool firstEdition = false, bool sgnd = false, bool altered = false, std::string note = "") { YuGiOhCard c; c.id = id; c.name = std::move(name); c.set.name = std::move(setName); c.set.releaseDate = std::move(releaseDate); c.setNo = std::move(setNo); c.rarity = std::move(rarity); c.amount = amount; c.language = lang; c.condition = cond; c.firstEdition = firstEdition; c.signed_ = sgnd; c.altered = altered; c.note = std::move(note); return c; } DigiBattle99Card db(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::English, Condition cond = Condition::NearMint, std::string note = "") { DigiBattle99Card 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; } 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 ids(const std::vector& v) { std::vector out; out.reserve(v.size()); for (const auto& c : v) out.push_back(c.id); return out; } std::vector ids(const std::vector& v) { std::vector out; out.reserve(v.size()); for (const auto& c : v) out.push_back(c.id); return out; } std::vector ids(const std::vector& v) { std::vector out; out.reserve(v.size()); for (const auto& c : v) out.push_back(c.id); return out; } std::vector ids(const std::vector& v) { std::vector out; out.reserve(v.size()); for (const auto& c : v) out.push_back(c.id); return out; } std::vector ids(const std::vector& v) { std::vector out; out.reserve(v.size()); for (const auto& c : v) out.push_back(c.id); return out; } } // namespace TEST_SUITE("CardSorter - Magic columns") { TEST_CASE("Name sorts case-insensitively (matches String.toLowerCase())") { // Three cards whose names only differ in casing - if sort were a // plain `<` this would put "ABC" before "abc" and wedge "Brainstorm" // somewhere wrong. The JS path normalizes to lowercase first. std::vector v = { mc(1, "brainstorm", "X", "2000/01/01"), mc(2, "ABC", "X", "2000/01/01"), mc(3, "abc", "X", "2000/01/01"), }; sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/true); // ABC and abc tie under case-insensitive compare; stable sort keeps // the input order (id 2 before id 3). CHECK(ids(v) == std::vector{2, 3, 1}); sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/false); CHECK(ids(v) == std::vector{1, 2, 3}); } TEST_CASE("Set column sorts by release date (chronological), not by name") { // The whole point of having distinct valueKey/sortKey: the // table *displays* set.name, but ascending order is chronological. std::vector v = { mc(1, "x", "Zendikar", "2009/10/02"), mc(2, "y", "Alpha", "1993/08/05"), mc(3, "z", "Modern Horizons","2019/06/14"), }; sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 1, 3}); // 1993 < 2009 < 2019 sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/false); CHECK(ids(v) == std::vector{3, 1, 2}); } TEST_CASE("Amount sorts numerically (no string-compare 10 < 2 trap)") { std::vector v = { mc(1, "a", "X", "2000/01/01", /*amount=*/10), mc(2, "b", "X", "2000/01/01", /*amount=*/2), mc(3, "c", "X", "2000/01/01", /*amount=*/4), }; sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); // 2 < 4 < 10 sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/false); CHECK(ids(v) == std::vector{1, 3, 2}); } TEST_CASE("boolean flag column orders false < true (asc puts unset first)") { std::vector v = { mc(1, "a", "X", "2000/01/01", 1, /*foil=*/true), mc(2, "b", "X", "2000/01/01", 1, /*foil=*/false), mc(3, "c", "X", "2000/01/01", 1, /*foil=*/true), mc(4, "d", "X", "2000/01/01", 1, /*foil=*/false), }; sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/true); // Stable: relative order within each bucket preserved. CHECK(ids(v) == std::vector{2, 4, 1, 3}); sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/false); CHECK(ids(v) == std::vector{1, 3, 2, 4}); } TEST_CASE("Signed and Altered booleans sort independently") { std::vector v = { mc(1, "a", "X", "2000/01/01", 1, false, /*sgnd=*/false, /*alt=*/true), mc(2, "b", "X", "2000/01/01", 1, false, /*sgnd=*/true, /*alt=*/false), }; sortMagicCards(v, MagicSortColumn::Signed, /*ascending=*/true); CHECK(ids(v) == std::vector{1, 2}); sortMagicCards(v, MagicSortColumn::Altered, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 1}); } TEST_CASE("Language and Condition sort by their string label, lowercased") { std::vector v = { mc(1, "a", "X", "2000/01/01", 1, false, false, false, Language::Japanese, Condition::Mint), mc(2, "b", "X", "2000/01/01", 1, false, false, false, Language::English, Condition::Played), mc(3, "c", "X", "2000/01/01", 1, false, false, false, Language::German, Condition::NearMint), }; sortMagicCards(v, MagicSortColumn::Language, /*ascending=*/true); // english < german < japanese (lowercased compare) CHECK(ids(v) == std::vector{2, 3, 1}); sortMagicCards(v, MagicSortColumn::Condition, /*ascending=*/true); // mint < nearmint < played (lowercased compare) CHECK(ids(v) == std::vector{1, 3, 2}); } TEST_CASE("Note sorts case-insensitively") { std::vector v = { mc(1, "a", "X", "2000/01/01", 1, false, false, false, Language::English, Condition::NearMint, "Zeta"), mc(2, "b", "X", "2000/01/01", 1, false, false, false, Language::English, Condition::NearMint, "alpha"), mc(3, "c", "X", "2000/01/01", 1, false, false, false, Language::English, Condition::NearMint, "Beta"), }; sortMagicCards(v, MagicSortColumn::Note, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); // alpha, beta, zeta } TEST_CASE("stable: sort by name then by set keeps name order within each set") { // Mirrors the UX expectation: a user clicks Name, then Set, and // sees rows grouped by set with names alphabetical inside each group. std::vector v = { mc(1, "Counterspell", "Beta", "1993/10/04"), mc(2, "Brainstorm", "Alpha", "1993/08/05"), mc(3, "Lightning Bolt","Beta", "1993/10/04"), mc(4, "Ancestral Recall","Alpha","1993/08/05"), }; sortMagicCards(v, MagicSortColumn::Name, /*asc=*/true); sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*asc=*/true); // Alpha (1993/08/05) first: ancestral, brainstorm // Beta (1993/10/04) next: counterspell, lightning bolt CHECK(ids(v) == std::vector{4, 2, 1, 3}); } } TEST_SUITE("CardSorter - Pokemon-specific columns") { TEST_CASE("Holo and FirstEdition each sort their own bool field") { std::vector v = { pc(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*1st=*/false), pc(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/true), pc(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/false), }; sortPokemonCards(v, PokemonSortColumn::Holo, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); sortPokemonCards(v, PokemonSortColumn::FirstEdition, /*ascending=*/true); // After previous sort: 2,3,1 (false=2, false=1 actually... let me think // -- with v in order {2,3,1} their firstEdition = {true,false,false}. // Stable asc on firstEdition keeps 3 before 1 in the false bucket.) CHECK(ids(v) == std::vector{3, 1, 2}); } TEST_CASE("Set column sorts by release date for Pokemon too") { std::vector v = { pc(1, "x", "Sun & Moon", "2017/02/03"), pc(2, "y", "Base Set", "1999/01/09"), pc(3, "z", "Sword & Shield","2020/02/07"), }; sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 1, 3}); } TEST_CASE("Amount sorts numerically") { std::vector v = { pc(1, "a", "X", "2000/01/01", 9), pc(2, "b", "X", "2000/01/01", 11), pc(3, "c", "X", "2000/01/01", 1), }; sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true); CHECK(ids(v) == std::vector{3, 1, 2}); } TEST_CASE("Name sorts case-insensitively") { std::vector v = { pc(1, "piKAchu", "X", "2000/01/01"), pc(2, "abra", "X", "2000/01/01"), pc(3, "CHARMANDER", "X", "2000/01/01"), }; sortPokemonCards(v, PokemonSortColumn::Name, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); } TEST_CASE("Language and Condition sort by lowercased labels") { std::vector v = { pc(1, "a", "X", "2000/01/01", 1, false, false, false, false, Language::Japanese, Condition::Mint), pc(2, "b", "X", "2000/01/01", 1, false, false, false, false, Language::English, Condition::Played), pc(3, "c", "X", "2000/01/01", 1, false, false, false, false, Language::German, Condition::NearMint), }; sortPokemonCards(v, PokemonSortColumn::Language, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); sortPokemonCards(v, PokemonSortColumn::Condition, /*ascending=*/true); CHECK(ids(v) == std::vector{1, 3, 2}); } TEST_CASE("Signed and Altered sort like Magic booleans") { std::vector v = { pc(1, "a", "X", "2000/01/01", 1, false, false, /*sgnd=*/false, /*alt=*/true), pc(2, "b", "X", "2000/01/01", 1, false, false, /*sgnd=*/true, /*alt=*/false), }; sortPokemonCards(v, PokemonSortColumn::Signed, /*ascending=*/true); CHECK(ids(v) == std::vector{1, 2}); sortPokemonCards(v, PokemonSortColumn::Altered, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 1}); } TEST_CASE("Note sorts case-insensitively") { std::vector v = { pc(1, "a", "X", "2000/01/01", 1, false, false, false, false, Language::English, Condition::NearMint, "ZETA"), pc(2, "b", "X", "2000/01/01", 1, false, false, false, false, Language::English, Condition::NearMint, "alpha"), pc(3, "c", "X", "2000/01/01", 1, false, false, false, false, Language::English, Condition::NearMint, "Beta"), }; sortPokemonCards(v, PokemonSortColumn::Note, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); } TEST_CASE("descending SetReleaseDate reverses chronological order") { std::vector v = { pc(1, "x", "Late", "2020/01/01"), pc(2, "y", "Early", "1999/01/01"), pc(3, "z", "Mid", "2015/06/01"), }; sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/false); CHECK(ids(v) == std::vector{1, 3, 2}); } } TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") { TEST_CASE("empty vector stays empty") { std::vector v; sortMagicCards(v, MagicSortColumn::Name, true); CHECK(v.empty()); } TEST_CASE("single element preserved") { std::vector v = { mc(42, "Solo", "X", "2000/01/01") }; sortMagicCards(v, MagicSortColumn::Amount, false); CHECK(v.size() == 1); CHECK(v.front().id == 42); } } TEST_SUITE("CardSorter - YuGiOh columns") { TEST_CASE("Name sorts case-insensitively") { std::vector v = { yc(1, "BLUE-EYES", "X", "2000/01/01"), yc(2, "dark magician", "X", "2000/01/01"), yc(3, "CELTIC_GUARDIAN", "X", "2000/01/01"), }; sortYuGiOhCards(v, YuGiOhSortColumn::Name, /*ascending=*/true); CHECK(ids(v) == std::vector{1, 3, 2}); } TEST_CASE("SetReleaseDate sorts chronologically") { std::vector v = { yc(1, "a", "Late", "2020/01/01"), yc(2, "b", "Early", "1999/01/01"), yc(3, "c", "Mid", "2015/06/01"), }; sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/false); CHECK(ids(v) == std::vector{1, 3, 2}); } TEST_CASE("Language and Condition sort by lowercased labels") { std::vector v = { yc(1, "a", "X", "2000/01/01", "", "", 1, Language::Japanese, Condition::Mint), yc(2, "b", "X", "2000/01/01", "", "", 1, Language::English, Condition::Played), yc(3, "c", "X", "2000/01/01", "", "", 1, Language::German, Condition::NearMint), }; sortYuGiOhCards(v, YuGiOhSortColumn::Language, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); sortYuGiOhCards(v, YuGiOhSortColumn::Condition, /*ascending=*/true); CHECK(ids(v) == std::vector{1, 3, 2}); } TEST_CASE("FirstEdition Signed Altered and Note columns sort consistently") { std::vector v = { yc(1, "a", "X", "2000/01/01", "", "", 1, Language::English, Condition::NearMint, /*firstEdition=*/true, /*sgnd=*/false, /*alt=*/true, "Z"), yc(2, "b", "X", "2000/01/01", "", "", 1, Language::English, Condition::NearMint, /*firstEdition=*/false, /*sgnd=*/true, /*alt=*/false, "a"), yc(3, "c", "X", "2000/01/01", "", "", 1, Language::English, Condition::NearMint, /*firstEdition=*/false, /*sgnd=*/false, /*alt=*/false, "m"), }; sortYuGiOhCards(v, YuGiOhSortColumn::FirstEdition, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); sortYuGiOhCards(v, YuGiOhSortColumn::Signed, /*ascending=*/true); // After FirstEdition sort order is {2,3,1}: signed=false wins first (stable: 3 then 1). CHECK(ids(v) == std::vector{3, 1, 2}); sortYuGiOhCards(v, YuGiOhSortColumn::Altered, /*ascending=*/true); // Previous order {3,1,2}: altered=false entries are 3 and 2 before true (1). CHECK(ids(v) == std::vector{3, 2, 1}); sortYuGiOhCards(v, YuGiOhSortColumn::Note, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); // a, m, Z (case-insensitive) } TEST_CASE("Rarity sorts by rarity shorthand") { std::vector v = { yc(1, "a", "X", "2000/01/01", "", "Ultra Rare", 1), yc(2, "b", "X", "2000/01/01", "", "Common", 1), yc(3, "c", "X", "2000/01/01", "", "Secret Rare", 1), }; sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); // C, ScR, UR } TEST_CASE("Rarity treats unknown labels as equal empty shorthand") { std::vector v = { yc(1, "alpha", "X", "2000/01/01", "", "Mythic Cosmic Rare", 1), yc(2, "beta", "X", "2000/01/01", "", "Other Unknown", 1), }; sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true); CHECK(ids(v) == std::vector{1, 2}); sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true); CHECK(ids(v) == std::vector{1, 2}); } TEST_CASE("Amount sorts numerically") { std::vector v = { yc(1, "a", "X", "2000/01/01", "", "", 9), yc(2, "b", "X", "2000/01/01", "", "", 11), yc(3, "c", "X", "2000/01/01", "", "", 1), }; sortYuGiOhCards(v, YuGiOhSortColumn::Amount, /*ascending=*/true); CHECK(ids(v) == std::vector{3, 1, 2}); } } TEST_SUITE("CardSorter - DigiBattle99 columns") { TEST_CASE("Holo and FirstEdition sort false before true") { std::vector v = { db(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*first=*/false), db(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/true), db(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/false), }; sortDigiBattle99Cards(v, DigiBattle99SortColumn::Holo, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 3, 1}); sortDigiBattle99Cards(v, DigiBattle99SortColumn::FirstEdition, /*ascending=*/true); CHECK(ids(v) == std::vector{3, 1, 2}); } TEST_CASE("Set column sorts by release date") { std::vector v = { db(1, "x", "Late", "2001/01/01"), db(2, "y", "Early", "1999/06/01"), }; sortDigiBattle99Cards(v, DigiBattle99SortColumn::SetReleaseDate, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 1}); } TEST_CASE("Name sorts case-insensitively") { std::vector v = { db(1, "greymon", "X", "2000/01/01"), db(2, "Agumon", "X", "2000/01/01"), }; sortDigiBattle99Cards(v, DigiBattle99SortColumn::Name, /*ascending=*/true); CHECK(ids(v) == std::vector{2, 1}); } } 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 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 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 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{2, 3, 1}); sortJapanesePokemonCards(v, JapanesePokemonSortColumn::FirstEdition, /*ascending=*/true); CHECK(ids(v) == std::vector{3, 1, 2}); } TEST_CASE("Set column sorts by release date") { std::vector 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{2, 1}); } TEST_CASE("Name sorts case-insensitively") { std::vector 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{2, 1}); } }