diff --git a/.ci/compile.sh b/.ci/compile.sh index bd8c900c8..8a16d3243 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -149,9 +149,6 @@ if [[ $MAKE_TEST ]]; then fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") - # PCH-aware caching is required or ccache refuses to cache any TU that - # consumes a precompiled header, silently recompiling everything on every run. - ccache --set-config sloppiness=pch_defines,time_macros if [[ $CCACHE_SIZE ]]; then # note, this setting persists after running the script ccache --max-size "$CCACHE_SIZE" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index b631f32d3..04037a74e 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -176,12 +176,8 @@ jobs: shell: bash run: | source .ci/docker.sh - args=() - [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") - args+=(--ccache "$CCACHE_SIZE") - args+=(--cmake-generator "$CMAKE_GENERATOR") - - RUN --server --debug --test "${args[@]}" + RUN --server --debug --test --ccache "$CCACHE_SIZE" \ + --cmake-generator "$CMAKE_GENERATOR" - name: "Build release package" id: build diff --git a/CMakeLists.txt b/CMakeLists.txt index 0da073464..35eb8111b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ cmake_minimum_required(VERSION 3.16) # Use compiler cache (ccache) -option(USE_CCACHE "Cache the build results with ccache" ON) +option(USE_CCACHE "Cache the build results with ccache" OFF) # Treat warnings as errors (Debug builds only) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) # Check for translation updates @@ -39,24 +39,13 @@ else() ) endif() -# ccache does not support MSVC and must not auto-engage on Windows -# (it is installed unintentionally on the Windows CI runner). -# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows -# also opts out of ccache even though the GNUCXX branch below supports it. -if(USE_CCACHE AND NOT WIN32) +if(USE_CCACHE) find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) # Support Unix Makefiles and Ninja set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}") - # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses - # to cache any TU that consumes a precompiled header, so every PCH-backed - # target recompiles from scratch on each build. - execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") endif() -elseif(USE_CCACHE AND WIN32) - # An explicit opt-in must not disappear silently on Windows. - message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows") endif() if(WIN32 OR USE_VCPKG) diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h deleted file mode 100644 index cc3dd12ee..000000000 --- a/cmake/pch/qtcore_pch.h +++ /dev/null @@ -1,24 +0,0 @@ -/** @file qtcore_pch.h - * @brief Precompiled header for all Qt targets (Qt Core only). - * - * Safe for every target that links Qt Core, including the headless - * Servatrice binary. Keep this header free of any widget/gui types. - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include diff --git a/cmake/pch/qtwidgets_pch.h b/cmake/pch/qtwidgets_pch.h deleted file mode 100644 index 2c63f450e..000000000 --- a/cmake/pch/qtwidgets_pch.h +++ /dev/null @@ -1,30 +0,0 @@ -/** @file qtwidgets_pch.h - * @brief Precompiled header for GUI targets (Cockatrice client, Oracle). - * - * Includes the Qt Core precompiled header plus the heavy Gui, Widgets and - * Network layers that virtually every client translation unit re-parses. - * Do not use on Servatrice (headless, QT_DONT_USE_QTGUI). - */ - -#include "qtcore_pch.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b6050a1bd..2f629fed2 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -214,7 +214,6 @@ 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 @@ -517,8 +516,6 @@ qt6_add_executable( MANUAL_FINALIZATION ) -target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") - qt6_add_shaders( cockatrice "onboarding_shaders" diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index fd0a12507..0c8bdb450 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -52,7 +52,6 @@ In this list of examples below, each entry has an explanation and can be clicked
Edition:
[set:lea](#set:lea) (Cards that appear in Alpha, which has the set code LEA)
[e:lea OR e:leb](#e:lea OR e:leb) (Cards that appear in Alpha or Beta)
-
[e<8ED](#e<8ED) (Cards that appear before 8th edition)
Negate:
[c:wu -c:m](#c:wu -c:m) (Any card that is white or blue, but not multicolored)
diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 1acd02a75..1278737a0 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, @@ -380,10 +381,12 @@ void DeckViewScene::rebuildTree() addItem(container); } - // Cards in custom zones nested under a board are regular board cards in-game. - // They are collected recursively (like every other consumer) and reported with - // the top-level board zone as their origin, so that sideboard plans keep working. - for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) { + for (int j = 0; j < currentZone->size(); j++) { + auto *currentCard = dynamic_cast(currentZone->at(j)); + if (!currentCard) { + continue; + } + for (int k = 0; k < currentCard->getNumber(); ++k) { auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName()); container->addCard(newCard); diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp index 08cb6cac9..7eb3945b3 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp @@ -12,13 +12,11 @@ TallyMenu::TallyMenu() aTallyNone = createTallyAction(TallyType::None); aTallySubtypes = createTallyAction(TallyType::Subtypes); aTallyTotalPower = createTallyAction(TallyType::TotalPower); - aTallyTotalToughness = createTallyAction(TallyType::TotalToughness); addAction(aTallyNone); addSeparator(); addAction(aTallySubtypes); addAction(aTallyTotalPower); - addAction(aTallyTotalToughness); retranslateUi(); } @@ -56,5 +54,4 @@ void TallyMenu::retranslateUi() aTallyNone->setText(tr("None")); aTallySubtypes->setText(tr("Subtypes")); aTallyTotalPower->setText(tr("Total Power")); - aTallyTotalToughness->setText(tr("Total Toughness")); } diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.h b/cockatrice/src/game_graphics/player/menu/tally_menu.h index 11802fd20..acd1daf67 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.h +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h @@ -24,7 +24,6 @@ private: QAction *aTallyNone = nullptr; QAction *aTallySubtypes = nullptr; QAction *aTallyTotalPower = nullptr; - QAction *aTallyTotalToughness = nullptr; QAction *createTallyAction(TallyType tallyType); }; diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp index 7e05c3fb1..e7a6621fa 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.cpp +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -34,31 +34,3 @@ QList StatsTally::computeTotalPower(const QList &cards) QString name = QCoreApplication::translate("StatsTally", "Total Power"); return {TallyRow{name, QString::number(total)}}; } - -static int sumToughness(const QList &cards) -{ - int total = 0; - for (auto card : cards) { - QVariantList parsed = CardItem::parsePT(card->getPT()); - if (parsed.size() == 2) { - int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int - total += qMax(toughness, 0); - } - } - return total; -} - -QList StatsTally::computeTotalToughness(const QList &cards) -{ - // don't bother if none of the cards have pt - bool hasPT = - std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); }); - if (!hasPT) { - return {}; - } - - int total = sumToughness(cards); - - QString name = QCoreApplication::translate("StatsTally", "Total Toughness"); - return {TallyRow{name, QString::number(total)}}; -} diff --git a/cockatrice/src/game_graphics/tally/stats_tally.h b/cockatrice/src/game_graphics/tally/stats_tally.h index e499587eb..4c3d93b56 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.h +++ b/cockatrice/src/game_graphics/tally/stats_tally.h @@ -16,14 +16,6 @@ namespace StatsTally */ QList computeTotalPower(const QList &cards); -/** - * @brief Sums the toughness of all selected cards - * - * @param cards The list of selected card items to analyze. - * @return A single row containing the total, or an empty list if none of the cards have pt - */ -QList computeTotalToughness(const QList &cards); - } // namespace StatsTally #endif // COCKATRICE_STATS_TALLY_H diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp index 21806ee84..aa2cae024 100644 --- a/cockatrice/src/game_graphics/tally/tally.cpp +++ b/cockatrice/src/game_graphics/tally/tally.cpp @@ -21,8 +21,6 @@ QList Tally::compute(const QList &cards, const TallyType t return SubtypeTally::countSubtypes(cards); case TallyType::TotalPower: return StatsTally::computeTotalPower(cards); - case TallyType::TotalToughness: - return StatsTally::computeTotalToughness(cards); } return {}; } diff --git a/cockatrice/src/game_graphics/tally/tally.h b/cockatrice/src/game_graphics/tally/tally.h index 84c54918f..97406cddb 100644 --- a/cockatrice/src/game_graphics/tally/tally.h +++ b/cockatrice/src/game_graphics/tally/tally.h @@ -21,8 +21,7 @@ enum class TallyType None, Subtypes, TotalPower, - TotalToughness, - MaxValue = TotalToughness // sentinel value + MaxValue = TotalPower // sentinel value }; namespace Tally diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index f03339da8..39a0c1071 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -375,32 +375,15 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL void DeckLoader::saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments, - bool addSetNameAndNumber, - const QString &boardZoneName) + bool addSetNameAndNumber) { - // Nested sub-zones keep their owning board's identity: the top-level call - // passes no board, so the zone's own name is used; recursive calls carry the - // owning board down so the sideboard marker survives sub-zone nesting. - const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName; - // group cards by card type and count the subtotals QMultiMap cardsByType; QMap cardTotalByType; int cardTotal = 0; - QList subZones; for (int j = 0; j < zoneNode->size(); j++) { auto *card = dynamic_cast(zoneNode->at(j)); - if (!card) { - // Cards collected in nested sub-zones are exported by recursion so - // they don't end up invisible in the plain text output. They are - // deferred until after this zone's own header and cards so they read - // as part of this zone's block. - if (auto *subZone = dynamic_cast(zoneNode->at(j))) { - subZones.append(subZone); - } - continue; - } CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); QString cardType = info ? info->getMainCardType() : "unknown"; @@ -428,30 +411,25 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out, QList cards = cardsByType.values(cardType); - saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName); + saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber); if (addComments) { out << "\n"; } } - - // Nested sub-zones come last, after the parent's own header and cards. - for (const auto *subZone : subZones) { - saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName); - } } void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out, + const InnerDecklistNode *zoneNode, QList cards, bool addComments, - bool addSetNameAndNumber, - const QString &boardZoneName) + bool addSetNameAndNumber) { // QMultiMap sorts values in reverse order for (int i = cards.size() - 1; i >= 0; --i) { DecklistCardNode *card = cards[i]; - if (boardZoneName == DECK_ZONE_SIDE && addComments) { + if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) { out << "SB: "; } @@ -532,26 +510,9 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck) void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node) { - if (!node || node->isEmpty()) { - return; - } - const int totalColumns = 2; - // Dispatch children by type instead of trusting a whole-node height: a deck - // node may hold direct cards and nested zones side by side (custom zones), - // and an empty node would previously crash on at(0). - QVector cards; - QVector subZones; - for (int i = 0; i < node->size(); i++) { - if (auto *card = dynamic_cast(node->at(i))) { - cards.append(card); - } else if (auto *zone = dynamic_cast(node->at(i))) { - subZones.append(zone); - } - } - - if (!cards.isEmpty()) { + if (node->height() == 1) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(11); @@ -562,9 +523,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setCellPadding(0); tableFormat.setCellSpacing(0); tableFormat.setBorder(0); - QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat); - for (int i = 0; i < cards.size(); i++) { - const AbstractDecklistCardNode *card = cards[i]; + QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat); + for (int i = 0; i < node->size(); i++) { + auto *card = dynamic_cast(node->at(i)); QTextCharFormat cellCharFormat; cellCharFormat.setFontPointSize(9); @@ -579,13 +540,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode cellCursor = cell.firstCursorPosition(); cellCursor.insertText(card->getName()); } - } - - for (const InnerDecklistNode *subZone : subZones) { - if (subZone->isEmpty()) { - continue; - } - + } else if (node->height() == 2) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(14); @@ -604,8 +559,10 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setColumnWidthConstraints(constraints); QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat); - QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition(); - printDeckListNode(&cellCursor, subZone); + for (int i = 0; i < node->size(); i++) { + QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition(); + printDeckListNode(&cellCursor, dynamic_cast(node->at(i))); + } } cursor->movePosition(QTextCursor::End); diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h index b851c6895..ac23e1ee0 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -159,13 +159,12 @@ private: static void saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments = true, - bool addSetNameAndNumber = true, - const QString &boardZoneName = QString()); + bool addSetNameAndNumber = true); static void saveToStream_DeckZoneCards(QTextStream &out, + const InnerDecklistNode *zoneNode, QList cards, bool addComments = true, - bool addSetNameAndNumber = true, - const QString &boardZoneName = QString()); + bool addSetNameAndNumber = true); }; #endif diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp index bfbdd7e42..3f36e559c 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp @@ -174,18 +174,16 @@ void CardGroupDisplayWidget::updateCardDisplays() QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); // 4. persist the source index - addCardWidgets(QPersistentModelIndex(sourceIndex)); - } -} + QPersistentModelIndex persistent(sourceIndex); -void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent) -{ - // Get the card amount - int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); + // Get the card amount + int cardAmount = + sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); - // Create multiple widgets for the card count - for (int copy = 0; copy < cardAmount; ++copy) { - addToLayout(constructWidgetForIndex(persistent)); + // Create multiple widgets for the card count + for (int copy = 0; copy < cardAmount; ++copy) { + addToLayout(constructWidgetForIndex(persistent)); + } } } diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h index a3bf70981..2308ccf8d 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h @@ -35,7 +35,6 @@ public: void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected); void refreshSelectionForIndex(const QPersistentModelIndex &persistent); void clearAllDisplayWidgets(); - void addCardWidgets(const QPersistentModelIndex &persistent); DeckListModel *deckListModel; QItemSelectionModel *selectionModel; diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index b00d9db1e..eaf3a67b0 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -5,7 +5,6 @@ #include "libcockatrice/card/database/card_database_manager.h" #include -#include #include DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, @@ -52,6 +51,11 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, // User Interaction // ===================================================================================================================== +void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card) +{ + emit cardClicked(event, card, zoneName); +} + void DeckCardZoneDisplayWidget::onHover(const ExactCard &card) { emit cardHovered(card); @@ -91,18 +95,12 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex } auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - // Cards in a custom zone belong to that zone, not the board zone, so that - // increment/decrement/swap actions target the custom zone. - const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool(); - const QString effectiveZoneName = isCustomZone ? categoryName : zoneName; - const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) { - emit cardClicked(event, card, effectiveZoneName); - }; if (displayType == DisplayType::Overlap) { auto *displayWidget = new OverlappedCardGroupDisplayWidget( - cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName, - activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick); + cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria, + activeSortCriteria, subBannerOpacity, cardSizeWidget); + connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, + &DeckCardZoneDisplayWidget::onClick); connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, @@ -113,9 +111,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex indexToWidgetMap.insert(index, displayWidget); } else if (displayType == DisplayType::Flat) { auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index, - effectiveZoneName, categoryName, activeGroupCriteria, + zoneName, categoryName, activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick); + connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick); connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, &DeckCardZoneDisplayWidget::cleanupInvalidCardGroup); @@ -128,18 +126,24 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex void DeckCardZoneDisplayWidget::displayCards() { - if (!trackedIndex.isValid()) { - return; - } + QSortFilterProxyModel proxy; + proxy.setSourceModel(deckListModel); + proxy.setSortRole(Qt::EditRole); + proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); - // Iterate the direct children of the tracked zone, keeping the tree view's row - // order (criteria groups first, then custom zones, both in the model's sort order). - QList rows; - for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) { - rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex))); - } + // 1. trackedIndex is a source index → map it to proxy space + QModelIndex proxyParent = proxy.mapFromSource(trackedIndex); + + // 2. iterate children under the proxy parent + for (int i = 0; i < proxy.rowCount(proxyParent); ++i) { + QModelIndex proxyIndex = proxy.index(i, 0, proxyParent); + + // 3. map back to source + QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); + + // 4. persist the source index + QPersistentModelIndex persistent(sourceIndex); - for (const QPersistentModelIndex &persistent : rows) { constructAppropriateWidget(persistent); } } diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h index 53f3fa7cf..b426fca30 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h @@ -42,6 +42,7 @@ public: void addCardsToOverlapWidget(); public slots: + void onClick(QMouseEvent *event, const ExactCard &card); void onHover(const ExactCard &card); void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget); void constructAppropriateWidget(QPersistentModelIndex index); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp index 00388a3cd..7c782b074 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -90,13 +90,6 @@ void CardDatabaseView::decrementCard(const QString &zoneName) emit cardDecremented(currentCardName(), zoneName); } -void CardDatabaseView::setZoneMenuProvider(const std::function>()> &provider, - const std::function &newZoneHandler) -{ - zoneMenuProvider = provider; - this->newZoneHandler = newZoneHandler; -} - void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/) { if (!current.isValid()) { @@ -149,50 +142,6 @@ void CardDatabaseView::openCustomMenu(QPoint point) [this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); }); connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked); - if (zoneMenuProvider) { - QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone")); - const auto zoneBoards = zoneMenuProvider(); - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - // Boards with zones nest their children so no two menu entries - // share a visible name: "Maindeck ▸ { Maindeck (whole board), … }". - const QStringList customZones = [&zoneBoards, boardName] { - for (const auto &zoneBoard : zoneBoards) { - if (zoneBoard.first == boardName) { - return zoneBoard.second; - } - } - return QStringList(); - }(); - if (customZones.isEmpty()) { - QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); - connect(action, &QAction::triggered, this, - [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); - } else { - QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName)); - QAction *wholeBoardAction = boardSubmenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); - connect(wholeBoardAction, &QAction::triggered, this, - [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); - for (const QString &zoneName : customZones) { - QAction *action = boardSubmenu->addAction(zoneName); - connect(action, &QAction::triggered, this, - [this, card, zoneName] { emit cardAdded(card->getName(), zoneName); }); - } - } - } - - if (newZoneHandler) { - addToZoneMenu->addSeparator(); - - QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone...")); - connect(newZoneAction, &QAction::triggered, this, [this, card] { - const QString zoneName = newZoneHandler(); - if (!zoneName.isEmpty()) { - emit cardAdded(card->getName(), zoneName); - } - }); - } - } - if (canBeCommander(*card)) { QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)")); connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); }); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h index 668444199..175ec12b9 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -4,7 +4,6 @@ #include "../../key_signals.h" #include -#include #include class CardDatabaseModel; @@ -20,13 +19,6 @@ class CardDatabaseView : public QTreeView KeySignals searchKeySignals; CardDatabaseDisplayModel *databaseDisplayModel; - /// Provides the custom zones available in the current deck, grouped by board zone. - /// The list contains (board zone name, custom zone names) pairs for every board. - std::function>()> zoneMenuProvider; - /// Handler invoked when the user picks "New zone..." from the add-to-zone menu. - /// Returns the name of the created zone, or an empty string if creation was cancelled. - std::function newZoneHandler; - public: explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model); @@ -41,17 +33,6 @@ public: return &searchKeySignals; } - /** - * @brief Sets the provider used to populate the "Add to zone" submenu of the context menu. - * If no provider is set, the submenu is not shown. - * - * @param provider Returns the custom zones of the current deck, grouped by board zone - * @param newZoneHandler Creates a new custom zone and returns its name, or an empty string - * if creation was cancelled. The menu entry is hidden when not provided. - */ - void setZoneMenuProvider(const std::function>()> &provider, - const std::function &newZoneHandler); - signals: void cardChanged(const QString &cardName); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp index 6269f0323..2a491de4f 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp @@ -1,12 +1,5 @@ #include "deck_editor_card_database_dock_widget.h" -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "card_database_view.h" -#include "deck_state_manager.h" -#include "deck_zone_dialog.h" - -#include - DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent) { setObjectName("databaseDisplayDock"); @@ -22,27 +15,6 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck { databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel); - databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider( - [deckEditor]() -> QList> { - QList> result; - auto *deckListModel = deckEditor->deckStateManager->getModel(); - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); - } - return result; - }, - [this, deckEditor]() -> QString { - QString boardName; - const QString zoneName = - DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) { - return deckEditor->deckStateManager->validateNewZoneName(candidate); - }); - if (!zoneName.isEmpty()) { - deckEditor->deckStateManager->createCustomZone(boardName, zoneName); - } - return zoneName; - }); - auto *frame = new QVBoxLayout; frame->setObjectName("databaseDisplayFrame"); frame->addWidget(databaseDisplayWidget); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index e2175a358..14defc8e9 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -7,18 +7,15 @@ #include "../tabs/api/commander_spellbook/commander_bracket_widget.h" #include "deck_list_style_proxy.h" #include "deck_state_manager.h" -#include "deck_zone_dialog.h" #include #include #include #include -#include #include #include #include #include -#include #include #include #include @@ -775,213 +772,14 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point) { - const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point)); - QMenu menu; - const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool(); - const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid(); - const bool isCardRow = - sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex); - - // Walk the row up to its top-level node to find the hosting board. Cards in - // the tokens board cannot be moved (moveCardToZone bails for it), so the - // move menu is skipped for them. - QString currentBoardName; - QModelIndex board = sourceIndex.parent(); - while (board.isValid() && board.parent().isValid()) { - board = board.parent(); - } - if (board.isValid()) { - currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - } - - if (isCardRow) { - if (currentBoardName != DECK_ZONE_TOKENS) { - addMoveToZoneMenu(&menu, sourceIndex, currentBoardName); - menu.addSeparator(); - } - } else if (isCustomZoneRow) { - const QString zoneName = - sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - - QAction *renameAction = menu.addAction(tr("&Rename zone...")); - connect(renameAction, &QAction::triggered, this, [this, zoneName] { - // The unchanged name must not validate as a duplicate. - const QString newName = - DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) { - return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate); - }); - if (!newName.isEmpty() && newName != zoneName) { - deckStateManager->renameCustomZone(zoneName, newName); - } - }); - - QMenu *boardMenu = menu.addMenu(tr("Change &board")); - addChangeBoardMenu(boardMenu, zoneName); - - QAction *deleteAction = menu.addAction(tr("&Delete zone")); - const bool zoneHasCards = getModel()->hasChildren(sourceIndex); - deleteAction->setEnabled(!zoneHasCards); - if (zoneHasCards) { - deleteAction->setToolTip(tr("Move or remove all cards first.")); - menu.setToolTipsVisible(true); - } - connect(deleteAction, &QAction::triggered, this, [this, zoneName] { - const auto result = - QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (result == QMessageBox::Yes) { - deckStateManager->removeCustomZone(zoneName); - } - }); - menu.addSeparator(); - } else if (isBoardZoneRow) { - const QString boardName = - sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - // Tokens cannot host custom zones, so only offer the action on real boards. - const bool canHostCustomZones = - boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD; - if (canHostCustomZones) { - addNewZoneAction(&menu, boardName); - menu.addSeparator(); - } - } else if (!sourceIndex.isValid()) { - addNewZoneAction(&menu); - menu.addSeparator(); - } - QAction *selectPrinting = menu.addAction(tr("Select Printing")); connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector); menu.exec(deckView->mapToGlobal(point)); } -void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu, - const QModelIndex &sourceCardIndex, - const QString ¤tBoardName) -{ - // The card's current *zone*, derived with the same ancestor walk as - // DeckStateManager::moveCardToZone (nearest custom-zone ancestor, else the - // top-level board/zone): a card inside "Removal" under the maindeck lives in - // "Removal", not "main". Comparing against that instead of the board keeps - // the enabled state and the same-zone no-op consistent with the move logic. - QString currentZoneName; - for (QModelIndex ancestor = sourceCardIndex.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { - if (ancestor.data(DeckRoles::IsCustomZoneRole).toBool() || !ancestor.parent().isValid()) { - currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - break; - } - } - - const auto addMoveAction = [this, sourceCardIndex](QMenu *targetMenu, const QString &targetZoneName, - const QString &label, bool enabled) { - QAction *action = targetMenu->addAction(label); - action->setEnabled(enabled); - if (enabled) { - connect(action, &QAction::triggered, this, [this, sourceCardIndex, targetZoneName] { - deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName); - }); - } - }; - - const auto tree = deckStateManager->getDeckListShared()->getTree(); - - QMenu *moveMenu = menu->addMenu(tr("Move to &zone")); - - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - const QString boardLabel = InnerDecklistNode::visibleNameFromName(boardName); - const auto customZones = tree->getCustomZones(boardName); - - // Boards with zones nest their children so no two menu entries share a - // visible name: "Maindeck ▸ { Maindeck (whole board), Removal, … }". - // The board the card already lives on is marked instead of offered. - if (!customZones.isEmpty()) { - QMenu *boardSubmenu = moveMenu->addMenu(boardLabel); - addMoveAction(boardSubmenu, boardName, boardLabel, boardName != currentZoneName); - for (const auto *customZone : customZones) { - addMoveAction(boardSubmenu, customZone->getName(), customZone->getName(), - customZone->getName() != currentZoneName); - } - } else { - addMoveAction(moveMenu, boardName, boardLabel, boardName != currentZoneName); - } - } - - moveMenu->addSeparator(); - - QAction *newZoneAction = moveMenu->addAction(tr("Create new zone and move &here...")); - connect(newZoneAction, &QAction::triggered, this, [this, sourceCardIndex, currentBoardName, currentZoneName] { - // Resolve the card's identity before creating the zone: - // createNewCustomZone rebuilds the model tree, so sourceCardIndex's - // internal pointer is freed by the time it would be used. - const QString cardName = - sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - const QString providerId = - sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); - const QString collectorNumber = sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_COLLECTOR_NUMBER) - .data(Qt::DisplayRole) - .toString(); - - const QString zoneName = createNewCustomZone(currentBoardName); - if (!zoneName.isEmpty()) { - // Re-find the card: the old index is no longer safe since rows were - // rebuilt. Mirror DeckStateManager::decrementCard's re-find pattern. - const QModelIndex refreshed = getModel()->findCard(cardName, currentZoneName, providerId, collectorNumber); - if (refreshed.isValid()) { - deckStateManager->moveCardToZone(refreshed, zoneName); - } - } - }); -} - -void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName) -{ - const auto tree = deckStateManager->getDeckListShared()->getTree(); - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); - - // The board currently holding the zone is marked instead of offered. - // Duplicate names cannot come up through the editor, so this doubles as - // the uniqueness guard for imported decks. - bool holdsTheZone = false; - for (const auto *customZone : tree->getCustomZones(boardName)) { - if (customZone->getName() == zoneName) { - holdsTheZone = true; - break; - } - } - if (holdsTheZone) { - action->setCheckable(true); - action->setChecked(true); - continue; - } - - connect(action, &QAction::triggered, this, - [this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); }); - } -} - -void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName) -{ - QAction *newZoneAction = menu->addAction(tr("Create &new zone...")); - connect(newZoneAction, &QAction::triggered, this, - [this, initialBoardName] { createNewCustomZone(initialBoardName); }); -} - -QString DeckEditorDeckDockWidget::createNewCustomZone(const QString &initialBoardName) -{ - QString boardName; - const QString zoneName = - DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) { - return deckStateManager->validateNewZoneName(candidate); - }); - if (!zoneName.isEmpty()) { - deckStateManager->createCustomZone(boardName, zoneName); - } - return zoneName; -} - void DeckEditorDeckDockWidget::refreshShortcuts() { ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 1e5f4e677..9db01e2e5 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -19,7 +19,6 @@ #include #include #include -#include #include #include #include @@ -103,11 +102,6 @@ private: [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); - void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName); - void addChangeBoardMenu(QMenu *menu, const QString &zoneName); - QString createNewCustomZone(const QString &initialBoardName = {}); - void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {}); - private slots: void decklistCustomMenu(QPoint point); void updateCard(QModelIndex, const QModelIndex ¤t); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index e563729a4..eda741728 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -2,7 +2,6 @@ #include #include -#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), @@ -308,170 +307,6 @@ 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 zone through the tree's own lookup, which walks every top-level - // zone (not just the standard boards) and covers the same-board no-op below. - const auto *zone = tree->findCustomZoneByName(zoneName); - if (!zone) { - return false; - } - - // Same-board moves are no-ops and must not pollute the history. - const QString currentBoardName = zone->getParent() ? zone->getParent()->getName() : QString(); - 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 *targetZone : tree->getCustomZones(newBoardZoneName)) { - if (targetZone->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()) { @@ -532,25 +367,6 @@ void DeckStateManager::requestHistorySave(const QString &reason) historyManager->save(deckList->createMemento(reason)); } -bool DeckStateManager::modifyTree(const QString &reason, const std::function &operation) -{ - DeckListMemento memento = deckList->createMemento(reason); - bool success = operation(deckList->getTree()); - - if (success) { - historyManager->save(memento); - deckListModel->rebuildTree(); - deckList->refreshDeckHash(); - emit deckListModel->deckHashChanged(); - // removeCustomZone can drop whole card sets the model never notified - // about (rebuildTree emits no cardNodesChanged), so tell the consumers. - emit deckListModel->cardNodesChanged(); - doCardModified(); - } - - return success; -} - /** * @brief Handles updating state and emitting signals whenever the cards are modified */ diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 2c8b34a39..b9c99903e 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -5,7 +5,6 @@ #include "deck_list_model.h" #include -#include #include class DeckListHistoryManager; @@ -237,68 +236,6 @@ 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. @@ -320,7 +257,6 @@ public slots: private: bool offsetCountAtIndex(const QModelIndex &idx, int offset); - bool modifyTree(const QString &reason, const std::function &operation); void doCardModified(); void doMetadataModified(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp deleted file mode 100644 index 9a0be2570..000000000 --- a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp +++ /dev/null @@ -1,145 +0,0 @@ -#include "deck_zone_dialog.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -DeckZoneDialog::DeckZoneDialog(QWidget *parent, - const QString &initialBoardName, - const std::function &_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 &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 ¤tZoneName, - const std::function &nameValidator) -{ - DeckZoneDialog dialog(parent, {}, nameValidator, false); - dialog.setZoneName(currentZoneName); - return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString(); -} diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h deleted file mode 100644 index 6f55617a8..000000000 --- a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @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 -#include -#include -#include - -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 &_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 &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 ¤tZoneName, - const std::function &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 nameValidator; - bool allowBoardSelection; -}; - -#endif // DECK_ZONE_DIALOG_H diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index 0f43893d3..209a30642 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -4,7 +4,6 @@ #include "../../../../client/settings/shortcuts_settings.h" #include "../../cards/card_info_display_widget.h" #include "../../deck_editor/deck_state_manager.h" -#include "../../deck_editor/deck_zone_dialog.h" #include "../../filters/filter_builder.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/widgets/cards/card_info_frame_widget.h" @@ -85,7 +84,6 @@ void TabDeckEditorVisual::createCentralFrame() connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this, &TabDeckEditorVisual::showPrintingSelector); connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo); - tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); }); centralFrame->addWidget(tabContainer); setCentralWidget(centralWidget); @@ -271,19 +269,6 @@ bool TabDeckEditorVisual::actSaveDeckAs() return result; } -/** @brief Prompts for and creates a new custom deck zone. Returns the name of the created zone. */ -QString TabDeckEditorVisual::createNewZone() -{ - QString boardName; - const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) { - return deckStateManager->validateNewZoneName(candidate); - }); - if (!zoneName.isEmpty()) { - deckStateManager->createCustomZone(boardName, zoneName); - } - return zoneName; -} - /** @brief Refreshes keyboard shortcuts for this tab from settings. */ void TabDeckEditorVisual::refreshShortcuts() { diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h index fb09578c4..21335d2d0 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h @@ -165,12 +165,6 @@ public slots: */ bool actSaveDeckAs() override; - /** - * @brief Prompts for and creates a new custom deck zone. - * @return The name of the created zone, or an empty string if creation was cancelled. - */ - QString createNewZone(); - private: /** * @brief Sets the deck for this tab and selects the sub-tab to open on diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 76bbf344b..0cdf60d5d 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -21,7 +21,6 @@ #include #include #include -#include #include #include @@ -90,19 +89,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseView->setItemDelegate(nullptr); databaseView->setVisible(false); - // Without a deck model there is nothing to add cards to, so the zone menu stays hidden. - if (deckListModel) { - databaseView->setZoneMenuProvider( - [deckListModel]() -> QList> { - QList> result; - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); - } - return result; - }, - [this] { return newZoneCreator ? newZoneCreator() : QString(); }); - } - searchEdit->setTreeView(databaseView); searchEdit->installEventFilter(databaseView->getKeySignals()); @@ -209,11 +195,6 @@ void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event) initializeFilters(); } -void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function &creator) -{ - newZoneCreator = creator; -} - void VisualDatabaseDisplayWidget::retranslateUi() { databaseLoadIndicator->setText(tr("Loading database ...")); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index d161ce362..6e4d87876 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -22,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -47,12 +46,6 @@ public: void sortCardList(const QStringList &properties, Qt::SortOrder order) const; void setDeckList(const DeckList &new_deck_list_model); - /** - * @brief Sets the callback used to create a custom zone from the add-to-zone menu. - * The callback returns the name of the created zone, or an empty string if creation was cancelled. - */ - void setNewZoneCreator(const std::function &creator); - CardDatabaseDisplayModel *getDatabaseDisplayModel() { return databaseDisplayModel; @@ -113,7 +106,6 @@ private: VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; CardDatabaseView *databaseView; - std::function newZoneCreator; QList *cards; QVBoxLayout *mainLayout; QScrollArea *scrollArea; diff --git a/format.sh b/format.sh index 9e3a6069b..ca3557ea7 100755 --- a/format.sh +++ b/format.sh @@ -18,7 +18,6 @@ include=("cockatrice/src" \ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ -"cmake/pch" \ "tests") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ 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 5d91cd233..af1193f26 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 @@ -115,25 +115,6 @@ public: */ QList 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 Finds a custom zone anywhere in the deck by name. - * - * Walks the children of every top-level zone, so a zone nested under any - * board (and not just the standard ones) is found. - * @param zoneName The zone name to find. - * @return The matching zone node, or nullptr if none exists. - */ - InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const; - /** * @brief Applies a function to every card in the deck tree. This can modify the cards. * @@ -147,6 +128,8 @@ private: 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/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index d082b3cca..ec860dc56 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -43,13 +43,6 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method) } } -const QList &InnerDecklistNode::boardZoneNames() -{ - static const QList names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), - QString(DECK_ZONE_MAYBEBOARD)}; - return names; -} - QString InnerDecklistNode::getVisibleName() const { return visibleNameFromName(name); @@ -94,9 +87,6 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber( int InnerDecklistNode::height() const { - if (isEmpty()) { - return 1; - } return at(0)->height() + 1; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 0d454c11e..906ed6cb5 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -18,9 +18,6 @@ #include "abstract_deck_list_node.h" -#include -#include - /** @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 &boardZoneNames(); - /** * @brief Get this node’s display-friendly name. * @return Human-readable name (zone/group name). diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index aaf391c03..25e8e97db 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include static peg::parser search(R"( @@ -20,7 +19,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue) +SetQuery <- ('e'/'set') [:] FlexStringValue OracleQuery <- 'o' [:] MatcherString @@ -65,8 +64,6 @@ RegexMatcherString <- ('\\/' / !'/' .)+ FlexStringValue <- CompactStringSet / String / [(] StringList [)] CompactStringSet <- StringListString ([,+] StringListString)+ -SetExpression <- NumericOperator ws? String - NumericExpression <- NumericOperator ws? NumericValue NumericOperator <- [=:] / <[> NumericValue <- [0-9]+ @@ -104,25 +101,12 @@ static void setupParserRules() return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { - if (sv.choice() == 1) { - auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); - - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::any_of(sets.begin(), sets.end(), matchesSet); - }; - } - - auto matcher = std::any_cast(sv[0]); + auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - const auto &sets = x->getSets().values(); - auto matchesSet = [&](const PrintingInfo &printing) { - return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay()); - }; - return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) { - return std::any_of(printings.begin(), printings.end(), matchesSet); - }); + QList sets = x->getSets().keys(); + + auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::any_of(sets.begin(), sets.end(), matchesSet); }; }; search["Rarity"] = [](const peg::SemanticValues &sv) -> QString { @@ -263,54 +247,40 @@ static void setupParserRules() return QString::fromStdString(std::string(sv.sv())); }; - search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer { - const auto op = QString::fromStdString(std::string(sv.sv())); + search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { + const auto arg = std::any_cast(sv[1]); + const auto op = std::any_cast(sv[0]); if (op == ">") { - return [=](const int s, const int arg) { return s > arg; }; + return [=](const int s) { return s > arg; }; } if (op == ">=") { - return [=](const int s, const int arg) { return s >= arg; }; + return [=](const int s) { return s >= arg; }; } if (op == "<") { - return [=](const int s, const int arg) { return s < arg; }; + return [=](const int s) { return s < arg; }; } if (op == "<=") { - return [=](const int s, const int arg) { return s <= arg; }; + return [=](const int s) { return s <= arg; }; } if (op == "=") { - return [=](const int s, const int arg) { return s == arg; }; + return [=](const int s) { return s == arg; }; } if (op == ":") { - return [=](const int s, const int arg) { return s == arg; }; + return [=](const int s) { return s == arg; }; } if (op == "!=") { - return [=](const int s, const int arg) { return s != arg; }; + return [=](const int s) { return s != arg; }; } - return [](int, int) { return false; }; + return [](int) { return false; }; }; search["NumericValue"] = [](const peg::SemanticValues &sv) -> int { return QString::fromStdString(std::string(sv.sv())).toInt(); }; - search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { - const auto comparer = std::any_cast(sv[0]); - const auto arg = std::any_cast(sv[1]); - return [=](int s) { return comparer(s, arg); }; - }; - - search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { - const auto comparer = std::any_cast(sv[0]); - const auto setCode = std::any_cast(sv[1]); - const auto allSets = CardDatabaseManager::getInstance()->getSetList(); - for (auto &set : allSets) { - if (set->getShortName() == setCode) { - const int releaseDate = set->getReleaseDate().toJulianDay(); - return [=](int s) { return comparer(s, releaseDate); }; - } - } - return [](int) { return false; }; + search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString { + return QString::fromStdString(std::string(sv.sv())); }; search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index a058f7d07..71a99f7b5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -22,7 +22,6 @@ typedef CardInfoPtr CardData; typedef std::function Filter; typedef std::function StringMatcher; typedef std::function NumberMatcher; -typedef std::function NumberComparer; namespace peg { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index a6ab2a204..d4aee3686 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -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}) diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 76afca0c4..9b43281c1 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -66,8 +66,7 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(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) { continue; } @@ -83,19 +82,8 @@ void DeckListModel::rebuildTree() new DecklistModelCardNode(currentCard, groupNode); } - - // Custom zones nested under the board zone are mirrored as-is, with their - // cards as direct children (no further grouping). - DeckListModelCustomZones::mirrorCustomZones(currentZone, node); } - // The shadow tree was built in deck file order. Apply the active sort while - // the reset is still open so every consumer (tree view and visual editor) - // sees the canonical order from the start. sortShadowTree emits no signals, - // which is only valid before endResetModel closes the reset. - root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName); - sortShadowTree(root, lastKnownOrder); - endResetModel(); refreshCardFormatLegalities(); @@ -166,9 +154,6 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const case DeckRoles::IsLegalRole: return true; - case DeckRoles::IsCustomZoneRole: - return DeckListModelCustomZones::isCustomZone(group); - default: return {}; } @@ -205,10 +190,6 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } - case DeckRoles::IsCustomZoneRole: { - return false; - } - default: { return {}; } @@ -346,13 +327,6 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) return false; } - // Custom zone rows are managed through the deck tree, never removed as model rows. - for (int i = 0; i < count; i++) { - if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) { - return false; - } - } - beginRemoveRows(parent, row, row + count - 1); for (int i = 0; i < count; i++) { AbstractDecklistNode *toDelete = node->takeAt(row); @@ -363,8 +337,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)) { removeRows(parent.row(), 1, parent.parent()); } else { emitRecursiveUpdates(parent); @@ -378,8 +351,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) { - // Group lookups must not resolve a mirrored custom zone that shares the name. - auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name); + auto *newNode = dynamic_cast(parent->findChild(name)); if (!newNode) { beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); newNode = new InnerDecklistNode(name, parent); @@ -393,44 +365,24 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName, const QString &providerId, const QString &cardNumber) const { + InnerDecklistNode *zoneNode = dynamic_cast(root->findChild(zoneName)); + if (!zoneNode) { + return nullptr; + } + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); if (!info) { return nullptr; } - // 1. Board zone lookup: search the criteria groups, then the custom zones - // nested under the board. - if (auto *zoneNode = dynamic_cast(root->findChild(zoneName))) { - QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); - if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) { - if (auto *card = dynamic_cast( - groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { - return card; - } - } - - for (auto *child : *zoneNode) { - if (!DeckListModelCustomZones::isCustomZone(child)) { - continue; - } - auto *customZone = dynamic_cast(child); - if (!customZone) { - continue; - } - if (auto *card = dynamic_cast( - customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { - return card; - } - } + QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); + InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria)); + if (!groupNode) { + return nullptr; } - // 2. Custom zone lookup by name (custom zone names are deck-unique). - if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) { - return dynamic_cast( - customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); - } - - return nullptr; + return dynamic_cast( + groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); } QModelIndex DeckListModel::findCard(const QString &cardName, @@ -471,95 +423,29 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam return {}; } + InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root); + CardInfoPtr cardInfo = card.getCardPtr(); PrintingInfo printingInfo = card.getPrinting(); - InnerDecklistNode *cardParent = nullptr; + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode); - auto *boardNode = dynamic_cast(root->findChild(zoneName)); - auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName); - - // Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row - // index is only usable as a deck-tree position while both sides have the same - // direct-children shape. When they diverge, the card is appended to the deck - // zone instead of being written out of range. - InnerDecklistNode *deckCardParent = nullptr; - bool customZoneNeedsAppend = false; - - if (boardNode) { - // Board zone: cards are grouped by the active criteria. - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - cardParent = createNodeIfNeeded(groupCriteria, boardNode); - } else if (customZoneNode) { - // Custom zone: cards live flat inside the zone. - cardParent = customZoneNode; - auto *listRoot = deckList->getTree()->getRoot(); - for (int i = 0; i < listRoot->size(); ++i) { - auto *boardZone = dynamic_cast(listRoot->at(i)); - if (!boardZone) { - continue; - } - deckCardParent = dynamic_cast(boardZone->findChild(zoneName)); - if (deckCardParent) { - break; - } - } - // A deck custom zone holding nested sub-zones mirrors with flattened rows, - // so a shadow row index does not map onto its direct children. - if (deckCardParent) { - for (int i = 0; i < deckCardParent->size(); ++i) { - if (dynamic_cast(deckCardParent->at(i))) { - customZoneNeedsAppend = true; - break; - } - } - } - } else { - // Not present in the shadow tree. The deck tree may still hold a custom - // zone that has not been mirrored (callers can add a zone and then a - // card without a rebuild). Check before falling back to creating a - // top-level zone the deck does not actually have. - auto *listRoot = deckList->getTree()->getRoot(); - bool hasDeckZone = false; - for (int i = 0; i < listRoot->size(); ++i) { - if (auto *boardZone = dynamic_cast(listRoot->at(i))) { - // Only real zones count: a card sitting directly under the board - // shares the name comparison but is not a zone, and treating it as - // one would recurse forever without mirroring anything. - if (dynamic_cast(boardZone->findChild(zoneName))) { - hasDeckZone = true; - break; - } - } - } - - if (hasDeckZone) { - rebuildTree(); - return addCard(card, zoneName); - } - - // Unknown zone: create a top-level zone (legacy behavior). - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - auto *newZone = createNodeIfNeeded(zoneName, root); - cardParent = createNodeIfNeeded(groupCriteria, newZone); - } - - const QModelIndex parentIndex = nodeToIndex(cardParent); - auto *cardNode = dynamic_cast(cardParent->findCardChildByNameProviderIdAndNumber( + const QModelIndex parentIndex = nodeToIndex(groupNode); + auto *cardNode = dynamic_cast(groupNode->findCardChildByNameProviderIdAndNumber( card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num"))); const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName(); bool cardNodeAdded = false; if (!cardNode) { // Determine the correct index - int insertRow = findSortedInsertRow(cardParent, cardInfo); - int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow; + int insertRow = findSortedInsertRow(groupNode, cardInfo); - auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName, + auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName, printingInfo.getProperty("num"), printingInfo.getProperty("uuid")); beginInsertRows(parentIndex, insertRow, insertRow); - cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow); + cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); endInsertRows(); cardNodeAdded = true; @@ -690,41 +576,21 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const return createIndex(node->getParent()->indexOf(node), 0, node); } -/** - * @brief Sorts a freshly built shadow subtree without emitting model signals. - * - * Used by rebuildTree while the model reset is still open (emitting layout - * changes during a reset is invalid). Reorders every node just like - * sortHelper does, but ignores the movement mapping because there are no - * persistent indices established yet. - */ -void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order) -{ - // The mapping is not needed: fresh shadow nodes have no persistent indices yet. - (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); - - for (int i = node->size() - 1; i >= 0; --i) { - if (auto *subNode = dynamic_cast(node->at(i))) { - sortShadowTree(subNode, 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> sortResult = node->sort(order); 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 i = sortResult.size() - 1; i >= 0; --i) { + const int fromRow = sortResult[i].first; + const int toRow = sortResult[i].second; + AbstractDecklistNode *temp = node->at(toRow); for (int j = 0; j < columns; ++j) { - from << createIndex(preSortRow, j, temp); - to << createIndex(finalRow, j, temp); + from << createIndex(fromRow, j, temp); + to << createIndex(toRow, j, temp); } } changePersistentIndexList(from, to); @@ -838,15 +704,6 @@ QList DeckListModel::getZones() const return zones; } -QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const -{ - QStringList zoneNames; - for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) { - zoneNames.append(customZone->getName()); - } - return zoneNames; -} - static int maxAllowedForLegality(const FormatRules &format, const QString &legality) { for (const AllowedCount &c : format.allowedCounts) { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 09600ca67..209ec8c42 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -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 @@ -32,8 +30,7 @@ enum { IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */ DepthRole, /**< Depth level within the deck's grouping hierarchy. */ - IsLegalRole, /**< Whether the card is legal in the current deck format. */ - IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */ + IsLegalRole /**< Whether the card is legal in the current deck format. */ }; } // namespace DeckRoles @@ -394,14 +391,6 @@ public: */ [[nodiscard]] QList getZones() const; - /** - * @brief Gets the names of the custom zones nested under the given board zone. - * - * @param boardZoneName The board zone to query (main/side/maybeboard) - * @return The custom zone names, in deck order - */ - [[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const; - private: QSharedPointer deckList; /**< Pointer to the decklist providing the underlying data. */ InnerDecklistNode *root; /**< Root node of the model tree. */ @@ -438,7 +427,6 @@ private: void emitRecursiveUpdates(const QModelIndex &index); void sortHelper(InnerDecklistNode *node, Qt::SortOrder order); - void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order); template T getNode(const QModelIndex &index) const { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp deleted file mode 100644 index 1dc745e63..000000000 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp +++ /dev/null @@ -1,152 +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 -#include - -namespace DeckListModelCustomZones -{ - -bool isCustomZone(const AbstractDecklistNode *node) -{ - return dynamic_cast(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(zone->at(k))) { - new DecklistModelCardNode(zoneCard, shadowZone); - } else if (auto *subZone = dynamic_cast(zone->at(k))) { - flattenCards(subZone, shadowZone); - } - } -} - -} // namespace - -void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone) -{ - for (int j = 0; j < deckBoardZone->size(); j++) { - auto *customZone = dynamic_cast(deckBoardZone->at(j)); - if (!customZone) { - continue; - } - - auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone); - flattenCards(customZone, shadowZone); - } -} - -InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name) -{ - for (int i = 0; i < parent->size(); i++) { - AbstractDecklistNode *child = parent->at(i); - if (isCustomZone(child)) { - continue; - } - auto *group = dynamic_cast(child); - if (group && group->getName() == name) { - return group; - } - } - return nullptr; -} - -DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName) -{ - for (int i = 0; i < root->size(); i++) { - auto *boardZone = dynamic_cast(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; -} - -namespace -{ - -/** - * @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping. - */ -QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) -{ - const QVector> sortResult = node->sort(order); - - QList> 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> boardSort(InnerDecklistNode *node, Qt::SortOrder order) -{ - const QVector> sortResult = node->sort(order); - - QVector groups; - QVector customZones; - QHash 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 ordered = groups + customZones; - for (int i = 0; i < ordered.size(); ++i) { - node->replace(i, ordered[i]); - } - - QList> mapping; - mapping.reserve(ordered.size()); - for (int i = 0; i < ordered.size(); ++i) { - mapping.append({preSortRowOf.value(ordered[i]), i}); - } - return mapping; -} - -} // namespace - -QList> 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 diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h deleted file mode 100644 index 518a9e1d2..000000000 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h +++ /dev/null @@ -1,98 +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 -#include -#include - -/** - * @class DecklistModelSubZoneNode - * @ingroup DeckModels - * @brief Model node representing a custom zone nested under a board zone. - * - * Custom zones group cards by user-defined names (e.g. "Removal", "Utility") - * inside a board zone. They are mirrored from the underlying deck tree so that - * they can be told apart from criteria group nodes by type. - */ -class DecklistModelSubZoneNode : public InnerDecklistNode -{ -public: - using InnerDecklistNode::InnerDecklistNode; -}; - -/** - * @namespace DeckListModelCustomZones - * @ingroup DeckModels - * @brief Tree-level helpers for the deck list model's custom-zone shadow nodes. - * - * The deck list model keeps a second "shadow" tree of InnerDecklistNode that - * mirrors the canonical deck tree for grouping and sorting. Custom zones add a - * layer of bookkeeping to that shadow tree: they must be mirrored alongside - * criteria groups, always sort after the groups within a board, and be - * resolvable by deck-unique name. - * - * This namespace centralizes every "what is / where is a custom zone" decision - * so the model itself only wires the results into Qt model signals. - */ -namespace DeckListModelCustomZones -{ - -/** - * @brief Whether the given node is a custom zone (as opposed to a criteria group). - */ -[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node); - -/** - * @brief Finds a criteria-group child of @p parent by name, skipping custom zones. - * - * The shadow tree keeps criteria groups and mirrored custom zones as siblings - * under a board zone, and `InnerDecklistNode::findChild` matches both by name. - * Group lookups must not resolve a custom zone that happens to share the group - * name (e.g. a zone called "Creature"), so this searches only non-custom - * children. - * - * @param parent The shadow node whose children are searched. - * @param name The group name to find. - * @return The matching group node, or nullptr if none exists. - */ -[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name); - -/** - * @brief Mirrors the custom zones of a deck board zone into its shadow board node. - * - * Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone - * with its cards as direct (un-grouped) children. - * - * @param deckBoardZone The board zone in the canonical deck tree. - * @param shadowBoardZone The matching board zone in the model's shadow tree. - */ -void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone); - -/** - * @brief Finds a custom zone in the shadow tree by deck-unique name. - * @param root Root of the shadow tree. - * @param zoneName The custom zone name to find. - * @return The matching custom zone node, or nullptr if not found. - */ -[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName); - -/** - * @brief Sorts a shadow node's children, keeping a board's custom zones last. - * - * Sorting alone would interleave custom zones with criteria groups by name, but - * custom zones must always stay after the groups within a board, regardless of - * name. This applies the sort and, for board zones, stably moves the custom - * zones to the end. - * - * @param root Root of the shadow tree (used to classify board zones). - * @param node The shadow node whose children are reordered. - * @param order Sort order to apply. - * @return A list of (preSortRow, finalRow) pairs describing how each node moved. - */ -[[nodiscard]] QList> -sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); - -} // namespace DeckListModelCustomZones - -#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 68c4709bb..6a29b6935 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -23,7 +23,6 @@ set(oracle_SOURCES src/pages.cpp src/pagetemplates.cpp src/parsehelpers.cpp - src/raw_json_scanner.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp @@ -113,8 +112,6 @@ qt6_add_executable( MANUAL_FINALIZATION ) -target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") - # ------------------------ # Link libraries # ------------------------ diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index d745b250c..49577baf0 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -45,23 +44,26 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s return priority; } -bool OracleImporter::readSetsFromByteArray(QByteArray data) +bool OracleImporter::readSetsFromByteArray(const QByteArray &data) { - RawJson::ScanError error; - const QList ranges = RawJson::scanSetRanges(data, &error); - if (error.isError()) { - qDebug() << "error: RawJson::scanSetRanges():" << error.message; + QJsonParseError error; + auto doc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qDebug() << "error: QJsonDocument::fromJson():" << error.errorString(); return false; } - QList newSetList; - newSetList.reserve(ranges.size()); + auto setsObj = doc.object().value("data").toObject(); - for (const RawJson::SetRange &range : ranges) { - QString shortName = range.code.toUpper(); - QString longName = range.name; - QString setType = range.type; - QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate); + QList newSetList; + + for (auto it = setsObj.constBegin(); it != setsObj.constEnd(); ++it) { + QJsonObject setObj = it.value().toObject(); + QString shortName = setObj.value("code").toString().toUpper(); + QString longName = setObj.value("name").toString(); + QJsonArray setCards = setObj.value("cards").toArray(); + QString setType = setObj.value("type").toString(); + QDate releaseDate = QDate::fromString(setObj.value("releaseDate").toString(), Qt::ISODate); CardSet::Priority priority = getSetPriority(setType, shortName); // capitalize set type if (setType.length() > 0) { @@ -81,9 +83,7 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data) } setType = setType.trimmed(); } - SetToDownload set(shortName, longName, priority, setType, releaseDate); - set.setRawRange(range.dataRange); - newSetList.append(set); + newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate)); } std::sort(newSetList.begin(), newSetList.end()); @@ -92,7 +92,6 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data) return false; } allSets = newSetList; - rawSetsData = std::move(data); return true; } @@ -551,16 +550,22 @@ int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); - // Pre-allocate the cards hash to avoid rehashing during import. Keys are - // distinct card names while raw ranges only count printings (AllPrintings - // ~100k printings vs ~35k names), so this over-reserves somewhat; an exact - // distinct-name count would require eagerly parsing, which the lazy reader - // deliberately avoids. It's a capacity hint, so the overshoot is harmless. - int estimatedCards = 0; - for (const SetToDownload &curSetToParse : allSets) { - estimatedCards += curSetToParse.getRawRange().cardCount; + // Pre-allocate the cards hash to avoid rehashing during import. The hash + // is keyed by distinct card name rather than by printings: AllPrintings + // ships ~100k printings for ~35k names, so reserving the printing count + // would overallocate ~3x (against this stack's RAM goal). Collecting + // distinct names is cheap — one pass over the already-parsed name fields. + { + QSet distinctNames; + for (const SetToDownload &curSetToParse : allSets) { + for (const QJsonValue &cardValue : curSetToParse.getCards()) { + distinctNames.insert(cardValue.toObject().value("name").toString()); + } + } + cards.reserve(distinctNames.size()); + // The set goes out of scope here, handing the ~35k name QStrings back + // to the allocator before the (memory-heavy) import loop starts. } - cards.reserve(estimatedCards); // add an empty set for tokens CardSetPtr tokenSet = @@ -573,44 +578,11 @@ int OracleImporter::startImport() CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), curSetToParse.getLongName(), curSetToParse.getSetType(), curSetToParse.getReleaseDate(), curSetToParse.getPriority()); - - // parse only this set's slice of the raw document so the whole JSON tree is - // never kept in memory at once - const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange(); - const qsizetype rangeEnd = rawRange.start + rawRange.length; - if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) { - // rawSetsData is cleared by releaseSetData() while SetToDownload copies - // taken from getSets() keep their ranges, and nothing else enforces the - // pairing — so never index past the buffer on stale/mismatched ranges. - qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping"; - ++setIndex; - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; - } - // sliced() shares the buffer instead of deep-copying the slice; the largest - // sets in AllPrintings are tens of MB, so the copy is worth avoiding here. - const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length); - QJsonParseError parseError; - const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError); - if (parseError.error != QJsonParseError::NoError) { - qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":" - << parseError.errorString(); - ++setIndex; - // Keep the progress accounting honest: a set that failed to parse - // still advanced the index, so report it (with zero imported cards) - // rather than letting SaveSetsPage's bar stall per failed set. - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; - } - - // Only add the set to the database once its slice parsed cleanly; - // a set that fails here must not persist as an empty set in cards.xml. if (!sets.contains(newSet->getShortName())) { sets.insert(newSet->getShortName(), newSet); } - const QJsonArray setCards = setDoc.object().value("cards").toArray(); - int numCardsInSet = importCardsFromSet(newSet, setCards); + int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards()); ++setIndex; @@ -633,7 +605,6 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr void OracleImporter::releaseSetData() { allSets.clear(); - rawSetsData.clear(); } void OracleImporter::clear() @@ -641,5 +612,4 @@ void OracleImporter::clear() sets.clear(); cards.clear(); allSets.clear(); - rawSetsData.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 8cb30ca40..52a7cd349 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,9 +1,6 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H -#include "raw_json_scanner.h" - -#include #include #include #include @@ -49,12 +46,10 @@ class SetToDownload { private: QString shortName, longName; + QJsonArray cards; QDate releaseDate; QString setType; CardSet::Priority priority; - // Byte range of this set's object within the importer's raw JSON text. Parsing - // one set at a time keeps peak memory low instead of holding the whole document. - RawJson::SetDataRange rawRange; public: const QString &getShortName() const @@ -65,6 +60,10 @@ public: { return longName; } + const QJsonArray &getCards() const + { + return cards; + } const QString &getSetType() const { return setType; @@ -77,23 +76,16 @@ public: { return priority; } - const RawJson::SetDataRange &getRawRange() const - { - return rawRange; - } SetToDownload(QString _shortName, QString _longName, + QJsonArray _cards, CardSet::Priority _priority, QString _setType = QString(), const QDate &_releaseDate = QDate()) - : shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate), - setType(std::move(_setType)), priority(_priority) + : shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)), + releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority) { } - void setRawRange(const RawJson::SetDataRange &_rawRange) - { - rawRange = _rawRange; - } bool operator<(const SetToDownload &set) const { return longName.compare(set.longName, Qt::CaseInsensitive) < 0; @@ -149,12 +141,6 @@ private: QList allSets; - /** - * The raw JSON text of the source document, retained for lazy per-set - * parsing during startImport(). Frees the card data as each set is imported. - */ - QByteArray rawSetsData; - CardInfoPtr addCard(QString name, const QString &text, bool isToken, @@ -167,11 +153,7 @@ signals: public: explicit OracleImporter(QObject *parent = nullptr); - /** - * Scans the given JSON document for set metadata. Takes the data by value so - * the wizard can hand over its decompressed buffer without copying it. - */ - bool readSetsFromByteArray(QByteArray data); + bool readSetsFromByteArray(const QByteArray &data); int startImport(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); @@ -187,10 +169,6 @@ public: { return allSets; } - const QByteArray &getRawSetsData() const - { - return rawSetsData; - } void releaseSetData(); void clear(); }; diff --git a/oracle/src/raw_json_scanner.cpp b/oracle/src/raw_json_scanner.cpp deleted file mode 100644 index db972491c..000000000 --- a/oracle/src/raw_json_scanner.cpp +++ /dev/null @@ -1,621 +0,0 @@ -#include "raw_json_scanner.h" - -#include - -namespace -{ - -// Nesting cap matching QJsonDocument's limit, so a pathologically deep document -// fails shallowly instead of overflowing the stack through the recursive -// skipValue/skipArray/skipObject walk (Qt's parser caps at 1024 for the same -// reason and reports DeepNesting). -constexpr int kMaxNestingDepth = 1024; - -inline bool isWhitespace(char c) -{ - return c == ' ' || c == '\t' || c == '\r' || c == '\n'; -} - -const char *skipWhitespace(const char *p, const char *end) -{ - while (p < end && isWhitespace(*p)) { - ++p; - } - return p; -} - -inline bool isHexDigit(char c) -{ - return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); -} - -inline quint8 hexValue(char c) -{ - if (c >= '0' && c <= '9') { - return c - '0'; - } - if (c >= 'a' && c <= 'f') { - return c - 'a' + 10; - } - return c - 'A' + 10; -} - -/** - * @brief Skips past a JSON string without decoding it, validating escapes. - * @param p In: pointing at the opening quote. Out: pointing past the closing quote. - */ -bool skipString(const char *&p, const char *end) -{ - ++p; // opening quote - for (;;) { - const void *quote = memchr(p, '"', static_cast(end - p)); - if (!quote) { - return false; // unterminated string - } - // Backslash escapes can only appear before the closing quote, so bound - // the scan to the string extent instead of the rest of the document. - const void *backslash = memchr(p, '\\', static_cast(static_cast(quote) - p)); - if (!backslash) { - p = static_cast(quote) + 1; - return true; - } - const char *b = static_cast(backslash); - if (end - b < 2) { - return false; - } - const char escaped = b[1]; - if (escaped == 'u') { - if (end - b < 6) { - return false; - } - quint32 codepoint = 0; - for (int i = 0; i < 4; ++i) { - if (!isHexDigit(b[2 + i])) { - return false; - } - codepoint = codepoint * 16 + hexValue(b[2 + i]); - } - p = b + 6; - if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { - // expect the low-surrogate escape for the second half - if (end - p < 6 || p[0] != '\\' || p[1] != 'u') { - return false; // unpaired high surrogate - } - quint32 low = 0; - for (int i = 0; i < 4; ++i) { - if (!isHexDigit(p[2 + i])) { - return false; - } - low = low * 16 + hexValue(p[2 + i]); - } - if (low < 0xDC00 || low > 0xDFFF) { - return false; - } - p += 6; - } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { - return false; // unpaired low surrogate - } - continue; - } - switch (escaped) { - case '"': - case '\\': - case '/': - case 'b': - case 'f': - case 'n': - case 'r': - case 't': - p = b + 2; - continue; - default: - return false; // invalid escape - } - } -} - -/** - * @brief Decodes a JSON string into @p out, validating it as it goes. - * @param p In: pointing at the opening quote. Out: pointing past the closing quote. - */ -bool decodeString(const char *&p, const char *end, QString &out) -{ - out.clear(); - QByteArray utf8; - auto flush = [&out, &utf8]() { - if (!utf8.isEmpty()) { - out += QString::fromUtf8(utf8); - utf8.clear(); - } - }; - - ++p; // opening quote - while (p < end) { - const char c = *p; - if (c == '\\') { - flush(); - ++p; // escaped character - if (p >= end) { - return false; - } - const char escaped = *p; - if (escaped == 'u') { - ++p; // first hex digit - if (p + 4 > end) { - return false; - } - quint32 codepoint = 0; - for (int i = 0; i < 4; ++i) { - if (!isHexDigit(p[i])) { - return false; - } - codepoint = codepoint * 16 + hexValue(p[i]); - } - p += 4; - if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { - // expect a low-surrogate escape for the second half - if (p + 6 > end || p[0] != '\\' || p[1] != 'u') { - return false; // unpaired high surrogate - } - quint32 low = 0; - for (int i = 0; i < 4; ++i) { - if (!isHexDigit(p[2 + i])) { - return false; - } - low = low * 16 + hexValue(p[2 + i]); - } - if (low < 0xDC00 || low > 0xDFFF) { - return false; - } - out += QChar(codepoint); - out += QChar(low); - p += 6; - } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { - return false; // unpaired low surrogate - } else { - out += QChar(codepoint); - } - continue; - } - switch (escaped) { - case '"': - out += '"'; - break; - case '\\': - out += '\\'; - break; - case '/': - out += '/'; - break; - case 'b': - out += '\b'; - break; - case 'f': - out += '\f'; - break; - case 'n': - out += '\n'; - break; - case 'r': - out += '\r'; - break; - case 't': - out += '\t'; - break; - default: - return false; - } - ++p; - continue; - } - if (c == '"') { - ++p; - flush(); - return true; - } - // Deliberately accept unescaped control characters (e.g. a tab inside - // a set name): QJsonDocument and skipString accept them too, so - // rejecting them here would fail the whole document on a byte that - // Qt is fine with — the very total-failure mode this scanner avoids. - utf8 += c; - ++p; - } - return false; -} - -/** - * @brief Reads a set-metadata field, tolerating null and non-string values. - * - * A set's metadata may carry null or non-string values in otherwise-valid - * payloads ("releaseDate": null, "type": 7). The token itself was already - * structurally validated by skipValue, so a non-string value is accepted and - * leaves @p out at its default (empty) — one bad set must not abort the - * import of every other set in the document. - */ -bool decodeStringMember(const char *&fs, const char *&fe, QString &out) -{ - if (fs >= fe) { - return false; - } - if (*fs != '"') { - return true; - } - return decodeString(fs, fe, out); -} - -bool matchLiteral(const char *&p, const char *end, const char *literal, int length) -{ - if (end - p < length || memcmp(p, literal, static_cast(length)) != 0) { - return false; - } - const char *after = p + length; - if (after < end && (QChar::isLetter(*after) || QChar::isDigit(*after) || *after == '_')) { - return false; - } - p = after; - return true; -} - -bool skipNumber(const char *&p, const char *end) -{ - // JSON number: -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? - if (p < end && *p == '-') { - ++p; - } - if (p < end && *p == '0') { - ++p; - } else if (p < end && *p >= '1' && *p <= '9') { - ++p; - while (p < end && QChar::isDigit(*p)) { - ++p; - } - } else { - return false; - } - if (p < end && *p == '.') { - ++p; - if (p >= end || !QChar::isDigit(*p)) { - return false; - } - while (p < end && QChar::isDigit(*p)) { - ++p; - } - } - if (p < end && (*p == 'e' || *p == 'E')) { - ++p; - if (p < end && (*p == '+' || *p == '-')) { - ++p; - } - if (p >= end || !QChar::isDigit(*p)) { - return false; - } - while (p < end && QChar::isDigit(*p)) { - ++p; - } - } - return true; -} - -bool skipValue(const char *&p, const char *end, int depth); -bool skipObject(const char *&p, const char *end, int depth); -bool skipArray(const char *&p, const char *end, int depth); - -bool skipPrimitive(const char *&p, const char *end) -{ - if (p >= end) { - return false; - } - const char c = *p; - if (c == '"') { - return skipString(p, end); - } - if (c == 't') { - return matchLiteral(p, end, "true", 4); - } - if (c == 'f') { - return matchLiteral(p, end, "false", 5); - } - if (c == 'n') { - return matchLiteral(p, end, "null", 4); - } - if (c == '-' || (c >= '0' && c <= '9')) { - return skipNumber(p, end); - } - return false; -} - -bool skipObject(const char *&p, const char *end, int depth) -{ - if (depth <= 0) { - return false; // nest deeper than the cap - } - ++p; // '{' - p = skipWhitespace(p, end); - if (p < end && *p == '}') { - ++p; - return true; - } - for (;;) { - p = skipWhitespace(p, end); - if (p >= end || *p != '"') { - return false; - } - if (!skipString(p, end)) { - return false; - } - p = skipWhitespace(p, end); - if (p >= end || *p != ':') { - return false; - } - ++p; - if (!skipValue(p, end, depth - 1)) { - return false; - } - p = skipWhitespace(p, end); - if (p >= end) { - return false; - } - if (*p == ',') { - ++p; - continue; - } - if (*p == '}') { - ++p; - return true; - } - return false; - } -} - -bool skipArray(const char *&p, const char *end, int depth) -{ - if (depth <= 0) { - return false; // nest deeper than the cap - } - ++p; // '[' - p = skipWhitespace(p, end); - if (p < end && *p == ']') { - ++p; - return true; - } - for (;;) { - if (!skipValue(p, end, depth - 1)) { - return false; - } - p = skipWhitespace(p, end); - if (p >= end) { - return false; - } - if (*p == ',') { - ++p; - continue; - } - if (*p == ']') { - ++p; - return true; - } - return false; - } -} - -bool skipValue(const char *&p, const char *end, int depth) -{ - p = skipWhitespace(p, end); - if (p >= end) { - return false; - } - const char c = *p; - if (c == '{') { - // pass depth through: skipObject consumes the single decrement for this level - return skipObject(p, end, depth); - } - if (c == '[') { - return skipArray(p, end, depth); - } - // a primitive is a leaf, so it never wastes a nesting level - return skipPrimitive(p, end); -} - -/** - * @brief Iterates the members of the object starting at @p p. - * - * For each member invokes @p memberCallback with the key and the byte range of - * its value. Advancing @p p is unaffected by the callback. - */ -template bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback) -{ - if (depth <= 0) { - return false; // nest deeper than the cap - } - ++p; // '{' - p = skipWhitespace(p, end); - if (p < end && *p == '}') { - ++p; - return true; - } - for (;;) { - p = skipWhitespace(p, end); - if (p >= end || *p != '"') { - return false; - } - QString key; - if (!decodeString(p, end, key)) { - return false; - } - p = skipWhitespace(p, end); - if (p >= end || *p != ':') { - return false; - } - ++p; - const char *valueStart = skipWhitespace(p, end); - const char *valueEnd = valueStart; - if (!skipValue(valueEnd, end, depth - 1)) { - return false; - } - if (!memberCallback(key, valueStart, valueEnd)) { - return false; - } - p = valueEnd; - p = skipWhitespace(p, end); - if (p >= end) { - return false; - } - if (*p == ',') { - ++p; - continue; - } - if (*p == '}') { - ++p; - return true; - } - return false; - } -} - -// Counts the direct elements of an array value; returns -1 if the array is malformed. -int countArrayElements(const char *p, const char *end, int depth) -{ - if (depth <= 0) { - return -1; // nest deeper than the cap - } - ++p; // '[' - p = skipWhitespace(p, end); - int count = 0; - if (p < end && *p == ']') { - return 0; - } - for (;;) { - if (!skipValue(p, end, depth - 1)) { - return -1; - } - ++count; - p = skipWhitespace(p, end); - if (p >= end) { - return -1; - } - if (*p == ',') { - ++p; - continue; - } - if (*p == ']') { - return count; - } - return -1; - } -} - -} // namespace - -namespace RawJson -{ - -QList scanSetRanges(const QByteArray &json, ScanError *error) -{ - QList ranges; - if (error) { - *error = ScanError{}; - } - - const auto fail = [&](const QString &message) -> QList { - if (error) { - error->message = message; - } - return {}; - }; - - const char *begin = json.constData(); - const char *end = begin + json.size(); - if (begin >= end) { - return fail(QStringLiteral("empty JSON document")); - } - - const char *p = skipWhitespace(begin, end); - if (p >= end || *p != '{') { - return fail(QStringLiteral("top-level JSON must be an object")); - } - - bool foundData = false; - bool malformedSetData = false; - - const auto topLevelCallback = [&](const QString &key, const char *valueStart, const char *valueEnd) { - if (key == QStringLiteral("data")) { - foundData = true; - if (valueStart >= valueEnd || *valueStart != '{') { - malformedSetData = true; - return false; - } - const char *setP = valueStart; - const bool ok = forEachObjectMember(setP, valueEnd, kMaxNestingDepth - 1, - [&](const QString &setCode, const char *setStart, const char *setEnd) { - if (setStart >= setEnd || *setStart != '{') { - malformedSetData = true; - return false; - } - SetRange range; - range.dataRange.start = setStart - begin; - range.dataRange.length = setEnd - setStart; - range.code = setCode; - - const char *memberP = setStart; - const bool metaOk = forEachObjectMember( - memberP, setEnd, kMaxNestingDepth - 2, - [&](const QString &field, const char *fs, const char *fe) { - if (field == QStringLiteral("code")) { - return decodeStringMember(fs, fe, range.code); - } - if (field == QStringLiteral("name")) { - return decodeStringMember(fs, fe, range.name); - } - if (field == QStringLiteral("type")) { - return decodeStringMember(fs, fe, range.type); - } - if (field == QStringLiteral("releaseDate")) { - return decodeStringMember(fs, fe, range.releaseDate); - } - if (field == QStringLiteral("cards")) { - if (fs >= fe) { - return false; - } - if (*fs != '[') { - // e.g. "cards": null — treat as an empty array, - // matching Qt's tolerance. - return true; - } - range.dataRange.cardCount = - countArrayElements(fs, fe, kMaxNestingDepth - 2); - return range.dataRange.cardCount >= 0; - } - return true; - }); - if (!metaOk) { - malformedSetData = true; - return false; - } - ranges.append(range); - return true; - }); - if (!ok) { - malformedSetData = true; - return false; - } - } - return true; - }; - - if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback)) { - return fail(malformedSetData ? QStringLiteral("malformed set data") : QStringLiteral("malformed JSON")); - } - p = skipWhitespace(p, end); - if (p != end) { - return fail(QStringLiteral("trailing content after top-level JSON object")); - } - if (!foundData) { - return fail(QStringLiteral("missing \"data\" object")); - } - if (ranges.isEmpty()) { - return fail(QStringLiteral("no sets found in \"data\"")); - } - return ranges; -} - -} // namespace RawJson \ No newline at end of file diff --git a/oracle/src/raw_json_scanner.h b/oracle/src/raw_json_scanner.h deleted file mode 100644 index f6e3a4647..000000000 --- a/oracle/src/raw_json_scanner.h +++ /dev/null @@ -1,76 +0,0 @@ -#ifndef RAW_JSON_SCANNER_H -#define RAW_JSON_SCANNER_H - -#include -#include -#include - -namespace RawJson -{ - -/** - * @brief The byte extent of a set's object inside the scanned document, plus - * the size of its cards array. This is the slice SetToDownload needs for lazy - * per-set parsing; the metadata strings live in SetRange alongside it. - */ -struct SetDataRange -{ - /** @brief Byte offset of the set's object within the scanned buffer. */ - qsizetype start = -1; - /** @brief Byte length of the set's object, including the surrounding braces. */ - qsizetype length = 0; - /** @brief Number of entries in the set's "cards" array. */ - int cardCount = 0; -}; - -struct SetRange -{ - /** @brief The byte slice of this set within the document. */ - SetDataRange dataRange; - QString code; - QString name; - QString type; - QString releaseDate; -}; - -struct ScanError -{ - bool isError() const - { - return !message.isEmpty(); - } - QString message; -}; - -/** - * @brief Scans a full MTGJSON document without materializing the JSON tree. - * - * Splits the top-level "data" object into per-set byte ranges and reads each - * set's metadata directly from the raw bytes. The oracle importer can then - * parse one set at a time during import, keeping peak memory far below a single - * QJsonDocument::fromJson() over the whole file. - * - * The whole document is structurally validated while scanning (strings, - * escapes, braces, and a trailing-content check) and nesting depth is capped at - * 1024 to match QJsonDocument, so pathologically deep documents fail shallowly - * instead of exhausting the stack. Verdicts agree with QJsonDocument::fromJson - * on structurally malformed input; unlike Qt, string metadata fields - * ("name", "type", "releaseDate", "code") tolerate null / non-string values by - * defaulting to empty rather than rejecting the whole document, so one broken - * set cannot abort the import of the rest. - * - * Following QJsonDocument::fromJson's convention, the parsed ranges are - * returned by value and any failure is reported through the @p error out - * parameter. - * - * @param json The raw MTGJSON document bytes. - * @param error Out parameter. Set to an error ScanError when the document - * cannot be parsed, otherwise left empty. Passing a null - * pointer disables error reporting. - * @return The detected per-set ranges, or an empty list on failure. - */ -QList scanSetRanges(const QByteArray &json, ScanError *error = nullptr); - -} // namespace RawJson - -#endif // RAW_JSON_SCANNER_H \ No newline at end of file diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 5d8089ad1..aba63800c 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -95,8 +95,6 @@ set(DESKTOPDIR # Build servatrice binary and link it add_executable(servatrice MACOSX_BUNDLE ${servatrice_MOC_SRCS} ${servatrice_RESOURCES_RCC} ${servatrice_SOURCES}) -target_precompile_headers(servatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") - if(CMAKE_HOST_SYSTEM MATCHES "FreeBSD") target_link_libraries( servatrice libcockatrice_deck_list libcockatrice_network_server_remote Threads::Threads ${SERVATRICE_QT_MODULES} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 18ab60d06..34784538b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt deleted file mode 100644 index e3096c559..000000000 --- a/tests/deck_list_model/CMakeLists.txt +++ /dev/null @@ -1,33 +0,0 @@ -add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(deck_list_model_custom_zones_test gtest) -endif() - -target_link_libraries( - deck_list_model_custom_zones_test - libcockatrice_models - libcockatrice_card - libcockatrice_deck_list - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) -add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test) - -add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(deck_list_model_zone_integration_test gtest) -endif() - -target_link_libraries( - deck_list_model_zone_integration_test - libcockatrice_models - libcockatrice_card - libcockatrice_deck_list - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) -add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test) diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp deleted file mode 100644 index 7f4ab81e1..000000000 --- a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp +++ /dev/null @@ -1,276 +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 -#include -#include -#include - -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(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(shadowZone->at(0)); - auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); - ASSERT_NE(shadowCard1, nullptr); - ASSERT_NE(shadowCard2, nullptr); - EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); - EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); -} - -TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop) -{ - // A board zone with only direct cards has nothing to mirror. - auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); - new DecklistCardNode("Direct", 2, deckBoard); - - InnerDecklistNode shadowRoot; - auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); - - DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); - EXPECT_EQ(shadowBoard->size(), 0); -} - -TEST(DeckListModelCustomZones, MirrorCustomZonesFlattensNestedSubzones) -{ - // Cards deeper than one level under a custom zone still get a model row. - auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); - auto *deckZone = new InnerDecklistNode("Removal", deckBoard); - auto *deckCard1 = new DecklistCardNode("Bolt", 1, deckZone); - auto *deeper = new InnerDecklistNode("Deeper", deckZone); - auto *deckCard2 = new DecklistCardNode("Swords", 1, deeper); - - InnerDecklistNode shadowRoot; - auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); - - DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); - - ASSERT_EQ(shadowBoard->size(), 1); - auto *shadowZone = dynamic_cast(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(shadowZone->at(0)); - auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); - ASSERT_NE(shadowCard1, nullptr); - ASSERT_NE(shadowCard2, nullptr); - EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); - EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); -} - -// ===================================================================================================================== -// findGroupChild -// ===================================================================================================================== - -TEST(DeckListModelCustomZones, FindGroupChildSkipsCustomZones) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - auto *group = new InnerDecklistNode("Creature", board); - new DecklistModelSubZoneNode("Creature", board); - - EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Creature"), group); - EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Missing"), nullptr); - EXPECT_EQ(DeckListModelCustomZones::findGroupChild(&root, DECK_ZONE_MAIN), board); -} - -// ===================================================================================================================== -// sortWithCustomZonesLast -// ===================================================================================================================== - -TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsAscending) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - new DecklistModelSubZoneNode("Zebra", board); - new InnerDecklistNode("Creature", board); - new InnerDecklistNode("Instant", board); - new DecklistModelSubZoneNode("Alpha", board); - - root.setSortMethod(DeckSortMethod::ByName); - - auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); - - // Groups sort first (by name), then custom zones (by name), always after groups. - EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); - - // Some non-identity movement occurred. - EXPECT_FALSE(mapping.isEmpty()); -} - -TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsDescending) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - new DecklistModelSubZoneNode("Zebra", board); - new InnerDecklistNode("Creature", board); - new InnerDecklistNode("Instant", board); - new DecklistModelSubZoneNode("Alpha", board); - - root.setSortMethod(DeckSortMethod::ByName); - - (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder); - - // Groups still lead (descending), custom zones still last. - EXPECT_EQ(childNames(board), (QStringList{"Instant", "Creature", "Zebra", "Alpha"})); -} - -TEST(DeckListModelCustomZones, SortBoardMappingIsConsistent) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - - QList originalOrder; - auto *g0 = new InnerDecklistNode("Creature", board); - originalOrder.append(g0); - auto *z0 = new DecklistModelSubZoneNode("Zebra", board); - originalOrder.append(z0); - auto *g1 = new InnerDecklistNode("Instant", board); - originalOrder.append(g1); - auto *z1 = new DecklistModelSubZoneNode("Alpha", board); - originalOrder.append(z1); - - root.setSortMethod(DeckSortMethod::ByName); - - auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); - - // The mapping reports, for each final row, the original row of the node now sitting there. - ASSERT_EQ(mapping.size(), board->size()); - for (const auto &move : mapping) { - const int preSortRow = move.first; - const int finalRow = move.second; - ASSERT_GE(preSortRow, 0); - ASSERT_LT(preSortRow, originalOrder.size()); - EXPECT_EQ(board->at(finalRow), originalOrder[preSortRow]) << "row " << finalRow; - } - - // Final order sanity: groups first in name order, then custom zones. - EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); -} - -TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) -{ - // A non-board node (e.g. a group whose children are cards) is sorted plainly; - // custom zones are not a special case there. Cards sort by name. - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - auto *group = new InnerDecklistNode("Creature", board); - cardNode(group, "Swords", 1); - cardNode(group, "Bolt", 3); - - root.setSortMethod(DeckSortMethod::ByName); - - auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, group, Qt::AscendingOrder); - EXPECT_EQ(childNames(group), (QStringList{"Bolt", "Swords"})); - ASSERT_EQ(mapping.size(), 2); - EXPECT_EQ(mapping[0].first, 1); // "Bolt" was originally at row 1 - EXPECT_EQ(mapping[0].second, 0); - EXPECT_EQ(mapping[1].first, 0); - EXPECT_EQ(mapping[1].second, 1); -} - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp deleted file mode 100644 index a4562c95d..000000000 --- a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp +++ /dev/null @@ -1,283 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace -{ - -int totalCustomZoneRows(const DeckListModel &model) -{ - int count = 0; - const int rootRows = model.rowCount(QModelIndex()); - for (int r = 0; r < rootRows; ++r) { - const QModelIndex board = model.index(r, 0, QModelIndex()); - const int childRows = model.rowCount(board); - for (int c = 0; c < childRows; ++c) { - const QModelIndex child = model.index(c, 0, board); - if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { - ++count; - } - } - } - return count; -} - -QModelIndex findBoardIndex(const DeckListModel &model, const QString &boardName) -{ - for (int r = 0; r < model.rowCount(QModelIndex()); ++r) { - const QModelIndex idx = model.index(r, 0, QModelIndex()); - if (idx.data(DeckRoles::IsCardRole).toBool()) { - continue; - } - const QString name = idx.sibling(idx.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - if (name == boardName) { - return idx; - } - } - return {}; -} - -QModelIndex findZoneRow(const DeckListModel &model, const QModelIndex &board) -{ - for (int r = 0; r < model.rowCount(board); ++r) { - const QModelIndex child = model.index(r, 0, board); - if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { - return child; - } - } - return {}; -} - -} // namespace - -// The "Add to Zone" combobox/submenu lists getCustomZoneNames(), which reads the -// deck tree. These verify the source data a freshly-created zone populates. - -TEST(DeckListModelZoneIntegration, CreateZoneThenReadCustomZoneNames) -{ - QSharedPointer 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 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 deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - // One direct mainboard card plus two nested custom zones. - tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Swords to Plowshares", 1, "Removal", -1); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); - - model.rebuildTree(); - - EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); - EXPECT_EQ(totalCustomZoneRows(model), 2); -} - -// ===================================================================================================================== -// Model behaviour: addCard routing, findCard lookup, removeRows guard, empty-zone survival. -// ===================================================================================================================== - -TEST(DeckListModelZoneIntegration, AddCardRoutesIntoMirroredCustomZone) -{ - QSharedPointer 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(listRoot->at(i))) { - topLevelRemoval |= zone->getName() == "Removal"; - } - } - EXPECT_FALSE(topLevelRemoval); -} - -TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel) -{ - QSharedPointer 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 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 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 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 deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - // An empty custom zone must be mirrored (the stack deliberately keeps it alive). - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - model.rebuildTree(); - - const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); - ASSERT_TRUE(mainIndex.isValid()); - EXPECT_EQ(model.rowCount(mainIndex), 1); - EXPECT_TRUE(findZoneRow(model, mainIndex).isValid()); -} - -// Regression: a board card named like the requested zone must not be mistaken for -// a zone. Previously `findChild` matched any child by name, so a mainboard card -// called "Lightning Bolt" made addCard believe a "Lightning Bolt" zone existed and -// recurse through rebuildTree forever. -TEST(DeckListModelZoneIntegration, AddCardToCardNamedZoneDoesNotRecurse) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); - - QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Lightning Bolt"); - ASSERT_TRUE(added.isValid()); -} - -// Regression: adding to a custom zone that holds a nested sub-zone mirrored the -// nested cards as flattened shadow rows, so the sorted shadow row index pointed -// past the deck zone's direct children. The card must be appended to the deck -// zone instead of being written out of range. -TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - auto *removal = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); - ASSERT_NE(removal, nullptr); - auto *deeper = new InnerDecklistNode("Deeper", removal); - new DecklistCardNode("Lightning Bolt", 2, deeper, -1); - model.rebuildTree(); - - QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Removal"); - ASSERT_TRUE(added.isValid()); - ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool()); -} - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp index 801f226a9..a5148621d 100644 --- a/tests/deck_list_zones/deck_list_zones_test.cpp +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -213,42 +213,6 @@ TEST(DeckListZones, MoveCustomZoneMovesCards) EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); } -TEST(DeckListZones, MoveCustomZoneFailsForUnknownBoard) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Lightning Bolt", 2, "Removal", -1); - - EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); - - // The zone is still under main. - EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); -} - -// Regression: findCustomZoneByName walks every top-level zone, so a custom zone -// an imported deck carries under a non-standard board (tokens) is still found and -// movable. The pre-fix manager-level moveCustomZone only scanned the standard -// boards and returned false for these with no feedback. -TEST(DeckListZones, MoveCustomZoneNestedUnderTokensBoard) -{ - DeckList deck; - auto *tree = deck.getTree(); - auto *root = tree->getRoot(); - - auto *tokens = new InnerDecklistNode(DECK_ZONE_TOKENS, root); - auto *removal = new InnerDecklistNode("Removal", tokens); - new DecklistCardNode("Lightning Bolt", 2, removal, -1); - - EXPECT_TRUE(tree->findCustomZoneByName("Removal")); - EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); - - auto pairs = collectBoardCardPairs(deck); - EXPECT_FALSE(hasPair(pairs, DECK_ZONE_TOKENS, "Lightning Bolt")); - EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); -} - TEST(DeckListZones, RemoveCustomZoneRemovesCards) { DeckList deck; diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 9bc5ee5be..d126390c7 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -11,7 +11,7 @@ add_test(NAME parse_cipt_test COMMAND parse_cipt_test) # Oracle importer unit tests add_executable( oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp - ../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp + oracle_importer_test.cpp ) if(NOT GTEST_FOUND) @@ -25,33 +25,10 @@ target_link_libraries( add_test(NAME oracle_importer_test COMMAND oracle_importer_test) -# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) -# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark -# can download and decompress whatever AllPrintings format the default URL selects. -find_package(ZLIB) -if(ZLIB_FOUND) - add_definitions("-DHAS_ZLIB") - set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) - set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) - include_directories(${ZLIB_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") -endif() - -find_package(LibLZMA) -if(LIBLZMA_FOUND) - add_definitions("-DHAS_LZMA") - list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) - list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) - include_directories(${LIBLZMA_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") -endif() - +# Oracle importer benchmark tests (manual, not run in CI) add_executable( - oracle_importer_benchmark_test - ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp - ../../oracle/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES} + oracle_importer_benchmark_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp + ../../oracle/src/parsehelpers.cpp oracle_importer_benchmark_test.cpp ) if(NOT GTEST_FOUND) @@ -59,11 +36,6 @@ if(NOT GTEST_FOUND) endif() target_link_libraries( - oracle_importer_benchmark_test - libcockatrice_card - libcockatrice_interfaces - Threads::Threads - ${GTEST_BOTH_LIBRARIES} + oracle_importer_benchmark_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} - ${_ORACLE_BENCH_EXTRA_LIBRARIES} ) diff --git a/tests/oracle/oracle_importer_benchmark_test.cpp b/tests/oracle/oracle_importer_benchmark_test.cpp index 633c9e40a..b3cc55319 100644 --- a/tests/oracle/oracle_importer_benchmark_test.cpp +++ b/tests/oracle/oracle_importer_benchmark_test.cpp @@ -1,33 +1,13 @@ #include "../../oracle/src/oracleimporter.h" #include "gtest/gtest.h" -#include -#include #include #include -#include -#include #include #include #include -#include -#include -#include -#include -#include #include -#if defined(HAS_LZMA) -#include "../../oracle/src/lzma/decompress.h" -#endif -#if defined(HAS_ZLIB) -#include "../../oracle/src/zip/unzip.h" -#endif -#if defined(Q_OS_MACOS) -#include -#include -#endif - // Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set static QByteArray buildSyntheticData(int numSets, int cardsPerSet) { @@ -157,10 +137,9 @@ TEST(OracleBenchmark, ParseJsonThroughput) for (int i = 0; i < iterations; ++i) { OracleImporter importer; - QByteArray source = data; QElapsedTimer timer; timer.start(); - bool ok = importer.readSetsFromByteArray(std::move(source)); + bool ok = importer.readSetsFromByteArray(data); ASSERT_TRUE(ok); totalMs += timer.elapsed(); } @@ -278,301 +257,8 @@ TEST(OracleBenchmark, ImportCardsWithColors) .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); } -// ============================================================================ -// RAM usage measurement -// ============================================================================ - -// Mirrors the default AllPrintings URL selection in oracle/src/pages.cpp. -#if defined(HAS_LZMA) -static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.xz"); -#elif defined(HAS_ZLIB) -static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.zip"); -#else -static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json"); -#endif - -// Magic bytes also from oracle/src/pages.cpp -static const QByteArray kXzSignature("\xFD\x37\x7A\x58\x5A", 6); -static const QByteArray kZipSignature("PK"); - -struct MemorySnapshot -{ - qint64 peakRssKb = -1; // process high-water mark (VmHWM on Linux, ru_maxrss on macOS) - qint64 rssKb = -1; // current resident set size - bool available = false; - - static MemorySnapshot current() - { - MemorySnapshot snap; -#if defined(Q_OS_LINUX) - QFile statusFile("/proc/self/status"); - if (statusFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - // /proc files report size() == 0, so atEnd() is immediately true: read everything first. - const QList lines = statusFile.readAll().split('\n'); - for (const QByteArray &line : lines) { - if (line.startsWith("VmHWM:")) { - snap.peakRssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); - } else if (line.startsWith("VmRSS:")) { - snap.rssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); - } - } - snap.available = snap.peakRssKb >= 0; - } -#elif defined(Q_OS_MACOS) - struct rusage usage; - if (getrusage(RUSAGE_SELF, &usage) == 0) { - snap.peakRssKb = usage.ru_maxrss / 1024; // bytes -> kB - snap.available = snap.peakRssKb >= 0; - } - // getrusage has no current-RSS equivalent; task_info's resident_size - // is the closest macOS analog to Linux VmRSS. - mach_task_basic_info info = {}; - mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; - if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast(&info), &count) == - KERN_SUCCESS) { - snap.rssKb = info.resident_size / 1024; - } -#endif - return snap; - } -}; - -static QString formatKb(qint64 kb) -{ - if (kb < 0) { - return "N/A"; - } - return QString("%1 MB").arg(kb / 1024.0, 0, 'f', 1); -} - -static void logRamPhase(const QString &phase, const MemorySnapshot &baseline, const MemorySnapshot ¤t) -{ - if (!baseline.available || !current.available) { - qDebug().noquote() << QString(" %1: memory stats unavailable on this platform").arg(phase); - return; - } - // VmHWM / ru_maxrss are monotonically non-decreasing high-water marks, so a - // peak-based delta between phases is ~0.0 MB by construction once the - // fixture build has set the process peak. The live signals are current RSS - // and the process peak; the delta is meaningful only where the baseline was - // taken immediately before the phase it measures (e.g. the import phase, - // which compares afterParse against afterImport). - QString rssDelta = "N/A"; - if (current.rssKb >= 0 && baseline.rssKb >= 0) { - rssDelta = formatKb(current.rssKb - baseline.rssKb); - } - qDebug().noquote() << QString(" %1: current RSS %2 | delta vs baseline %3 | process peak %4") - .arg(phase) - .arg(formatKb(current.rssKb)) - .arg(rssDelta) - .arg(formatKb(current.peakRssKb)); -} - -// Decompresses the download payload when the default URL is a compressed build, -// mirroring the wizard's magic-byte handling in oracle/src/pages.cpp. -static QByteArray decompressSetsData(const QByteArray &payload) -{ - if (payload.startsWith(kXzSignature)) { -#if defined(HAS_LZMA) - QBuffer inBuffer(const_cast(&payload)); - QByteArray out; - QBuffer outBuffer(&out); - inBuffer.open(QIODevice::ReadOnly); - outBuffer.open(QIODevice::WriteOnly); - XzDecompressor xz; - if (!xz.decompress(&inBuffer, &outBuffer)) { - qDebug() << "RAM benchmark: xz decompression failed"; - return {}; - } - return out; -#else - qDebug() << "RAM benchmark: download is xz-compressed but this build has no LZMA support"; - return {}; -#endif - } - if (payload.startsWith(kZipSignature)) { -#if defined(HAS_ZLIB) - QBuffer inBuffer(const_cast(&payload)); - inBuffer.open(QIODevice::ReadOnly); - UnZip unzip; - if (unzip.openArchive(&inBuffer) != UnZip::Ok) { - qDebug() << "RAM benchmark: zip archive open failed"; - return {}; - } - if (unzip.fileList().size() != 1) { - qDebug() << "RAM benchmark: zip archive doesn't contain exactly one file"; - return {}; - } - QByteArray out; - QBuffer outBuffer(&out); - outBuffer.open(QIODevice::WriteOnly); - const auto errorCode = unzip.extractFile(unzip.fileList().value(0), &outBuffer); - unzip.closeArchive(); - if (errorCode != UnZip::Ok) { - qDebug() << "RAM benchmark: zip extraction failed"; - return {}; - } - return out; -#else - qDebug() << "RAM benchmark: download is zip-compressed but this build has no zlib support"; - return {}; -#endif - } - return payload; -} - -TEST(OracleBenchmark, ImportRamUsage) -{ - static constexpr int numSets = 30; - static constexpr int cardsPerSet = 2000; // ~60k cards, roughly AllPrintings scale - - // Baseline must precede the fixture build: a high-water mark set while - // generating the synthetic JSON would otherwise mask the importer phases. - // Where memory stats are unavailable (Windows), skip before doing the - // 60k-card fixture build, which would otherwise be pure wasted work. - const MemorySnapshot baseline = MemorySnapshot::current(); - if (!baseline.available) { - GTEST_SKIP() << "Memory stats unavailable on this platform"; - } - - const QByteArray data = buildSyntheticData(numSets, cardsPerSet); - - // The fixture build leaves freed-but-unreturned arenas behind (current RSS - // rarely falls once glibc allocates). Baseline immediately after it so the - // parse phase measures only the importer's own growth (~40 MB) rather than - // swallowing the fixture builder's spike. - const MemorySnapshot afterFixture = MemorySnapshot::current(); - logRamPhase("fixture build", baseline, afterFixture); - - NoopCardSetPriorityController controller; - OracleImporter importer; - - QElapsedTimer timer; - timer.start(); - ASSERT_TRUE(importer.readSetsFromByteArray(std::move(data))); - const qint64 parseMs = timer.elapsed(); - const MemorySnapshot afterParse = MemorySnapshot::current(); - - timer.restart(); - const int importedSets = importer.startImport(); - const qint64 importMs = timer.elapsed(); - const MemorySnapshot afterImport = MemorySnapshot::current(); - - importer.releaseSetData(); - const MemorySnapshot afterRelease = MemorySnapshot::current(); - - const int totalCards = importer.getCardList().size(); - qDebug().noquote() << QString("Oracle RAM Benchmark (synthetic): %1 sets, %2 cards, %3 MB JSON") - .arg(importedSets) - .arg(totalCards) - .arg(data.size() / (1024.0 * 1024.0), 0, 'f', 1); - qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); - qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); - logRamPhase("parse", afterFixture, afterParse); - logRamPhase("import", afterParse, afterImport); - logRamPhase("after releaseSetData()", afterImport, afterRelease); - - // Freeing the parsed tree rarely moves current RSS (allocator reuse), so the - // meaningful signal that release actually dropped the buffers is emptiness, - // not an RSS delta. - ASSERT_TRUE(importer.getSets().isEmpty()); -} - -TEST(OracleBenchmark, ImportRamUsageAllPrintings) -{ - // Only "1" enables the download: unset (the default and the CI setup) and - // an explicit "0" both disable it. - bool envOk = false; - const int enabled = qEnvironmentVariableIntValue("COCKATRICE_ORACLE_RAM_BENCHMARK", &envOk); - if (!envOk || enabled == 0) { - GTEST_SKIP() << "Set COCKATRICE_ORACLE_RAM_BENCHMARK=1 to download the real AllPrintings dataset for this " - "RAM benchmark. Default URL: " - << kDefaultAllPrintingsUrl.toDisplayString().toStdString(); - } - - // Baseline must precede the request so the phase covers the download + - // decompress step, including the payload materialized by readAll(). - const MemorySnapshot baseline = MemorySnapshot::current(); - if (!baseline.available) { - GTEST_SKIP() << "Memory stats unavailable on this platform"; - } - - QNetworkAccessManager nam; - QNetworkRequest request(kDefaultAllPrintingsUrl); - request.setHeader(QNetworkRequest::UserAgentHeader, "Cockatrice Oracle RAM benchmark"); - QNetworkReply *reply = nam.get(request); - - QEventLoop loop; - QTimer timeoutTimer; - timeoutTimer.setSingleShot(true); - bool timedOut = false; - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&] { - timedOut = true; - reply->abort(); - }); - timeoutTimer.start(10 * 60 * 1000); - loop.exec(); - timeoutTimer.stop(); - - // abort() leaves reply->error() as OperationCanceledError, so a timed-out - // download takes the same GTEST_SKIP path as any other network error - // instead of reading a truncated body and failing the parse below. - if (timedOut || reply->error() != QNetworkReply::NoError) { - GTEST_SKIP() << "Download failed: " << reply->errorString().toStdString(); - } - const QByteArray payload = reply->readAll(); - reply->deleteLater(); - - // mtgjson can answer 200 with an HTML page (mirrors the wizard's '<' check - // in pages.cpp); reject it before trying to decompress/parse. - if (payload.startsWith("<")) { - GTEST_SKIP() << "Download returned a non-JSON body (HTML page instead of data), skipping"; - } - - const QByteArray setsData = decompressSetsData(payload); - const MemorySnapshot afterDownload = MemorySnapshot::current(); - if (setsData.isEmpty()) { - GTEST_SKIP() << "No data to import (download or decompression failed)"; - } - - NoopCardSetPriorityController controller; - OracleImporter importer; - - QElapsedTimer timer; - timer.start(); - ASSERT_TRUE(importer.readSetsFromByteArray(std::move(setsData))); - const qint64 parseMs = timer.elapsed(); - const MemorySnapshot afterParse = MemorySnapshot::current(); - - timer.restart(); - const int importedSets = importer.startImport(); - const qint64 importMs = timer.elapsed(); - const MemorySnapshot afterImport = MemorySnapshot::current(); - - importer.releaseSetData(); - const MemorySnapshot afterRelease = MemorySnapshot::current(); - - const int totalCards = importer.getCardList().size(); - qDebug().noquote() << QString("Oracle RAM Benchmark (real AllPrintings): %1 sets, %2 unique cards") - .arg(importedSets) - .arg(totalCards); - qDebug().noquote() << QString(" URL: %1").arg(kDefaultAllPrintingsUrl.toDisplayString()); - qDebug().noquote() << QString(" Downloaded: %1 MB, decompressed: %2 MB") - .arg(payload.size() / (1024.0 * 1024.0), 0, 'f', 1) - .arg(setsData.size() / (1024.0 * 1024.0), 0, 'f', 1); - qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); - qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); - logRamPhase("download+decompress", baseline, afterDownload); - logRamPhase("parse", afterDownload, afterParse); - logRamPhase("import", afterParse, afterImport); - logRamPhase("after releaseSetData()", afterImport, afterRelease); -} - int main(int argc, char **argv) { - // Required for the event loop used by the real-AllPrintings download benchmark - QCoreApplication app(argc, argv); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 3834be9ed..145a2ca0f 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -545,202 +545,6 @@ TEST_F(OracleImporterTest, ApostropheNormalized) ASSERT_TRUE(importer->getCardList().contains("Jace's Ingenuity")); } -// ============================================================================ -// RawJson scanner tests -// ============================================================================ - -TEST_F(OracleImporterTest, ScanSetRangesMatchFullJsonParse) -{ - QJsonObject root; - QJsonObject data; - data["AAA"] = makeCard("Alpha Card"); - data["BBB"] = makeCard("Beta Card"); - root["data"] = data; - - const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Compact); - - RawJson::ScanError error; - const QList ranges = RawJson::scanSetRanges(bytes, &error); - ASSERT_FALSE(error.isError()) << error.message.toStdString(); - ASSERT_EQ(ranges.size(), 2); - - const QJsonObject wholeData = QJsonDocument::fromJson(bytes).object().value("data").toObject(); - for (const RawJson::SetRange &range : ranges) { - QJsonParseError parseError; - const QJsonDocument sliceDoc = QJsonDocument::fromJson( - QByteArray(bytes.constData() + range.dataRange.start, range.dataRange.length), &parseError); - ASSERT_EQ(parseError.error, QJsonParseError::NoError) - << range.code.toStdString() << ": " << parseError.errorString().toStdString(); - ASSERT_EQ(sliceDoc.object(), wholeData.value(range.code).toObject()) << "set " << range.code.toStdString(); - } -} - -TEST_F(OracleImporterTest, ScanSetRangesDecodesEscapesAndCountsCards) -{ - const QByteArray json = "{\"data\":{\"KEY\":{\"code\":\"zzz\",\"name\":\"\\u00c9tude \\ud83d\\ude00\"," - "\"type\":\"expansion\",\"releaseDate\":\"2024-01-05\"," - "\"cards\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}]}}}"; - - RawJson::ScanError error; - const QList ranges = RawJson::scanSetRanges(json, &error); - ASSERT_FALSE(error.isError()); - ASSERT_EQ(ranges.size(), 1); - - const RawJson::SetRange &range = ranges.first(); - ASSERT_EQ(range.code, "zzz"); // inner "code" wins over the object key - const QString expectedName = QString::fromUtf8("\xC3\x89tude ") + QChar(0xD83D) + QChar(0xDE00); - ASSERT_EQ(range.name, expectedName); - ASSERT_EQ(range.type, "expansion"); - ASSERT_EQ(range.releaseDate, "2024-01-05"); - ASSERT_EQ(range.dataRange.cardCount, 3); - - QJsonParseError parseError; - const QJsonDocument sliceDoc = QJsonDocument::fromJson( - QByteArray(json.constData() + range.dataRange.start, range.dataRange.length), &parseError); - ASSERT_EQ(parseError.error, QJsonParseError::NoError); - ASSERT_EQ(sliceDoc.object().value("name").toString(), expectedName); - ASSERT_EQ(sliceDoc.object().value("cards").toArray().size(), 3); -} - -TEST_F(OracleImporterTest, ScanSetRangesRejectsInvalidJson) -{ - const QList invalid = {"not json", - "[]", - "{\"data\":[]}", - "{\"data\":{}}", - "{\"other\":{}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\"," - "\"releaseDate\":\"2024-01-01\",\"cards\":[]}}} trailing", - "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\uZZZZ\"}]}}}", - "{\"data\":{\"A\":{\"cards\":[{\"name\":\"bad \\q escape\"}]}}}", - "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\ud800\"}]}}}"}; - - for (const QByteArray &json : invalid) { - RawJson::ScanError error; - RawJson::scanSetRanges(json, &error); - EXPECT_TRUE(error.isError()) << "expected failure for: " << json.constData(); - } -} - -TEST_F(OracleImporterTest, ScanSetRangesMatchesFullJsonParseVerdicts) -{ - // Verdicts must agree with QJsonDocument::fromJson for the inputs below — - // including the metadata quirks ("name": null, "type": 7, "releaseDate": null, - // "cards": null) that used to make the scanner reject sets Qt accepts. - const QList inputs = { - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[{" - "\"n\":1}]}}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":null,\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":null,\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":7,\"releaseDate\":\"2024-01-01\",\"cards\":null}}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"releaseDate\":\"2024-01-01\",\"cards\":[1,2,3]}}}", - // unescaped control character inside a string: QJsonDocument and - // skipString both accept it, so the scanner must not reject the whole doc - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"N\tX\",\"releaseDate\":\"2024-01-01\",\"cards\":[{\"n\":1}]}}}", - // structurally invalid JSON (both parsers must reject) - "not json", - "{\"data\":{\"A\":{\"name\":\"unterminated}}", - }; - - for (const QByteArray &input : inputs) { - QJsonParseError qtError; - QJsonDocument::fromJson(input, &qtError); - const bool qtOk = qtError.error == QJsonParseError::NoError; - - RawJson::ScanError scanError; - const QList ranges = RawJson::scanSetRanges(input, &scanError); - EXPECT_EQ(qtOk, !scanError.isError()) << "verdict mismatch for: " << input.constData(); - if (scanError.isError()) { - continue; - } - for (const RawJson::SetRange &range : ranges) { - QJsonParseError sliceError; - QJsonDocument::fromJson(QByteArray(input.constData() + range.dataRange.start, range.dataRange.length), - &sliceError); - EXPECT_EQ(sliceError.error, QJsonParseError::NoError) << "bad range slice for: " << input.constData(); - } - } -} - -TEST_F(OracleImporterTest, ScanSetRangesRejectsDeepNesting) -{ - // Far beyond the shared 1024 container cap: Qt reports DeepNesting and the - // scanner must reject too, without overflowing the stack through its - // recursive skipValue walk. - QString nesting; - nesting.reserve(10000); - for (int i = 0; i < 5000; ++i) { - nesting += '['; - } - for (int i = 0; i < 5000; ++i) { - nesting += ']'; - } - const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); - - QJsonParseError qtError; - QJsonDocument::fromJson(json, &qtError); - ASSERT_NE(qtError.error, QJsonParseError::NoError) << "expected Qt to reject deep nesting"; - - RawJson::ScanError scanError; - RawJson::scanSetRanges(json, &scanError); - ASSERT_TRUE(scanError.isError()) << "scanner accepted a document Qt rejects as too deeply nested"; -} - -TEST_F(OracleImporterTest, ScanSetRangesAcceptsQtMaxNesting) -{ - // Pins the boundary rather than only the far-past case: a depth Qt still - // accepts must be accepted by the scanner too. Before the fix the scanner's - // cap was roughly half of Qt's (each level cost two decrements), so a - // depth of 1000 here was rejected even though QJsonDocument parses it. - constexpr int depth = 1000; - QString nesting; - nesting.reserve(2 * depth); - for (int i = 0; i < depth; ++i) { - nesting += '['; - } - for (int i = 0; i < depth; ++i) { - nesting += ']'; - } - const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); - - QJsonParseError qtError; - QJsonDocument::fromJson(json, &qtError); - ASSERT_EQ(qtError.error, QJsonParseError::NoError) << "expected Qt to accept depth " << depth; - - RawJson::ScanError scanError; - RawJson::scanSetRanges(json, &scanError); - ASSERT_FALSE(scanError.isError()) << "scanner rejected a document Qt accepts at depth " << depth; -} - -// ============================================================================ -// Lazy per-set parsing tests -// ============================================================================ - -TEST_F(OracleImporterTest, StartImportParsesSetsLazily) -{ - QJsonObject setObj = makeCard("Lazy Import Card"); - QJsonArray cards; - cards.append(setObj); - QJsonObject dataSet; - dataSet["code"] = "tst"; - dataSet["name"] = "Test Set"; - dataSet["type"] = "expansion"; - dataSet["releaseDate"] = "2024-01-01"; - dataSet["cards"] = cards; - - QJsonObject root; - root["data"] = QJsonObject{{"TST", dataSet}}; - - const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); - ASSERT_TRUE(importer->readSetsFromByteArray(data)); - ASSERT_FALSE(importer->getRawSetsData().isEmpty()); - - const int importedSets = importer->startImport(); - ASSERT_EQ(importedSets, 1); - ASSERT_EQ(importer->getCardList().size(), 1); - ASSERT_FALSE(importer->getCardList().value("Lazy Import Card").isNull()); -} - int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv);