From 6e83d64622ba36b7d44fb668f784b39404e4ae00 Mon Sep 17 00:00:00 2001 From: DawnFire42 Date: Wed, 25 Feb 2026 13:35:09 -0500 Subject: [PATCH] feat(command-zone): add graphics implementation with integration tests Implement CommandZone - the main Qt graphics class for rendering and interacting with command zones. This commit brings together all previous components: - Uses CommandZoneState for visibility management - Uses CommandZoneLogic for card data handling - Uses ZoneToggleButton for visibility controls - Uses CommanderTaxCounter for tax display - Uses z_values.h for proper visual layering Zone modifications for command zone support: - SelectZone: add command zone to zone selection - StackZone: support command zone in stack operations - TableZone: command zone positioning integration - ViewZoneWidget: command zone viewing support Integration tests verify: - Counter parenting behavior - Zone state coordination - Full graphics stack interaction --- cockatrice/CMakeLists.txt | 1 + cockatrice/src/game/zones/command_zone.cpp | 374 +++++++ cockatrice/src/game/zones/command_zone.h | 190 ++++ cockatrice/src/game/zones/select_zone.cpp | 9 + cockatrice/src/game/zones/select_zone.h | 13 +- cockatrice/src/game/zones/stack_zone.cpp | 13 + cockatrice/src/game/zones/stack_zone.h | 1 + cockatrice/src/game/zones/table_zone.cpp | 9 +- .../src/game/zones/view_zone_widget.cpp | 9 +- tests/command_zone/CMakeLists.txt | 21 + .../command_zone_integration_test.cpp | 916 ++++++++++++++++++ tests/command_zone/command_zone_test_common.h | 22 + 12 files changed, 1570 insertions(+), 8 deletions(-) create mode 100644 cockatrice/src/game/zones/command_zone.cpp create mode 100644 cockatrice/src/game/zones/command_zone.h create mode 100644 tests/command_zone/command_zone_integration_test.cpp create mode 100644 tests/command_zone/command_zone_test_common.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 25705e2e2..70c7a3b04 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -106,6 +106,7 @@ set(cockatrice_SOURCES src/game/zones/card_zone.cpp src/game/zones/command_zone_state.cpp src/game/zones/hand_zone.cpp + src/game/zones/command_zone.cpp src/game/zones/logic/command_zone_logic.cpp src/game/zones/logic/card_zone_logic.cpp src/game/zones/logic/hand_zone_logic.cpp diff --git a/cockatrice/src/game/zones/command_zone.cpp b/cockatrice/src/game/zones/command_zone.cpp new file mode 100644 index 000000000..19e4ab81d --- /dev/null +++ b/cockatrice/src/game/zones/command_zone.cpp @@ -0,0 +1,374 @@ +#include "command_zone.h" + +// @see CommandZoneState for the extracted visibility/height state machine + +#include "../../client/settings/cache_settings.h" +#include "../../interface/theme_manager.h" +#include "../board/abstract_counter.h" +#include "../board/card_drag_item.h" +#include "../board/card_item.h" +#include "../player/player.h" +#include "../player/player_actions.h" +#include "../z_values.h" +#include "select_zone.h" +#include "zone_toggle_button.h" + +#include +#include +#include +#include +#include +#include + +CommandZone::CommandZone(CommandZoneLogic *_logic, int _zoneHeight, CommandZoneType _zoneType, QGraphicsItem *parent) + : SelectZone(_logic, parent), m_state(_zoneHeight, _zoneType), toggleButton(nullptr), cardClipContainer(nullptr) +{ + connect(themeManager, &ThemeManager::themeChanged, this, &CommandZone::updateBg); + updateBg(); + setCacheMode(DeviceCoordinateCache); + setFlag(QGraphicsItem::ItemClipsChildrenToShape, false); + + cardClipContainer = new QGraphicsRectItem(this); + cardClipContainer->setFlag(QGraphicsItem::ItemClipsChildrenToShape, true); + cardClipContainer->setPen(Qt::NoPen); + cardClipContainer->setBrush(Qt::NoBrush); + cardClipContainer->setRect(boundingRect()); + cardClipContainer->setZValue(ZValues::CARD_BASE); + + disconnect(getLogic(), &CardZoneLogic::cardAdded, this, &CardZone::onCardAdded); + connect(getLogic(), &CardZoneLogic::cardAdded, this, [this](CardItem *card) { + if (cardClipContainer && card) { + card->setParentItem(cardClipContainer); + card->update(); + } + }); + + if (!isPrimary()) { + toggleButton = new ZoneToggleButton(getZoneType(), this); + connect(toggleButton, &ZoneToggleButton::clicked, this, &CommandZone::toggleExpanded); + repositionToggleButton(); + } +} + +CommandZone::~CommandZone() +{ + disconnect(getLogic(), &CardZoneLogic::cardAdded, this, nullptr); +} + +void CommandZone::updateBg() +{ + update(); +} + +QRectF CommandZone::boundingRect() const +{ + return {0, 0, ZoneSizes::COMMAND_ZONE_WIDTH, currentHeight()}; +} + +qreal CommandZone::currentHeight() const +{ + return m_state.currentHeight(); +} + +bool CommandZone::isPrimary() const +{ + return m_state.isPrimary(); +} + +bool CommandZone::isMinimized() const +{ + return m_state.isMinimized(); +} + +bool CommandZone::isExpanded() const +{ + return m_state.isExpanded(); +} + +bool CommandZone::isCollapsed() const +{ + return m_state.isCollapsed(); +} + +void CommandZone::toggleExpanded() +{ + applyStateChange(m_state.tryToggleExpanded(shouldPreventCollapse())); +} + +void CommandZone::toggleMinimized() +{ + applyStateChange(m_state.tryToggleMinimized(), true); +} + +void CommandZone::applyStateChange(const StateChangeResult &result, bool updateTogglePosition) +{ + if (!result.geometryChanged) { + return; + } + + prepareGeometryChange(); + if (cardClipContainer) { + cardClipContainer->setRect(boundingRect()); + } + if (toggleButton) { + toggleButton->setExpanded(m_state.isExpanded()); + } + reorganizeCards(); + if (updateTogglePosition) { + repositionToggleButton(); + } + update(); + + if (result.shouldEmitMinimized) { + emit minimizedChanged(m_state.isMinimized()); + } + if (result.shouldEmitExpanded) { + emit expandedChanged(m_state.isExpanded()); + } +} + +void CommandZone::paint(QPainter *painter, + [[maybe_unused]] const QStyleOptionGraphicsItem *option, + [[maybe_unused]] QWidget *widget) +{ + if (isCollapsed()) { + return; + } + + QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Command, getLogic()->getPlayer()->getZoneId()); + + QPointF scenePos = mapToScene(QPointF(0, 0)); + painter->setBrushOrigin(-scenePos); + + painter->fillRect(boundingRect(), brush); +} + +void CommandZone::handleDropEvent(const QList &dragItems, + CardZoneLogic *startZone, + const QPoint &dropPoint) +{ + if (dragItems.isEmpty()) { + return; + } + + if (startZone == nullptr || startZone->getPlayer() == nullptr) { + qCWarning(CommandZoneLog) << "handleDropEvent: Invalid startZone or player"; + return; + } + + // Primary and Partner zones hold exactly one card; Companion and Background support multiple + if (!supportsMultipleCards(getZoneType()) && !getLogic()->getCards().isEmpty()) { + qCDebug(CommandZoneLog) << "handleDropEvent: Single-card zone already occupied, ignoring drop"; + return; + } + + Command_MoveCard cmd; + cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_start_zone(startZone->getName().toStdString()); + cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); + cmd.set_target_zone(getLogic()->getName().toStdString()); + + // Calculate insertion index from drop position for multi-card zones + int index = 0; + const auto &cards = getLogic()->getCards(); + if (!cards.isEmpty()) { + const auto cardCount = static_cast(cards.size()); + const qreal cardHeight = cards.at(0)->boundingRect().height(); + + index = qRound(divideCardSpaceInZone(dropPoint.y(), cardCount, boundingRect().height(), cardHeight, true)); + index = qBound(0, index, cardCount); + } + + cmd.set_x(index); + cmd.set_y(0); + + // Add all dragged cards to the move command + for (const CardDragItem *item : dragItems) { + if (item != nullptr) { + cmd.mutable_cards_to_move()->add_card()->set_card_id(item->getId()); + } + } + + getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); +} + +void CommandZone::reorganizeCards() +{ + const auto &cards = getLogic()->getCards(); + + if (cardClipContainer) { + cardClipContainer->setRect(boundingRect()); + } + + // Hide all cards when collapsed + if (isCollapsed()) { + for (CardItem *card : cards) { + if (card != nullptr) { + card->setVisible(false); + } + } + update(); + return; + } + + if (cards.isEmpty()) { + update(); + return; + } + + const auto cardCount = static_cast(cards.size()); + const qreal totalWidth = boundingRect().width(); + const qreal totalHeight = boundingRect().height(); + const qreal cardWidth = cards.at(0)->boundingRect().width(); + const qreal cardHeight = cards.at(0)->boundingRect().height(); + + // Zigzag horizontal positioning (StackZone pattern) + // Single card stays centered for visual consistency with Primary/Partner zones + const qreal xMargin = 5; + const qreal x1 = xMargin; + const qreal x2 = totalWidth - xMargin - cardWidth; + + for (int i = 0; i < cardCount; i++) { + CardItem *card = cards.at(i); + if (card == nullptr) { + continue; + } + + // Horizontal: center single card, zigzag for multiple + qreal x = (cardCount == 1) ? (totalWidth - cardWidth) / 2.0 : ((i % 2) ? x2 : x1); + + // Vertical: use divideCardSpaceInZone for proper stacking with overlap compression. + // divideCardSpaceInZone can return negative Y when totalHeight < cardHeight. + // Clamp to 0 so card tops remain visible when clipped by cardClipContainer. + const qreal y = qMax(0.0, divideCardSpaceInZone(i, cardCount, totalHeight, cardHeight)); + + card->setVisible(true); + card->setPos(x, y); + card->setRealZValue(ZValues::CARD_BASE + i); + } + update(); +} + +void CommandZone::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + toggleMinimized(); + event->accept(); + } else { + SelectZone::mouseDoubleClickEvent(event); + } +} + +void CommandZone::repositionToggleButton() +{ + if (toggleButton) { + QRectF btnRect = toggleButton->boundingRect(); + qreal btnX = (boundingRect().width() - btnRect.width()) / 2.0; + qreal btnY = 0; + toggleButton->setPos(btnX, btnY); + } +} + +void CommandZone::setExpanded(bool _expanded) +{ + applyStateChange(m_state.trySetExpanded(_expanded, shouldPreventCollapse())); +} + +bool CommandZone::shouldPreventCollapse() const +{ + if (isPrimary()) + return false; + + if (!getLogic()->getCards().isEmpty()) + return true; + + if (getZoneType() == CommandZoneType::Partner) { + Player *player = getLogic()->getPlayer(); + if (player) { + AbstractCounter *partnerTax = player->getCounters().value(CounterIds::PartnerTax, nullptr); + if (partnerTax && partnerTax->getValue() > 0) + return true; + } + } + + // Check Qt children (Partner has toggle button as child, and Partner is nested under Command) + for (QGraphicsItem *child : childItems()) { + if (auto *childZone = dynamic_cast(child)) { + if (childZone->shouldPreventCollapse()) + return true; + } + } + + // Check logical children (for sibling zones: Companion is logical child of Partner, + // Background is logical child of Companion) + for (CommandZone *childZone : logicalChildZones) { + if (childZone != nullptr && childZone->shouldPreventCollapse()) + return true; + } + + return false; +} + +bool CommandZone::tryAutoMinimize() +{ + // Check Qt children first (Partner is nested under Command) + for (QGraphicsItem *child : childItems()) { + if (auto *childZone = dynamic_cast(child)) { + if (childZone->tryAutoMinimize()) + return true; + } + } + + // Check logical children (sibling zones: Companion under Partner, Background under Companion) + for (CommandZone *childZone : logicalChildZones) { + if (childZone != nullptr && childZone->tryAutoMinimize()) + return true; + } + + if (isExpanded() && !isMinimized() && !isPrimary()) { + toggleMinimized(); + return true; + } + + return false; +} + +CommandZone *CommandZone::getParentCommandZone() const +{ + QGraphicsItem *parent = parentItem(); + while (parent != nullptr) { + if (auto *parentZone = dynamic_cast(parent)) { + return parentZone; + } + parent = parent->parentItem(); + } + return nullptr; +} + +bool CommandZone::tryMinimizeAbove() +{ + // First check logical parent (for sibling zones: Companion, Background) + CommandZone *parentZone = logicalParentZone; + + // Fall back to Qt parent chain for Partner zone (still nested under Command) + if (parentZone == nullptr) { + parentZone = getParentCommandZone(); + } + + if (parentZone != nullptr && parentZone->isExpanded() && !parentZone->isMinimized()) { + parentZone->toggleMinimized(); + return true; + } + // If parent can't minimize, try grandparent (recursion bounded by zone depth) + if (parentZone != nullptr) { + return parentZone->tryMinimizeAbove(); + } + return false; +} + +void CommandZone::setToggleButtonVisible(bool visible) +{ + if (toggleButton) { + toggleButton->setVisible(visible); + } +} diff --git a/cockatrice/src/game/zones/command_zone.h b/cockatrice/src/game/zones/command_zone.h new file mode 100644 index 000000000..a33b61bdc --- /dev/null +++ b/cockatrice/src/game/zones/command_zone.h @@ -0,0 +1,190 @@ +/** + * @file command_zone.h + * @ingroup GameGraphicsZones + * @brief Graphics layer for the command zone, used for Commander format. + */ + +#ifndef COCKATRICE_COMMAND_ZONE_H +#define COCKATRICE_COMMAND_ZONE_H + +#include "command_zone_state.h" +#include "command_zone_types.h" +#include "logic/command_zone_logic.h" +#include "select_zone.h" + +#include + +inline Q_LOGGING_CATEGORY(CommandZoneLog, "command_zone"); + +class ZoneToggleButton; + +/** + * @class CommandZone + * @brief Graphics layer for command zones in Commander format games. + * + * Supports primary (always visible) and collapsible (partner, companion, background) + * variants. Primary/Partner hold one card each; Companion/Background support multiple + * cards for edge-case mechanics. Single cards display centered; multiple cards use + * zigzag horizontal positioning with vertical stacking. + * + * @see CommandZoneState for visibility/height state machine + * @see supportsMultipleCards() in command_zone_types.h + */ +class CommandZone : public SelectZone +{ + Q_OBJECT +private: + CommandZoneState m_state; ///< Pure state machine for visibility/height + ZoneToggleButton *toggleButton; ///< Only created for collapsible zones + QGraphicsRectItem *cardClipContainer; ///< Clips cards to zone bounds when minimized + + /** + * @brief Logical parent for Qt-sibling zones (Companion, Background). + * + * INVARIANT: A zone has EITHER a Qt parent CommandZone (Partner) OR this + * logical parent, never both. tryMinimizeAbove() relies on this exclusivity. + */ + CommandZone *logicalParentZone = nullptr; + + /** + * @brief Logical child zones for sibling architecture. + * + * Tracks zones that are logically children but Qt siblings. Used by + * shouldPreventCollapse() and tryAutoMinimize() to traverse the hierarchy. + */ + QList logicalChildZones; + +public: + /** + * @brief Constructs a CommandZone graphics item. + * @param _logic Logic layer managing card data + * @param _zoneHeight Zone height in pixels + * @param _zoneType Type of command zone (Primary, Partner, Companion, Background) + * @param parent Parent graphics item + */ + CommandZone(CommandZoneLogic *_logic, int _zoneHeight, CommandZoneType _zoneType, QGraphicsItem *parent); + ~CommandZone() override; + + /** + * @brief Handles card drops, calculating insertion position from drop point. + * @param dragItems Cards being dragged + * @param startZone Source zone + * @param dropPoint Drop position in local coordinates + */ + void + handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; + + [[nodiscard]] QRectF boundingRect() const override; + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + void reorganizeCards() override; + + /** + * @brief Sets expanded/collapsed state of a collapsible zone. + * + * When collapsed, background and cards are hidden but toggle button remains. + * When expanded, zone renders normally with centered cards. + * No-op for Primary zone. + * + * @param expanded True to show zone content, false to collapse + */ + void setExpanded(bool expanded); + + void toggleMinimized(); + [[nodiscard]] bool isMinimized() const; + [[nodiscard]] bool isExpanded() const; + [[nodiscard]] bool isCollapsed() const; + [[nodiscard]] bool isPrimary() const; + [[nodiscard]] CommandZoneType getZoneType() const + { + return m_state.getZoneType(); + } + [[nodiscard]] qreal currentHeight() const; + + /** + * @brief Sets the logical parent zone for sibling architecture. + * @param parent The logical parent CommandZone, or nullptr for nested zones + */ + void setLogicalParentZone(CommandZone *parent) + { + logicalParentZone = parent; + } + + /** + * @brief Gets the logical parent zone. + * @return The logical parent, or nullptr if using Qt parent-child hierarchy + */ + [[nodiscard]] CommandZone *getLogicalParentZone() const + { + return logicalParentZone; + } + + /** + * @brief Adds a logical child zone for sibling architecture. + * @param child The zone that is logically a child but a Qt sibling + */ + void addLogicalChildZone(CommandZone *child) + { + logicalChildZones.append(child); + } + + /** + * @brief Attempts to auto-minimize this zone or a child zone (depth-first). + * + * Tries children first (depth-first, bottom-up) to minimize the + * deepest non-minimized zone. Used as a fallback safety-net in + * rearrangeZones() for non-expansion triggers (e.g., window resize). + * + * For user-initiated zone expansion, tryMinimizeAbove() is preferred + * as it minimizes the zone above instead of below. + * + * @return True if a zone was minimized, false if no minimization possible + * @see tryMinimizeAbove() + */ + bool tryAutoMinimize(); + + /** + * @brief Returns the nearest CommandZone ancestor in the graphics item hierarchy. + * @return Pointer to parent CommandZone, or nullptr if none exists. + */ + [[nodiscard]] CommandZone *getParentCommandZone() const; + + /** + * @brief Attempts to minimize the zone directly above this one. + * + * Walks up the parent chain to find the first ancestor CommandZone that + * can be minimized (is expanded and not already minimized). The Primary + * zone can be minimized (reduced to 25% height) but cannot be collapsed. + * + * Recursion is bounded by zone hierarchy depth (max 4 zones). + * + * @return True if an ancestor zone was minimized, false if none could be. + * @see tryAutoMinimize() + */ + bool tryMinimizeAbove(); + + /** + * @brief Sets toggle button visibility. + * @param visible Whether toggle should be visible + */ + void setToggleButtonVisible(bool visible); + +public slots: + void toggleExpanded(); + +signals: + void expandedChanged(bool isExpanded); + void minimizedChanged(bool isMinimized); + +protected: + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + +private slots: + void updateBg(); + +private: + void repositionToggleButton(); + void applyStateChange(const StateChangeResult &result, bool updateTogglePosition = false); + [[nodiscard]] bool shouldPreventCollapse() const; +}; + +#endif // COCKATRICE_COMMAND_ZONE_H diff --git a/cockatrice/src/game/zones/select_zone.cpp b/cockatrice/src/game/zones/select_zone.cpp index 719eec148..dbaa4fbba 100644 --- a/cockatrice/src/game/zones/select_zone.cpp +++ b/cockatrice/src/game/zones/select_zone.cpp @@ -9,6 +9,15 @@ qreal divideCardSpaceInZone(qreal index, int cardCount, qreal totalHeight, qreal cardHeight, bool reverse) { + // Handle single card case - return centered position or index 0 + if (cardCount <= 1) { + if (reverse) { + return 0; // Single card maps to index 0 + } else { + return (totalHeight - cardHeight) / 2.0; // Center the single card + } + } + qreal cardMinOverlap = cardHeight * SettingsCache::instance().getStackCardOverlapPercent() / 100; qreal desiredHeight = cardHeight * cardCount - cardMinOverlap * (cardCount - 1); qreal y; diff --git a/cockatrice/src/game/zones/select_zone.h b/cockatrice/src/game/zones/select_zone.h index d6fd3e10e..68bf059c1 100644 --- a/cockatrice/src/game/zones/select_zone.h +++ b/cockatrice/src/game/zones/select_zone.h @@ -1,7 +1,7 @@ /** * @file select_zone.h * @ingroup GameGraphicsZones - * @brief TODO: Document this. + * @brief Base class for zones with directly clickable, laid-out cards. */ #ifndef SELECTZONE_H @@ -12,7 +12,16 @@ #include /** - * A CardZone where the cards are laid out, with each card directly interactable by clicking. + * @class SelectZone + * @brief Base class for zones where cards are laid out and directly clickable. + * + * SelectZone provides mouse interaction handling for zones that display cards + * in a visible layout (as opposed to hidden zones like the deck). It supports + * click-to-select and rectangle selection via click-and-drag. + * + * Subclasses include TableZone, HandZone, and CommandZone. + * + * @see CardZone */ class SelectZone : public CardZone { diff --git a/cockatrice/src/game/zones/stack_zone.cpp b/cockatrice/src/game/zones/stack_zone.cpp index 820008b27..d0ea76f3d 100644 --- a/cockatrice/src/game/zones/stack_zone.cpp +++ b/cockatrice/src/game/zones/stack_zone.cpp @@ -33,6 +33,11 @@ QRectF StackZone::boundingRect() const void StackZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId()); + + // Set brush origin to compensate for zone position, keeping texture fixed in scene coordinates + QPointF scenePos = pos(); + painter->setBrushOrigin(-scenePos); + painter->fillRect(boundingRect(), brush); } @@ -106,3 +111,11 @@ void StackZone::reorganizeCards() } update(); } + +void StackZone::setHeight(qreal newHeight) +{ + prepareGeometryChange(); + zoneHeight = newHeight; + reorganizeCards(); + update(); +} diff --git a/cockatrice/src/game/zones/stack_zone.h b/cockatrice/src/game/zones/stack_zone.h index 7c98f5128..3156ffa8f 100644 --- a/cockatrice/src/game/zones/stack_zone.h +++ b/cockatrice/src/game/zones/stack_zone.h @@ -25,6 +25,7 @@ public: QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void reorganizeCards() override; + void setHeight(qreal newHeight); }; #endif diff --git a/cockatrice/src/game/zones/table_zone.cpp b/cockatrice/src/game/zones/table_zone.cpp index c76514350..0e4f0a6b0 100644 --- a/cockatrice/src/game/zones/table_zone.cpp +++ b/cockatrice/src/game/zones/table_zone.cpp @@ -7,6 +7,7 @@ #include "../board/card_item.h" #include "../player/player.h" #include "../player/player_actions.h" +#include "../z_value_layer_manager.h" #include "logic/table_zone_logic.h" #include @@ -167,7 +168,9 @@ void TableZone::reorganizeCards() actualY += 15; getLogic()->getCards()[i]->setPos(actualX, actualY); - getLogic()->getCards()[i]->setRealZValue((actualY + CARD_HEIGHT) * 100000 + (actualX + 1) * 100); + qreal cardZValue = (actualY + CARD_HEIGHT) * 100000 + (actualX + 1) * 100; + Q_ASSERT(ZValueLayerManager::isValidCardZValue(cardZValue)); + getLogic()->getCards()[i]->setRealZValue(cardZValue); QListIterator attachedCardIterator(getLogic()->getCards()[i]->getAttachedCards()); int j = 0; @@ -177,7 +180,9 @@ void TableZone::reorganizeCards() qreal childX = actualX - j * STACKED_CARD_OFFSET_X; qreal childY = y + 5; attachedCard->setPos(childX, childY); - attachedCard->setRealZValue((childY + CARD_HEIGHT) * 100000 + (childX + 1) * 100); + qreal attachedZValue = (childY + CARD_HEIGHT) * 100000 + (childX + 1) * 100; + Q_ASSERT(ZValueLayerManager::isValidCardZValue(attachedZValue)); + attachedCard->setRealZValue(attachedZValue); } } diff --git a/cockatrice/src/game/zones/view_zone_widget.cpp b/cockatrice/src/game/zones/view_zone_widget.cpp index 3ea4eb119..0ad3b10f1 100644 --- a/cockatrice/src/game/zones/view_zone_widget.cpp +++ b/cockatrice/src/game/zones/view_zone_widget.cpp @@ -7,6 +7,7 @@ #include "../game_scene.h" #include "../player/player.h" #include "../player/player_actions.h" +#include "../z_values.h" #include "view_zone.h" #include @@ -47,7 +48,7 @@ ZoneViewWidget::ZoneViewWidget(Player *_player, { setAcceptHoverEvents(true); setAttribute(Qt::WA_DeleteOnClose); - setZValue(2000000006); + setZValue(ZValues::ZONE_VIEW_WIDGET); setFlag(ItemIgnoresTransformations); QGraphicsLinearLayout *vbox = new QGraphicsLinearLayout(Qt::Vertical); @@ -71,7 +72,7 @@ ZoneViewWidget::ZoneViewWidget(Player *_player, QGraphicsProxyWidget *searchEditProxy = new QGraphicsProxyWidget; searchEditProxy->setWidget(&searchEdit); - searchEditProxy->setZValue(2000000007); + searchEditProxy->setZValue(ZValues::DRAG_ITEM); vbox->addItem(searchEditProxy); // top row @@ -80,13 +81,13 @@ ZoneViewWidget::ZoneViewWidget(Player *_player, // groupBy options QGraphicsProxyWidget *groupBySelectorProxy = new QGraphicsProxyWidget; groupBySelectorProxy->setWidget(&groupBySelector); - groupBySelectorProxy->setZValue(2000000008); + groupBySelectorProxy->setZValue(ZValues::TOP_UI); hTopRow->addItem(groupBySelectorProxy); // sortBy options QGraphicsProxyWidget *sortBySelectorProxy = new QGraphicsProxyWidget; sortBySelectorProxy->setWidget(&sortBySelector); - sortBySelectorProxy->setZValue(2000000007); + sortBySelectorProxy->setZValue(ZValues::DRAG_ITEM); hTopRow->addItem(sortBySelectorProxy); vbox->addItem(hTopRow); diff --git a/tests/command_zone/CMakeLists.txt b/tests/command_zone/CMakeLists.txt index 783949b7e..412a03d0e 100644 --- a/tests/command_zone/CMakeLists.txt +++ b/tests/command_zone/CMakeLists.txt @@ -69,6 +69,26 @@ target_link_libraries(command_zone_state_test add_test(NAME command_zone_state_test COMMAND command_zone_state_test) +# ------------------------ +# Command Zone Integration Test +# Tests counter parenting and zone coordination +# ------------------------ +add_executable(command_zone_integration_test + command_zone_integration_test.cpp +) + +target_include_directories(command_zone_integration_test + PRIVATE ${CMAKE_SOURCE_DIR} +) + +target_link_libraries(command_zone_integration_test + PRIVATE Threads::Threads + PRIVATE ${GTEST_BOTH_LIBRARIES} + PRIVATE ${TEST_QT_MODULES} +) + +add_test(NAME command_zone_integration_test COMMAND command_zone_integration_test) + # ------------------------ # Counter Visibility Test # Tests counter visibility during zone state transitions @@ -92,5 +112,6 @@ if(NOT GTEST_FOUND) add_dependencies(command_zone_logic_test gtest) add_dependencies(commander_tax_counter_test gtest) add_dependencies(command_zone_state_test gtest) + add_dependencies(command_zone_integration_test gtest) add_dependencies(counter_visibility_test gtest) endif() diff --git a/tests/command_zone/command_zone_integration_test.cpp b/tests/command_zone/command_zone_integration_test.cpp new file mode 100644 index 000000000..206d87e2b --- /dev/null +++ b/tests/command_zone/command_zone_integration_test.cpp @@ -0,0 +1,916 @@ +/** + * @file command_zone_integration_test.cpp + * @brief Integration tests for CommandZone counter coordination + * + * Tests the interaction between command zones and tax counters: + * - Counter parenting to correct zone + * - Counter positioning follows zone movement + * - Primary, partner, companion, and background zone coordination + */ + +#include "command_zone_test_common.h" + +#include +#include +#include + +/** + * @brief Mock counter for testing parenting and positioning. + * + * Simulates CommanderTaxCounter without QGraphicsScene dependencies. + * Tracks parent relationship and position for verification. + */ +class MockTaxCounter +{ + void *parentItem_; + QPointF pos_; + qreal zValue_; + bool visible_; + +public: + explicit MockTaxCounter(void *parent = nullptr) : parentItem_(parent), pos_(0, 0), zValue_(0), visible_(true) + { + } + + void setParentItem(void *parent) + { + parentItem_ = parent; + } + + [[nodiscard]] void *parentItem() const + { + return parentItem_; + } + + void setPos(qreal x, qreal y) + { + pos_ = QPointF(x, y); + } + + void setPos(const QPointF &p) + { + pos_ = p; + } + + [[nodiscard]] QPointF pos() const + { + return pos_; + } + + [[nodiscard]] QPointF scenePos() const + { + if (parentItem_) { + return pos_; + } + return pos_; + } + + void setZValue(qreal z) + { + zValue_ = z; + } + + [[nodiscard]] qreal zValue() const + { + return zValue_; + } + + void setVisible(bool v) + { + visible_ = v; + } + + [[nodiscard]] bool isVisible() const + { + return visible_; + } +}; + +/** + * @brief Mock command zone for testing counter coordination. + * + * Simulates CommandZone state management without QGraphicsScene. + * Supports all 4 zone types: Primary, Partner, Companion, Background. + */ +class MockCommandZone +{ + CommandZoneType zoneType_; + ZoneVisibility visibility_; + bool visible_; + QPointF pos_; + qreal height_; + +public: + explicit MockCommandZone(CommandZoneType zoneType, qreal height = 100.0) + : zoneType_(zoneType), + visibility_(zoneType == CommandZoneType::Primary ? ZoneVisibility::Expanded : ZoneVisibility::Collapsed), + visible_(true), pos_(0, 0), height_(height) + { + } + + [[nodiscard]] bool isPrimary() const + { + return zoneType_ == CommandZoneType::Primary; + } + + [[nodiscard]] CommandZoneType getZoneType() const + { + return zoneType_; + } + + [[nodiscard]] bool isExpanded() const + { + return visibility_ != ZoneVisibility::Collapsed; + } + + void setExpanded(bool e) + { + visibility_ = e ? ZoneVisibility::Expanded : ZoneVisibility::Collapsed; + } + + [[nodiscard]] bool isMinimized() const + { + return visibility_ == ZoneVisibility::Minimized; + } + + void setMinimized(bool m) + { + if (visibility_ != ZoneVisibility::Collapsed) { + visibility_ = m ? ZoneVisibility::Minimized : ZoneVisibility::Expanded; + } + } + + [[nodiscard]] bool isVisible() const + { + return visible_; + } + + void setVisible(bool v) + { + visible_ = v; + } + + void setPos(const QPointF &p) + { + pos_ = p; + } + + [[nodiscard]] QPointF pos() const + { + return pos_; + } + + [[nodiscard]] qreal currentHeight() const + { + if (visibility_ == ZoneVisibility::Collapsed) { + return 0; + } + return visibility_ == ZoneVisibility::Minimized ? (height_ * 0.25) : height_; + } +}; + +/** + * @brief Test harness for counter-zone integration logic. + * + * Extracts the counter positioning and visibility logic from + * PlayerGraphicsItem::rearrangeCounters for isolated testing. + * + * @warning SYNC REQUIRED: This harness duplicates logic from + * player_graphics_item.cpp. Update if implementation changes. + */ +class CounterZoneIntegrationHarness +{ +public: + MockCommandZone commandZone{CommandZoneType::Primary, 100.0}; + MockCommandZone partnerZone{CommandZoneType::Partner, 100.0}; + MockCommandZone companionZone{CommandZoneType::Companion, 100.0}; + MockCommandZone backgroundZone{CommandZoneType::Background, 100.0}; + MockTaxCounter commanderTaxCounter{nullptr}; + MockTaxCounter partnerTaxCounter{nullptr}; + + static constexpr qreal TAX_COUNTERS_Z = 200000002.0; + + CounterZoneIntegrationHarness() + { + } + + void setupCounterParenting() + { + commanderTaxCounter.setParentItem(static_cast(&commandZone)); + partnerTaxCounter.setParentItem(static_cast(&partnerZone)); + } + + void rearrangeCounters() + { + bool commandZonesVisible = commandZone.isVisible(); + + if (commandZonesVisible) { + commanderTaxCounter.setPos(2, 2); + commanderTaxCounter.setZValue(TAX_COUNTERS_Z); + commanderTaxCounter.setVisible(true); + } else { + commanderTaxCounter.setVisible(false); + } + + if (commandZonesVisible && partnerZone.isExpanded()) { + partnerTaxCounter.setPos(2, 2); + partnerTaxCounter.setZValue(TAX_COUNTERS_Z); + partnerTaxCounter.setVisible(true); + } else { + partnerTaxCounter.setVisible(false); + } + } +}; + +class CommandZoneIntegrationTest : public ::testing::Test +{ +protected: + CounterZoneIntegrationHarness *harness; + + void SetUp() override + { + harness = new CounterZoneIntegrationHarness(); + harness->setupCounterParenting(); + } + + void TearDown() override + { + delete harness; + } +}; + +// ============ Counter Parenting Tests ============ + +TEST_F(CommandZoneIntegrationTest, CommanderTaxCounter_ParentedToCommandZone) +{ + EXPECT_NE(nullptr, harness->commanderTaxCounter.parentItem()) << "Commander tax counter should have a parent"; + + EXPECT_EQ(static_cast(&harness->commandZone), harness->commanderTaxCounter.parentItem()) + << "Commander tax counter should be parented to command zone"; +} + +TEST_F(CommandZoneIntegrationTest, PartnerTaxCounter_ParentedToPartnerZone) +{ + EXPECT_NE(nullptr, harness->partnerTaxCounter.parentItem()) << "Partner tax counter should have a parent"; + + EXPECT_EQ(static_cast(&harness->partnerZone), harness->partnerTaxCounter.parentItem()) + << "Partner tax counter should be parented to partner zone"; +} + +// ============ Counter Positioning Tests ============ + +TEST_F(CommandZoneIntegrationTest, Counters_PositionedAtTopLeft) +{ + harness->commandZone.setVisible(true); + harness->partnerZone.setExpanded(true); + harness->rearrangeCounters(); + + EXPECT_EQ(QPointF(2, 2), harness->commanderTaxCounter.pos()) + << "Commander tax counter should be at (2, 2) in command zone"; + + EXPECT_EQ(QPointF(2, 2), harness->partnerTaxCounter.pos()) + << "Partner tax counter should be at (2, 2) in partner zone"; +} + +TEST_F(CommandZoneIntegrationTest, Counters_CorrectZValue) +{ + harness->commandZone.setVisible(true); + harness->partnerZone.setExpanded(true); + harness->rearrangeCounters(); + + EXPECT_EQ(CounterZoneIntegrationHarness::TAX_COUNTERS_Z, harness->commanderTaxCounter.zValue()) + << "Commander tax counter should have TAX_COUNTERS z-value"; + + EXPECT_EQ(CounterZoneIntegrationHarness::TAX_COUNTERS_Z, harness->partnerTaxCounter.zValue()) + << "Partner tax counter should have TAX_COUNTERS z-value"; +} + +// ============ Zone Type Tests ============ + +TEST_F(CommandZoneIntegrationTest, ZoneTypes_CorrectlyIdentified) +{ + EXPECT_EQ(CommandZoneType::Primary, harness->commandZone.getZoneType()); + EXPECT_EQ(CommandZoneType::Partner, harness->partnerZone.getZoneType()); + EXPECT_EQ(CommandZoneType::Companion, harness->companionZone.getZoneType()); + EXPECT_EQ(CommandZoneType::Background, harness->backgroundZone.getZoneType()); +} + +TEST_F(CommandZoneIntegrationTest, ZoneTypes_IsPrimaryCorrect) +{ + EXPECT_TRUE(harness->commandZone.isPrimary()); + EXPECT_FALSE(harness->partnerZone.isPrimary()); + EXPECT_FALSE(harness->companionZone.isPrimary()); + EXPECT_FALSE(harness->backgroundZone.isPrimary()); +} + +// ============ Zone Coordination Tests ============ + +TEST_F(CommandZoneIntegrationTest, ZoneInitialState) +{ + EXPECT_TRUE(harness->commandZone.isExpanded()) << "Command zone should start expanded"; + EXPECT_FALSE(harness->partnerZone.isExpanded()) << "Partner zone should start collapsed"; + EXPECT_FALSE(harness->companionZone.isExpanded()) << "Companion zone should start collapsed"; + EXPECT_FALSE(harness->backgroundZone.isExpanded()) << "Background zone should start collapsed"; +} + +TEST_F(CommandZoneIntegrationTest, PartnerZone_CollapsedHeight) +{ + EXPECT_FALSE(harness->partnerZone.isExpanded()); + EXPECT_EQ(0, harness->partnerZone.currentHeight()) << "Collapsed partner zone should have 0 height"; +} + +TEST_F(CommandZoneIntegrationTest, PartnerZone_ExpandedHeight) +{ + harness->partnerZone.setExpanded(true); + EXPECT_EQ(100.0, harness->partnerZone.currentHeight()) << "Expanded partner zone should have full height"; +} + +TEST_F(CommandZoneIntegrationTest, CompanionZone_ExpandedHeight) +{ + harness->companionZone.setExpanded(true); + EXPECT_EQ(100.0, harness->companionZone.currentHeight()) << "Expanded companion zone should have full height"; +} + +TEST_F(CommandZoneIntegrationTest, BackgroundZone_ExpandedHeight) +{ + harness->backgroundZone.setExpanded(true); + EXPECT_EQ(100.0, harness->backgroundZone.currentHeight()) << "Expanded background zone should have full height"; +} + +TEST_F(CommandZoneIntegrationTest, Zone_MinimizedHeight) +{ + harness->commandZone.setMinimized(true); + EXPECT_EQ(25.0, harness->commandZone.currentHeight()) << "Minimized zone should have 25% height"; +} + +// ============ Counter Visibility During Transitions ============ + +TEST_F(CommandZoneIntegrationTest, AllCountersVisible_WhenZonesExpanded) +{ + harness->commandZone.setVisible(true); + harness->partnerZone.setExpanded(true); + harness->rearrangeCounters(); + + EXPECT_TRUE(harness->commanderTaxCounter.isVisible()) << "Commander tax counter should be visible"; + EXPECT_TRUE(harness->partnerTaxCounter.isVisible()) << "Partner tax counter should be visible"; +} + +TEST_F(CommandZoneIntegrationTest, PartnerCounter_HiddenWhenCollapsed) +{ + harness->commandZone.setVisible(true); + harness->partnerZone.setExpanded(false); + harness->rearrangeCounters(); + + EXPECT_TRUE(harness->commanderTaxCounter.isVisible()) << "Commander tax counter should remain visible"; + EXPECT_FALSE(harness->partnerTaxCounter.isVisible()) << "Partner tax counter should be hidden when collapsed"; +} + +TEST_F(CommandZoneIntegrationTest, AllCountersHidden_WhenZonesNotVisible) +{ + harness->commandZone.setVisible(false); + harness->rearrangeCounters(); + + EXPECT_FALSE(harness->commanderTaxCounter.isVisible()) + << "Commander tax counter should be hidden when zones not visible"; + EXPECT_FALSE(harness->partnerTaxCounter.isVisible()) + << "Partner tax counter should be hidden when zones not visible"; +} + +TEST_F(CommandZoneIntegrationTest, CommanderCounter_VisibleWhenMinimized) +{ + harness->commandZone.setVisible(true); + harness->commandZone.setMinimized(true); + harness->rearrangeCounters(); + + EXPECT_TRUE(harness->commanderTaxCounter.isVisible()) + << "Commander tax counter should remain visible when zone minimized"; +} + +TEST_F(CommandZoneIntegrationTest, VisibilityUpdates_DuringToggleCycle) +{ + harness->commandZone.setVisible(true); + + harness->partnerZone.setExpanded(false); + harness->rearrangeCounters(); + EXPECT_FALSE(harness->partnerTaxCounter.isVisible()) << "Partner counter hidden when collapsed"; + + harness->partnerZone.setExpanded(true); + harness->rearrangeCounters(); + EXPECT_TRUE(harness->partnerTaxCounter.isVisible()) << "Partner counter visible when expanded"; + + harness->partnerZone.setExpanded(false); + harness->rearrangeCounters(); + EXPECT_FALSE(harness->partnerTaxCounter.isVisible()) << "Partner counter hidden after collapse"; +} + +// ============ Companion/Background Zone Tests (No Tax Counters) ============ + +TEST_F(CommandZoneIntegrationTest, CompanionZone_NoTaxCounter) +{ + // Companion zone has no tax counter - this test verifies zone behavior independently + harness->companionZone.setExpanded(true); + EXPECT_TRUE(harness->companionZone.isExpanded()); + EXPECT_EQ(100.0, harness->companionZone.currentHeight()); +} + +TEST_F(CommandZoneIntegrationTest, BackgroundZone_NoTaxCounter) +{ + // Background zone has no tax counter - this test verifies zone behavior independently + harness->backgroundZone.setExpanded(true); + EXPECT_TRUE(harness->backgroundZone.isExpanded()); + EXPECT_EQ(100.0, harness->backgroundZone.currentHeight()); +} + +// ============ Zone Positioning Tests ============ + +/** + * @brief Test harness for zone positioning calculation. + * + * Uses a HYBRID architecture matching the actual implementation: + * - Partner Zone: Qt child of Command Zone (uses relative coordinates) + * - Companion/Background Zones: Qt siblings of Command Zone (use absolute coordinates) + * + * This distinction matters because Qt transforms work differently for children vs siblings. + */ +class ZonePositioningHarness +{ +public: + MockCommandZone commandZone{CommandZoneType::Primary, 100.0}; + MockCommandZone partnerZone{CommandZoneType::Partner, 100.0}; + MockCommandZone companionZone{CommandZoneType::Companion, 100.0}; + MockCommandZone backgroundZone{CommandZoneType::Background, 100.0}; + + /** + * @brief Calculates scene position for a zone in the parent chain. + * + * Command zone position is the base. Each subsequent zone is offset + * by the cumulative height of all ancestor zones. + */ + QPointF scenePositionOf(const MockCommandZone &zone) const + { + QPointF base = commandZone.pos(); + + if (&zone == &commandZone) { + return base; + } + + qreal offset = commandZone.currentHeight(); + if (&zone == &partnerZone) { + return base + QPointF(0, offset); + } + + offset += partnerZone.currentHeight(); + if (&zone == &companionZone) { + return base + QPointF(0, offset); + } + + offset += companionZone.currentHeight(); + if (&zone == &backgroundZone) { + return base + QPointF(0, offset); + } + + return base; + } + + /** + * @brief Calculates the actual position for a zone based on hybrid architecture. + * + * - Partner Zone: Returns relative position (Qt child of Command Zone) + * - Companion/Background: Returns absolute position (Qt siblings) + */ + QPointF positionFor(const MockCommandZone &zone) const + { + QPointF base = commandZone.pos(); + + if (&zone == &commandZone) { + return base; + } + if (&zone == &partnerZone) { + return QPointF(0, commandZone.currentHeight()); // Relative (Qt child) + } + + // Companion and Background use ABSOLUTE positioning (Qt siblings) + qreal runningY = commandZone.currentHeight() + partnerZone.currentHeight(); + if (&zone == &companionZone) { + return QPointF(base.x(), base.y() + runningY); + } + + runningY += companionZone.currentHeight(); + if (&zone == &backgroundZone) { + return QPointF(base.x(), base.y() + runningY); + } + + return QPointF(0, 0); + } + + /** + * @brief Calculates total zone height for stack zone positioning. + * + * This matches the visibility-aware logic in PlayerGraphicsItem::totalCommandZoneHeight(). + * Command zone and partner use Qt parent-child relationship, so checking command visibility + * implicitly covers partner (Qt hides children when parent is hidden). + * Companion and background are Qt siblings with independent visibility. + */ + qreal totalHeight() const + { + qreal total = 0; + + // Command zone and partner: Qt parent-child relationship + if (commandZone.isVisible()) { + total += commandZone.currentHeight(); + if (partnerZone.isExpanded()) { + total += partnerZone.currentHeight(); + } + } + + // Sibling zones: independent visibility + if (companionZone.isVisible()) { + total += companionZone.currentHeight(); + } + + if (backgroundZone.isVisible()) { + total += backgroundZone.currentHeight(); + } + + return total; + } + + /** + * @brief Legacy total height calculation (non-visibility-aware). + * + * Used for tests that need the original behavior for comparison. + */ + qreal totalHeightIgnoringVisibility() const + { + return commandZone.currentHeight() + partnerZone.currentHeight() + companionZone.currentHeight() + + backgroundZone.currentHeight(); + } +}; + +class ZonePositioningTest : public ::testing::Test +{ +protected: + ZonePositioningHarness harness; +}; + +TEST_F(ZonePositioningTest, AllZonesExpanded_CorrectScenePositions) +{ + harness.commandZone.setPos(QPointF(50, 0)); + harness.partnerZone.setExpanded(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setExpanded(true); + + EXPECT_EQ(QPointF(50, 0), harness.scenePositionOf(harness.commandZone)); + EXPECT_EQ(QPointF(50, 100), harness.scenePositionOf(harness.partnerZone)); + EXPECT_EQ(QPointF(50, 200), harness.scenePositionOf(harness.companionZone)); + EXPECT_EQ(QPointF(50, 300), harness.scenePositionOf(harness.backgroundZone)); +} + +TEST_F(ZonePositioningTest, AllZonesExpanded_CorrectPositions) +{ + harness.commandZone.setPos(QPointF(0, 0)); + harness.partnerZone.setExpanded(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setExpanded(true); + + // Partner: relative position (Qt child of Command) + EXPECT_EQ(QPointF(0, 100), harness.positionFor(harness.partnerZone)); + // Companion: absolute position at (0, 200) - base + command height + partner height + EXPECT_EQ(QPointF(0, 200), harness.positionFor(harness.companionZone)); + // Background: absolute position at (0, 300) - base + command + partner + companion + EXPECT_EQ(QPointF(0, 300), harness.positionFor(harness.backgroundZone)); +} + +TEST_F(ZonePositioningTest, PartnerCollapsed_CompanionAtCommandBottom) +{ + harness.commandZone.setPos(QPointF(50, 0)); + harness.partnerZone.setExpanded(false); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setExpanded(true); + + EXPECT_EQ(0, harness.partnerZone.currentHeight()) << "Collapsed partner should have 0 height"; + EXPECT_EQ(QPointF(50, 100), harness.scenePositionOf(harness.companionZone)) + << "Companion should be at command bottom when partner is collapsed"; + EXPECT_EQ(QPointF(50, 200), harness.scenePositionOf(harness.backgroundZone)); +} + +TEST_F(ZonePositioningTest, PartnerCollapsed_CompanionPosition) +{ + harness.commandZone.setPos(QPointF(0, 0)); + harness.partnerZone.setExpanded(false); + harness.companionZone.setExpanded(true); + + // Companion: absolute position = base + command height + partner height (0) + EXPECT_EQ(QPointF(0, 100), harness.positionFor(harness.companionZone)) + << "Companion at (0, 100) when partner collapsed (0 + 100 + 0)"; +} + +TEST_F(ZonePositioningTest, AllCollapsed_TotalHeightIsCommandOnly) +{ + harness.partnerZone.setExpanded(false); + harness.companionZone.setExpanded(false); + harness.backgroundZone.setExpanded(false); + + EXPECT_EQ(100, harness.totalHeight()) << "Only command zone height when others collapsed"; +} + +TEST_F(ZonePositioningTest, AllExpanded_TotalHeightIs400) +{ + harness.partnerZone.setExpanded(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setExpanded(true); + + EXPECT_EQ(400, harness.totalHeight()) << "4 zones × 100 = 400"; +} + +TEST_F(ZonePositioningTest, MixedStates_CorrectTotalHeight) +{ + harness.partnerZone.setExpanded(true); + harness.partnerZone.setMinimized(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setExpanded(false); + + EXPECT_EQ(100 + 25 + 100 + 0, harness.totalHeight()) + << "Command (100) + Partner minimized (25) + Companion (100) + Background collapsed (0)"; +} + +// ============ Position Verification After State Changes ============ + +TEST_F(ZonePositioningTest, MinimizeCommand_CompanionMovesUp) +{ + harness.commandZone.setPos(QPointF(50, 0)); + harness.partnerZone.setExpanded(true); + harness.companionZone.setExpanded(true); + + EXPECT_EQ(QPointF(50, 200), harness.scenePositionOf(harness.companionZone)); + + harness.commandZone.setMinimized(true); + + EXPECT_EQ(QPointF(50, 125), harness.scenePositionOf(harness.companionZone)) + << "Companion should move up when command zone minimizes (25 + 100 = 125)"; +} + +TEST_F(ZonePositioningTest, CollapsePartner_CompanionMovesUp) +{ + harness.commandZone.setPos(QPointF(50, 0)); + harness.partnerZone.setExpanded(true); + harness.companionZone.setExpanded(true); + + EXPECT_EQ(QPointF(50, 200), harness.scenePositionOf(harness.companionZone)); + + harness.partnerZone.setExpanded(false); + + EXPECT_EQ(QPointF(50, 100), harness.scenePositionOf(harness.companionZone)) + << "Companion should move up when partner collapses (100 + 0 = 100)"; +} + +// ============ Visibility-Aware totalHeight Tests ============ + +TEST_F(ZonePositioningTest, CommandZoneHidden_HeightExcludesCommandAndPartner) +{ + harness.commandZone.setVisible(false); + harness.partnerZone.setExpanded(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setExpanded(true); + + EXPECT_EQ(200, harness.totalHeight()) << "Only companion (100) + background (100) when command zone hidden"; +} + +TEST_F(ZonePositioningTest, CommandZoneHidden_PartnerExpandedIgnored) +{ + harness.commandZone.setVisible(false); + harness.partnerZone.setExpanded(true); + + EXPECT_EQ(0, harness.totalHeight()) + << "Partner height ignored when command zone is Qt-hidden (Qt cascades visibility to children)"; +} + +TEST_F(ZonePositioningTest, OnlyCompanionVisible_HeightIsCompanionOnly) +{ + harness.commandZone.setVisible(false); + harness.companionZone.setVisible(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setVisible(false); + + EXPECT_EQ(100, harness.totalHeight()) << "Only companion height (100) when it's the only visible zone"; +} + +TEST_F(ZonePositioningTest, OnlyBackgroundVisible_HeightIsBackgroundOnly) +{ + harness.commandZone.setVisible(false); + harness.companionZone.setVisible(false); + harness.backgroundZone.setVisible(true); + harness.backgroundZone.setExpanded(true); + + EXPECT_EQ(100, harness.totalHeight()) << "Only background height (100) when it's the only visible zone"; +} + +TEST_F(ZonePositioningTest, CompanionAndBackgroundVisible_HeightIsBoth) +{ + harness.commandZone.setVisible(false); + harness.companionZone.setVisible(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setVisible(true); + harness.backgroundZone.setExpanded(true); + + EXPECT_EQ(200, harness.totalHeight()) << "Companion (100) + Background (100) when both visible, command hidden"; +} + +TEST_F(ZonePositioningTest, AllZonesVisible_HeightIncludesAll) +{ + harness.commandZone.setVisible(true); + harness.partnerZone.setExpanded(true); + harness.companionZone.setVisible(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setVisible(true); + harness.backgroundZone.setExpanded(true); + + EXPECT_EQ(400, harness.totalHeight()) << "All zones visible and expanded: 4 × 100 = 400"; +} + +TEST_F(ZonePositioningTest, CompanionCollapsed_NoHeightContribution) +{ + harness.commandZone.setVisible(false); + harness.companionZone.setVisible(true); + harness.companionZone.setExpanded(false); + harness.backgroundZone.setVisible(true); + harness.backgroundZone.setExpanded(true); + + EXPECT_EQ(100, harness.totalHeight()) << "Only background (100) contributes when companion is collapsed"; +} + +TEST_F(ZonePositioningTest, CompanionMinimized_ReducedHeightContribution) +{ + harness.commandZone.setVisible(false); + harness.companionZone.setVisible(true); + harness.companionZone.setExpanded(true); + harness.companionZone.setMinimized(true); + harness.backgroundZone.setVisible(false); + + EXPECT_EQ(25, harness.totalHeight()) << "Minimized companion contributes 25% height (25)"; +} + +// ============ Toggle Visibility Tests ============ + +/** + * @brief Test harness for toggle visibility logic. + * + * Simulates the toggle visibility rules from positionCommandZones() + * to test behavior when intermediate zones are disabled. + * + * @warning SYNC REQUIRED: This harness duplicates logic from + * player_graphics_item.cpp. Update if implementation changes. + */ +class ToggleVisibilityHarness +{ +public: + MockCommandZone commandZone{CommandZoneType::Primary, 100.0}; + MockCommandZone partnerZone{CommandZoneType::Partner, 100.0}; + MockCommandZone companionZone{CommandZoneType::Companion, 100.0}; + MockCommandZone backgroundZone{CommandZoneType::Background, 100.0}; + + bool partnerToggleVisible = false; + bool companionToggleVisible = false; + bool backgroundToggleVisible = false; + + /** + * @brief Calculates toggle visibility using the same logic as positionCommandZones(). + */ + void updateToggleVisibility() + { + bool commandZoneEnabled = commandZone.isVisible(); + bool companionZoneEnabled = companionZone.isVisible(); + bool backgroundZoneEnabled = backgroundZone.isVisible(); + + if (!commandZoneEnabled && !companionZoneEnabled && !backgroundZoneEnabled) { + partnerToggleVisible = false; + companionToggleVisible = false; + backgroundToggleVisible = false; + return; + } + + bool partnerCollapsed = !partnerZone.isExpanded(); + bool companionCollapsed = !companionZone.isExpanded(); + bool backgroundCollapsed = !backgroundZone.isExpanded(); + + bool partnerIsOpen = !partnerCollapsed; + bool companionIsOpen = !companionCollapsed; + bool backgroundIsOpen = !backgroundCollapsed; + bool noDeeperZonesOpen = companionCollapsed && backgroundCollapsed; + + partnerToggleVisible = commandZoneEnabled && (partnerIsOpen || noDeeperZonesOpen); + + companionToggleVisible = companionZoneEnabled && (companionIsOpen || (partnerIsOpen && backgroundCollapsed) || + (!commandZoneEnabled && noDeeperZonesOpen)); + + backgroundToggleVisible = + backgroundZoneEnabled && (backgroundIsOpen || companionIsOpen || (partnerIsOpen && !companionZoneEnabled) || + (!commandZoneEnabled && !companionZoneEnabled)); + } +}; + +class ToggleVisibilityTest : public ::testing::Test +{ +protected: + ToggleVisibilityHarness harness; + + void SetUp() override + { + harness.commandZone.setVisible(true); + harness.partnerZone.setVisible(true); + harness.companionZone.setVisible(true); + harness.backgroundZone.setVisible(true); + } +}; + +// Original bug: Companion disabled, command zone enabled +TEST_F(ToggleVisibilityTest, CompanionDisabled_PartnerOpens_BackgroundToggleAppears) +{ + harness.companionZone.setVisible(false); + + harness.updateToggleVisibility(); + EXPECT_TRUE(harness.partnerToggleVisible) << "Partner toggle at game start"; + + harness.partnerZone.setExpanded(true); + harness.updateToggleVisibility(); + + EXPECT_TRUE(harness.backgroundToggleVisible) + << "Background toggle should appear when partner opens (skipping disabled companion)"; +} + +// Bug 2: Command zone disabled, companion enabled +TEST_F(ToggleVisibilityTest, CommandZoneDisabled_CompanionEnabled_CompanionIsEntryPoint) +{ + harness.commandZone.setVisible(false); + + harness.updateToggleVisibility(); + + EXPECT_FALSE(harness.partnerToggleVisible) << "Partner toggle hidden when command zone disabled"; + EXPECT_TRUE(harness.companionToggleVisible) << "Companion toggle should be entry point when command zone disabled"; +} + +// Edge case: Only background enabled +TEST_F(ToggleVisibilityTest, OnlyBackgroundEnabled_BackgroundIsEntryPoint) +{ + harness.commandZone.setVisible(false); + harness.companionZone.setVisible(false); + + harness.updateToggleVisibility(); + + EXPECT_FALSE(harness.partnerToggleVisible); + EXPECT_FALSE(harness.companionToggleVisible); + EXPECT_TRUE(harness.backgroundToggleVisible) + << "Background toggle should be entry point when it's the only zone enabled"; +} + +// Regression: All zones enabled, normal cascade +TEST_F(ToggleVisibilityTest, AllEnabled_NormalCascade) +{ + harness.updateToggleVisibility(); + EXPECT_TRUE(harness.partnerToggleVisible) << "Partner toggle at game start"; + EXPECT_FALSE(harness.companionToggleVisible) << "Companion toggle hidden at start"; + EXPECT_FALSE(harness.backgroundToggleVisible) << "Background toggle hidden at start"; + + harness.partnerZone.setExpanded(true); + harness.updateToggleVisibility(); + EXPECT_TRUE(harness.companionToggleVisible) << "Companion toggle after partner opens"; + EXPECT_FALSE(harness.backgroundToggleVisible) << "Background still hidden"; + + harness.companionZone.setExpanded(true); + harness.updateToggleVisibility(); + EXPECT_TRUE(harness.backgroundToggleVisible) << "Background toggle after companion opens"; +} + +// Edge case: Partner opens but all deeper zones disabled (dead-end cascade) +TEST_F(ToggleVisibilityTest, AllDeeperZonesDisabled_PartnerOpens_NoFurtherToggle) +{ + harness.companionZone.setVisible(false); + harness.backgroundZone.setVisible(false); + + harness.partnerZone.setExpanded(true); + harness.updateToggleVisibility(); + + EXPECT_TRUE(harness.partnerToggleVisible) << "Partner toggle visible (open)"; + EXPECT_FALSE(harness.companionToggleVisible) << "Companion disabled"; + EXPECT_FALSE(harness.backgroundToggleVisible) << "Background disabled"; +} + +// Verify toggle stays visible when zone is open +TEST_F(ToggleVisibilityTest, OpenZone_ToggleRemainsVisible) +{ + harness.partnerZone.setExpanded(true); + harness.companionZone.setExpanded(true); + harness.backgroundZone.setExpanded(true); + + harness.updateToggleVisibility(); + + EXPECT_TRUE(harness.partnerToggleVisible) << "Partner toggle visible when open"; + EXPECT_TRUE(harness.companionToggleVisible) << "Companion toggle visible when open"; + EXPECT_TRUE(harness.backgroundToggleVisible) << "Background toggle visible when open"; +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/command_zone/command_zone_test_common.h b/tests/command_zone/command_zone_test_common.h new file mode 100644 index 000000000..ebabf1f2d --- /dev/null +++ b/tests/command_zone/command_zone_test_common.h @@ -0,0 +1,22 @@ +/** + * @file command_zone_test_common.h + * @brief Shared types and utilities for command zone unit tests. + * + * Provides access to production CommandZoneType and ZoneVisibility enums + * without pulling in full Qt graphics dependencies. Tests remain isolated + * while using the canonical enum definitions. + */ + +#ifndef COMMAND_ZONE_TEST_COMMON_H +#define COMMAND_ZONE_TEST_COMMON_H + +#include "cockatrice/src/game/zones/command_zone_types.h" + +// Re-export the minimized height ratio using the canonical constant name +// that tests were already using (ZoneSizes vs CommandZoneConstants) +namespace ZoneSizes +{ +using namespace CommandZoneConstants; +} + +#endif // COMMAND_ZONE_TEST_COMMON_H