#include "catch.hpp" #include "PlacementRules.h" #include "FactoryQueries.h" #include "ProductionRules.h" #include #include #include #include #include #include #include #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "ConstructionSystem.h" #include "DeconstructionSystem.h" #include "FactoryState.h" #include "BuildingType.h" #include "ConfigLoader.h" #include "Item.h" #include "ItemType.h" #include "Port.h" #include "Rotation.h" #include "Tick.h" #include "TestConfig.h" // --------------------------------------------------------------------------- // Fixture helpers // --------------------------------------------------------------------------- static Item makeItem(const std::string& id) { Item item; item.type.id = id; return item; } static Port eastPort(QPoint tile) { Port p; p.tile = tile; p.direction = Rotation::East; return p; } static Port westPort(QPoint tile) { Port p; p.tile = tile; p.direction = Rotation::West; return p; } // Run N full sim ticks: construction, belt-pull, production, belt-push, belt tick. static void runTicks(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state_bs, BeltSystem& belts, int& stock, int n, Tick& tick) { for (int i = 0; i < n; ++i) { ConstructionSystem(cfg).tick(state_bs, belts, tick); DeconstructionSystem(cfg, [&stock](int n) { stock += n; }).tick(state_bs, tick); bs.tickBeltPull(state_bs); bs.tickProduction(state_bs, tick); bs.tickOutputBelts(state_bs); belts.tick(); ++tick; } } // All items currently on a building's output side: buffered plus still-emerging on // the virtual output belts (REQ-MAT-OUTPUT-EMERGE). A produced item leaves the // output buffer the moment it starts emerging, so tests count both. static std::vector outputSideItems(const Building& b) { std::vector items = b.outputBuffer.items; for (const std::vector& lane : b.emergingItems) { for (const BeltItemSlot& slot : lane) { items.push_back(slot.item); } } return items; } // Owns a BuildingSystem and its dependencies for placement-bounds tests. // Belt speed for the tests that need an item to cross a tile in a single tick, so // it is available to peek or take on the next one. constexpr double kFastBeltSpeed_tps = static_cast(kTickRateHz); struct PlacementFixture { GameConfig cfg = loadTestConfig(); FactoryState state = makeFactoryState(cfg); BeltSystem belts; int stock = 0; std::mt19937 rng{0}; BuildingId nextBuildingId = 1; BuildingSystem bs; // Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test // needs items to arrive immediately. explicit PlacementFixture(std::optional beltSpeed_tps = std::nullopt) : belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps)) , bs(cfg, belts, [this]() { return nextBuildingId++; }, [this](int n) { stock += n; }, [](const std::string&, QVector2D, const std::optional&) {}, [](const std::string&) -> bool { return true; }, rng) { } }; // --------------------------------------------------------------------------- // Placement // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); REQUIRE(id != kInvalidBuildingId); // Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1). REQUIRE(isTileOccupied(f.state, QPoint(0, 0))); REQUIRE(isTileOccupied(f.state, QPoint(1, 0))); REQUIRE(isTileOccupied(f.state, QPoint(0, 1))); // (1,1) is the output-port tile, NOT a body cell. REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1))); } // -- World-bounds rejection (REQ-BLD-PLACE-VALID) --------------------------- TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]") { PlacementFixture f; // Miner mask ["AA","A>"] East → body at (0,0),(1,0),(0,1); at y=-1 the top // row sits above the world. const std::optional id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, -1), Rotation::East, 0); REQUIRE_FALSE(id.has_value()); REQUIRE(getAllSites(f.state).empty()); REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0))); } TEST_CASE("BuildingSystem: place rejects a building below the world (y >= height)", "[building]") { PlacementFixture f; const int heightTiles = f.cfg.world.heightTiles; // Anchored on the last in-bounds row, the miner's lower body row reaches // y == heightTiles, which is outside the world. const std::optional id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, heightTiles - 1), Rotation::East, 0); REQUIRE_FALSE(id.has_value()); REQUIRE(getAllSites(f.state).empty()); } TEST_CASE("BuildingSystem: place rejects a building left of the asteroid edge", "[building]") { PlacementFixture f; const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles; const std::optional id = f.bs.place(f.state, BuildingType::Miner, QPoint(leftEdgeX - 1, 0), Rotation::East, 0); REQUIRE_FALSE(id.has_value()); REQUIRE(getAllSites(f.state).empty()); } TEST_CASE("BuildingSystem: place accepts a building flush against the world's left edge", "[building]") { PlacementFixture f; const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles; // Miner body min relative x is 0, so its leftmost cell sits exactly on the edge. const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East, 0).value(); REQUIRE(id != kInvalidBuildingId); REQUIRE(isTileOccupied(f.state, QPoint(leftEdgeX, 0))); } TEST_CASE("BuildingSystem: place imposes no right-side bound (space extends rightward)", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(1000, 0), Rotation::East, 0).value(); REQUIRE(id != kInvalidBuildingId); } TEST_CASE("BuildingSystem: isPlacementValid enforces terrain and world bounds", "[building]") { PlacementFixture f; const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles; // Miner is all-asteroid (A): valid only fully on the asteroid (x < 0). REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(-3, 0), Rotation::East)); REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, 0), Rotation::East)); // A cells in space REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, -1), Rotation::East)); // above world REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East)); REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX - 1, 0), Rotation::East)); // past left edge // Shipyard mask ["AAAS>","AAAS "] straddles the boundary: A cells on the // asteroid, the S (dock) cell in space. At anchor (-3,0) the A cells land at // x=-3..-1 and the dock at x=0. REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-3, 0), Rotation::East)); REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(0, 0), Rotation::East)); // A cells in space REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-4, 0), Rotation::East)); // dock on asteroid } TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after construction", "[building]") { PlacementFixture f; f.bs.place(f.state, BuildingType::Belt, QPoint(5, 5), Rotation::East, 0); // Belt is queued — not yet in BeltSystem. REQUIRE_FALSE(f.belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East)); // Complete construction (1 s). Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(1.0)) + 1, tick); REQUIRE(f.belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East)); REQUIRE(getAllBuildings(f.state).size() == 1); REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Belt); REQUIRE(getAllBuildings(f.state)[0].anchor == QPoint(5, 5)); } TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); REQUIRE(getAllSites(f.state).size() == 1); REQUIRE(getAllBuildings(f.state).empty()); REQUIRE(findSite(f.state, id) != nullptr); } TEST_CASE("BuildingSystem: deconstructing a construction site removes it instantly with full refund", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); // Still queued for construction (not yet built): instant removal, full cost // refunded immediately, never entering the deconstruction queue (REQ-BLD-DECONSTRUCT). const int refund = f.bs.deconstruct(f.state, id, 0); REQUIRE(refund == 15); // Miner cost = 15 REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0))); REQUIRE(getAllSites(f.state).empty()); } // --------------------------------------------------------------------------- // Construction queue // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: first queued building starts construction immediately", "[building]") { PlacementFixture f; f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); REQUIRE(getAllSites(f.state).front().completesAt > 0); } TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]") { PlacementFixture f; f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0); REQUIRE(getAllSites(f.state).size() == 2); REQUIRE(getAllSites(f.state)[0].completesAt > 0); REQUIRE(getAllSites(f.state)[1].completesAt == 0); } TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); // Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300. // We need to process tick 300 itself, so run 301 ticks (ticks 0..300). Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(getAllSites(f.state).empty()); REQUIRE(findBuilding(f.state, id) != nullptr); } // --------------------------------------------------------------------------- // Deconstruction queue (REQ-BLD-DECON-QUEUE) // --------------------------------------------------------------------------- // Runs ticks until the building with the given id is operational, or fails. static void runUntilBuilt(PlacementFixture& f, BuildingId id, Tick& tick) { for (int i = 0; i < 100000 && findBuilding(f.state, id) == nullptr; ++i) { runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick); } REQUIRE(findBuilding(f.state, id) != nullptr); } TEST_CASE("BuildingSystem: deconstructing a built building queues it; refund credited on completion", "[building][decon]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runUntilBuilt(f, id, tick); // Deconstructing a built building returns nothing immediately and queues it, // stopping it operating while its tiles stay occupied (REQ-BLD-DECON-QUEUE). const int refund = f.bs.deconstruct(f.state, id, tick); REQUIRE(refund == 0); REQUIRE(isQueuedForDeconstruction(f.state, id)); REQUIRE(isTileOccupied(f.state, QPoint(0, 0))); REQUIRE(f.stock == 0); // After the deconstruction time (0.1s = 3 ticks) it is removed and the partial // refund (15 * 75 / 100 = 11) is credited exactly once. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(0.1)) + 1, tick); REQUIRE(findBuilding(f.state, id) == nullptr); REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0))); REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100); } TEST_CASE("BuildingSystem: deconstruction queue removes one building at a time", "[building][decon]") { PlacementFixture f; const BuildingId a = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId b = f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value(); Tick tick = 0; runUntilBuilt(f, a, tick); runUntilBuilt(f, b, tick); // Queue both in one tick; 'a' is at the front of the deconstruction queue. f.bs.deconstruct(f.state, a, tick); f.bs.deconstruct(f.state, b, tick); REQUIRE(isQueuedForDeconstruction(f.state, a)); REQUIRE(isQueuedForDeconstruction(f.state, b)); // After one deconstruction interval only the front building is gone; the // second is still queued and its refund not yet credited. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(0.1)) + 1, tick); REQUIRE(findBuilding(f.state, a) == nullptr); REQUIRE(findBuilding(f.state, b) != nullptr); REQUIRE(isQueuedForDeconstruction(f.state, b)); REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100); // The second drains next. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(0.1)) + 2, tick); REQUIRE(findBuilding(f.state, b) == nullptr); REQUIRE(f.stock == 2 * (15 * f.cfg.world.refundPercentage / 100)); } TEST_CASE("BuildingSystem: cancelling deconstruction resumes the building with no refund", "[building][decon]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runUntilBuilt(f, id, tick); f.bs.deconstruct(f.state, id, tick); REQUIRE(isQueuedForDeconstruction(f.state, id)); // Un-queue before it drains: it operates again, no refund, tiles still occupied. f.bs.cancelDeconstruction(f.state, id); REQUIRE_FALSE(isQueuedForDeconstruction(f.state, id)); REQUIRE(findBuilding(f.state, id) != nullptr); REQUIRE(isTileOccupied(f.state, QPoint(0, 0))); REQUIRE(f.stock == 0); // It is never removed even after more than a deconstruction interval passes. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(0.1)) + 5, tick); REQUIRE(findBuilding(f.state, id) != nullptr); REQUIRE(f.stock == 0); } TEST_CASE("BuildingSystem: queued belt stops transporting; cancel restores it", "[building][decon]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runUntilBuilt(f, id, tick); REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East)); // Queuing a belt unregisters its tile from the belt subsystem, so it no longer // accepts or transports items, though the tile stays occupied (REQ-BLD-DECON-QUEUE). f.bs.deconstruct(f.state, id, tick); REQUIRE_FALSE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East)); REQUIRE(isTileOccupied(f.state, QPoint(0, 0))); // Un-queuing re-registers the belt tile so it transports again. f.bs.cancelDeconstruction(f.state, id); REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East)); } TEST_CASE("BuildingSystem: splitter filters survive a queue/un-queue round-trip", "[building][decon]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Splitter, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runUntilBuilt(f, id, tick); f.belts.setSplitterFilters(QPoint(0, 0), {ItemType{"iron_ore"}}, {}); // Queue: the belt subsystem tile (and its filters) are unregistered, but the // filters are captured so an un-queue can restore them. f.bs.deconstruct(f.state, id, tick); REQUIRE_FALSE(f.belts.getSplitterInfo(QPoint(0, 0)).has_value()); f.bs.cancelDeconstruction(f.state, id); const std::optional info = f.belts.getSplitterInfo(QPoint(0, 0)); REQUIRE(info.has_value()); REQUIRE(info->filterA.size() == 1); REQUIRE(info->filterA[0].id == "iron_ore"); REQUIRE(info->filterB.empty()); } TEST_CASE("BuildingSystem: second building starts after first completes", "[building]") { PlacementFixture f; f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id2 = f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value(); // Process through tick 300 to complete first miner's construction. Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(getAllSites(f.state).size() == 1); REQUIRE(getAllSites(f.state).front().id == id2); REQUIRE(getAllSites(f.state).front().completesAt > 0); } // --------------------------------------------------------------------------- // Miner production cycle // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); f.bs.setRecipe(f.state, id, "mine_iron_ore"); Tick tick = 0; // Construction completes on tick 300; production cycle starts tick 300, // completes on tick 330. Process through tick 330: 331 ticks total. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); // No belt at the output port, so the produced item emerges and stays on the // building's virtual output belt (REQ-MAT-OUTPUT-EMERGE). const std::vector out = outputSideItems(*b); REQUIRE(out.size() == 1); REQUIRE(out.front().type.id == "iron_ore"); } TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); f.bs.setRecipe(f.state, id, "mine_iron_ore"); Tick tick = 0; // Construction (10s) then cycle 1 starts at tick 300 (completesAt=330). // Cycle 1 completes at tick 330: deposit item, continue (no same-tick restart). // Cycle 2 starts at tick 331 (completesAt=361). // Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls. // Need to process through tick 361: 362 ticks total. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) + 2 * static_cast(secondsToTicks(1.0)) + 2, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); // Both produced items are held on the output side (buffer + emerging lane), // which is what the capacity rule counts (REQ-MAT-OUTPUT-EMERGE). REQUIRE(b->getOutputItemCount() == 2); REQUIRE_FALSE(b->production.has_value()); } // --------------------------------------------------------------------------- // REQ-UI-DEBUG-OVERLAY production counts // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", "[building]") { PlacementFixture f; const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId smelterId = f.bs.place(f.state, BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0).value(); (void)smelterId; Tick tick = 0; // Both still under construction. REQUIRE(getProductionBuildingCount(f.state) == 0); // The queue builds one at a time: miner (10s) completes at tick 300, then // the smelter (15s) starts and completes at tick 300 + 450 = 750. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(getProductionBuildingCount(f.state) == 1); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(15.0)), tick); REQUIRE(getProductionBuildingCount(f.state) == 2); // Neither is producing yet: the miner has no recipe selected, and the // smelter (auto-recipe, REQ-BLD-SMELTER) has no input feeding it. REQUIRE(getActiveProductionBuildingCount(f.state) == 0); f.bs.setRecipe(f.state, minerId, "mine_iron_ore"); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick); REQUIRE(getActiveProductionBuildingCount(f.state) == 1); } TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); f.bs.setRecipe(f.state, id, "mine_iron_ore"); Tick tick = 0; // Not yet operational while under construction. REQUIRE(getActiveProductionBuildingCount(f.state) == 0); // Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330). runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(getActiveProductionBuildingCount(f.state) == 1); // Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the // output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER). runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 2 * static_cast(secondsToTicks(1.0)) + 1, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); REQUIRE(b->getOutputItemCount() == 2); REQUIRE_FALSE(b->production.has_value()); REQUIRE(getActiveProductionBuildingCount(f.state) == 0); } // --------------------------------------------------------------------------- // Belt pull → input buffer // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing belt", "[building]") { // Fast belt so items are immediately available for peek/take. PlacementFixture f(kFastBeltSpeed_tps); // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1). // Output port (2,1) East. Input port example: (2,0) West. const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); // Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any // ore/scrap that is an input to a smelter recipe. // Complete construction (15s → tick 450+1 = 451 ticks). Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(15.0)) + 1, tick); // Place west-flowing belt at (2,0): belt flows West, delivers to smelter. f.belts.placeBelt(QPoint(2, 0), Rotation::West); f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); f.bs.tickBeltPull(f.state); const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); // The item was accepted; it may still be travelling inward on the input belt, // so count buffered + in-transit (REQ-MAT-INPUT-INTAKE). REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1); } // An accepted input item travels inward on its input belt before it becomes usable // f.stock: it is reserved (counts against the cap) on entry and only enters the // buffer on reaching the tile centre (REQ-MAT-INPUT-INTAKE). TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer", "[building]") { PlacementFixture f(kFastBeltSpeed_tps); const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(15.0)) + 1, tick); f.belts.placeBelt(QPoint(2, 0), Rotation::West); f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); f.bs.tickBeltPull(f.state); // accepts the item onto the input belt at progress 0.0 const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); // Reserved but not yet consumable: nothing in the buffer, but it counts against // the cap via pendingInputCount. const std::map::const_iterator it0 = b->inputBuffer.counts.find(ItemType{"iron_ore"}); REQUIRE((it0 == b->inputBuffer.counts.end() || it0->second == 0)); REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1); // One more pull tick advances the input belt to the centre; the item arrives. f.bs.tickBeltPull(f.state); REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1); REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1); } // The acceptance test counts in-transit items, so buffered + reserved never exceeds // the per-material cap; excess items stay on the belt (REQ-MAT-INPUT-INTAKE). TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap", "[building]") { PlacementFixture f(kFastBeltSpeed_tps); const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(25.0)) + 1, tick); // Feed scrap via an input belt without ever running production (only pull), so // the buffer fills and stays full. Try to over-fill it well past the cap. f.belts.placeBelt(QPoint(-1, 0), Rotation::East); for (int i = 0; i < 20; ++i) { f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East); f.belts.tick(); f.bs.tickBeltPull(f.state); } const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); const int cap = b->inputBuffer.caps.at(ItemType{"scrap"}); REQUIRE(cap > 0); // buffered + in-transit is capped; the plant never over-pulls. REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == cap); // Excess scrap is left stuck on the feeding belt rather than silently dropped. REQUIRE(f.belts.peekItem(eastPort(QPoint(-1, 0))).has_value()); } // A smelter auto-selects the matching recipe for whatever it is fed, with no // player recipe selection (REQ-BLD-SMELTER). TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection", "[building]") { PlacementFixture f(kFastBeltSpeed_tps); const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(15.0)) + 1, tick); // Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a // west-flowing belt at input port (2,0). f.belts.placeBelt(QPoint(2, 0), Rotation::West); for (int i = 0; i < 2; ++i) { f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); f.bs.tickBeltPull(f.state); } // iron_ingot recipe cycle is 2s; run to completion. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(2.0)) + 2, tick); const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); bool hasIronIngot = false; for (const Item& item : outputSideItems(*b)) { if (item.type.id == "iron_ingot") { hasIronIngot = true; } } REQUIRE(hasIronIngot); } // With mixed inputs, the smelter runs whichever recipe is currently satisfiable // and leaves an incomplete batch of another input waiting (see the union-of- // inputs caps in initAutoBuffers). TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits", "[building]") { PlacementFixture f(kFastBeltSpeed_tps); const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(15.0)) + 1, tick); // Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore // (copper_ingot needs 2 — satisfiable) via the west-flowing input belt. f.belts.placeBelt(QPoint(2, 0), Rotation::West); const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" }; for (const char* id : fed) { f.belts.tryPutItem(QPoint(2, 0), makeItem(id)); f.belts.tick(); f.bs.tickBeltPull(f.state); } // copper_ingot cycle is 2.5s; run to completion. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(2.5)) + 2, tick); const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); // Copper was smelted; the lone iron_ore still waits for a second unit. bool hasCopperIngot = false; for (const Item& item : outputSideItems(*b)) { if (item.type.id == "copper_ingot") { hasCopperIngot = true; } } REQUIRE(hasCopperIngot); const std::map::const_iterator ironIt = b->inputBuffer.counts.find(ItemType{"iron_ore"}); REQUIRE(ironIt != b->inputBuffer.counts.end()); REQUIRE(ironIt->second == 1); } // --------------------------------------------------------------------------- // Belt push → belt tile // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[building]") { PlacementFixture f(kFastBeltSpeed_tps); const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); f.bs.setRecipe(f.state, id, "mine_iron_ore"); // Belt at the miner's output port tile (1,1) flowing East. f.belts.placeBelt(QPoint(1, 1), Rotation::East); Tick tick = 0; // Construction (10s) + 1 production cycle (1s) + 1 extra tick. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, tick); // Item should have been pushed onto the belt this tick or a subsequent one. // Run one more tick to ensure tickBeltPush fires after the deposit tick. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick); const std::optional item = f.belts.tryTakeItem(eastPort(QPoint(1, 1))); REQUIRE(item.has_value()); REQUIRE(item->type.id == "iron_ore"); } // Two directly adjacent buildings whose ports meet transfer items with no belt in // between: a miner's iron_ore output feeds straight into a smelter, which smelts it // (REQ-MAT-DIRECT-COUPLE). TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port", "[building]") { PlacementFixture f; // Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East. const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); f.bs.setRecipe(f.state, minerId, "mine_iron_ore"); // Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is // the miner's output-port tile, and its west input edge there faces East, so the // two ports meet — no belt placed anywhere. const BuildingId smelterId = f.bs.place(f.state, BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0).value(); Tick tick = 0; // Smelter build (15s) + margin for coupling and a smelt cycle. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(30.0)), tick); const Building* smelter = findBuilding(f.state, smelterId); REQUIRE(smelter != nullptr); // iron_ore reached the smelter over the direct coupling and was smelted. bool hasIronIngot = false; for (const Item& produced : outputSideItems(*smelter)) { if (produced.type.id == "iron_ingot") { hasIronIngot = true; } } REQUIRE(hasIronIngot); } // A producer coupled to a building that cannot accept its item delivers nothing; the // item stays stuck at the producer's output port (REQ-MAT-DIRECT-COUPLE acceptance). TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck", "[building]") { PlacementFixture f; // Producing miner at (0,0), output port (1,1) East. const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); f.bs.setRecipe(f.state, minerId, "mine_iron_ore"); // A second, idle miner anchored at (1,1) occupies the output-port tile but takes // no inputs, so it cannot accept the iron_ore. const BuildingId sinkId = f.bs.place(f.state, BuildingType::Miner, QPoint(1, 1), Rotation::East, 0).value(); Tick tick = 0; // Both miners build sequentially (10s each), then the producer runs and jams. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(25.0)), tick); const Building* miner = findBuilding(f.state, minerId); const Building* sink = findBuilding(f.state, sinkId); REQUIRE(miner != nullptr); REQUIRE(sink != nullptr); // Nothing was delivered, and the producer's output side has backed up to its cap. REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0); REQUIRE(miner->getOutputItemCount() == miner->outputBuffer.capacity); } // --------------------------------------------------------------------------- // setRecipe clears buffers // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production", "[building]") { PlacementFixture f(kFastBeltSpeed_tps); const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); f.bs.setRecipe(f.state, id, "mine_iron_ore"); Tick tick = 0; // Run until first item is in output buffer. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, tick); { const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); REQUIRE(b->getOutputItemCount() > 0); } f.bs.setRecipe(f.state, id, "mine_copper_ore"); const Building* b = findBuilding(f.state, id); // Clearing the output buffer on a recipe change also discards emerging items // (REQ-MAT-OUTPUT-EMERGE). REQUIRE(b->getOutputItemCount() == 0); REQUIRE_FALSE(b->production.has_value()); } // --------------------------------------------------------------------------- // Reprocessing plant — output buffer capacity (REQ-MAT-OUTPUT-BUFFER-REPROCESSING) // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the // single reprocessing recipe is applied automatically on completion. // Complete construction (25s). Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(25.0)) + 1, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); // reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%), // 1 advanced_alloy (10%). Max per roll = 2. Capacity = 2 (1× max). REQUIRE(b->outputBuffer.capacity == 2); } TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls", "[building]") { // Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer. PlacementFixture f(kFastBeltSpeed_tps); const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the // single reprocessing recipe is applied automatically on completion. // Complete construction (25s). Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(25.0)) + 1, tick); // Feed 5 scrap into the building via a belt at an input port. // Reprocessing plant body (East rotation) = 3×3 at (0,0). // Valid input port: tile (-1,0) flowing East. f.belts.placeBelt(QPoint(-1, 0), Rotation::East); for (int i = 0; i < 5; ++i) { f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East); f.belts.tick(); f.bs.tickBeltPull(f.state); } // Verify all five scrap were accepted; some may still be travelling inward on // the input belt (REQ-MAT-INPUT-INTAKE), so count buffered + in-transit. { const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5); } // Run production cycle (3s = 90 ticks + 1 for the completion tick). runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(3.0)) + 1, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); // Cycle should have completed and output deposited. REQUIRE_FALSE(b->outputBuffer.items.empty()); // No new production: inputs were consumed and not replenished. REQUIRE_FALSE(b->production.has_value()); } // --------------------------------------------------------------------------- // findRotateInPlaceTarget // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is empty", "[building][rotate-in-place]") { PlacementFixture f; REQUIRE_FALSE( findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::East).has_value()); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a queued belt (same type, different rotation)", "[building][rotate-in-place]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); const std::optional result = findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::North); REQUIRE(result.has_value()); REQUIRE(*result == id); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a completed operational belt", "[building][rotate-in-place]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(1.0)) + 1, tick); REQUIRE(getAllSites(f.state).empty()); const std::optional result = findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::South); REQUIRE(result.has_value()); REQUIRE(*result == id); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building type differs", "[building][rotate-in-place]") { PlacementFixture f; f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); // Querying with Splitter at the same tile — type mismatch → nullopt. REQUIRE_FALSE( findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value()); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place", "[building][rotate-in-place]") { PlacementFixture f; // Even with a coincident same-type tunnel under the ghost, rotate-in-place is // never offered for tunnels (REQ-BLD-ROTATE-IN-PLACE exception). f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0); f.bs.place(f.state, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0); REQUIRE_FALSE( findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value()); REQUIRE_FALSE( findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value()); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap", "[building][rotate-in-place]") { PlacementFixture f; // Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1). f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); // Ghost anchored at (1,0) would cover (1,0),(2,0),(1,1),(2,1): // only (1,0) and (1,1) are occupied — not a full coincidence. REQUIRE_FALSE( findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Smelter, QPoint(1, 0), Rotation::East).has_value()); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost", "[building][rotate-in-place]") { PlacementFixture f; // Smelter is a fully filled 2×2 footprint — rotating the ghost produces the // same four body tiles, so findRotateInPlaceTarget must still return the id. const BuildingId id = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); const std::optional result = findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Smelter, QPoint(0, 0), Rotation::North); REQUIRE(result.has_value()); REQUIRE(*result == id); } // --------------------------------------------------------------------------- // resolveBlueprintGhost // --------------------------------------------------------------------------- // What a blueprint ghost does where it meets an existing building // (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). Blueprint placement never // rotates anything, so a coinciding building must either take the blueprint's settings // or already match it exactly. namespace { // A single-building blueprint, whose cursor sits on the ghost's own anchor unless a test // says otherwise. That gesture hit-tests the cursor for its transfer target. BlueprintGhostResolved resolveOne(const PlacementFixture& f, BuildingType type, QPoint anchor, Rotation rotation) { return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, anchor); } BlueprintGhostResolved resolveOneHovering(const PlacementFixture& f, BuildingType type, QPoint anchor, Rotation rotation, QPoint cursorTile) { return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, cursorTile); } // One ghost of a constellation: no cursor hit-test, judged purely on where it sits. BlueprintGhostResolved resolveInConstellation(const PlacementFixture& f, BuildingType type, QPoint anchor, Rotation rotation) { return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, std::nullopt); } } // namespace TEST_CASE("isConfigurableBuildingType: only types with player-facing settings", "[blueprint]") { // The gate on whether a single-building blueprint transfers anything at all. CHECK(isConfigurableBuildingType(BuildingType::Miner)); CHECK(isConfigurableBuildingType(BuildingType::Assembler)); CHECK(isConfigurableBuildingType(BuildingType::Shipyard)); CHECK(isConfigurableBuildingType(BuildingType::Splitter)); // Smelter and Reprocessing Plant run implicit recipes (REQ-BLD-SMELTER, // REQ-BLD-REPROCESSING) and the rest have no settings whatsoever. CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter)); CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant)); CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay)); CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt)); CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry)); CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelExit)); CHECK_FALSE(isConfigurableBuildingType(BuildingType::Hq)); } TEST_CASE("resolveBlueprintGhost: free valid cells place a new building", "[blueprint]") { PlacementFixture f; // Anchored on the asteroid (x < 0): a miner is all-asteroid cells. BuildingSystem's // place() skips the terrain rules, but resolveBlueprintGhost applies them. const BlueprintGhostResolved resolved = resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East); CHECK(resolved.action == BlueprintGhostAction::PlaceNew); CHECK_FALSE(resolved.targetId.has_value()); } TEST_CASE("resolveBlueprintGhost: terrain-invalid positions are invalid", "[blueprint]") { PlacementFixture f; // A miner is all-asteroid (A) cells, so it cannot sit out in space (x >= 0). CHECK(resolveOne(f, BuildingType::Miner, QPoint(5, 0), Rotation::East).action == BlueprintGhostAction::Invalid); } TEST_CASE("resolveBlueprintGhost: overlapping a different building type is invalid", "[blueprint]") { PlacementFixture f; f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0); CHECK(resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::East).action == BlueprintGhostAction::Invalid); } TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid", "[blueprint]") { PlacementFixture f; // Smelter at (-3,0) covers (-3,0),(-2,0),(-3,1),(-2,1); a ghost at (-2,0) covers only // two of those, so it coincides with nothing and is an ordinary occupied overlap. // Both footprints stay on the asteroid, so terrain is not what fails here. f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0); CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action == BlueprintGhostAction::Invalid); } TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings", "[blueprint]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value(); const BlueprintGhostResolved resolved = resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East); REQUIRE(resolved.action == BlueprintGhostAction::Transfer); REQUIRE(resolved.targetId.has_value()); CHECK(*resolved.targetId == id); } TEST_CASE("resolveBlueprintGhost: a transfer ignores the target's rotation", "[blueprint]") { // A transfer never rotates anything, so which way the target faces cannot matter // (REQ-UI-BLUEPRINT-TRANSFER). The ghost snaps to the target's facing rather than // keeping the blueprint's. PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0).value(); const BlueprintGhostResolved resolved = resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North); REQUIRE(resolved.action == BlueprintGhostAction::Transfer); REQUIRE(resolved.targetId.has_value()); CHECK(*resolved.targetId == id); CHECK(resolved.ghostRotation == Rotation::East); } TEST_CASE("resolveBlueprintGhost: a single-building blueprint transfers from anywhere on the target", "[blueprint]") { // The point of hit-testing the cursor instead of comparing footprints. Coincidence // needs the ghost's anchor to land on the target's own anchor, so with a 2x2 body // three of its four tiles missed and read as an ordinary overlap. Hovering any body // tile now targets it, and the ghost snaps onto the building // (REQ-UI-BLUEPRINT-TRANSFER). PlacementFixture f; // Assembler body covers (-3,0),(-2,0),(-3,1),(-2,1); its anchor is (-3,0). const BuildingId id = f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value(); const QPoint offAnchorTile(-2, 1); const BlueprintGhostResolved resolved = resolveOneHovering(f, BuildingType::Assembler, offAnchorTile, Rotation::East, offAnchorTile); REQUIRE(resolved.action == BlueprintGhostAction::Transfer); CHECK(*resolved.targetId == id); CHECK(resolved.ghostAnchor == QPoint(-3, 0)); // The same misaligned ghost inside a constellation still just overlaps invalidly: // a layout is placed where the blueprint puts it, and nothing snaps. CHECK(resolveInConstellation(f, BuildingType::Assembler, offAnchorTile, Rotation::East).action == BlueprintGhostAction::Invalid); } TEST_CASE("resolveBlueprintGhost: hovering a different building type does not transfer", "[blueprint]") { PlacementFixture f; f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0); // A miner blueprint over a smelter: the cursor hit-test only matches its own type. CHECK(resolveOne(f, BuildingType::Miner, QPoint(-3, 0), Rotation::East).action == BlueprintGhostAction::Invalid); } TEST_CASE("resolveBlueprintGhost: a construction site is a transfer target too", "[blueprint]") { PlacementFixture f; // Not ticked to completion, so it is still queued (REQ-BLD-SITE-CONFIG). const BuildingId id = f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value(); REQUIRE_FALSE(getAllSites(f.state).empty()); const BlueprintGhostResolved resolved = resolveOne(f, BuildingType::Assembler, QPoint(-3, 0), Rotation::East); REQUIRE(resolved.action == BlueprintGhostAction::Transfer); CHECK(*resolved.targetId == id); } TEST_CASE("resolveBlueprintGhost: a single building with no settings overlaps instead", "[blueprint]") { // A belt carries nothing to transfer, so the same footprint is a compatible overlap // when the facings match -- and invalid when they do not, since nothing here may // re-orient it (REQ-UI-BLUEPRINT-OVERLAP). PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0).value(); const BlueprintGhostResolved matching = resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::East); REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap); CHECK(*matching.targetId == id); CHECK(resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::North).action == BlueprintGhostAction::Invalid); } TEST_CASE("resolveBlueprintGhost: a constellation transfers onto a matching building", "[blueprint]") { // Blueprint size does not gate the transfer itself: a configurable building already // standing where the blueprint wants it, facing the same way, takes its settings // whatever else the blueprint holds (REQ-UI-BLUEPRINT-TRANSFER). PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value(); const BlueprintGhostResolved resolved = resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East); REQUIRE(resolved.action == BlueprintGhostAction::Transfer); CHECK(*resolved.targetId == id); } TEST_CASE("resolveBlueprintGhost: a constellation still requires a matching rotation", "[blueprint]") { // Only the single-building gesture is forgiving about facing. A constellation's // ghosts stay where the blueprint puts them, and one that cannot be re-oriented to // match blocks the whole placement (REQ-UI-BLUEPRINT-OVERLAP). PlacementFixture f; f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0); CHECK(resolveInConstellation(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North).action == BlueprintGhostAction::Invalid); } TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain overlaps", "[blueprint]") { // One drop can reconfigure some of the buildings already there while leaving others // alone: the split is by whether the type has settings at all, not by blueprint size // (REQ-UI-BLUEPRINT-OVERLAP). PlacementFixture f; const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value(); const BuildingId smelterId = f.bs.place(f.state, BuildingType::Smelter, QPoint(-5, 0), Rotation::East, 0).value(); const BlueprintGhostResolved miner = resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East); REQUIRE(miner.action == BlueprintGhostAction::Transfer); CHECK(*miner.targetId == minerId); // A smelter runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand // over and it is simply left as it is. const BlueprintGhostResolved smelter = resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East); REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap); CHECK(*smelter.targetId == smelterId); } TEST_CASE("resolveBlueprintGhost: an identical tunnel is a compatible overlap", "[blueprint]") { // findRotateInPlaceTarget refuses tunnels because re-orienting one is unsupported. // Nothing is re-oriented here, so that reason does not apply and the tunnel the // blueprint wants -- already there, same facing -- is simply left alone. PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0).value(); f.bs.place(f.state, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0); REQUIRE_FALSE( findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East) .has_value()); const BlueprintGhostResolved resolved = resolveInConstellation(f, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East); REQUIRE(resolved.action == BlueprintGhostAction::CompatibleOverlap); CHECK(*resolved.targetId == id); } // --------------------------------------------------------------------------- // rotateInPlace // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site", "[building][rotate-in-place]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); REQUIRE(findSite(f.state, id)->rotation == Rotation::East); f.bs.rotateInPlace(f.state, id, Rotation::North); REQUIRE(findSite(f.state, id)->rotation == Rotation::North); } TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site", "[building][rotate-in-place]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); const Tick completesAt = findSite(f.state, id)->completesAt; REQUIRE(completesAt > 0); f.bs.rotateInPlace(f.state, id, Rotation::South); REQUIRE(findSite(f.state, id)->completesAt == completesAt); } TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building", "[building][rotate-in-place]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(1.0)) + 1, tick); REQUIRE(findBuilding(f.state, id) != nullptr); const Building& before = *findBuilding(f.state, id); REQUIRE(before.outputPorts[0].direction == Rotation::East); f.bs.rotateInPlace(f.state, id, Rotation::North); const Building& after = *findBuilding(f.state, id); REQUIRE(after.rotation == Rotation::North); REQUIRE(after.outputPorts[0].direction == Rotation::North); } TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSystem so it still accepts items", "[building][rotate-in-place]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(1.0)) + 1, tick); f.bs.rotateInPlace(f.state, id, Rotation::North); // Belt tile must still be registered after rotation — items can be placed on it. REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"))); } TEST_CASE("BuildingSystem: rotateInPlace preserves the output filters of a splitter " "(REQ-BLD-SPLITTER)", "[building][rotate-in-place]") { PlacementFixture f; const QPoint tile(5, 5); const BuildingId id = f.bs.place(f.state, BuildingType::Splitter, tile, Rotation::East, 0).value(); // Run until construction completes, so the splitter is registered with BeltSystem. Tick tick = 0; while (getAllBuildings(f.state).empty() && tick < 100000) { runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick); } REQUIRE(getAllBuildings(f.state).size() == 1); const std::vector filterA{ ItemType{"iron_ore"} }; const std::vector filterB{ ItemType{"copper_ore"} }; f.belts.setSplitterFilters(tile, filterA, filterB); f.bs.rotateInPlace(f.state, id, Rotation::North); // The tile is re-registered with BeltSystem carrying the filters it had before // the rotation — rotating must not reset a configured splitter to "accept all". const std::optional info = f.belts.getSplitterInfo(tile); REQUIRE(info.has_value()); REQUIRE(info->filterA == filterA); REQUIRE(info->filterB == filterB); } TEST_CASE("BuildingSystem: splitter filters configured on a construction site carry over " "to the built splitter (REQ-BLD-SITE-CONFIG)", "[building]") { PlacementFixture f; const QPoint tile(5, 5); const BuildingId id = f.bs.place(f.state, BuildingType::Splitter, tile, Rotation::East, 0).value(); REQUIRE(id != kInvalidBuildingId); REQUIRE(findSite(f.state, id) != nullptr); // Configure an output filter on the still-queued splitter site. const std::vector filterA{ ItemType{"iron_ore"} }; const std::vector filterB{}; f.bs.setSiteSplitterFilters(f.state, id, filterA, filterB); // The site reports its two output directions and the stored filters before // it is built; it is not yet registered with BeltSystem. const std::optional siteInfo = getSiteSplitterInfo(f.state, f.cfg, id); REQUIRE(siteInfo.has_value()); REQUIRE(siteInfo->filterA == filterA); REQUIRE(siteInfo->filterB.empty()); REQUIRE_FALSE(f.belts.getSplitterInfo(tile).has_value()); // Run until construction completes. Tick tick = 0; while (getAllBuildings(f.state).empty() && tick < 100000) { runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick); } REQUIRE(getAllBuildings(f.state).size() == 1); REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Splitter); // The built splitter is registered with BeltSystem carrying the filters. const std::optional builtInfo = f.belts.getSplitterInfo(tile); REQUIRE(builtInfo.has_value()); REQUIRE(builtInfo->filterA == filterA); REQUIRE(builtInfo->filterB.empty()); } // --------------------------------------------------------------------------- // Production status classifier (REQ-UI-STATUS-LIGHT) // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[building]") { PlacementFixture f; // Pick representative config ids so the test survives content edits. std::string minerRecipeId; for (const RecipeDef& r : f.cfg.recipes.recipes) { if (r.building == BuildingType::Miner) { minerRecipeId = r.id; break; } } REQUIRE_FALSE(minerRecipeId.empty()); const RecipeDef* assemblerRecipe = nullptr; for (const RecipeDef& r : f.cfg.recipes.recipes) { if (r.building == BuildingType::Assembler && !r.inputs.empty()) { assemblerRecipe = &r; break; } } REQUIRE(assemblerRecipe != nullptr); std::string shipId; for (const ShipDef& s : f.cfg.ships.ships) { if (!s.schematic.materials.empty()) { shipId = s.id; break; } } REQUIRE_FALSE(shipId.empty()); const auto statusOf = [&f](const Building& b) { return getProductionStatus(f.cfg, b); }; SECTION("non-production buildings show no status light") { Building belt; belt.type = BuildingType::Belt; Building hq; hq.type = BuildingType::Hq; REQUIRE_FALSE(statusOf(belt).has_value()); REQUIRE_FALSE(statusOf(hq).has_value()); } SECTION("Miner: unconfigured, producing, output-blocked") { Building miner; miner.type = BuildingType::Miner; REQUIRE(statusOf(miner) == ProductionStatus::Unconfigured); // no recipe -> grey miner.recipeId = minerRecipeId; miner.production = Production{}; REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green // A miner has no inputs, so its only idle reason is an output buffer with no // room for the next cycle's output. miner.production = std::nullopt; miner.outputBuffer.capacity = 2; miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") }; REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow // One item handed off: the next cycle fits again, so the idle tick between two // cycles reads as producing rather than blinking yellow (REQ-UI-STATUS-LIGHT). miner.outputBuffer.items.pop_back(); REQUIRE(statusOf(miner) == ProductionStatus::Producing); // -> green // An emerging item has not left the building, so it fills the freed slot and // blocks the cycle again (REQ-MAT-OUTPUT-EMERGE). miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } }); REQUIRE(miner.getOutputItemCount() == 2); REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow } SECTION("Assembler: starved, the transient between cycles, then blocked") { Building assembler; assembler.type = BuildingType::Assembler; assembler.recipeId = assemblerRecipe->id; int cycleOutput = 0; for (const RecipeOutput& out : assemblerRecipe->outputs) { cycleOutput += out.amount; } REQUIRE(cycleOutput > 0); // The buffer the simulation would give it (REQ-MAT-OUTPUT-BUFFER). assembler.outputBuffer.capacity = 2 * cycleOutput; // Idle with inputs missing -> red. REQUIRE(statusOf(assembler) == ProductionStatus::Starved); // Inputs present and the output fits: nothing blocks a cycle, so the building // is merely between cycles -> green, not yellow (REQ-UI-STATUS-LIGHT). for (const RecipeIngredient& ing : assemblerRecipe->inputs) { assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount; } REQUIRE(statusOf(assembler) == ProductionStatus::Producing); // Filled to within less than one cycle's output of capacity: no cycle can start // -> yellow. for (int i = 0; i < cycleOutput + 1; ++i) { assembler.outputBuffer.items.push_back(makeItem("x")); } REQUIRE(statusOf(assembler) == ProductionStatus::Blocked); // Inputs missing AND output blocked -> red wins over yellow. assembler.inputBuffer.counts.clear(); REQUIRE(statusOf(assembler) == ProductionStatus::Starved); } SECTION("A multi-item cycle blocks before the output buffer is full") { // Free space smaller than one cycle's output stops the cycle even though the // buffer still has room, so yellow is not the same as "full" (REQ-MAT-CYCLE). const RecipeDef* multiOutputRecipe = nullptr; for (const RecipeDef& r : f.cfg.recipes.recipes) { if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; } int total = 0; for (const RecipeOutput& out : r.outputs) { total += out.amount; } if (total >= 2) { multiOutputRecipe = &r; break; } } REQUIRE(multiOutputRecipe != nullptr); int cycleOutput = 0; for (const RecipeOutput& out : multiOutputRecipe->outputs) { cycleOutput += out.amount; } Building assembler; assembler.type = BuildingType::Assembler; assembler.recipeId = multiOutputRecipe->id; assembler.outputBuffer.capacity = 2 * cycleOutput; for (const RecipeIngredient& ing : multiOutputRecipe->inputs) { assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount; } // One item short of a full cycle's worth of free space. for (int i = 0; i < cycleOutput + 1; ++i) { assembler.outputBuffer.items.push_back(makeItem("x")); } REQUIRE(assembler.getOutputItemCount() < assembler.outputBuffer.capacity); REQUIRE(statusOf(assembler) == ProductionStatus::Blocked); // Exactly one cycle's worth of free space: the cycle fits again. assembler.outputBuffer.items.pop_back(); REQUIRE(statusOf(assembler) == ProductionStatus::Producing); } SECTION("Reprocessing Plant: judged by the smallest output a roll could yield") { // The plant rolls one of its outputs per cycle (REQ-BLD-REPROCESSING) and the // roll belongs to the simulation, so the status can only say whether *some* // roll could start: it is blocked once not even the smallest output fits. const RecipeDef* reprocessingRecipe = nullptr; for (const RecipeDef& r : f.cfg.recipes.recipes) { if (r.building == BuildingType::ReprocessingPlant && !r.inputs.empty()) { reprocessingRecipe = &r; break; } } REQUIRE(reprocessingRecipe != nullptr); int smallestOutput = 0; int largestOutput = 0; for (const RecipeOutput& out : reprocessingRecipe->outputs) { if (smallestOutput == 0 || out.amount < smallestOutput) { smallestOutput = out.amount; } if (out.amount > largestOutput) { largestOutput = out.amount; } } REQUIRE(smallestOutput > 0); Building plant; plant.type = BuildingType::ReprocessingPlant; // One cycle's largest output, as initAutoBuffers sizes it // (REQ-MAT-OUTPUT-BUFFER-REPROCESSING). plant.outputBuffer.capacity = largestOutput; for (const RecipeIngredient& ing : reprocessingRecipe->inputs) { plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount; } // Empty buffer: a roll fits -> green. REQUIRE(statusOf(plant) == ProductionStatus::Producing); // Room for the smallest output but not for the largest: some roll can still // start, so the plant is waiting on the roll rather than blocked. if (smallestOutput < largestOutput) { plant.outputBuffer.items.push_back(makeItem("iron_ingot")); REQUIRE(plant.getOutputItemCount() + largestOutput > plant.outputBuffer.capacity); REQUIRE(statusOf(plant) == ProductionStatus::Producing); plant.outputBuffer.items.pop_back(); } // Filled so that not even the smallest output fits -> yellow. for (int i = 0; i < largestOutput - smallestOutput + 1; ++i) { plant.outputBuffer.items.push_back(makeItem("iron_ingot")); } REQUIRE(statusOf(plant) == ProductionStatus::Blocked); // Without the scrap it is starved regardless of the buffer. plant.inputBuffer.counts.clear(); REQUIRE(statusOf(plant) == ProductionStatus::Starved); } SECTION("Smelter (auto-recipe) is never grey") { Building smelter; smelter.type = BuildingType::Smelter; // No player-selectable recipe and empty inputs -> red, not grey. REQUIRE(statusOf(smelter) == ProductionStatus::Starved); } SECTION("Shipyard: unconfigured, then starved without materials, then producing") { Building yard; yard.type = BuildingType::Shipyard; REQUIRE(statusOf(yard) == ProductionStatus::Unconfigured); // no schematic -> grey yard.recipeId = shipId; REQUIRE(statusOf(yard) == ProductionStatus::Starved); // no materials -> red yard.production = Production{}; REQUIRE(statusOf(yard) == ProductionStatus::Producing); // active cycle -> green } SECTION("Salvage Bay: red when empty, green when holding scrap") { Building bay; bay.type = BuildingType::SalvageBay; bay.outputBuffer.capacity = 20; REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red bay.outputBuffer.items = { makeItem("scrap") }; REQUIRE(statusOf(bay) == ProductionStatus::Producing); // holding scrap -> green } } // --------------------------------------------------------------------------- // getInputPorts (REQ-BLD-BELT-DRAG snapping, REQ-MAT-INPUT-PORTS) // --------------------------------------------------------------------------- namespace { QPoint directionDelta(Rotation direction) { switch (direction) { case Rotation::North: return QPoint(0, -1); case Rotation::East: return QPoint(1, 0); case Rotation::South: return QPoint(0, 1); case Rotation::West: return QPoint(-1, 0); } return QPoint(0, 0); } bool hasInputPort(const std::vector& ports, QPoint tile, Rotation direction) { for (const Port& port : ports) { if (port.tile == tile && port.direction == direction) { return true; } } return false; } // Advances the sim until the given site becomes an operational building, or a // safety cap is reached. void buildToCompletion(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state, BeltSystem& belts, int& stock, BuildingId id, Tick& tick) { for (int i = 0; i < 20000 && findBuilding(state, id) == nullptr; ++i) { runTicks(bs, cfg, state, belts, stock, 1, tick); } } } TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge", "[building]") { PlacementFixture f; // Miner mask ["AA","A>"] East → body (0,0),(1,0),(0,1); output tile (1,1) East. const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); const std::vector ports = getInputPorts(f.state, f.cfg, id); // Every perimeter edge except the output-port edge at (1,1), each pointing in. REQUIRE(ports.size() == 6); REQUIRE(hasInputPort(ports, QPoint(-1, 0), Rotation::East)); REQUIRE(hasInputPort(ports, QPoint(0, -1), Rotation::South)); REQUIRE(hasInputPort(ports, QPoint(2, 0), Rotation::West)); REQUIRE(hasInputPort(ports, QPoint(1, -1), Rotation::South)); REQUIRE(hasInputPort(ports, QPoint(-1, 1), Rotation::East)); REQUIRE(hasInputPort(ports, QPoint(0, 2), Rotation::North)); // The output-port tile is never an input port. REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::North)); REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::West)); } TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built building", "[building]") { PlacementFixture f; Tick tick = 0; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); const std::vector sitePorts = getInputPorts(f.state, f.cfg, id); buildToCompletion(f.bs, f.cfg, f.state, f.belts, f.stock, id, tick); REQUIRE(findBuilding(f.state, id) != nullptr); const std::vector builtPorts = getInputPorts(f.state, f.cfg, id); // The operational path (stored inputPorts) agrees with the site path (mask-derived). REQUIRE(builtPorts.size() == sitePorts.size()); for (const Port& port : sitePorts) { REQUIRE(hasInputPort(builtPorts, port.tile, port.direction)); } } TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[building]") { PlacementFixture f; const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::South, 0).value(); const ConstructionSite* site = findSite(f.state, id); REQUIRE(site != nullptr); std::set> bodySet; for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); } const std::vector ports = getInputPorts(f.state, f.cfg, id); REQUIRE_FALSE(ports.empty()); for (const Port& port : ports) { // Each port tile is outside the footprint... REQUIRE(bodySet.count({port.tile.x(), port.tile.y()}) == 0); // ...and its direction points into an adjacent body cell. const QPoint into = port.tile + directionDelta(port.direction); REQUIRE(bodySet.count({into.x(), into.y()}) == 1); } }