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
This commit is contained in:
DawnFire42 2026-02-25 13:35:09 -05:00
parent 78db49a9b3
commit 6e83d64622
12 changed files with 1570 additions and 8 deletions

View file

@ -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

View file

@ -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 <QDebug>
#include <QGraphicsRectItem>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <libcockatrice/common/counter_ids.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
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<CardDragItem *> &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<int>(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<int>(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<CommandZone *>(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<CommandZone *>(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<CommandZone *>(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);
}
}

View file

@ -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 <QLoggingCategory>
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<CommandZone *> 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<CardDragItem *> &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

View file

@ -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;

View file

@ -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 <QSet>
/**
* 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
{

View file

@ -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();
}

View file

@ -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

View file

@ -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 <QGraphicsScene>
@ -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<CardItem *> 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);
}
}

View file

@ -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 <QCheckBox>
@ -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);

View file

@ -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()

View file

@ -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 <QPointF>
#include <QRectF>
#include <gtest/gtest.h>
/**
* @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<void *>(&commandZone));
partnerTaxCounter.setParentItem(static_cast<void *>(&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<void *>(&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<void *>(&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();
}

View file

@ -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