Compare commits

..

3 commits

Author SHA1 Message Date
Lukas Brübach
fc6e1af1df
[Client] Expose custom zone management in the deck editor
Wires the state layer into every editor surface that shows deck zones.

- Deck dock: context menu on zones gains New/Rename/Delete/Change
  board actions, with per-zone submenus for adding cards.
- Card database dock and visual database display gain an add-to-zone
  submenu listing custom zones per board plus a create-zone entry.
- All prompt call sites pass validateNewZoneName so duplicates and
  reserved names are rejected inline before Ok unlocks.
- Rename reuses the same dialog in name-only mode, keeping one
  validation contract for every zone-name entry point.
- Change board marks the current board instead of offering a no-op,
  and the state layer refuses moves onto boards holding a same-named
  zone from imported decks.
2026-08-29 15:59:20 +02:00
Lukas Brübach
5a18de28be
[Client] Add zone management to the deck state manager
State-layer operations for custom deck zones, plus the shared prompt
dialog that later editor menus will call into.

- moveCardToZone relocates every copy of a card row into any zone,
  refusing non-card rows and tokens so miswired selections can never
  shred a group or turn tokens into deck cards. The current zone is
  found by walking ancestors, which also handles legacy top-level
  zones.
- createCustomZone, renameCustomZone, moveCustomZone and
  removeCustomZone wrap the tree API with memento history, model
  rebuilds and deck hash refreshes via modifyTree.
- Same-board zone moves return success without minting a history
  entry, keeping the undo log honest.
- promptForNewZone asks for a name and the parent zone, keeps Ok
  disabled until the trimmed name passes a caller-supplied validator
  (shown inline as an error), and reports its own translation context.

Took 14 minutes

# Commit time for manual adjustment:
# Took 6 minutes

