Compare commits

...

3 commits

Author SHA1 Message Date
Lukas Brübach
17ed97be13 [DeckEditor] Address zone-management review feedback
- Expose DecklistNodeTree::hasZoneName and use it in validateNewZoneName
  so the uniqueness scan covers custom zones on every board, not just the
  standard ones.
- Hide the board selector in the rename dialog path where it is not used.
- Emit deckHashChanged after refreshDeckHash so the deck hash label stays
  current after zone create/rename/move/remove.
2026-08-30 22:26:41 +02:00
Lukas Brübach
f1923e7c13 [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-30 22:20:50 +02:00
Lukas Brübach
257fe7be82 [Models] Route group lookups around mirrored custom zones
Group lookups (createNodeIfNeeded, findCardNode) must not resolve a
mirrored custom zone that shares the group name. Introduce
findGroupChild to search only non-custom children, and make addCard
consult the deck tree before falling back to creating a top-level zone
so cards added to an un-mirrored custom zone land inside it.

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

Add model behaviour tests (addCard routing, same-name group/zone
collision, removeRows guard, empty-zone survival, findCard inside a
custom zone) and fix the missing main() in the unit test binaries.
2026-08-30 22:20:04 +02:00
14 changed files with 853 additions and 13 deletions

View file

@ -214,6 +214,7 @@ set(cockatrice_SOURCES
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp
src/interface/widgets/deck_editor/deck_list_style_proxy.cpp
src/interface/widgets/deck_editor/deck_state_manager.cpp
src/interface/widgets/deck_editor/deck_zone_dialog.cpp
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
src/interface/widgets/general/background_sources.cpp
src/interface/widgets/general/display/background_plate_widget.cpp

View file

@ -2,6 +2,7 @@
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list_history_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
DeckStateManager::DeckStateManager(QObject *parent)
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
@ -307,6 +308,180 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx)
return offsetCountAtIndex(idx, -1);
}
bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName)
{
if (!idx.isValid()) {
return false;
}
// Only actual card rows can be moved. Group or zone rows report an
// aggregate amount and must never be deleted by this operation.
if (!idx.data(DeckRoles::IsCardRole).toBool()) {
return false;
}
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString();
int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
if (copies <= 0) {
return false;
}
// Tokens only live in the tokens zone and cannot be moved into decks.
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
if (info && info->getIsToken()) {
return false;
}
// Determine the zone the card currently lives in: the enclosing custom
// zone, or the nearest top-level zone (board zone or legacy zone).
QString currentZoneName;
for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) {
bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool();
if (isCustomZone || !ancestor.parent().isValid()) {
currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
break;
}
}
if (currentZoneName == targetZoneName) {
return false;
}
QString reason = tr("Moved %1 × \"%2\" (%3) to %4")
.arg(copies)
.arg(cardName)
.arg(providerId)
.arg(InnerDecklistNode::visibleNameFromName(targetZoneName));
return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) {
if (!model->removeRow(idx.row(), idx.parent())) {
return false;
}
if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) {
for (int i = 0; i < copies; ++i) {
model->addCard(card, targetZoneName);
}
} else {
for (int i = 0; i < copies; ++i) {
model->addPreferredPrintingCard(cardName, targetZoneName, true);
}
}
return true;
});
}
bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName)
{
const QString trimmedZoneName = zoneName.trimmed();
if (trimmedZoneName.isEmpty()) {
return false;
}
QString reason =
tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName));
return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) {
return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr;
});
}
bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName)
{
const QString trimmedNewZoneName = newZoneName.trimmed();
if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) {
return false;
}
QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName);
return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) {
return tree->renameCustomZone(oldZoneName, trimmedNewZoneName);
});
}
bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName)
{
const auto *tree = deckList->getTree();
// Locate the board currently holding the zone.
QString currentBoardName;
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
for (const auto *zone : tree->getCustomZones(boardName)) {
if (zone->getName() == zoneName) {
currentBoardName = boardName;
break;
}
}
if (!currentBoardName.isEmpty()) {
break;
}
}
if (currentBoardName.isEmpty()) {
return false;
}
// Same-board moves are no-ops and must not pollute the history.
if (currentBoardName == newBoardZoneName) {
return true;
}
// Zone names are deck-unique among zones created through this manager, so a
// same-named zone on the target board can only come from an imported deck.
// Refuse the move instead of silently stacking same-named zones.
for (const auto *zone : tree->getCustomZones(newBoardZoneName)) {
if (zone->getName() == zoneName) {
return false;
}
}
QString reason =
tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName));
return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) {
return tree->moveCustomZone(zoneName, newBoardZoneName);
});
}
bool DeckStateManager::removeCustomZone(const QString &zoneName)
{
QString reason = tr("Deleted zone \"%1\"").arg(zoneName);
return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); });
}
QString DeckStateManager::validateNewZoneName(const QString &zoneName) const
{
if (zoneName.trimmed().isEmpty()) {
return tr("Enter a zone name.");
}
const QString trimmedZoneName = zoneName.trimmed();
// The standard zone names are reserved even before they exist.
if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE ||
trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) {
return tr("This name is reserved.");
}
const auto *tree = deckList->getTree();
// Reuse the tree's own uniqueness contract: any top-level zone and any
// custom zone on *every* board claims the name (hasZoneName also reserves
// the standard board names, which we already rejected with a dedicated
// message above). Scanning only the standard boards here would miss a
// custom zone an imported deck carries under `tokens`.
if (tree->hasZoneName(trimmedZoneName)) {
return tr("A zone with this name already exists.");
}
return {};
}
bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset)
{
if (!idx.isValid()) {
@ -367,6 +542,22 @@ void DeckStateManager::requestHistorySave(const QString &reason)
historyManager->save(deckList->createMemento(reason));
}
bool DeckStateManager::modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation)
{
DeckListMemento memento = deckList->createMemento(reason);
bool success = operation(deckList->getTree());
if (success) {
historyManager->save(memento);
deckListModel->rebuildTree();
deckList->refreshDeckHash();
emit deckListModel->deckHashChanged();
doCardModified();
}
return success;
}
/**
* @brief Handles updating state and emitting signals whenever the cards are modified
*/

