Files
dota_factory/src/test/BuildModeControllerTest.cpp
Malte Langkabel b2148f00ac stop hovering when the cursor points at no tile
A cursor resting on a panel or outside the window kept whatever it last
pointed at: the ghost, the tunnel preview, the deconstruct tint all stayed
put, because "not hovering" was not a state the build mode could hold. Make
both ghost tiles optional, clear the hover with them, and re-derive it when
the cursor comes back or a mode is entered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 15:19:22 +02:00

362 lines
12 KiB
C++

#include "catch.hpp"
#include <memory>
#include "BlueprintModeExitedEvent.h"
#include "BuildModeController.h"
#include "BuilderModeExitedEvent.h"
#include "DeconstructModeChangedEvent.h"
#include "EventHandler.h"
#include "EventManager.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Records the mode events, which are the half of this class's contract that the
// panels depend on: a mode that ends without announcing it leaves its button
// stuck highlighted.
class ModeEventSpy : public CombinedEventHandler<BuilderModeExitedEvent,
BlueprintModeExitedEvent,
DeconstructModeChangedEvent>
{
public:
ModeEventSpy() { registerForEvents(); }
~ModeEventSpy() { unregisterForEvents(); }
int builderExits = 0;
int blueprintExits = 0;
int deconstructChanges = 0;
bool lastDeconstructActive = false;
private:
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/) override
{
++builderExits;
}
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/) override
{
++blueprintExits;
}
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override
{
++deconstructChanges;
lastDeconstructActive = event->active;
}
};
static Blueprint makeBlueprint()
{
Blueprint blueprint;
BlueprintBuilding building;
building.type = BuildingType::Belt;
building.offset = QPoint(0, 0);
building.rotation = Rotation::East;
blueprint.buildings.push_back(building);
return blueprint;
}
// ---------------------------------------------------------------------------
// Exclusivity
// ---------------------------------------------------------------------------
TEST_CASE("Only one mode is active at a time", "[buildmode]")
{
BuildModeController controller;
REQUIRE(controller.getMode() == BuildMode::None);
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.isDeconstructMode());
controller.enterBlueprintMode(makeBlueprint());
REQUIRE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.isBuilderMode());
controller.toggleDeconstructMode();
REQUIRE(controller.isDeconstructMode());
REQUIRE_FALSE(controller.isBlueprintMode());
}
TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]")
{
// Regression: entering builder mode used to drop the blueprint silently, so the
// blueprint library kept tracking an active blueprint for a mode that was over.
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(spy.blueprintExits == 1);
}
TEST_CASE("Every mode announces its exit however it is left", "[buildmode]")
{
// The point of routing all transitions through one place: which mode the player
// switches to must not change what the mode they left announces.
SECTION("builder, left for a blueprint")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.enterBlueprintMode(makeBlueprint());
REQUIRE(spy.builderExits == 1);
}
SECTION("builder, left for deconstruct")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(spy.builderExits == 1);
}
SECTION("blueprint, left for deconstruct")
{
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(spy.blueprintExits == 1);
}
SECTION("deconstruct, left for builder")
{
BuildModeController controller;
controller.toggleDeconstructMode();
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(spy.deconstructChanges == 1);
REQUIRE_FALSE(spy.lastDeconstructActive);
}
}
TEST_CASE("Switching between builder types stays in builder mode", "[buildmode]")
{
// Picking a different building is not leaving the mode, so nothing is announced.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Splitter);
REQUIRE(controller.getBuilderType() == BuildingType::Splitter);
REQUIRE(spy.builderExits == 0);
}
TEST_CASE("Exiting a mode that is not active does nothing", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.exitBlueprintMode();
REQUIRE(controller.isBuilderMode());
REQUIRE(spy.blueprintExits == 0);
REQUIRE(spy.builderExits == 0);
}
TEST_CASE("Deconstruct mode toggles off and announces both edges", "[buildmode]")
{
BuildModeController controller;
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(controller.isDeconstructMode());
REQUIRE(spy.lastDeconstructActive);
controller.toggleDeconstructMode();
REQUIRE(controller.getMode() == BuildMode::None);
REQUIRE_FALSE(spy.lastDeconstructActive);
REQUIRE(spy.deconstructChanges == 2);
}
TEST_CASE("Leaving the current mode works from any of them", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
controller.enterBlueprintMode(makeBlueprint());
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
controller.toggleDeconstructMode();
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
}
// ---------------------------------------------------------------------------
// State cleared on transition
// ---------------------------------------------------------------------------
TEST_CASE("Leaving builder mode drops an in-progress belt drag", "[buildmode]")
{
// A drag surviving the mode change would place belts on the next release.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(3, 4));
controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}});
REQUIRE(controller.isDraggingBelt());
controller.toggleDeconstructMode();
REQUIRE_FALSE(controller.isDraggingBelt());
REQUIRE(controller.getBeltDragPath().empty());
}
TEST_CASE("Leaving deconstruct mode drops the hovered building", "[buildmode]")
{
BuildModeController controller;
controller.toggleDeconstructMode();
controller.setDeconstructHoverBuildingId(BuildingId(4));
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value());
}
TEST_CASE("Entering builder mode resets the ghost", "[buildmode]")
{
// A fresh builder starts facing East and invalid until the first hover, rather
// than inheriting the previous building's facing (REQ-BLD-GHOST).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.rotateGhost(true);
controller.setGhostValidity(true);
controller.enterBuilderMode(BuildingType::Splitter);
REQUIRE(controller.getGhostRotation() == Rotation::East);
REQUIRE_FALSE(controller.isGhostValid());
}
TEST_CASE("Cancelling a belt drag stays in builder mode", "[buildmode]")
{
// Right-click during a drag abandons the path but keeps the belt selected
// (REQ-BLD-BELT-DRAG).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(1, 1));
ModeEventSpy spy;
controller.cancelBeltDrag();
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.isDraggingBelt());
REQUIRE(spy.builderExits == 0);
}
// ---------------------------------------------------------------------------
// Ghost and tunnel state
// ---------------------------------------------------------------------------
TEST_CASE("Rotating the ghost cycles through the four facings", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.rotateGhost(true);
REQUIRE(controller.getGhostRotation() == Rotation::South);
controller.rotateGhost(true);
REQUIRE(controller.getGhostRotation() == Rotation::West);
controller.rotateGhost(false);
REQUIRE(controller.getGhostRotation() == Rotation::South);
}
TEST_CASE("Tunnel mode is the tunnel entry builder type", "[buildmode]")
{
// REQ-BLD-TUNNEL-MODE: one builder type covers both tunnel ends.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE_FALSE(controller.isTunnelMode());
controller.enterBuilderMode(BuildingType::TunnelEntry);
REQUIRE(controller.isTunnelMode());
}
TEST_CASE("The effective builder type follows the resolved tunnel end", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5));
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelExit);
REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5));
}
TEST_CASE("Clearing the hover drops everything the cursor pointed at", "[buildmode]")
{
// A cursor that leaves the world hovers nothing, so the ghost and its resolved
// tunnel end go with it, while the mode itself stays active (REQ-BLD-GHOST).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
controller.setGhostTile(QPoint(7, 2));
controller.setGhostValidity(true);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 2));
controller.clearHover();
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.getGhostTile().has_value());
REQUIRE_FALSE(controller.isGhostValid());
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry);
REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value());
}
TEST_CASE("Clearing the hover keeps a belt drag's path", "[buildmode]")
{
// A drag holds the button and goes on hovering wherever the cursor travels, so
// nothing clears it short of releasing or cancelling (REQ-BLD-BELT-DRAG).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(3, 4));
controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}});
controller.clearHover();
REQUIRE(controller.isDraggingBelt());
REQUIRE(controller.getBeltDragPath().size() == 1);
}
TEST_CASE("Clearing the hover drops the blueprint ghost and its transfer", "[buildmode]")
{
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
controller.setBlueprintGhostTile(QPoint(9, 9));
controller.setHoveredGhostTransfer(true);
controller.clearHover();
REQUIRE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.getBlueprintGhostTile().has_value());
REQUIRE_FALSE(controller.isHoveredGhostTransfer());
}
TEST_CASE("Clearing the hover drops the deconstruct hover", "[buildmode]")
{
BuildModeController controller;
controller.toggleDeconstructMode();
controller.setDeconstructHoverBuildingId(BuildingId(4));
controller.clearHover();
REQUIRE(controller.isDeconstructMode());
REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value());
}
TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5));
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::Belt);
REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value());
}