# Commit time for manual adjustment:
# Took 33 seconds
2026-08-29 15:59:20 +02:00
Lukas Brübach
dc84ba3ca3
[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.

- 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. sortHelper remaps persistent indexes
  through the pre-shift rows to keep selections valid.
- 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.

Took 5 minutes


Took 20 minutes
2026-08-29 15:59:19 +02:00
29 changed files with 233 additions and 780 deletions

View file

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

View file

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

View file

@ -151,7 +151,8 @@ void CardDatabaseView::openCustomMenu(QPoint point)
if (zoneMenuProvider) {
QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone"));
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
for (const QString &boardName :
{QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
connect(action, &QAction::triggered, this,
[this, card, boardName] { emit cardAdded(card->getName(), boardName); });

View file

@ -26,17 +26,17 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck
[deckEditor]() -> QList<QPair<QString, QStringList>> {
QList<QPair<QString, QStringList>> result;
auto *deckListModel = deckEditor->deckStateManager->getModel();
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
for (const QString &boardName :
{QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
}
return result;
},
[this, deckEditor] {
QString boardName;
const QString zoneName =
DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) {
return deckEditor->deckStateManager->validateNewZoneName(candidate);
});
const QString zoneName = promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) {
return deckEditor->deckStateManager->validateNewZoneName(candidate);
});
if (!zoneName.isEmpty()) {
deckEditor->deckStateManager->createCustomZone(boardName, zoneName);
}

View file

@ -793,7 +793,7 @@ void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point)
QAction *renameAction = menu.addAction(tr("&Rename zone..."));
connect(renameAction, &QAction::triggered, this, [this, zoneName] {
const QString newName = DeckZoneDialog::promptForRename(this, zoneName, [this](const QString &candidate) {
const QString newName = promptForRename(this, zoneName, [this](const QString &candidate) {
return deckStateManager->validateNewZoneName(candidate);
});
if (!newName.isEmpty() && newName != zoneName) {
@ -843,14 +843,14 @@ void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu, const QModelIndex
deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName);
};
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
for (const QString &boardName : {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
connect(action, &QAction::triggered, this, [moveToZone, boardName] { moveToZone(boardName); });
}
const auto tree = deckStateManager->getDeckListShared()->getTree();
bool anyCustomZone = false;
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
for (const QString &boardName : {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
QList<const InnerDecklistNode *> customZones = tree->getCustomZones(boardName);
if (customZones.isEmpty()) {
continue;
@ -873,7 +873,7 @@ void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu, const QModelIndex
void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName)
{
const auto tree = deckStateManager->getDeckListShared()->getTree();
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
for (const QString &boardName : {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
// The board currently holding the zone is marked instead of offered.
@ -902,10 +902,9 @@ void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &init
QAction *newZoneAction = menu->addAction(tr("Create &new zone..."));
connect(newZoneAction, &QAction::triggered, this, [this, initialBoardName] {
QString boardName;
const QString zoneName =
DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) {
return deckStateManager->validateNewZoneName(candidate);
});
const QString zoneName = promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) {
return deckStateManager->validateNewZoneName(candidate);
});
if (!zoneName.isEmpty()) {
deckStateManager->createCustomZone(boardName, zoneName);
}

View file

@ -409,7 +409,7 @@ bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &ne
// Locate the board currently holding the zone.
QString currentBoardName;
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
for (const QString &boardName : {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
for (const auto *zone : tree->getCustomZones(boardName)) {
if (zone->getName() == zoneName) {
currentBoardName = boardName;
@ -478,7 +478,7 @@ QString DeckStateManager::validateNewZoneName(const QString &zoneName) const
}
// Custom zone names are unique across the whole deck.
for (const QString &board : InnerDecklistNode::boardZoneNames()) {
for (const QString &board : {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
for (const auto *customZone : tree->getCustomZones(board)) {
if (customZone->getName() == trimmedZoneName) {
return tr("A zone with this name already exists.");

View file

@ -20,16 +20,13 @@ DeckZoneDialog::DeckZoneDialog(QWidget *parent,
nameEdit->setMaxLength(MAX_NAME_LENGTH);
errorLabel = new QLabel(this);
errorLabel->setStyleSheet("color: palette(bright-text);");
errorLabel->hide();
boardLabel = new QLabel(this);
boardCombo = new QComboBox(this);
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
// Use the icon overload explicitly so `boardName` lands in the user data role
// (visible text is applied below in retranslateUi). The two-argument form
// addItem({}, boardName) would be ambiguous and resolve to the icon overload
// with empty user data, yielding empty entries and an empty getBoardName().
boardCombo->addItem({}, {}, boardName);
for (const QString &boardName : {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
boardCombo->addItem({}, boardName);
}
if (!initialBoardName.isEmpty()) {
int idx = boardCombo->findData(initialBoardName);
@ -116,10 +113,10 @@ void DeckZoneDialog::validateName()
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty());
}
QString DeckZoneDialog::promptForNewZone(QWidget *parent,
const QString &initialBoardName,
QString *chosenBoardName,
const std::function<QString(const QString &)> &nameValidator)
QString promptForNewZone(QWidget *parent,
const QString &initialBoardName,
QString *chosenBoardName,
const std::function<QString(const QString &)> &nameValidator)
{
DeckZoneDialog dialog(parent, initialBoardName, nameValidator);
if (dialog.exec() != QDialog::Accepted) {
@ -132,9 +129,9 @@ QString DeckZoneDialog::promptForNewZone(QWidget *parent,
return dialog.getZoneName();
}
QString DeckZoneDialog::promptForRename(QWidget *parent,
const QString &currentZoneName,
const std::function<QString(const QString &)> &nameValidator)
QString promptForRename(QWidget *parent,
const QString &currentZoneName,
const std::function<QString(const QString &)> &nameValidator)
{
DeckZoneDialog dialog(parent, {}, nameValidator, false);
dialog.setZoneName(currentZoneName);

View file

@ -62,39 +62,6 @@ public:
*/
void setZoneName(const QString &zoneName);
/**
* @brief Prompts the user for a new custom zone name and the board zone to nest it under.
*
* Convenience wrapper that runs DeckZoneDialog modally.
*
* @param parent The parent widget for the dialog
* @param initialBoardName The board zone to preselect in the dialog. Unknown names fall
* back to main.
* @param chosenBoardName (out) The internal name of the board zone the user chose
* @param nameValidator Optional validator forwarded to the dialog
* @return The trimmed zone name, or an empty string if the user cancelled
*/
static QString promptForNewZone(QWidget *parent,
const QString &initialBoardName,
QString *chosenBoardName,
const std::function<QString(const QString &)> &nameValidator = {});
/**
* @brief Prompts the user for a new name for an existing custom zone.
*
* Same inline validation as promptForNewZone, but without a parent-zone picker.
*
* @param parent The parent widget for the dialog
* @param currentZoneName The current name, prefilled for editing
* @param nameValidator Validator deciding whether a candidate name is usable. It sees
* the current name too, so callers wanting to allow unchanged names must
* special-case that themselves.
* @return The trimmed new name, or an empty string if the user cancelled
*/
static QString promptForRename(QWidget *parent,
const QString &currentZoneName,
const std::function<QString(const QString &)> &nameValidator = {});
protected:
void changeEvent(QEvent *event) override;
@ -120,4 +87,37 @@ private:
bool allowBoardSelection;
};
/**
* @brief Prompts the user for a new custom zone name and the board zone to nest it under.
*
* Convenience wrapper that runs DeckZoneDialog modally.
*
* @param parent The parent widget for the dialog
* @param initialBoardName The board zone to preselect in the dialog. Unknown names fall
* back to main.
* @param chosenBoardName (out) The internal name of the board zone the user chose
* @param nameValidator Optional validator forwarded to the dialog
* @return The trimmed zone name, or an empty string if the user cancelled
*/
QString promptForNewZone(QWidget *parent,
const QString &initialBoardName,
QString *chosenBoardName,
const std::function<QString(const QString &)> &nameValidator = {});
/**
* @brief Prompts the user for a new name for an existing custom zone.
*
* Same inline validation as promptForNewZone, but without a parent-zone picker.
*
* @param parent The parent widget for the dialog
* @param currentZoneName The current name, prefilled for editing
* @param nameValidator Validator deciding whether a candidate name is usable. It sees
* the current name too, so callers wanting to allow unchanged names must
* special-case that themselves.
* @return The trimmed new name, or an empty string if the user cancelled
*/
QString promptForRename(QWidget *parent,
const QString &currentZoneName,
const std::function<QString(const QString &)> &nameValidator = {});
#endif // DECK_ZONE_DIALOG_H

View file

@ -1,17 +1,9 @@
#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)
{
@ -46,71 +38,3 @@ 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,9 +13,6 @@
#include <QDialogButtonBox>
#include <QLabel>
#include <QVBoxLayout>
#include <functional>
class QWidget;
class DialogConvertDeckToCodFormat : public QDialog
{
@ -27,21 +24,6 @@ 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,13 +525,6 @@ 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,7 +9,6 @@
#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>
@ -150,17 +149,6 @@ 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();
@ -171,7 +159,6 @@ 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);
@ -213,7 +200,6 @@ 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,11 +345,6 @@ 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);
@ -667,8 +662,6 @@ 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,7 +22,6 @@
#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,8 +1091,7 @@ 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 (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) {
TabGame *tab = it.value();
for (TabGame *tab : gameTabs) {
GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo();
if (metaInfo->proto().room_id() != roomId) {
continue;

View file

@ -275,7 +275,7 @@ bool TabDeckEditorVisual::actSaveDeckAs()
void TabDeckEditorVisual::createNewZone()
{
QString boardName;
const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) {
const QString zoneName = promptForNewZone(this, {}, &boardName, [this](const QString &candidate) {
return deckStateManager->validateNewZoneName(candidate);
});
if (!zoneName.isEmpty()) {

View file

@ -21,7 +21,6 @@
#include <libcockatrice/card/card_info_comparator.h>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <utility>
@ -93,9 +92,10 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
// Without a deck model there is nothing to add cards to, so the zone menu stays hidden.
if (deckListModel) {
databaseView->setZoneMenuProvider(
[deckListModel]() -> QList<QPair<QString, QStringList>> {
[this, deckListModel]() -> QList<QPair<QString, QStringList>> {
QList<QPair<QString, QStringList>> result;
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
for (const QString &boardName :
{QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), QString(DECK_ZONE_MAYBEBOARD)}) {
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
}
return result;

View file

@ -10,6 +10,8 @@
#include "../visual_deck_storage_widget.h"
#include "deck_preview_deck_tags_display_widget.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QInputDialog>
#include <QLabel>
@ -497,6 +499,21 @@ 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.
@ -504,8 +521,45 @@ void DeckPreviewWidget::actDeleteFile()
*/
bool DeckPreviewWidget::promptFileConversionIfRequired()
{
return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] {
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;
}
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

@ -43,13 +43,6 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method)
}
}
const QList<QString> &InnerDecklistNode::boardZoneNames()
{
static const QList<QString> names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE),
QString(DECK_ZONE_MAYBEBOARD)};
return names;
}
QString InnerDecklistNode::getVisibleName() const
{
return visibleNameFromName(name);

View file

@ -18,9 +18,6 @@
#include "abstract_deck_list_node.h"
#include <QList>
#include <QString>
/** @brief Constant for the "main" deck zone name. */
#define DECK_ZONE_MAIN "main"
/** @brief Constant for the "sideboard" zone name. */
@ -121,13 +118,6 @@ public:
*/
static QString visibleNameFromName(const QString &_name);
/**
* @brief The standard board zone names, in display order.
*
* @return main, side and maybeboard.
*/
static const QList<QString> &boardZoneNames();
/**
* @brief Get this nodes display-friendly name.
* @return Human-readable name (zone/group name).

View file

@ -7,8 +7,7 @@ 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_model_custom_zones.cpp
deck_list_sort_filter_proxy_model.cpp
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp
)
target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View file

@ -66,9 +66,18 @@ void DeckListModel::rebuildTree()
for (int j = 0; j < currentZone->size(); j++) {
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
// Non-card children are custom zones; they are mirrored in a single
// pass below so each is mirrored exactly once.
//! \todo Better sanity checking.
if (currentCard == nullptr) {
// Custom zones nested under the board zone are mirrored as-is,
// with their cards as direct children (no further grouping).
if (auto *customZone = dynamic_cast<InnerDecklistNode *>(currentZone->at(j))) {
auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), node);
for (int k = 0; k < customZone->size(); k++) {
if (auto *customCard = dynamic_cast<DecklistCardNode *>(customZone->at(k))) {
new DecklistModelCardNode(customCard, shadowZone);
}
}
}
continue;
}
@ -83,10 +92,6 @@ 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
@ -167,7 +172,7 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
return true;
case DeckRoles::IsCustomZoneRole:
return DeckListModelCustomZones::isCustomZone(group);
return dynamic_cast<const DecklistModelSubZoneNode *>(group) != nullptr;
default:
return {};
@ -348,7 +353,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
// 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))) {
if (dynamic_cast<DecklistModelSubZoneNode *>(node->at(row + i))) {
return false;
}
}
@ -364,7 +369,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
endRemoveRows();
// Empty criteria groups get pruned, but custom zones stay until explicitly deleted.
if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) {
if (node->empty() && (node != root) && !dynamic_cast<DecklistModelSubZoneNode *>(node)) {
removeRows(parent.row(), 1, parent.parent());
} else {
emitRecursiveUpdates(parent);
@ -409,10 +414,7 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
}
for (auto *child : *zoneNode) {
if (!DeckListModelCustomZones::isCustomZone(child)) {
continue;
}
auto *customZone = dynamic_cast<InnerDecklistNode *>(child);
auto *customZone = dynamic_cast<DecklistModelSubZoneNode *>(child);
if (!customZone) {
continue;
}
@ -424,7 +426,7 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
}
// 2. Custom zone lookup by name (custom zone names are deck-unique).
if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) {
if (auto *customZone = findSubZoneNodeByName(zoneName)) {
return dynamic_cast<DecklistModelCardNode *>(
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
}
@ -445,6 +447,25 @@ QModelIndex DeckListModel::findCard(const QString &cardName,
return nodeToIndex(cardNode);
}
DecklistModelSubZoneNode *DeckListModel::findSubZoneNodeByName(const QString &zoneName) const
{
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;
}
QModelIndex DeckListModel::addPreferredPrintingCard(const QString &cardName, const QString &zoneName, bool abAddAnyway)
{
ExactCard card = CardDatabaseManager::query()->getCard({cardName});
@ -476,7 +497,7 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam
InnerDecklistNode *cardParent = nullptr;
auto *boardNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName);
auto *customZoneNode = boardNode ? nullptr : findSubZoneNodeByName(zoneName);
if (boardNode) {
// Board zone: cards are grouped by the active criteria.
@ -637,18 +658,39 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
return createIndex(node->getParent()->indexOf(node), 0, node);
}
/**
* @brief Moves custom zones of a board zone after the criteria groups, stably.
*
* Sorting would interleave custom zones with criteria groups by name; custom
* zones must always stay after the groups, regardless of their names.
*/
void DeckListModel::shiftCustomZonesToEnd(InnerDecklistNode *boardZone)
{
QList<AbstractDecklistNode *> customZones;
for (int i = boardZone->size() - 1; i >= 0; --i) {
if (dynamic_cast<DecklistModelSubZoneNode *>(boardZone->at(i))) {
customZones.prepend(boardZone->takeAt(i));
}
}
for (auto *customZone : customZones) {
boardZone->append(customZone);
}
}
/**
* @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.
* changes during a reset is invalid). Mirrors the reordering part of
* sortHelper: sort every node, then move board-level custom zones after groups.
*/
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);
node->sort(order);
if (node != root && node->getParent() == root) {
shiftCustomZonesToEnd(node);
}
for (int i = node->size() - 1; i >= 0; --i) {
if (auto *subNode = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
@ -659,19 +701,33 @@ void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order)
void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder 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);
// Sort children of node and save the information needed to
// update the list of persistent indexes.
QVector<QPair<int, int>> sortResult = node->sort(order);
// For board zones, custom zones must stay after the criteria groups,
// regardless of their names. Shift them to the end (stable).
QHash<AbstractDecklistNode *, int> rowAfterSort;
bool isBoardZone = (node != root) && (node->getParent() == root);
if (isBoardZone) {
for (int i = 0; i < node->size(); ++i) {
rowAfterSort.insert(node->at(i), i);
}
shiftCustomZonesToEnd(node);
}
QModelIndexList from, to;
int columns = columnCount();
for (const auto &move : mapping) {
const int preSortRow = move.first;
const int finalRow = move.second;
AbstractDecklistNode *temp = node->at(finalRow);
for (int newRow = 0; newRow < node->size(); ++newRow) {
AbstractDecklistNode *temp = node->at(newRow);
int preSortRow = sortResult.value(newRow).first;
if (isBoardZone) {
const int rowAfterSortOfNode = rowAfterSort.value(temp, newRow);
preSortRow = sortResult.value(rowAfterSortOfNode).first;
}
for (int j = 0; j < columns; ++j) {
from << createIndex(preSortRow, j, temp);
to << createIndex(finalRow, j, temp);
to << createIndex(newRow, j, temp);
}
}
changePersistentIndexList(from, to);

View file

@ -1,8 +1,6 @@
#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>
@ -193,6 +191,21 @@ public:
}
};
/**
* @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.
*/
class DecklistModelSubZoneNode : public InnerDecklistNode
{
public:
using InnerDecklistNode::InnerDecklistNode;
};
/**
* @class DeckListModel
* @ingroup DeckModels
@ -416,6 +429,16 @@ private:
const QString &providerId = "",
const QString &cardNumber = "") const;
/**
* @brief Finds a custom zone node in the model tree by name.
*
* Custom zone names are unique across the whole deck.
*
* @param zoneName Name of the custom zone.
* @return The custom zone node, or nullptr if not found.
*/
[[nodiscard]] DecklistModelSubZoneNode *findSubZoneNodeByName(const QString &zoneName) const;
/**
* @brief Determines the sorted insertion row for a card.
* @param parent The parent node where the card will be inserted.
@ -439,6 +462,7 @@ private:
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order);
void shiftCustomZonesToEnd(InnerDecklistNode *boardZone);
template <typename T> T getNode(const QModelIndex &index) const
{

View file

@ -1,123 +0,0 @@
#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

@ -1,83 +0,0 @@
#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,7 +114,6 @@ 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

@ -1,33 +0,0 @@
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

@ -1,225 +0,0 @@
/**
* @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

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