[Models] Route group lookups around mirrored custom zones

Group lookups (createNodeIfNeeded, findCardNode) must not resolve a
mirrored custom zone that shares the group name. Introduce
findGroupChild to search only non-custom children, and make addCard
consult the deck tree before falling back to creating a top-level zone
so cards added to an un-mirrored custom zone land inside it.

mirrorCustomZones now flattens cards nested at any depth into the
mirrored zone so no card is left without a model row.

Add model behaviour tests (addCard routing, same-name group/zone
collision, removeRows guard, empty-zone survival, findCard inside a
custom zone) and fix the missing main() in the unit test binaries.
This commit is contained in:
Lukas Brübach 2026-08-30 22:15:58 +02:00 committed by GitHub
parent 80784d4e02
commit e221d8e1bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 303 additions and 12 deletions

View file

@ -1,4 +1,4 @@
add_executable(deck_list_model_custom_zones_test deck_list_model_custom_zones_test.cpp)
add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp)
if(NOT GTEST_FOUND)
add_dependencies(deck_list_model_custom_zones_test gtest)

View file

@ -129,6 +129,51 @@ TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop)
EXPECT_EQ(shadowBoard->size(), 0);
}
TEST(DeckListModelCustomZones, MirrorCustomZonesFlattensNestedSubzones)
{
// Cards deeper than one level under a custom zone still get a model row.
auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN);
auto *deckZone = new InnerDecklistNode("Removal", deckBoard);
auto *deckCard1 = new DecklistCardNode("Bolt", 1, deckZone);
auto *deeper = new InnerDecklistNode("Deeper", deckZone);
auto *deckCard2 = new DecklistCardNode("Swords", 1, deeper);
InnerDecklistNode shadowRoot;
auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot);
DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard);
ASSERT_EQ(shadowBoard->size(), 1);
auto *shadowZone = dynamic_cast<DecklistModelSubZoneNode *>(shadowBoard->at(0));
ASSERT_NE(shadowZone, nullptr);
EXPECT_EQ(shadowZone->getName(), QString("Removal"));
// Both cards are flattened into the mirrored zone, preserving order.
ASSERT_EQ(shadowZone->size(), 2);
auto *shadowCard1 = dynamic_cast<DecklistModelCardNode *>(shadowZone->at(0));
auto *shadowCard2 = dynamic_cast<DecklistModelCardNode *>(shadowZone->at(1));
ASSERT_NE(shadowCard1, nullptr);
ASSERT_NE(shadowCard2, nullptr);
EXPECT_EQ(shadowCard1->getDataNode(), deckCard1);
EXPECT_EQ(shadowCard2->getDataNode(), deckCard2);
}
// =====================================================================================================================
// findGroupChild
// =====================================================================================================================
TEST(DeckListModelCustomZones, FindGroupChildSkipsCustomZones)
{
InnerDecklistNode root;
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
auto *group = new InnerDecklistNode("Creature", board);
new DecklistModelSubZoneNode("Creature", board);
EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Creature"), group);
EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Missing"), nullptr);
EXPECT_EQ(DeckListModelCustomZones::findGroupChild(&root, DECK_ZONE_MAIN), board);
}
// =====================================================================================================================
// sortWithCustomZonesLast
// =====================================================================================================================
@ -223,3 +268,9 @@ TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones)
EXPECT_EQ(mapping[1].first, 0);
EXPECT_EQ(mapping[1].second, 1);
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -1,4 +1,8 @@
#include <gtest/gtest.h>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/game_specific_terms.h>
#include <libcockatrice/card/printing/exact_card.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/deck_list_node_tree.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
@ -25,6 +29,32 @@ int totalCustomZoneRows(const DeckListModel &model)
return count;
}
QModelIndex findBoardIndex(const DeckListModel &model, const QString &boardName)
{
for (int r = 0; r < model.rowCount(QModelIndex()); ++r) {
const QModelIndex idx = model.index(r, 0, QModelIndex());
if (idx.data(DeckRoles::IsCardRole).toBool()) {
continue;
}
const QString name = idx.sibling(idx.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
if (name == boardName) {
return idx;
}
}
return {};
}
QModelIndex findZoneRow(const DeckListModel &model, const QModelIndex &board)
{
for (int r = 0; r < model.rowCount(board); ++r) {
const QModelIndex child = model.index(r, 0, board);
if (child.data(DeckRoles::IsCustomZoneRole).toBool()) {
return child;
}
}
return {};
}
} // namespace
// The "Add to Zone" combobox/submenu lists getCustomZoneNames(), which reads the
@ -69,3 +99,148 @@ TEST(DeckListModelZoneIntegration, RebuildTreeMirrorsEachZoneOnce)
EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"}));
EXPECT_EQ(totalCustomZoneRows(model), 2);
}
// =====================================================================================================================
// Model behaviour: addCard routing, findCard lookup, removeRows guard, empty-zone survival.
// =====================================================================================================================
TEST(DeckListModelZoneIntegration, AddCardRoutesIntoMirroredCustomZone)
{
QSharedPointer<DeckList> deck(new DeckList());
DeckListModel model(nullptr, deck);
auto *tree = deck->getTree();
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
model.rebuildTree();
QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal");
ASSERT_TRUE(added.isValid());
// The card is a direct child of the mirrored custom zone, not a new top-level zone.
const QModelIndex zoneParent = added.parent();
ASSERT_TRUE(zoneParent.isValid());
EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool());
EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(),
QString("Removal"));
// No "Removal" top-level zone appeared in the deck tree.
auto *listRoot = tree->getRoot();
bool topLevelRemoval = false;
for (int i = 0; i < listRoot->size(); ++i) {
if (auto *zone = dynamic_cast<InnerDecklistNode *>(listRoot->at(i))) {
topLevelRemoval |= zone->getName() == "Removal";
}
}
EXPECT_FALSE(topLevelRemoval);
}
TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel)
{
QSharedPointer<DeckList> deck(new DeckList());
DeckListModel model(nullptr, deck);
auto *tree = deck->getTree();
// The zone exists on the deck tree but the shadow tree has never mirrored it.
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal");
ASSERT_TRUE(added.isValid());
const QModelIndex zoneParent = added.parent();
ASSERT_TRUE(zoneParent.isValid());
EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool());
EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(),
QString("Removal"));
}
TEST(DeckListModelZoneIntegration, AddCardCreatesGroupSeparatelyFromSameNamedZone)
{
QSharedPointer<DeckList> deck(new DeckList());
DeckListModel model(nullptr, deck);
auto *tree = deck->getTree();
// A custom zone named exactly like a grouping criterion.
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Creature"), nullptr);
model.rebuildTree();
CardInfoPtr bear = CardInfo::newInstance("Grizzly Bears");
bear->setProperty(Mtg::MainCardType, "Creature");
QModelIndex added = model.addCard(ExactCard(bear), DECK_ZONE_MAIN);
ASSERT_TRUE(added.isValid());
// The card lands in a *group* node called "Creature", not swallowed by the custom zone.
const QModelIndex groupParent = added.parent();
ASSERT_TRUE(groupParent.isValid());
EXPECT_FALSE(groupParent.data(DeckRoles::IsCustomZoneRole).toBool());
EXPECT_EQ(groupParent.sibling(groupParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(),
QString("Creature"));
// The board keeps both rows: the "Creature" group and the "Creature" custom zone.
const QModelIndex boardIndex = groupParent.parent();
ASSERT_TRUE(boardIndex.isValid());
EXPECT_EQ(model.rowCount(boardIndex), 2);
}
TEST(DeckListModelZoneIntegration, FindCardResolvesCardInsideCustomZone)
{
QSharedPointer<DeckList> deck(new DeckList());
DeckListModel model(nullptr, deck);
auto *tree = deck->getTree();
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
model.rebuildTree();
// findCard resolves through the card database; register the card we add.
const QString cardName = "Swords to Plowshares";
CardInfoPtr info = CardInfo::newInstance(cardName);
CardDatabaseManager::getInstance()->addCard(info);
QModelIndex added = model.addCard(ExactCard(info), "Removal");
ASSERT_TRUE(added.isValid());
QModelIndex found = model.findCard(cardName, "Removal");
EXPECT_TRUE(found.isValid());
EXPECT_EQ(found, added);
}
TEST(DeckListModelZoneIntegration, RemoveRowsRefusesCustomZoneRow)
{
QSharedPointer<DeckList> deck(new DeckList());
DeckListModel model(nullptr, deck);
auto *tree = deck->getTree();
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1);
model.rebuildTree();
const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN);
ASSERT_TRUE(mainIndex.isValid());
const QModelIndex zoneRow = findZoneRow(model, mainIndex);
ASSERT_TRUE(zoneRow.isValid());
EXPECT_FALSE(model.removeRow(zoneRow.row(), zoneRow.parent()));
EXPECT_EQ(model.rowCount(mainIndex), 2); // the zone survives, alongside the card group
}
TEST(DeckListModelZoneIntegration, EmptyCustomZoneSurvivesMirrorAndPruning)
{
QSharedPointer<DeckList> deck(new DeckList());
DeckListModel model(nullptr, deck);
auto *tree = deck->getTree();
// An empty custom zone must be mirrored (the stack deliberately keeps it alive).
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
model.rebuildTree();
const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN);
ASSERT_TRUE(mainIndex.isValid());
EXPECT_EQ(model.rowCount(mainIndex), 1);
EXPECT_TRUE(findZoneRow(model, mainIndex).isValid());
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}