add blueprint tests

This commit is contained in:
2026-04-26 22:19:46 +02:00
parent 71677b806a
commit 1e2135dc5b
5 changed files with 408 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/EntityId.h
${CMAKE_CURRENT_SOURCE_DIR}/Rotation.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.h
${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
${CMAKE_CURRENT_SOURCE_DIR}/Item.h
${CMAKE_CURRENT_SOURCE_DIR}/Port.h

406
src/test/BlueprintTest.cpp Normal file
View File

@@ -0,0 +1,406 @@
#include "catch.hpp"
#include <algorithm>
#include <climits>
#include <vector>
#include <QPoint>
#include "Blueprint.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "EntityId.h"
#include "Rotation.h"
#include "Simulation.h"
// ---------------------------------------------------------------------------
// Helpers that mirror the production implementations under test.
// ---------------------------------------------------------------------------
// Mirror of the CW / CCW offset transforms in GameWorldView::keyPressEvent.
static QPoint rotateCW(QPoint p) { return QPoint(-p.y(), p.x()); }
static QPoint rotateCCW(QPoint p) { return QPoint( p.y(), -p.x()); }
// Mirror of the Rotation cycling in GameWorldView (anonymous-namespace helpers).
static Rotation rotCW(Rotation r)
{
switch (r)
{
case Rotation::North: return Rotation::East;
case Rotation::East: return Rotation::South;
case Rotation::South: return Rotation::West;
case Rotation::West: return Rotation::North;
}
return Rotation::East;
}
static Rotation rotCCW(Rotation r)
{
switch (r)
{
case Rotation::North: return Rotation::West;
case Rotation::East: return Rotation::North;
case Rotation::South: return Rotation::East;
case Rotation::West: return Rotation::South;
}
return Rotation::East;
}
// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building
// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division
// bounding-box center and per-building tile offsets.
struct BuildingSpec
{
QPoint anchor;
std::vector<QPoint> bodyCells;
BuildingType type;
Rotation rotation;
};
static Blueprint buildBlueprint(const std::vector<BuildingSpec>& specs)
{
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const BuildingSpec& s : specs)
{
for (const QPoint& cell : s.bodyCells)
{
minX = std::min(minX, cell.x());
maxX = std::max(maxX, cell.x());
minY = std::min(minY, cell.y());
maxY = std::max(maxY, cell.y());
}
}
const QPoint center((minX + maxX) / 2, (minY + maxY) / 2);
Blueprint bp;
for (const BuildingSpec& s : specs)
{
BlueprintBuilding bb;
bb.type = s.type;
bb.rotation = s.rotation;
bb.offset = s.anchor - center;
bp.buildings.push_back(bb);
}
return bp;
}
// Apply one CW/CCW rotation to every building in the constellation, mirroring
// the Q / E handling in GameWorldView::keyPressEvent.
static void applyRotationCW(Blueprint& bp)
{
for (BlueprintBuilding& bb : bp.buildings)
{
bb.offset = rotateCW(bb.offset);
bb.rotation = rotCW(bb.rotation);
}
}
static void applyRotationCCW(Blueprint& bp)
{
for (BlueprintBuilding& bb : bp.buildings)
{
bb.offset = rotateCCW(bb.offset);
bb.rotation = rotCCW(bb.rotation);
}
}
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// ---------------------------------------------------------------------------
// Offset computation
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: single 1x1 building gets zero offset", "[blueprint]")
{
// Body = one tile at (-5, 3). Center = (-5, 3). Offset = (0, 0).
const BuildingSpec spec{ QPoint(-5, 3), {QPoint(-5, 3)}, BuildingType::Belt, Rotation::East };
const Blueprint bp = buildBlueprint({ spec });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].offset == QPoint(0, 0));
}
TEST_CASE("Blueprint: two 1x1 buildings with odd span get symmetric offsets", "[blueprint]")
{
// Anchors at (-6, 0) and (-4, 0). bboxX = [-6, -4], center.x = -5.
// Offsets: -6 - (-5) = -1, -4 - (-5) = +1.
const BuildingSpec left { QPoint(-6, 0), {QPoint(-6, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec right{ QPoint(-4, 0), {QPoint(-4, 0)}, BuildingType::Belt, Rotation::East };
const Blueprint bp = buildBlueprint({ left, right });
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
REQUIRE(bp.buildings[1].offset == QPoint( 1, 0));
}
TEST_CASE("Blueprint: even span uses C++ integer truncation for center", "[blueprint]")
{
// Anchors at (-5, 0) and (-4, 0). bboxX = [-5, -4], sum = -9.
// center.x = -9 / 2 = -4 (C++ truncates toward zero, i.e. rounds up for negatives).
// Offsets: -5 - (-4) = -1, -4 - (-4) = 0.
const BuildingSpec left { QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec right{ QPoint(-4, 0), {QPoint(-4, 0)}, BuildingType::Belt, Rotation::East };
const Blueprint bp = buildBlueprint({ left, right });
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
REQUIRE(bp.buildings[1].offset == QPoint( 0, 0));
}
TEST_CASE("Blueprint: bounding box is computed from body cells, not anchors alone", "[blueprint]")
{
// A 2x1 building: anchor (-6, 0), body cells (-6, 0) and (-5, 0).
// A 1x1 building: anchor (-3, 0), body cell (-3, 0).
// All body cells: {(-6,0),(-5,0),(-3,0)}. bboxX = [-6, -3], sum = -9.
// center.x = -9 / 2 = -4 (C++ truncation).
// Wide building anchor offset: -6 - (-4) = -2.
// Small building anchor offset: -3 - (-4) = +1.
const BuildingSpec wide{
QPoint(-6, 0),
{ QPoint(-6, 0), QPoint(-5, 0) },
BuildingType::Belt,
Rotation::East
};
const BuildingSpec small{
QPoint(-3, 0),
{ QPoint(-3, 0) },
BuildingType::Belt,
Rotation::East
};
const Blueprint bp = buildBlueprint({ wide, small });
REQUIRE(bp.buildings[0].offset == QPoint(-2, 0));
REQUIRE(bp.buildings[1].offset == QPoint( 1, 0));
}
TEST_CASE("Blueprint: 2-D bounding box with buildings on both axes", "[blueprint]")
{
// Four 1x1 buildings at corners of a 4x2 rectangle (-7,0),(-5,0),(-7,2),(-5,2).
// bboxX = [-7, -5], center.x = -6. bboxY = [0, 2], center.y = 1.
// Offsets: (-1,-1), (1,-1), (-1,1), (1,1).
const auto belt = BuildingType::Belt;
const auto east = Rotation::East;
std::vector<BuildingSpec> specs = {
{ QPoint(-7, 0), {QPoint(-7, 0)}, belt, east },
{ QPoint(-5, 0), {QPoint(-5, 0)}, belt, east },
{ QPoint(-7, 2), {QPoint(-7, 2)}, belt, east },
{ QPoint(-5, 2), {QPoint(-5, 2)}, belt, east },
};
const Blueprint bp = buildBlueprint(specs);
REQUIRE(bp.buildings[0].offset == QPoint(-1, -1));
REQUIRE(bp.buildings[1].offset == QPoint( 1, -1));
REQUIRE(bp.buildings[2].offset == QPoint(-1, 1));
REQUIRE(bp.buildings[3].offset == QPoint( 1, 1));
}
// ---------------------------------------------------------------------------
// Offset rotation math
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: CW rotation in screen space maps right→down, down→left", "[blueprint]")
{
// Screen space has Y growing downward. CW means: right→down, down→left, left→up, up→right.
REQUIRE(rotateCW(QPoint( 1, 0)) == QPoint( 0, 1));
REQUIRE(rotateCW(QPoint( 0, 1)) == QPoint(-1, 0));
REQUIRE(rotateCW(QPoint(-1, 0)) == QPoint( 0, -1));
REQUIRE(rotateCW(QPoint( 0, -1)) == QPoint( 1, 0));
}
TEST_CASE("Blueprint: CCW rotation is the inverse of CW rotation", "[blueprint]")
{
REQUIRE(rotateCCW(QPoint( 1, 0)) == QPoint( 0, -1));
REQUIRE(rotateCCW(QPoint( 0, -1)) == QPoint(-1, 0));
REQUIRE(rotateCCW(QPoint(-1, 0)) == QPoint( 0, 1));
REQUIRE(rotateCCW(QPoint( 0, 1)) == QPoint( 1, 0));
}
TEST_CASE("Blueprint: four CW rotations restore any offset to its original", "[blueprint]")
{
const QPoint original(-3, 5);
QPoint p = original;
for (int i = 0; i < 4; ++i) { p = rotateCW(p); }
REQUIRE(p == original);
}
TEST_CASE("Blueprint: CW followed by CCW is identity", "[blueprint]")
{
const QPoint original(2, -7);
REQUIRE(rotateCCW(rotateCW(original)) == original);
REQUIRE(rotateCW(rotateCCW(original)) == original);
}
TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]")
{
// (2, 3) → CW → (-3, 2) → CW → (-2, -3) → CW → (3, -2) → CW → (2, 3)
QPoint p(2, 3);
p = rotateCW(p); REQUIRE(p == QPoint(-3, 2));
p = rotateCW(p); REQUIRE(p == QPoint(-2, -3));
p = rotateCW(p); REQUIRE(p == QPoint( 3, -2));
p = rotateCW(p); REQUIRE(p == QPoint( 2, 3));
}
// ---------------------------------------------------------------------------
// Constellation rotation: offsets AND building rotations both update
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]")
{
// Building one tile to the right, facing East.
Blueprint bp;
bp.name = "test";
BlueprintBuilding bb;
bb.type = BuildingType::Belt;
bb.rotation = Rotation::East;
bb.offset = QPoint(1, 0);
bp.buildings.push_back(bb);
applyRotationCW(bp);
// Offset: right → down.
REQUIRE(bp.buildings[0].offset == QPoint(0, 1));
// Building rotation: East → South.
REQUIRE(bp.buildings[0].rotation == Rotation::South);
}
TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]")
{
Blueprint bp;
bp.name = "test";
BlueprintBuilding bb;
bb.type = BuildingType::Belt;
bb.rotation = Rotation::East;
bb.offset = QPoint(1, 0);
bp.buildings.push_back(bb);
applyRotationCCW(bp);
// Offset: right → up.
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
// Building rotation: East → North.
REQUIRE(bp.buildings[0].rotation == Rotation::North);
}
TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]")
{
Blueprint bp;
bp.name = "test";
BlueprintBuilding bb;
bb.type = BuildingType::Belt;
bb.rotation = Rotation::East;
bb.offset = QPoint(2, -1);
bp.buildings.push_back(bb);
const QPoint originalOffset = bb.offset;
const Rotation originalRotation = bb.rotation;
for (int i = 0; i < 4; ++i) { applyRotationCW(bp); }
REQUIRE(bp.buildings[0].offset == originalOffset);
REQUIRE(bp.buildings[0].rotation == originalRotation);
}
TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]")
{
// Two buildings left and right of center; after CW they should be above and below.
Blueprint bp;
bp.name = "test";
BlueprintBuilding left, right;
left.type = right.type = BuildingType::Belt;
left.rotation = right.rotation = Rotation::East;
left.offset = QPoint(-1, 0);
right.offset = QPoint( 1, 0);
bp.buildings = { left, right };
applyRotationCW(bp);
// left (-1, 0) → CW → (0, -1) (above center)
// right ( 1, 0) → CW → (0, 1) (below center)
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
REQUIRE(bp.buildings[1].offset == QPoint(0, 1));
REQUIRE(bp.buildings[0].rotation == Rotation::South);
REQUIRE(bp.buildings[1].rotation == Rotation::South);
}
// ---------------------------------------------------------------------------
// Simulation-level: blueprint placement places buildings at correct tiles
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor", "[blueprint]")
{
// Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0)
// at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0).
// (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).)
Simulation sim(loadConfig());
const QPoint cursor(-5, 0);
const QPoint offsetA(-1, 0);
const QPoint offsetB( 1, 0);
const EntityId idA = sim.tryPlaceBuilding(
BuildingType::Belt, cursor + offsetA, Rotation::East);
const EntityId idB = sim.tryPlaceBuilding(
BuildingType::Belt, cursor + offsetB, Rotation::East);
REQUIRE(idA != kInvalidEntityId);
REQUIRE(idB != kInvalidEntityId);
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetA)); // (-6, 0)
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetB)); // (-4, 0)
REQUIRE_FALSE(sim.buildings().isTileOccupied(cursor)); // center not occupied
}
TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]")
{
Simulation sim(loadConfig());
// Find belt cost from config (belt cost = 2 in test config).
int beltCost = 0;
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Belt) { beltCost = def.cost; break; }
}
REQUIRE(beltCost > 0);
const int startBlocks = sim.buildingBlocksStock();
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
}
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidEntityId and deducts nothing",
"[blueprint]")
{
Simulation sim(loadConfig());
// Find miner cost (15 in test config) — expensive enough to exhaust a small stock.
int minerCost = 0;
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Miner) { minerCost = def.cost; break; }
}
REQUIRE(minerCost > 0);
// Drain the stock by placing miners until we no longer have enough.
// Non-overlapping columns: miner body is 2 wide, so step by 2.
int col = -2;
while (sim.buildingBlocksStock() >= minerCost)
{
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East);
col -= 2;
}
const int blocksBeforeAttempt = sim.buildingBlocksStock();
const EntityId id = sim.tryPlaceBuilding(
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged.
REQUIRE(id == kInvalidEntityId);
REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt);
}

View File

@@ -15,4 +15,5 @@ add_files(
WaveSystemTest.cpp
CombatSystemTest.cpp
ShipyardTest.cpp
BlueprintTest.cpp
)

View File

@@ -7,7 +7,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
PARENT_SCOPE
)