View file

@ -5,6 +5,7 @@
#include "deck_list_model.h"
#include <QSharedPointer>
#include <functional>
#include <libcockatrice/deck_list/deck_list.h>
class DeckListHistoryManager;
@ -236,6 +237,68 @@ public:
*/
bool decrementCountAtIndex(const QModelIndex &idx);
/**
* @brief Moves all copies of the card at the given index to the given zone.
* No-ops if the index is invalid, not a card node, the card is a token, or the
* card is already in the target zone.
* Saves the operation to history if successful.
*
* @param idx The model index of the card to move
* @param targetZoneName The zone to move the card to (board zone or custom zone name)
* @return Whether the operation was successfully performed
*/
bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName);
/**
* @brief Creates a new custom zone nested under a board zone.
* Saves the operation to history if successful.
*
* @param boardZoneName The board zone to nest the custom zone under
* @param zoneName The name of the new custom zone. Gets trimmed and must be
* unique across the deck.
* @return Whether the zone was created
*/
bool createCustomZone(const QString &boardZoneName, const QString &zoneName);
/**
* @brief Renames a custom zone.
* Saves the operation to history if successful.
*
* @param oldZoneName The current name of the custom zone
* @param newZoneName The new name. Gets trimmed and must be unique across the deck.
* @return Whether the rename succeeded
*/
bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName);
/**
* @brief Moves a custom zone (and its cards) to a different board zone.
* Same-board moves succeed without creating a history entry.
* Saves the operation to history if successful.
*
* @param zoneName The custom zone to move
* @param newBoardZoneName The board zone to move the custom zone under
* @return Whether the move succeeded
*/
bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName);
/**
* @brief Removes a custom zone and all its cards.
* Saves the operation to history if successful.
*
* @param zoneName The custom zone to remove
* @return Whether the zone was removed
*/
bool removeCustomZone(const QString &zoneName);
/**
* @brief Checks whether a candidate name is usable for a new custom zone.
*
* @param zoneName The candidate name
* @return An empty string when the name is usable, otherwise a user-facing
* error message describing the problem
*/
[[nodiscard]] QString validateNewZoneName(const QString &zoneName) const;
/**
* Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager.
* @param steps Number of steps to undo.
@ -257,6 +320,7 @@ public slots:
private:
bool offsetCountAtIndex(const QModelIndex &idx, int offset);
bool modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation);
void doCardModified();
void doMetadataModified();

View file

@ -0,0 +1,145 @@
#include "deck_zone_dialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/utility/string_limits.h>
DeckZoneDialog::DeckZoneDialog(QWidget *parent,
const QString &initialBoardName,
const std::function<QString(const QString &)> &_nameValidator,
bool _allowBoardSelection)
: QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection)
{
nameLabel = new QLabel(this);
nameEdit = new QLineEdit(this);
nameEdit->setMaxLength(MAX_NAME_LENGTH);
errorLabel = new QLabel(this);
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);
}
if (!initialBoardName.isEmpty()) {
int idx = boardCombo->findData(initialBoardName);
if (idx != -1) {
boardCombo->setCurrentIndex(idx);
}
}
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addWidget(nameLabel);
layout->addWidget(nameEdit);
layout->addWidget(errorLabel);
if (allowBoardSelection) {
layout->addWidget(boardLabel);
layout->addWidget(boardCombo);
} else {
boardLabel->hide();
boardCombo->hide();
}
layout->addWidget(buttonBox);
retranslateUi();
connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); });
validateName();
nameEdit->setFocus();
}
QString DeckZoneDialog::getZoneName() const
{
return nameEdit->text().trimmed();
}
QString DeckZoneDialog::getBoardName() const
{
return boardCombo->currentData().toString();
}
void DeckZoneDialog::setZoneName(const QString &zoneName)
{
nameEdit->setText(zoneName);
nameEdit->selectAll();
}
void DeckZoneDialog::changeEvent(QEvent *event)
{
QDialog::changeEvent(event);
if (event->type() == QEvent::LanguageChange) {
retranslateUi();
}
}
void DeckZoneDialog::retranslateUi()
{
setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone"));
nameLabel->setText(tr("Zone &name:"));
nameLabel->setBuddy(nameEdit);
boardLabel->setText(tr("&Parent zone:"));
boardLabel->setBuddy(boardCombo);
for (int i = 0; i < boardCombo->count(); i++) {
boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString()));
}
}
void DeckZoneDialog::validateName()
{
const QString zoneName = nameEdit->text().trimmed();
QString error;
if (zoneName.isEmpty()) {
error = tr("Enter a zone name.");
} else if (nameValidator) {
error = nameValidator(zoneName);
}
errorLabel->setText(error);
errorLabel->setVisible(!error.isEmpty());
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty());
}
QString DeckZoneDialog::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) {
return {};
}
if (chosenBoardName) {
*chosenBoardName = dialog.getBoardName();
}
return dialog.getZoneName();
}
QString DeckZoneDialog::promptForRename(QWidget *parent,
const QString &currentZoneName,
const std::function<QString(const QString &)> &nameValidator)
{
DeckZoneDialog dialog(parent, {}, nameValidator, false);
dialog.setZoneName(currentZoneName);
return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString();
}

View file

@ -0,0 +1,123 @@
/**
* @file deck_zone_dialog.h
* @ingroup DeckEditorWidgets
* @brief Shared dialog for creating custom deck zones.
*/
#ifndef DECK_ZONE_DIALOG_H
#define DECK_ZONE_DIALOG_H
#include <QDialog>
#include <QEvent>
#include <QString>
#include <functional>
class QComboBox;
class QDialogButtonBox;
class QLabel;
class QLineEdit;
class QWidget;
/**
* @brief Modal dialog asking for the name and parent zone of a new custom deck zone.
*
* Menus construct the dialog transiently around exec(), so validation state only
* ever reflects the name currently typed.
*/
class DeckZoneDialog : public QDialog
{
Q_OBJECT
public:
/**
* @brief Constructs the dialog and runs the initial validation pass.
*
* @param parent The parent widget for the dialog
* @param initialBoardName The board zone to preselect in the combo. Unknown names
* fall back to main.
* @param _nameValidator Given the trimmed candidate name, returns an empty string
* when it is usable, otherwise a user-facing error message. May be empty.
* @param _allowBoardSelection When false the parent-zone combo is hidden and the
* dialog acts as a rename prompt for an existing zone.
*/
explicit DeckZoneDialog(QWidget *parent = nullptr,
const QString &initialBoardName = {},
const std::function<QString(const QString &)> &_nameValidator = {},
bool _allowBoardSelection = true);
/**
* @brief The trimmed zone name entered by the user.
*/
[[nodiscard]] QString getZoneName() const;
/**
* @brief The internal name of the board zone selected in the combo.
*/
[[nodiscard]] QString getBoardName() const;
/**
* @brief Prefills the name field, e.g. with the current name when renaming.
*
* @param zoneName The text to put into the name field, selected for quick editing
*/
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;
private:
/**
* @brief Sets every user-visible string. Runs on construction and on runtime
* language changes.
*/
void retranslateUi();
/**
* @brief Validates the current input, toggling Ok and the inline error label.
*/
void validateName();
QLabel *nameLabel;
QLineEdit *nameEdit;
QLabel *errorLabel;
QLabel *boardLabel;
QComboBox *boardCombo;
QDialogButtonBox *buttonBox;
std::function<QString(const QString &)> nameValidator;
bool allowBoardSelection;
};
#endif // DECK_ZONE_DIALOG_H

