diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index d4aee3686..a6ab2a204 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -7,7 +7,8 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( - libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp + libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp + deck_list_sort_filter_proxy_model.cpp ) target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 9b43281c1..76afca0c4 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -66,7 +66,8 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(currentZone->at(j)); - //! \todo Better sanity checking. + // Non-card children are custom zones; they are mirrored in a single + // pass below so each is mirrored exactly once. if (currentCard == nullptr) { continue; } @@ -82,8 +83,19 @@ void DeckListModel::rebuildTree() new DecklistModelCardNode(currentCard, groupNode); } + + // Custom zones nested under the board zone are mirrored as-is, with their + // cards as direct children (no further grouping). + DeckListModelCustomZones::mirrorCustomZones(currentZone, node); } + // The shadow tree was built in deck file order. Apply the active sort while + // the reset is still open so every consumer (tree view and visual editor) + // sees the canonical order from the start. sortShadowTree emits no signals, + // which is only valid before endResetModel closes the reset. + root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName); + sortShadowTree(root, lastKnownOrder); + endResetModel(); refreshCardFormatLegalities(); @@ -154,6 +166,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const case DeckRoles::IsLegalRole: return true; + case DeckRoles::IsCustomZoneRole: + return DeckListModelCustomZones::isCustomZone(group); + default: return {}; } @@ -190,6 +205,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } + case DeckRoles::IsCustomZoneRole: { + return false; + } + default: { return {}; } @@ -327,6 +346,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) return false; } + // Custom zone rows are managed through the deck tree, never removed as model rows. + for (int i = 0; i < count; i++) { + if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) { + return false; + } + } + beginRemoveRows(parent, row, row + count - 1); for (int i = 0; i < count; i++) { AbstractDecklistNode *toDelete = node->takeAt(row); @@ -337,7 +363,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) } endRemoveRows(); - if (node->empty() && (node != root)) { + // Empty criteria groups get pruned, but custom zones stay until explicitly deleted. + if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) { removeRows(parent.row(), 1, parent.parent()); } else { emitRecursiveUpdates(parent); @@ -351,7 +378,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) { - auto *newNode = dynamic_cast(parent->findChild(name)); + // Group lookups must not resolve a mirrored custom zone that shares the name. + auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name); if (!newNode) { beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); newNode = new InnerDecklistNode(name, parent); @@ -365,24 +393,44 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName, const QString &providerId, const QString &cardNumber) const { - InnerDecklistNode *zoneNode = dynamic_cast(root->findChild(zoneName)); - if (!zoneNode) { - return nullptr; - } - CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); if (!info) { return nullptr; } - QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); - InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria)); - if (!groupNode) { - return nullptr; + // 1. Board zone lookup: search the criteria groups, then the custom zones + // nested under the board. + if (auto *zoneNode = dynamic_cast(root->findChild(zoneName))) { + QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); + if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) { + if (auto *card = dynamic_cast( + groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } + + for (auto *child : *zoneNode) { + if (!DeckListModelCustomZones::isCustomZone(child)) { + continue; + } + auto *customZone = dynamic_cast(child); + if (!customZone) { + continue; + } + if (auto *card = dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } } - return dynamic_cast( - groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + // 2. Custom zone lookup by name (custom zone names are deck-unique). + if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) { + return dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + } + + return nullptr; } QModelIndex DeckListModel::findCard(const QString &cardName, @@ -423,29 +471,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam return {}; } - InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root); - CardInfoPtr cardInfo = card.getCardPtr(); PrintingInfo printingInfo = card.getPrinting(); - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode); + InnerDecklistNode *cardParent = nullptr; - const QModelIndex parentIndex = nodeToIndex(groupNode); - auto *cardNode = dynamic_cast(groupNode->findCardChildByNameProviderIdAndNumber( + auto *boardNode = dynamic_cast(root->findChild(zoneName)); + auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName); + + // Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row + // index is only usable as a deck-tree position while both sides have the same + // direct-children shape. When they diverge, the card is appended to the deck + // zone instead of being written out of range. + InnerDecklistNode *deckCardParent = nullptr; + bool customZoneNeedsAppend = false; + + if (boardNode) { + // Board zone: cards are grouped by the active criteria. + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + cardParent = createNodeIfNeeded(groupCriteria, boardNode); + } else if (customZoneNode) { + // Custom zone: cards live flat inside the zone. + cardParent = customZoneNode; + auto *listRoot = deckList->getTree()->getRoot(); + for (int i = 0; i < listRoot->size(); ++i) { + auto *boardZone = dynamic_cast(listRoot->at(i)); + if (!boardZone) { + continue; + } + deckCardParent = dynamic_cast(boardZone->findChild(zoneName)); + if (deckCardParent) { + break; + } + } + // A deck custom zone holding nested sub-zones mirrors with flattened rows, + // so a shadow row index does not map onto its direct children. + if (deckCardParent) { + for (int i = 0; i < deckCardParent->size(); ++i) { + if (dynamic_cast(deckCardParent->at(i))) { + customZoneNeedsAppend = true; + break; + } + } + } + } else { + // Not present in the shadow tree. The deck tree may still hold a custom + // zone that has not been mirrored (callers can add a zone and then a + // card without a rebuild). Check before falling back to creating a + // top-level zone the deck does not actually have. + auto *listRoot = deckList->getTree()->getRoot(); + bool hasDeckZone = false; + for (int i = 0; i < listRoot->size(); ++i) { + if (auto *boardZone = dynamic_cast(listRoot->at(i))) { + // Only real zones count: a card sitting directly under the board + // shares the name comparison but is not a zone, and treating it as + // one would recurse forever without mirroring anything. + if (dynamic_cast(boardZone->findChild(zoneName))) { + hasDeckZone = true; + break; + } + } + } + + if (hasDeckZone) { + rebuildTree(); + return addCard(card, zoneName); + } + + // Unknown zone: create a top-level zone (legacy behavior). + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + auto *newZone = createNodeIfNeeded(zoneName, root); + cardParent = createNodeIfNeeded(groupCriteria, newZone); + } + + const QModelIndex parentIndex = nodeToIndex(cardParent); + auto *cardNode = dynamic_cast(cardParent->findCardChildByNameProviderIdAndNumber( card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num"))); const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName(); bool cardNodeAdded = false; if (!cardNode) { // Determine the correct index - int insertRow = findSortedInsertRow(groupNode, cardInfo); + int insertRow = findSortedInsertRow(cardParent, cardInfo); + int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow; - auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName, + auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName, printingInfo.getProperty("num"), printingInfo.getProperty("uuid")); beginInsertRows(parentIndex, insertRow, insertRow); - cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); + cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow); endInsertRows(); cardNodeAdded = true; @@ -576,21 +690,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const return createIndex(node->getParent()->indexOf(node), 0, node); } +/** + * @brief Sorts a freshly built shadow subtree without emitting model signals. + * + * Used by rebuildTree while the model reset is still open (emitting layout + * changes during a reset is invalid). Reorders every node just like + * sortHelper does, but ignores the movement mapping because there are no + * persistent indices established yet. + */ +void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order) +{ + // The mapping is not needed: fresh shadow nodes have no persistent indices yet. + (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + + for (int i = node->size() - 1; i >= 0; --i) { + if (auto *subNode = dynamic_cast(node->at(i))) { + sortShadowTree(subNode, order); + } + } +} + void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order) { - // Sort children of node and save the information needed to - // update the list of persistent indexes. - QVector> sortResult = node->sort(order); + // Sort children (custom zones always sorted after groups within a board) and + // use the movement mapping to update the list of persistent indices. + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); QModelIndexList from, to; int columns = columnCount(); - for (int i = sortResult.size() - 1; i >= 0; --i) { - const int fromRow = sortResult[i].first; - const int toRow = sortResult[i].second; - AbstractDecklistNode *temp = node->at(toRow); + for (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + AbstractDecklistNode *temp = node->at(finalRow); for (int j = 0; j < columns; ++j) { - from << createIndex(fromRow, j, temp); - to << createIndex(toRow, j, temp); + from << createIndex(preSortRow, j, temp); + to << createIndex(finalRow, j, temp); } } changePersistentIndexList(from, to); @@ -704,6 +838,15 @@ QList DeckListModel::getZones() const return zones; } +QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const +{ + QStringList zoneNames; + for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) { + zoneNames.append(customZone->getName()); + } + return zoneNames; +} + static int maxAllowedForLegality(const FormatRules &format, const QString &legality) { for (const AllowedCount &c : format.allowedCounts) { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 209ec8c42..09600ca67 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -1,6 +1,8 @@ #ifndef DECKLISTMODEL_H #define DECKLISTMODEL_H +#include "deck_list_model_custom_zones.h" + #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h> #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> #include @@ -30,7 +32,8 @@ enum { IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */ DepthRole, /**< Depth level within the deck's grouping hierarchy. */ - IsLegalRole /**< Whether the card is legal in the current deck format. */ + IsLegalRole, /**< Whether the card is legal in the current deck format. */ + IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */ }; } // namespace DeckRoles @@ -391,6 +394,14 @@ public: */ [[nodiscard]] QList getZones() const; + /** + * @brief Gets the names of the custom zones nested under the given board zone. + * + * @param boardZoneName The board zone to query (main/side/maybeboard) + * @return The custom zone names, in deck order + */ + [[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const; + private: QSharedPointer deckList; /**< Pointer to the decklist providing the underlying data. */ InnerDecklistNode *root; /**< Root node of the model tree. */ @@ -427,6 +438,7 @@ private: void emitRecursiveUpdates(const QModelIndex &index); void sortHelper(InnerDecklistNode *node, Qt::SortOrder order); + void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order); template T getNode(const QModelIndex &index) const { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp new file mode 100644 index 000000000..1dc745e63 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -0,0 +1,152 @@ +#include "deck_list_model_custom_zones.h" + +#include "deck_list_model.h" + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> +#include +#include + +namespace DeckListModelCustomZones +{ + +bool isCustomZone(const AbstractDecklistNode *node) +{ + return dynamic_cast(node) != nullptr; +} + +namespace +{ + +/** + * @brief Flattens every card under @p zone into @p shadowZone, preserving order. + * + * Custom zones mirror as a single row level: cards nested in sub-zones of any + * depth are added as direct children of the mirrored zone so no card is left + * without a model row. + */ +void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone) +{ + for (int k = 0; k < zone->size(); k++) { + if (auto *zoneCard = dynamic_cast(zone->at(k))) { + new DecklistModelCardNode(zoneCard, shadowZone); + } else if (auto *subZone = dynamic_cast(zone->at(k))) { + flattenCards(subZone, shadowZone); + } + } +} + +} // namespace + +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone) +{ + for (int j = 0; j < deckBoardZone->size(); j++) { + auto *customZone = dynamic_cast(deckBoardZone->at(j)); + if (!customZone) { + continue; + } + + auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone); + flattenCards(customZone, shadowZone); + } +} + +InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name) +{ + for (int i = 0; i < parent->size(); i++) { + AbstractDecklistNode *child = parent->at(i); + if (isCustomZone(child)) { + continue; + } + auto *group = dynamic_cast(child); + if (group && group->getName() == name) { + return group; + } + } + return nullptr; +} + +DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName) +{ + for (int i = 0; i < root->size(); i++) { + auto *boardZone = dynamic_cast(root->at(i)); + if (!boardZone) { + continue; + } + + for (int j = 0; j < boardZone->size(); j++) { + auto *customZone = dynamic_cast(boardZone->at(j)); + if (customZone && customZone->getName() == zoneName) { + return customZone; + } + } + } + + return nullptr; +} + +namespace +{ + +/** + * @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping. + */ +QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QList> mapping; + mapping.reserve(node->size()); + for (int i = 0; i < node->size(); ++i) { + mapping.append({sortResult[i].first, i}); + } + return mapping; +} + +/** + * @brief Sorts a board zone's children, then stably moves custom zones to the end. + * + * @return The (preSortRow, finalRow) mapping covering both the sort and the shift. + */ +QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QVector groups; + QVector customZones; + QHash preSortRowOf; + + groups.reserve(node->size()); + customZones.reserve(node->size()); + + for (int i = 0; i < node->size(); ++i) { + AbstractDecklistNode *child = node->at(i); + preSortRowOf.insert(child, sortResult[i].first); + if (isCustomZone(child)) { + customZones.append(child); + } else { + groups.append(child); + } + } + + QVector ordered = groups + customZones; + for (int i = 0; i < ordered.size(); ++i) { + node->replace(i, ordered[i]); + } + + QList> mapping; + mapping.reserve(ordered.size()); + for (int i = 0; i < ordered.size(); ++i) { + mapping.append({preSortRowOf.value(ordered[i]), i}); + } + return mapping; +} + +} // namespace + +QList> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order) +{ + const bool isBoardZone = (node != root) && (node->getParent() == root); + return isBoardZone ? boardSort(node, order) : plainSort(node, order); +} + +} // namespace DeckListModelCustomZones diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h new file mode 100644 index 000000000..518a9e1d2 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -0,0 +1,98 @@ +#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H +#define DECK_LIST_MODEL_CUSTOM_ZONES_H + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h> +#include +#include +#include + +/** + * @class DecklistModelSubZoneNode + * @ingroup DeckModels + * @brief Model node representing a custom zone nested under a board zone. + * + * Custom zones group cards by user-defined names (e.g. "Removal", "Utility") + * inside a board zone. They are mirrored from the underlying deck tree so that + * they can be told apart from criteria group nodes by type. + */ +class DecklistModelSubZoneNode : public InnerDecklistNode +{ +public: + using InnerDecklistNode::InnerDecklistNode; +}; + +/** + * @namespace DeckListModelCustomZones + * @ingroup DeckModels + * @brief Tree-level helpers for the deck list model's custom-zone shadow nodes. + * + * The deck list model keeps a second "shadow" tree of InnerDecklistNode that + * mirrors the canonical deck tree for grouping and sorting. Custom zones add a + * layer of bookkeeping to that shadow tree: they must be mirrored alongside + * criteria groups, always sort after the groups within a board, and be + * resolvable by deck-unique name. + * + * This namespace centralizes every "what is / where is a custom zone" decision + * so the model itself only wires the results into Qt model signals. + */ +namespace DeckListModelCustomZones +{ + +/** + * @brief Whether the given node is a custom zone (as opposed to a criteria group). + */ +[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node); + +/** + * @brief Finds a criteria-group child of @p parent by name, skipping custom zones. + * + * The shadow tree keeps criteria groups and mirrored custom zones as siblings + * under a board zone, and `InnerDecklistNode::findChild` matches both by name. + * Group lookups must not resolve a custom zone that happens to share the group + * name (e.g. a zone called "Creature"), so this searches only non-custom + * children. + * + * @param parent The shadow node whose children are searched. + * @param name The group name to find. + * @return The matching group node, or nullptr if none exists. + */ +[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name); + +/** + * @brief Mirrors the custom zones of a deck board zone into its shadow board node. + * + * Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone + * with its cards as direct (un-grouped) children. + * + * @param deckBoardZone The board zone in the canonical deck tree. + * @param shadowBoardZone The matching board zone in the model's shadow tree. + */ +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone); + +/** + * @brief Finds a custom zone in the shadow tree by deck-unique name. + * @param root Root of the shadow tree. + * @param zoneName The custom zone name to find. + * @return The matching custom zone node, or nullptr if not found. + */ +[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName); + +/** + * @brief Sorts a shadow node's children, keeping a board's custom zones last. + * + * Sorting alone would interleave custom zones with criteria groups by name, but + * custom zones must always stay after the groups within a board, regardless of + * name. This applies the sort and, for board zones, stably moves the custom + * zones to the end. + * + * @param root Root of the shadow tree (used to classify board zones). + * @param node The shadow node whose children are reordered. + * @param order Sort order to apply. + * @return A list of (preSortRow, finalRow) pairs describing how each node moved. + */ +[[nodiscard]] QList> +sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); + +} // namespace DeckListModelCustomZones + +#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 34784538b..18ab60d06 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -114,6 +114,7 @@ target_link_libraries( add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) +add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt new file mode 100644 index 000000000..e3096c559 --- /dev/null +++ b/tests/deck_list_model/CMakeLists.txt @@ -0,0 +1,33 @@ +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) +endif() + +target_link_libraries( + deck_list_model_custom_zones_test + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test) + +add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_model_zone_integration_test gtest) +endif() + +target_link_libraries( + deck_list_model_zone_integration_test + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test) diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp new file mode 100644 index 000000000..7f4ab81e1 --- /dev/null +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -0,0 +1,276 @@ +/** + * @file deck_list_model_custom_zones_test.cpp + * @brief Tests for the deck list model's custom-zone shadow-tree helpers. + * + * DeckListModelCustomZones centralizes every "what is / where is a custom zone" + * decision for the model's shadow tree: type testing, mirroring from the deck + * tree, name lookup, and the sort-with-custom-zones-last ordering. These tests + * exercise that logic directly on hand-built shadow trees, independent of the + * full model and card database machinery. + */ + +#include +#include +#include +#include + +namespace +{ + +DecklistModelCardNode *cardNode(InnerDecklistNode *parent, const QString &name, int number) +{ + // The underlying data node is detached; only the model wrapper is attached to the shadow tree. + auto *data = new DecklistCardNode(name, number, nullptr); + return new DecklistModelCardNode(data, parent); +} + +QStringList childNames(const InnerDecklistNode *node) +{ + QStringList names; + for (int i = 0; i < node->size(); ++i) { + names.append(node->at(i)->getName()); + } + return names; +} + +} // namespace + +// ===================================================================================================================== +// isCustomZone +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, IsCustomZoneDistinguishesZoneFromGroup) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + auto *zone = new DecklistModelSubZoneNode("Removal", board); + + auto *card = cardNode(group, "A", 1); + + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(board)); + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(group)); + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(card)); + EXPECT_TRUE(DeckListModelCustomZones::isCustomZone(zone)); +} + +// ===================================================================================================================== +// findSubZoneByName +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, FindSubZoneByNameFindsAcrossBoards) +{ + InnerDecklistNode root; + auto *main = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *side = new InnerDecklistNode(DECK_ZONE_SIDE, &root); + new DecklistModelSubZoneNode("Removal", main); + new DecklistModelSubZoneNode("Utility", side); + new InnerDecklistNode("Plain", main); // not a custom zone + + auto *removal = DeckListModelCustomZones::findSubZoneByName(&root, "Removal"); + ASSERT_NE(removal, nullptr); + EXPECT_EQ(removal->getName(), QString("Removal")); + + auto *utility = DeckListModelCustomZones::findSubZoneByName(&root, "Utility"); + ASSERT_NE(utility, nullptr); + EXPECT_EQ(utility->getName(), QString("Utility")); + + // Names are deck-unique; a plain group or built-in board is not matched. + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Plain"), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, DECK_ZONE_MAIN), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Missing"), nullptr); +} + +// ===================================================================================================================== +// mirrorCustomZones +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, MirrorCustomZonesCopiesCardsFlat) +{ + // Deck-tree board zone: one direct card plus one nested custom zone. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + new DecklistCardNode("Direct", 2, deckBoard); + + auto *deckZone = new InnerDecklistNode("Removal", deckBoard); + auto *deckCard1 = new DecklistCardNode("Bolt", 3, deckZone); + auto *deckCard2 = new DecklistCardNode("Swords", 1, deckZone); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + + // Only the custom zone is mirrored as a sub-zone; the direct card is not. + ASSERT_EQ(shadowBoard->size(), 1); + auto *shadowZone = dynamic_cast(shadowBoard->at(0)); + ASSERT_NE(shadowZone, nullptr); + EXPECT_EQ(shadowZone->getName(), QString("Removal")); + + // Cards live flat (un-grouped) inside the mirrored zone, wrapping the same data nodes. + ASSERT_EQ(shadowZone->size(), 2); + auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); + ASSERT_NE(shadowCard1, nullptr); + ASSERT_NE(shadowCard2, nullptr); + EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); + EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); +} + +TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop) +{ + // A board zone with only direct cards has nothing to mirror. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + new DecklistCardNode("Direct", 2, deckBoard); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + 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(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(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(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 +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsAscending) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + new DecklistModelSubZoneNode("Zebra", board); + new InnerDecklistNode("Creature", board); + new InnerDecklistNode("Instant", board); + new DecklistModelSubZoneNode("Alpha", board); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + + // Groups sort first (by name), then custom zones (by name), always after groups. + EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); + + // Some non-identity movement occurred. + EXPECT_FALSE(mapping.isEmpty()); +} + +TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsDescending) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + new DecklistModelSubZoneNode("Zebra", board); + new InnerDecklistNode("Creature", board); + new InnerDecklistNode("Instant", board); + new DecklistModelSubZoneNode("Alpha", board); + + root.setSortMethod(DeckSortMethod::ByName); + + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder); + + // Groups still lead (descending), custom zones still last. + EXPECT_EQ(childNames(board), (QStringList{"Instant", "Creature", "Zebra", "Alpha"})); +} + +TEST(DeckListModelCustomZones, SortBoardMappingIsConsistent) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + + QList originalOrder; + auto *g0 = new InnerDecklistNode("Creature", board); + originalOrder.append(g0); + auto *z0 = new DecklistModelSubZoneNode("Zebra", board); + originalOrder.append(z0); + auto *g1 = new InnerDecklistNode("Instant", board); + originalOrder.append(g1); + auto *z1 = new DecklistModelSubZoneNode("Alpha", board); + originalOrder.append(z1); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + + // The mapping reports, for each final row, the original row of the node now sitting there. + ASSERT_EQ(mapping.size(), board->size()); + for (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + ASSERT_GE(preSortRow, 0); + ASSERT_LT(preSortRow, originalOrder.size()); + EXPECT_EQ(board->at(finalRow), originalOrder[preSortRow]) << "row " << finalRow; + } + + // Final order sanity: groups first in name order, then custom zones. + EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); +} + +TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) +{ + // A non-board node (e.g. a group whose children are cards) is sorted plainly; + // custom zones are not a special case there. Cards sort by name. + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + cardNode(group, "Swords", 1); + cardNode(group, "Bolt", 3); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, group, Qt::AscendingOrder); + EXPECT_EQ(childNames(group), (QStringList{"Bolt", "Swords"})); + ASSERT_EQ(mapping.size(), 2); + EXPECT_EQ(mapping[0].first, 1); // "Bolt" was originally at row 1 + EXPECT_EQ(mapping[0].second, 0); + 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(); +} diff --git a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp new file mode 100644 index 000000000..a4562c95d --- /dev/null +++ b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp @@ -0,0 +1,283 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +int totalCustomZoneRows(const DeckListModel &model) +{ + int count = 0; + const int rootRows = model.rowCount(QModelIndex()); + for (int r = 0; r < rootRows; ++r) { + const QModelIndex board = model.index(r, 0, QModelIndex()); + const int childRows = model.rowCount(board); + for (int c = 0; c < childRows; ++c) { + const QModelIndex child = model.index(c, 0, board); + if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { + ++count; + } + } + } + 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 +// deck tree. These verify the source data a freshly-created zone populates. + +TEST(DeckListModelZoneIntegration, CreateZoneThenReadCustomZoneNames) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal"})); +} + +TEST(DeckListModelZoneIntegration, CreateTwoZonesThenReadBoth) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); +} + +// Mirroring regression: rebuildTree must mirror each custom zone exactly once. +TEST(DeckListModelZoneIntegration, RebuildTreeMirrorsEachZoneOnce) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // One direct mainboard card plus two nested custom zones. + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Swords to Plowshares", 1, "Removal", -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); + + model.rebuildTree(); + + 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 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(listRoot->at(i))) { + topLevelRemoval |= zone->getName() == "Removal"; + } + } + EXPECT_FALSE(topLevelRemoval); +} + +TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel) +{ + QSharedPointer 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 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 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 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 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()); +} + +// Regression: a board card named like the requested zone must not be mistaken for +// a zone. Previously `findChild` matched any child by name, so a mainboard card +// called "Lightning Bolt" made addCard believe a "Lightning Bolt" zone existed and +// recurse through rebuildTree forever. +TEST(DeckListModelZoneIntegration, AddCardToCardNamedZoneDoesNotRecurse) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Lightning Bolt"); + ASSERT_TRUE(added.isValid()); +} + +// Regression: adding to a custom zone that holds a nested sub-zone mirrored the +// nested cards as flattened shadow rows, so the sorted shadow row index pointed +// past the deck zone's direct children. The card must be appended to the deck +// zone instead of being written out of range. +TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + auto *removal = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); + ASSERT_NE(removal, nullptr); + auto *deeper = new InnerDecklistNode("Deeper", removal); + new DecklistCardNode("Lightning Bolt", 2, deeper, -1); + model.rebuildTree(); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Removal"); + ASSERT_TRUE(added.isValid()); + ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}