#include "catch.hpp" #include "FactoryQueries.h" #include #include "Building.h" #include "BuildingConfig.h" #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" #include "GameConfig.h" #include "Rotation.h" #include "ShipLayout.h" #include "ShipsConfig.h" #include "Simulation.h" #include "SimulationTestAccess.h" #include "TestConfig.h" // readBuildingConfig underpins blueprint capture (REQ-UI-BLUEPRINT-STORAGE): it // extracts a building's recipe / schematic / layout / splitter filters so they can be // stored in the blueprint and reapplied on placement. It reads operational buildings // and construction sites alike. namespace { const BuildingDef* findDef(const GameConfig& cfg, BuildingType type) { for (const BuildingDef& def : cfg.buildings.buildings) { if (def.type == type) { return &def; } } return nullptr; } BuildingId placeOperational(Simulation& sim, const GameConfig& cfg, BuildingType type, QPoint anchor) { const BuildingDef* def = findDef(cfg, type); REQUIRE(def != nullptr); return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), type, def->surfaceMask, anchor, Rotation::East); } const ShipDef* findAvailableSchematic(const GameConfig& cfg) { for (const ShipDef& def : cfg.ships.ships) { // A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT). bool granted = false; for (const UnlockGroupDef& group : cfg.unlocks.groups) { if (std::find(group.ships.begin(), group.ships.end(), def.id) != group.ships.end()) { granted = true; break; } } if (!granted) { return &def; } } return nullptr; } } // namespace TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[blueprint]") { const GameConfig cfg = loadTestConfig(); Simulation sim(loadTestConfig(), 7); const BuildingId id = placeOperational(sim, cfg, BuildingType::Miner, QPoint(0, 0)); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, "mine_iron_ore"); const std::optional config = readBuildingConfig(sim, id); REQUIRE(config.has_value()); CHECK(config->type == BuildingType::Miner); REQUIRE(config->recipeId.has_value()); CHECK(*config->recipeId == "mine_iron_ore"); CHECK_FALSE(config->isSplitter); CHECK_FALSE(config->shipLayout.has_value()); } TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected", "[blueprint]") { const GameConfig cfg = loadTestConfig(); Simulation sim(loadTestConfig(), 7); const BuildingId id = placeOperational(sim, cfg, BuildingType::Assembler, QPoint(0, 0)); const std::optional config = readBuildingConfig(sim, id); REQUIRE(config.has_value()); CHECK(config->type == BuildingType::Assembler); CHECK_FALSE(config->recipeId.has_value()); // nothing to copy CHECK_FALSE(config->isSplitter); } TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout", "[blueprint]") { const GameConfig cfg = loadTestConfig(); Simulation sim(loadTestConfig(), 7); const ShipDef* schematic = findAvailableSchematic(cfg); REQUIRE(schematic != nullptr); const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0)); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, schematic->id); // A real layout, not an empty one: applying an empty layout to a shipyard that has // none is a no-op (REQ-MAT-INPUT-BUFFER), so it would leave nothing to read back. ShipLayoutConfig layout; PlacedModule placed; placed.moduleId = "armor_plate"; placed.position = QPoint(0, 0); placed.rotation = Rotation::East; layout.placedModules.push_back(placed); SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, layout); const std::optional config = readBuildingConfig(sim, id); REQUIRE(config.has_value()); CHECK(config->type == BuildingType::Shipyard); REQUIRE(config->recipeId.has_value()); CHECK(*config->recipeId == schematic->id); REQUIRE(config->shipLayout.has_value()); REQUIRE(config->shipLayout->placedModules.size() == 1); CHECK(config->shipLayout->placedModules[0].moduleId == "armor_plate"); } TEST_CASE("readBuildingConfig reads a queued construction site", "[blueprint]") { const GameConfig cfg = loadTestConfig(); Simulation sim(loadTestConfig(), 7); // A placed miner enters the construction queue as a site (not yet operational). const BuildingId id = SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value(); REQUIRE(id != kInvalidBuildingId); REQUIRE(findBuilding(sim.getFactoryState(), id) == nullptr); REQUIRE(findSite(sim.getFactoryState(), id) != nullptr); SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, "mine_iron_ore"); const std::optional config = readBuildingConfig(sim, id); REQUIRE(config.has_value()); CHECK(config->type == BuildingType::Miner); REQUIRE(config->recipeId.has_value()); CHECK(*config->recipeId == "mine_iron_ore"); } TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[blueprint]") { Simulation sim(loadTestConfig(), 7); CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value()); } // The derived facts a blueprint card shows (REQ-UI-BLUEPRINT-CARD). They live here // rather than in the dialog so they can be tested: src/ui is off the test include path. namespace { Blueprint makeBlueprint(const std::vector& types) { Blueprint blueprint; for (const BuildingType type : types) { BlueprintBuilding building; building.type = type; building.rotation = Rotation::East; blueprint.buildings.push_back(building); } return blueprint; } } // namespace TEST_CASE("computeBlueprintCost sums the constituent placement costs", "[blueprint]") { const GameConfig cfg = loadTestConfig(); // belt 2 + belt 2 + miner 15. const Blueprint blueprint = makeBlueprint( {BuildingType::Belt, BuildingType::Belt, BuildingType::Miner}); CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 19); } TEST_CASE("computeBlueprintCost ignores types absent from buildings.toml", "[blueprint]") { const GameConfig cfg = loadTestConfig(); REQUIRE(cfg.buildings.findBuildingDef(BuildingType::Hq) == nullptr); const Blueprint blueprint = makeBlueprint({BuildingType::Belt, BuildingType::Hq}); CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 2); } TEST_CASE("computeBlueprintCost is zero for an empty blueprint", "[blueprint]") { const GameConfig cfg = loadTestConfig(); CHECK(computeBlueprintCost(Blueprint{}, cfg.buildings) == 0); } TEST_CASE("summarizeBlueprintContents orders by descending count", "[blueprint]") { const GameConfig cfg = loadTestConfig(); const Blueprint blueprint = makeBlueprint( {BuildingType::Belt, BuildingType::Smelter, BuildingType::Miner, BuildingType::Miner, BuildingType::Smelter, BuildingType::Miner}); const std::vector summary = summarizeBlueprintContents(blueprint, cfg.buildings); REQUIRE(summary.size() == 3); CHECK(summary[0].buildingName == "Miner"); CHECK(summary[0].count == 3); CHECK(summary[1].buildingName == "Smelter"); CHECK(summary[1].count == 2); CHECK(summary[2].buildingName == "Belt"); CHECK(summary[2].count == 1); } TEST_CASE("summarizeBlueprintContents breaks ties in buildings.toml order", "[blueprint]") { const GameConfig cfg = loadTestConfig(); // Miner is declared after Belt in buildings.toml, which is the order the build // button bar lays its buttons out, so Belt wins the tie despite the equal counts // and despite sorting later by enum value. const Blueprint blueprint = makeBlueprint( {BuildingType::Miner, BuildingType::Belt, BuildingType::Miner, BuildingType::Belt}); const std::vector summary = summarizeBlueprintContents(blueprint, cfg.buildings); REQUIRE(summary.size() == 2); CHECK(summary[0].buildingName == "Belt"); CHECK(summary[1].buildingName == "Miner"); } TEST_CASE("summarizeBlueprintContents spells multi-word ids as display names", "[blueprint]") { const GameConfig cfg = loadTestConfig(); const Blueprint blueprint = makeBlueprint({BuildingType::TunnelEntry}); const std::vector summary = summarizeBlueprintContents(blueprint, cfg.buildings); REQUIRE(summary.size() == 1); CHECK(summary[0].buildingName == "Tunnel Entry"); } TEST_CASE("summarizeBlueprintContents is empty for an empty blueprint", "[blueprint]") { const GameConfig cfg = loadTestConfig(); CHECK(summarizeBlueprintContents(Blueprint{}, cfg.buildings).empty()); }