View file

@ -115,6 +115,15 @@ public:
*/
QList<const InnerDecklistNode *> getCustomZones(const QString &boardZoneName) const;
/**
* @brief Checks whether a zone name is taken anywhere in the deck.
*
* Covers the standard board names and any top-level or nested custom zone.
* @param zoneName The checked name.
* @return true if the name is reserved or already in use.
*/
bool hasZoneName(const QString &zoneName) const;
/**
* @brief Applies a function to every card in the deck tree. This can modify the cards.
*
@ -129,7 +138,6 @@ private:
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
bool hasZoneName(const QString &zoneName) const;
};
#endif // COCKATRICE_DECKLIST_NODE_TREE_H

View file

@ -43,6 +43,13 @@ 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,6 +18,9 @@
#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. */
@ -118,6 +121,13 @@ 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 node’s display-friendly name.
* @return Human-readable name (zone/group name).

View file

@ -378,7 +378,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent)
{
auto *newNode = dynamic_cast<InnerDecklistNode *>(parent->findChild(name));
// Group lookups must not resolve a mirrored custom zone that shares the name.
auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name);
if (!newNode) {
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
newNode = new InnerDecklistNode(name, parent);
@ -401,7 +402,7 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
// 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 *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) {
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
return card;
@ -486,6 +487,26 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam
// Custom zone: cards live flat inside the zone.
cardParent = customZoneNode;
} else {
// Not present in the shadow tree. The deck tree may still hold a custom
// zone that has not been mirrored (callers can add a zone and then a
// card without a rebuild). Check before falling back to creating a
// top-level zone the deck does not actually have.
auto *listRoot = deckList->getTree()->getRoot();
bool hasDeckZone = false;
for (int i = 0; i < listRoot->size(); ++i) {
if (auto *boardZone = dynamic_cast<InnerDecklistNode *>(listRoot->at(i))) {
if (boardZone->findChild(zoneName)) {
hasDeckZone = true;
break;
}
}
}
if (hasDeckZone) {
rebuildTree();
return addCard(card, zoneName);
}
// Unknown zone: create a top-level zone (legacy behavior).
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
auto *newZone = createNodeIfNeeded(zoneName, root);

View file

@ -14,26 +14,55 @@ bool isCustomZone(const AbstractDecklistNode *node)
return dynamic_cast<const DecklistModelSubZoneNode *>(node) != nullptr;
}
namespace
{
/**
* @brief Flattens every card under @p zone into @p shadowZone, preserving order.
*
* Custom zones mirror as a single row level: cards nested in sub-zones of any
* depth are added as direct children of the mirrored zone so no card is left
* without a model row.
*/
void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone)
{
for (int k = 0; k < zone->size(); k++) {
if (auto *zoneCard = dynamic_cast<DecklistCardNode *>(zone->at(k))) {
new DecklistModelCardNode(zoneCard, shadowZone);
} else if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(zone->at(k))) {
flattenCards(subZone, shadowZone);
}
}
}
} // namespace
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone)
{
for (int j = 0; j < deckBoardZone->size(); j++) {
auto *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);
}
flattenCards(customZone, shadowZone);
}
}
InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name)
{
for (int i = 0; i < parent->size(); i++) {
AbstractDecklistNode *child = parent->at(i);
if (isCustomZone(child)) {
continue;
}
auto *group = dynamic_cast<InnerDecklistNode *>(child);
if (group && group->getName() == name) {
return group;
}
}
return nullptr;
}
DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName)

View file

@ -43,6 +43,21 @@ namespace DeckListModelCustomZones
*/
[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node);
/**
* @brief Finds a criteria-group child of @p parent by name, skipping custom zones.
*
* The shadow tree keeps criteria groups and mirrored custom zones as siblings
* under a board zone, and `InnerDecklistNode::findChild` matches both by name.
* Group lookups must not resolve a custom zone that happens to share the group
* name (e.g. a zone called "Creature"), so this searches only non-custom
* children.
*
* @param parent The shadow node whose children are searched.
* @param name The group name to find.
* @return The matching group node, or nullptr if none exists.
*/
[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name);
/**
* @brief Mirrors the custom zones of a deck board zone into its shadow board node.
*

View file

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

View file

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

View file

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