[Models] Mirror custom deck zones in the deck list model (#7203)

* [Models] Mirror custom deck zones in the deck list model

DeckListModel now surfaces the custom zones from the deck tree so
views can render and edit them alongside criteria groups.

The custom-zone bookkeeping that made the model unwieldy is extracted
into DeckListModelCustomZones (deck_list_model_custom_zones.h/.cpp), a
single self-contained unit owning every "what is / where is a custom
zone" decision for the model's shadow tree:

- rebuildTree mirrors each custom zone as a DecklistModelSubZoneNode
  under its board zone, cards flat inside (no further grouping).
- The freshly built shadow tree is sorted while the model reset is
  still open, so views never observe unsorted intermediate order and
  proxies cannot desync.
- Custom zones always sort after criteria groups within a board,
  regardless of their names. One shared sortWithCustomZonesLast backs
  both the live sortHelper (which remaps persistent indexes from the
  movement mapping) and the silent reset-time sortShadowTree.
- addCard inserts flat into a custom zone by name and keeps grouping
  by active criteria for board zones. findCardNode resolves cards in
  both layouts, legacy top-level zones unchanged.
- New IsCustomZoneRole lets views tell zones apart from groups.
- Empty custom zones survive row removal. Zone rows themselves are
  only mutable through the deck tree API.

A new deck_list_model_custom_zones_test suite locks the extracted
shadow-tree logic (type testing, mirroring, name lookup, and the
sort-with-custom-zones-last mapping).

No behavior change.

* [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.

* [Models] Fix addCard routing for card-named zones and nested custom zones

- hasDeckZone no longer matches board cards that merely share the zone
  name, which previously caused infinite addCard/rebuildTree recursion
- Adding to a custom zone whose deck side holds nested sub-zones appends
  to the deck tree instead of writing past its direct children

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL
2026-09-05 22:00:20 +02:00
committed by GitHub
parent 0c725f9a03
commit e8ec28572f
9 changed files with 1033 additions and 34 deletions

View File

@@ -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})

View File

@@ -66,7 +66,8 @@ void DeckListModel::rebuildTree()
for (int j = 0; j < currentZone->size(); j++) {
auto *currentCard = dynamic_cast<DecklistCardNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(root->findChild(zoneName))) {
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) {
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
return card;
}
}
for (auto *child : *zoneNode) {
if (!DeckListModelCustomZones::isCustomZone(child)) {
continue;
}
auto *customZone = dynamic_cast<InnerDecklistNode *>(child);
if (!customZone) {
continue;
}
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
return card;
}
}
}
return dynamic_cast<DecklistModelCardNode *>(
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<DecklistModelCardNode *>(
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<DecklistModelCardNode *>(groupNode->findCardChildByNameProviderIdAndNumber(
auto *boardNode = dynamic_cast<InnerDecklistNode *>(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<InnerDecklistNode *>(listRoot->at(i));
if (!boardZone) {
continue;
}
deckCardParent = dynamic_cast<InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(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<DecklistModelCardNode *>(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<InnerDecklistNode *>(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<QPair<int, int>> 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<QString> 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) {

View File

@@ -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 <QAbstractItemModel>
@@ -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<QString> 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> 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 <typename T> T getNode(const QModelIndex &index) const
{

View File

@@ -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 <QHash>
#include <QVector>
namespace DeckListModelCustomZones
{
bool isCustomZone(const AbstractDecklistNode *node)
{
return dynamic_cast<const DecklistModelSubZoneNode *>(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<DecklistCardNode *>(zone->at(k))) {
new DecklistModelCardNode(zoneCard, shadowZone);
} else if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(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<const InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(root->at(i));
if (!boardZone) {
continue;
}
for (int j = 0; j < boardZone->size(); j++) {
auto *customZone = dynamic_cast<DecklistModelSubZoneNode *>(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<QPair<int, int>> plainSort(InnerDecklistNode *node, Qt::SortOrder order)
{
const QVector<QPair<int, int>> sortResult = node->sort(order);
QList<QPair<int, int>> 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<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
{
const QVector<QPair<int, int>> sortResult = node->sort(order);
QVector<AbstractDecklistNode *> groups;
QVector<AbstractDecklistNode *> customZones;
QHash<AbstractDecklistNode *, int> 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<AbstractDecklistNode *> ordered = groups + customZones;
for (int i = 0; i < ordered.size(); ++i) {
node->replace(i, ordered[i]);
}
QList<QPair<int, int>> mapping;
mapping.reserve(ordered.size());
for (int i = 0; i < ordered.size(); ++i) {
mapping.append({preSortRowOf.value(ordered[i]), i});
}
return mapping;
}
} // namespace
QList<QPair<int, int>> 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

View File

@@ -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 <QList>
#include <QPair>
#include <QtGlobal>
/**
* @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<QPair<int, int>>
sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order);
} // namespace DeckListModelCustomZones
#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 <gtest/gtest.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/models/deck_list/deck_list_model.h>
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<DecklistModelSubZoneNode *>(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<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);
}
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<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
// =====================================================================================================================
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<AbstractDecklistNode *> 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();
}

View File

@@ -0,0 +1,283 @@
#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>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/models/deck_list/deck_list_model.h>
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<DeckList> 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<DeckList> 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<DeckList> 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<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());
}
// 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<DeckList> 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<DeckList> 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();
}