From 0a09884c784859397bf8b24bda9a133bff6085bc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:15:16 +0200 Subject: [PATCH] [DeckList] Add custom deck zones to the deck tree (#7176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [DeckList] Add custom deck zones to the deck tree Introduce user-definable zones nested under a board zone (main, side or maybeboard) so players can organize cards inside a board without changing board semantics. - addCustomZone, renameCustomZone, moveCustomZone and removeCustomZone manage zones. Names are unique across the whole deck and the standard zone names (main/side/maybeboard/tokens) stay reserved. - Board zones are created lazily on first use. - getZoneObjFromName resolves custom names to their nested node so addCard and XML loading route cards into them. Unknown names keep creating legacy top-level zones. - deleteNode keeps empty custom zones alive and only prunes empty board zones. - New deck_list_zones test suite locks hash parity with flat decks, sideboard size accounting, maybeboard exclusion from plain export and native-format round-trips. Took 17 minutes Took 11 minutes * Extract to function Took 4 minutes --------- Co-authored-by: Lukas BrĂ¼bach --- .../deck_list/deck_list_node_tree.cpp | 147 +++++++- .../deck_list/deck_list_node_tree.h | 41 ++ .../deck_list/tree/abstract_deck_list_node.h | 6 + tests/CMakeLists.txt | 1 + tests/deck_list_zones/CMakeLists.txt | 10 + .../deck_list_zones/deck_list_zones_test.cpp | 356 ++++++++++++++++++ 6 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 tests/deck_list_zones/CMakeLists.txt create mode 100644 tests/deck_list_zones/deck_list_zones_test.cpp diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index efe20595b..21f628f9d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp @@ -145,7 +145,8 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode if (index != -1) { delete rootNode->takeAt(index); - if (rootNode->empty()) { + // Empty custom zones are kept while empty board zones get pruned. + if (rootNode->empty() && rootNode->getParent() == root) { deleteNode(rootNode, rootNode->getParent()); } @@ -188,15 +189,157 @@ void DecklistNodeTree::forEachCard(const std::functionsize(); i++) { auto *node = dynamic_cast(root->at(i)); - if (node->getName() == zoneName) { + if (node && node->getName() == zoneName) { return node; } } + if (auto *customZone = findCustomZoneByName(zoneName)) { + return customZone; + } + return new InnerDecklistNode(zoneName, root); } + +InnerDecklistNode *DecklistNodeTree::findBoardZone(const QString &boardZoneName) const +{ + return dynamic_cast(root->findChild(boardZoneName)); +} + +InnerDecklistNode *DecklistNodeTree::findOrCreateBoardZone(const QString &boardZoneName) +{ + auto *boardZone = findBoardZone(boardZoneName); + if (!boardZone && + (boardZoneName == DECK_ZONE_MAYBEBOARD || boardZoneName == DECK_ZONE_MAIN || boardZoneName == DECK_ZONE_SIDE)) { + // The boards are lazy zones: they only exist once cards or custom zones need them. + boardZone = new InnerDecklistNode(boardZoneName, root); + } + return boardZone; +} + +InnerDecklistNode *DecklistNodeTree::addCustomZone(const QString &boardZoneName, const QString &zoneName) +{ + if (hasZoneName(zoneName)) { + return nullptr; + } + + auto *boardZone = findOrCreateBoardZone(boardZoneName); + + if (!boardZone) { + return nullptr; + } + + return new InnerDecklistNode(zoneName, boardZone); +} + +bool DecklistNodeTree::renameCustomZone(const QString &oldZoneName, const QString &newZoneName) +{ + if (hasZoneName(newZoneName)) { + return false; + } + + auto *zone = findCustomZoneByName(oldZoneName); + if (!zone) { + return false; + } + + zone->setName(newZoneName); + return true; +} + +bool DecklistNodeTree::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName) +{ + auto *zone = findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + auto *currentBoardZone = zone->getParent(); + if (currentBoardZone && currentBoardZone->getName() == newBoardZoneName) { + return true; + } + + auto *newBoardZone = findOrCreateBoardZone(newBoardZoneName); + if (!newBoardZone) { + return false; + } + + currentBoardZone->removeOne(zone); + newBoardZone->append(zone); + zone->setParent(newBoardZone); + return true; +} + +bool DecklistNodeTree::removeCustomZone(const QString &zoneName) +{ + auto *zone = findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + // Detach and delete without pruning the board zone. + auto *boardZone = zone->getParent(); + boardZone->removeOne(zone); + delete zone; + return true; +} + +QList DecklistNodeTree::getCustomZones(const QString &boardZoneName) const +{ + QList result; + + auto *boardZone = findBoardZone(boardZoneName); + if (!boardZone) { + return result; + } + + for (int i = 0; i < boardZone->size(); i++) { + if (auto *customZone = dynamic_cast(boardZone->at(i))) { + result.append(customZone); + } + } + + return result; +} + +InnerDecklistNode *DecklistNodeTree::findCustomZoneByName(const QString &zoneName) const +{ + for (int i = 0; i < root->size(); i++) { + auto *boardZone = dynamic_cast(root->at(i)); + if (!boardZone) { + continue; + } + + for (int j = 0; j < boardZone->size(); j++) { + auto *customZone = dynamic_cast(boardZone->at(j)); + if (customZone && customZone->getName() == zoneName) { + return customZone; + } + } + } + + return nullptr; +} + +bool DecklistNodeTree::hasZoneName(const QString &zoneName) const +{ + // The standard zones are reserved names even before they are created lazily. + if (zoneName == DECK_ZONE_MAIN || zoneName == DECK_ZONE_SIDE || zoneName == DECK_ZONE_MAYBEBOARD || + zoneName == DECK_ZONE_TOKENS) { + return true; + } + + if (root->findChild(zoneName)) { + return true; + } + + return findCustomZoneByName(zoneName) != nullptr; +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index eae20aa23..1012d5919 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -77,6 +77,43 @@ public: const bool formatLegal = true); bool deleteNode(AbstractDecklistNode *node, InnerDecklistNode *rootNode = nullptr); + /** + * @brief Creates a new custom zone nested under a board zone. + * + * Custom zone names must be unique across the whole deck so that cards can be + * added to a custom zone without specifying its board zone. + * + * @param boardZoneName Name of the board zone (e.g. DECK_ZONE_MAIN). + * @param zoneName Name of the custom zone. + * @return The created zone node, or nullptr if the name is already in use. + */ + InnerDecklistNode *addCustomZone(const QString &boardZoneName, const QString &zoneName); + + /** + * @brief Renames a custom zone. + * @return true on success, false if the zone was not found or the new name is taken. + */ + bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName); + + /** + * @brief Moves a custom zone (and all its cards) to another board zone. + * @return true on success, false if the zone or the new board zone was not found. + */ + bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName); + + /** + * @brief Removes a custom zone and all its cards. + * @return true if the zone was found and removed. + */ + bool removeCustomZone(const QString &zoneName); + + /** + * @brief Gets all custom zones nested under a board zone. + * @param boardZoneName Name of the board zone. + * @return The custom zones, in insertion order. + */ + QList getCustomZones(const QString &boardZoneName) const; + /** * @brief Applies a function to every card in the deck tree. This can modify the cards. * @@ -88,6 +125,10 @@ public: private: // Helpers for traversing the tree InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const; + 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 diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h index a39f0e7b2..c5cb25d8f 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h @@ -142,6 +142,12 @@ public: return parent; } + /** @param newParent Reparent this node. The new parent takes ownership. */ + void setParent(InnerDecklistNode *newParent) + { + parent = newParent; + } + /** * @brief Compute the depth of this node in the tree. * @return Distance from the root (root = 0, children = 1, etc.). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 29caf257e..a28f671c9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -114,6 +114,7 @@ target_link_libraries( add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) +add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) add_subdirectory(oracle) diff --git a/tests/deck_list_zones/CMakeLists.txt b/tests/deck_list_zones/CMakeLists.txt new file mode 100644 index 000000000..0710be94d --- /dev/null +++ b/tests/deck_list_zones/CMakeLists.txt @@ -0,0 +1,10 @@ +add_executable(deck_list_zones_test deck_list_zones_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_zones_test gtest) +endif() + +target_link_libraries( + deck_list_zones_test libcockatrice_deck_list Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +add_test(NAME deck_list_zones_test COMMAND deck_list_zones_test) diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp new file mode 100644 index 000000000..a5148621d --- /dev/null +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -0,0 +1,356 @@ +/** + * @file deck_list_zones_test.cpp + * @brief Tests for custom deck zones (deck-unique zones nested under a board zone). + * + * Custom zones allow players to organize cards within a board (e.g. "Removal" under + * the mainboard) without changing the board semantics: cards in a custom zone under + * "main" are still mainboard cards for hashing, sideboard size, legality and export. + */ + +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Collects (board zone name, card node) pairs via forEachCard. + */ +struct BoardCardPair +{ + QString boardZone; + QString cardName; + int amount; +}; + +QList collectBoardCardPairs(const DeckList &deck) +{ + QList result; + deck.forEachCard([&result](InnerDecklistNode *boardZone, DecklistCardNode *card) { + result.append({boardZone->getName(), card->getName(), card->getNumber()}); + }); + return result; +} + +bool hasPair(const QList &pairs, const QString &boardZone, const QString &cardName) +{ + for (const auto &pair : pairs) { + if (pair.boardZone == boardZone && pair.cardName == cardName) { + return true; + } + } + return false; +} + +int totalCards(const QList &pairs) +{ + int total = 0; + for (const auto &pair : pairs) { + total += pair.amount; + } + return total; +} + +} // namespace + +// ===================================================================================================================== +// Zone creation +// ===================================================================================================================== + +TEST(DeckListZones, AddCustomZoneNestsUnderBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + + auto *zone = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); + ASSERT_NE(zone, nullptr); + EXPECT_EQ(zone->getName(), QString("Removal")); + ASSERT_NE(zone->getParent(), nullptr); + EXPECT_EQ(zone->getParent()->getName(), QString(DECK_ZONE_MAIN)); + + // The custom zone is nested, not a new top-level zone. + auto topLevelZones = tree->getZoneNodes(); + QStringList topLevelNames; + for (auto *node : topLevelZones) { + topLevelNames.append(node->getName()); + } + EXPECT_FALSE(topLevelNames.contains("Removal")); + + // It is discoverable through the board zone. + auto customZones = tree->getCustomZones(DECK_ZONE_MAIN); + ASSERT_EQ(customZones.size(), 1); + EXPECT_EQ(customZones.first()->getName(), QString("Removal")); +} + +TEST(DeckListZones, CustomZoneNamesAreDeckUnique) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + // Same name on a different board is rejected. + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_SIDE, "Removal"), nullptr); + // A name that collides with a built-in board zone is rejected. + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_MAIN), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_SIDE), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_MAYBEBOARD), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_TOKENS), nullptr); +} + +TEST(DeckListZones, AddCustomZoneUnknownBoardFails) +{ + DeckList deck; + auto *tree = deck.getTree(); + + EXPECT_EQ(tree->addCustomZone("not_a_board", "Removal"), nullptr); +} + +TEST(DeckListZones, MaybeboardIsLazilyCreated) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + + // The maybeboard board zone now exists, with the custom zone nested inside. + auto customZones = tree->getCustomZones(DECK_ZONE_MAYBEBOARD); + ASSERT_EQ(customZones.size(), 1); + EXPECT_EQ(customZones.first()->getName(), QString("Candidates")); +} + +// ===================================================================================================================== +// Card placement +// ===================================================================================================================== + +TEST(DeckListZones, AddCardToCustomZoneKeepsBoardSemantics) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 4, "Removal", -1); + + // The card is reported as a mainboard card. + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); + + // It is physically nested inside the custom zone. + auto customZones = tree->getCustomZones(DECK_ZONE_MAIN); + ASSERT_EQ(customZones.size(), 1); + ASSERT_EQ(customZones.first()->size(), 1); + auto *card = dynamic_cast(customZones.first()->at(0)); + ASSERT_NE(card, nullptr); + EXPECT_EQ(card->getName(), QString("Lightning Bolt")); + EXPECT_EQ(card->getNumber(), 4); + + // Zone-scoped queries include it. + EXPECT_TRUE(deck.getCardList({DECK_ZONE_MAIN}).contains("Lightning Bolt")); + EXPECT_FALSE(deck.getCardList({DECK_ZONE_SIDE}).contains("Lightning Bolt")); + EXPECT_EQ(deck.getCardNodes({DECK_ZONE_MAIN}).size(), 1); +} + +TEST(DeckListZones, LegacyTopLevelZoneStillWorks) +{ + DeckList deck; + auto *tree = deck.getTree(); + + // Unknown zone names create a legacy top-level zone (backwards compatibility). + tree->addCard("Legacy Card", 2, "custom_legacy_zone", -1); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, "custom_legacy_zone", "Legacy Card")); + EXPECT_EQ(deck.getCardList({}).count("Legacy Card"), 1); +} + +// ===================================================================================================================== +// Zone management +// ===================================================================================================================== + +TEST(DeckListZones, RenameCustomZone) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->renameCustomZone("Removal", "Bolt Zone")); + EXPECT_TRUE(hasPair(collectBoardCardPairs(deck), DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).first()->getName(), QString("Bolt Zone")); + + // Renaming to a taken name fails. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Other"), nullptr); + EXPECT_FALSE(tree->renameCustomZone("Bolt Zone", "Other")); + // Renaming a nonexistent zone fails. + EXPECT_FALSE(tree->renameCustomZone("Ghost Zone", "Whatever")); +} + +TEST(DeckListZones, MoveCustomZoneMovesCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + + // The custom zone is now nested under side. + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 0); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_SIDE).size(), 1); + + // Moving to an unknown board fails. + EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); +} + +TEST(DeckListZones, RemoveCustomZoneRemovesCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->removeCustomZone("Removal")); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 0); + EXPECT_TRUE(deck.getCardList({DECK_ZONE_MAIN}).isEmpty()); + EXPECT_FALSE(tree->removeCustomZone("Removal")); +} + +TEST(DeckListZones, EmptyCustomZoneIsKeptOnCardDeletion) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + auto *card = tree->addCard("Lightning Bolt", 1, "Removal", -1); + + // Deleting the last card must not delete the empty custom zone. + EXPECT_TRUE(tree->deleteNode(card)); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); +} + +// ===================================================================================================================== +// Deck-wide behavior +// ===================================================================================================================== + +TEST(DeckListZones, HashCountsCustomZoneCardsByBoard) +{ + // Deck A: cards directly in main and side. + DeckList direct; + direct.addCard("Mountain", DECK_ZONE_MAIN); + direct.addCard("Lightning Bolt", DECK_ZONE_MAIN); + direct.addCard("Island", DECK_ZONE_SIDE); + + // Deck B: identical, but organized in custom zones. + DeckList organized; + auto *tree = organized.getTree(); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Lands"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Mountain", 1, "Lands", -1); + tree->addCard("Lightning Bolt", 1, "Removal", -1); + tree->addCard("Island", 1, "Side Tech", -1); + + EXPECT_EQ(direct.getDeckHash(), organized.getDeckHash()); +} + +TEST(DeckListZones, SideboardSizeCountsCustomZoneCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Island", 3, "Side Tech", -1); + tree->addCard("Forest", 2, DECK_ZONE_SIDE, -1); + + EXPECT_EQ(deck.getSideboardSize(), 5); +} + +TEST(DeckListZones, PlainExportIncludesMainAndSideCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + tree->addCard("Island", 1, "Side Tech", -1); + + const QString plain = deck.writeToString_Plain(false, false); + EXPECT_TRUE(plain.contains("2 Lightning Bolt")); + EXPECT_TRUE(plain.contains("1 Island")); +} + +TEST(DeckListZones, PlainExportSkipsMaybeboardCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Wish Card", 4, "Candidates", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + const QString plain = deck.writeToString_Plain(false, false); + EXPECT_FALSE(plain.contains("Wish Card")); + EXPECT_TRUE(plain.contains("1 Mountain")); +} + +TEST(DeckListZones, NativeRoundTripPreservesCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + tree->addCard("Island", 3, "Side Tech", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + // Round-trip through the native format. + DeckList restored(deck.writeToString_Native()); + auto *restoredTree = restored.getTree(); + + EXPECT_EQ(restored.getDeckHash(), deck.getDeckHash()); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_MAIN).size(), 1); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_MAIN).first()->getName(), QString("Removal")); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_SIDE).size(), 1); + + auto pairs = collectBoardCardPairs(restored); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Island")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Mountain")); + EXPECT_EQ(totalCards(pairs), 6); +} + +TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash) +{ + // Maybeboard cards are editor-only and must never affect the deck hash. + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Wish Card", 4, "Candidates", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + DeckList expected; + expected.addCard("Mountain", DECK_ZONE_MAIN); + + EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}