Compare commits

...

5 commits

Author SHA1 Message Date
Lukas Brübach
2a6f5a6953
[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.
2026-08-30 13:26:08 +02:00
BruebachL
68e4fa054d
[UserList] Add invite button to hover popup (#7144)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
* [Client] Send game invites from the user context menu via a private message

The user context menu gains an "Invite to Game" submenu listing the
inviteable games in the room (the inviter's own games, honoring the
buddy-only setting). Picking one opens a private message to the target
user with a cockatrice://joingame link naming the game, so the target
gets a clickable invite instead of a raw URL. Multi-game rooms offer a
picker; a single inviteable game sends directly. Sending a message to
an offline user no longer swallows the draft — it reports that the
user is offline and keeps the typed text.

Took 30 seconds

Took 1 minute

* [Client] Open the invite dialog taller by default without enforcing a minimum size

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-29 21:35:28 +02:00
BruebachL
6f86c45ea8
[Refactor] Extract shared deck conversion prompt helper (#7107)
Move the deck-to-.cod conversion prompt logic (format check, saved
preference handling, overwrite confirmation, dialog) out of
DeckPreviewWidget into dlg_convert_deck_to_cod_format so the deck
editor can reuse it without duplicating it.

Took 4 minutes

Took 4 minutes

Took 1 minute

# Commit time for manual adjustment:
# Took 3 minutes

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-29 21:12:11 +02:00
dependabot[bot]
dade7ae78a
Bump actions/checkout from 6 to 7 (#7210)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 18:23:45 +02:00
dependabot[bot]
8f52223322
Bump actions/download-artifact from 7 to 8 (#7209)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 18:11:11 +02:00
19 changed files with 800 additions and 91 deletions

View file

@ -40,7 +40,7 @@ jobs:
steps:
- name: "Checkout repository"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Initialize CodeQL"
uses: github/codeql-action/init@v4

View file

@ -127,7 +127,7 @@ jobs:
steps:
- name: "Download digests"
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digest-*

View file

@ -1,9 +1,17 @@
#include "dlg_convert_deck_to_cod_format.h"
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLabel>
#include <QMessageBox>
#include <QVBoxLayout>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent)
{
@ -38,3 +46,71 @@ bool DialogConvertDeckToCodFormat::dontAskAgain() const
{
return dontAskAgainCheckbox->isChecked();
}
namespace
{
bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
{
QFileInfo fileInfo(filePath);
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
if (QFile::exists(newFileName)) {
QMessageBox::StandardButton reply =
QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"),
QObject::tr("A .cod version of this deck already exists. Overwrite it?"),
QMessageBox::Yes | QMessageBox::No);
return reply == QMessageBox::Yes;
}
return true; // Safe to proceed
}
} // namespace
bool DialogConvertDeckToCodFormat::promptIfRequired(QWidget *parent,
const QString &filePath,
const std::function<bool()> &convert)
{
if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) {
return true;
}
// Retrieve saved preference if the prompt is disabled
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) {
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) {
return false;
}
if (!confirmOverwriteIfExists(parent, filePath)) {
return false;
}
return convert();
}
// Show the dialog to the user
DialogConvertDeckToCodFormat conversionDialog(parent);
if (conversionDialog.exec() != QDialog::Accepted) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(
!conversionDialog.dontAskAgain());
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false);
return false;
}
// Try to convert file
if (!confirmOverwriteIfExists(parent, filePath)) {
return false;
}
if (!convert()) {
return false;
}
if (conversionDialog.dontAskAgain()) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true);
}
return true;
}

View file

@ -13,6 +13,9 @@
#include <QDialogButtonBox>
#include <QLabel>
#include <QVBoxLayout>
#include <functional>
class QWidget;
class DialogConvertDeckToCodFormat : public QDialog
{
@ -24,6 +27,21 @@ public:
[[nodiscard]] bool dontAskAgain() const;
/**
* @brief Checks whether the deck file at \a filePath can store tags.
*
* If the file is not a .cod deck, prompts the user for conversion to the
* Cockatrice format, honoring the saved "always convert / don't ask again"
* preference. On acceptance \a convert is called to perform the conversion.
*
* @param parent The widget to parent the prompt to.
* @param filePath The path of the deck file to check.
* @param convert Called to convert the deck once the user agrees.
* @return true if tags can be stored (no conversion needed, or the conversion
* was performed), false if the user declined to convert.
*/
static bool promptIfRequired(QWidget *parent, const QString &filePath, const std::function<bool()> &convert);
private:
QVBoxLayout *layout;
QLabel *label;

View file

@ -525,6 +525,13 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); });
add(games);
// ── Invite (only while the inviter has a joinable game for this user) ────
if (!isSelf && online && gameInviteAvailable && gameInviteAvailable(name)) {
auto *invite = makeBtn(tr("Invite"), tr("Invite to your game"), actionArea, theme);
connect(invite, &QPushButton::clicked, this, [this, name] { emit inviteRequested(name); });
add(invite);
}
// ── Buddy / ignore (registered users only) ────────────────────────────────
if (!isSelf && isReg) {
if (isBuddy) {

View file

@ -9,6 +9,7 @@
#include <QMap>
#include <QPixmap>
#include <QStandardItemModel>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
@ -149,6 +150,17 @@ public:
/** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */
void refreshHeader();
/**
* Sets a predicate evaluated on every action-button rebuild. It receives
* the name of the user the popup currently shows; when it returns true an
* "Invite" button is shown. The popup itself never resolves the invite
* link, it just forwards the request.
*/
void setGameInviteAvailable(std::function<bool(const QString &userName)> available)
{
gameInviteAvailable = std::move(available);
}
signals:
void mouseEnteredPopup();
void mouseLeftPopup();
@ -159,6 +171,7 @@ signals:
// ── Action signals — connect to UserContextMenu::exec*() ──────────────────
void chatRequested(const QString &userName);
void inviteRequested(const QString &userName);
void detailsRequested(const QString &userName);
void showGamesRequested(const QString &userName);
void addBuddyRequested(const QString &userName);
@ -200,6 +213,7 @@ private:
QString currentUser;
ServerInfo_User currentUserInfo;
bool currentOnline = false;
std::function<bool(const QString &userName)> gameInviteAvailable;
UserInfoHeaderWidget *header;
QWidget *actionArea; ///< rebuilt per user

View file

@ -345,6 +345,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
&cardArtProvider->cache(), &cardArtParamsMap,
window()); // parented to main window so it floats above siblings
// The invite availability is scoped to the room this list belongs to,
// and gated on the room's buddy-only setting for the hovered user.
userInfoPopup->setGameInviteAvailable(
[this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); });
userInfoPopup->hide();
userInfoPopup->setWindowOpacity(0.0);
userInfoPopup->installEventFilter(this);
@ -662,6 +667,8 @@ void UserListWidget::connectPopupSignals()
// Wire all action signals to UserContextMenu::exec*()
connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat);
connect(userInfoPopup, &UserInfoPopup::inviteRequested, this,
[this](const QString &userName) { userContextMenu->execInvite(userName); });
connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails);
connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames);
connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy);

View file

@ -22,6 +22,7 @@
#include <QTextEdit>
#include <QTreeWidgetItem>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
class QTreeWidget;

View file

@ -1091,7 +1091,8 @@ QList<GameInviteOption> TabSupervisor::getGameInviteLinksForRoom(int roomId) con
// The inviter may be in several games of the same room (hosting one and
// spectating another, for example). Return every game so the caller can
// let the user choose which one to invite to.
for (TabGame *tab : gameTabs) {
for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) {
TabGame *tab = it.value();
GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo();
if (metaInfo->proto().room_id() != roomId) {
continue;

View file

@ -10,8 +10,6 @@
#include "../visual_deck_storage_widget.h"
#include "deck_preview_deck_tags_display_widget.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QInputDialog>
#include <QLabel>
@ -499,21 +497,6 @@ void DeckPreviewWidget::actDeleteFile()
// The folder widget removes this preview once the row is gone.
}
static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
{
QFileInfo fileInfo(filePath);
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
if (QFile::exists(newFileName)) {
QMessageBox::StandardButton reply =
QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"),
QObject::tr("A .cod version of this deck already exists. Overwrite it?"),
QMessageBox::Yes | QMessageBox::No);
return reply == QMessageBox::Yes;
}
return true; // Safe to proceed
}
/**
* Checks if the deck's file format supports tags.
* If not, then prompt the user for file conversion.
@ -521,45 +504,8 @@ static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
*/
bool DeckPreviewWidget::promptFileConversionIfRequired()
{
if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) {
return true;
}
// Retrieve saved preference if the prompt is disabled
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) {
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) {
return false;
}
if (!confirmOverwriteIfExists(this, filePath)) {
return false;
}
return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] {
model->convertToCockatriceFormat(row());
return true;
}
// Show the dialog to the user
DialogConvertDeckToCodFormat conversionDialog(this);
if (conversionDialog.exec() != QDialog::Accepted) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(
!conversionDialog.dontAskAgain());
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false);
return false;
}
// Try to convert file
if (!confirmOverwriteIfExists(this, filePath)) {
return false;
}
model->convertToCockatriceFormat(row());
if (conversionDialog.dontAskAgain()) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true);
}
return true;
});
}

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);
@ -365,24 +392,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 = dynamic_cast<InnerDecklistNode *>(zoneNode->findChild(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 +470,43 @@ 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);
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;
} else {
// 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);
auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, 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 +637,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 +785,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,123 @@
#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;
}
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone)
{
for (int j = 0; j < deckBoardZone->size(); j++) {
auto *customCard = dynamic_cast<DecklistCardNode *>(deckBoardZone->at(j));
if (customCard) {
continue;
}
auto *customZone = dynamic_cast<const InnerDecklistNode *>(deckBoardZone->at(j));
if (!customZone) {
continue;
}
auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone);
for (int k = 0; k < customZone->size(); k++) {
if (auto *zoneCard = dynamic_cast<DecklistCardNode *>(customZone->at(k))) {
new DecklistModelCardNode(zoneCard, shadowZone);
}
}
}
}
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,83 @@
#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 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 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,225 @@
/**
* @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);
}
// =====================================================================================================================
// 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);
}

View file

@ -0,0 +1,71 @@
#include <gtest/gtest.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;
}
} // 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);
}