Files
dota_factory/src/test/BeltSystemTest.cpp

935 lines
31 KiB
C++

#include "catch.hpp"
#include <vector>
#include <QPoint>
#include <QRect>
#include "BeltSystem.h"
#include "Item.h"
#include "ItemType.h"
#include "Port.h"
#include "Rotation.h"
#include "Tick.h"
// Belt speed of 30 t/s means progress/tick = 30/30 = 1.0.
// One tick() call advances any item exactly one full tile.
// This makes test assertions on item positions simple and deterministic.
static constexpr double kFastBeltSpeed = static_cast<double>(kTickRateHz);
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;
}
// ---------------------------------------------------------------------------
// Placement
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore")));
}
TEST_CASE("BeltSystem: tryPutItem fails on unregistered tile", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
REQUIRE_FALSE(bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
}
TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.removeTile(tile);
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore")));
}
// ---------------------------------------------------------------------------
// Capacity
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: two items fit in one tile", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore")));
REQUIRE(bs.tryPutItem(tile, makeItem("copper_ore")));
}
TEST_CASE("BeltSystem: third tryPutItem on full tile returns false", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("a"));
bs.tryPutItem(tile, makeItem("b"));
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("c")));
}
// ---------------------------------------------------------------------------
// tryTakeItem
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: tryTakeItem returns placed item after reaching output edge", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
bs.tick(); // advance to output edge
const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile));
REQUIRE(taken.has_value());
REQUIRE(taken->type.id == "iron_ore");
}
TEST_CASE("BeltSystem: tryTakeItem requires item to reach output edge before yielding", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
// Item placed but not yet at output edge — must not be available.
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
REQUIRE_FALSE(bs.peekItem(eastPort(tile)).has_value());
// After one tick the item has reached progress 1.0 and is available.
bs.tick();
REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value());
}
TEST_CASE("BeltSystem: tryTakeItem with two items returns both after each reaches output edge", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("first"));
bs.tryPutItem(tile, makeItem("second"));
// Front item reaches output edge after one tick.
bs.tick();
const std::optional<Item> taken1 = bs.tryTakeItem(eastPort(tile));
REQUIRE(taken1.has_value());
// Back item (now promoted to front) needs another tick to reach output edge.
bs.tick();
const std::optional<Item> taken2 = bs.tryTakeItem(eastPort(tile));
REQUIRE(taken2.has_value());
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
}
TEST_CASE("BeltSystem: tryTakeItem returns nullopt on empty tile", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
bs.placeBelt(QPoint(0, 0), Rotation::East);
REQUIRE_FALSE(bs.tryTakeItem(eastPort(QPoint(0, 0))).has_value());
}
// ---------------------------------------------------------------------------
// tick() — item advancement
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: item transfers from tile A to tile B and becomes available after two ticks", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tileA(0, 0);
const QPoint tileB(1, 0);
bs.placeBelt(tileA, Rotation::East);
bs.placeBelt(tileB, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick(); // item reaches output edge of A, moves to B at progress 0
bs.tick(); // item reaches output edge of B
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value());
const std::optional<Item> inB = bs.tryTakeItem(eastPort(tileB));
REQUIRE(inB.has_value());
REQUIRE(inB->type.id == "iron_ore");
}
TEST_CASE("BeltSystem: item stays at progress 1.0 when next tile is absent", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tileA(0, 0);
bs.placeBelt(tileA, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick();
// Item should still be on tileA (no registered tile to the east).
REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value());
}
TEST_CASE("BeltSystem: item traverses 3-tile chain in 3 ticks (one per tile)", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tileA(0, 0);
const QPoint tileB(1, 0);
const QPoint tileC(2, 0);
bs.placeBelt(tileA, Rotation::East);
bs.placeBelt(tileB, Rotation::East);
bs.placeBelt(tileC, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick(); // A output edge → moves to B at progress 0
bs.tick(); // B output edge → moves to C at progress 0
bs.tick(); // C output edge → available for pickup
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value());
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileB)).has_value());
REQUIRE(bs.tryTakeItem(eastPort(tileC)).has_value());
}
TEST_CASE("BeltSystem: item stays blocked when next tile is full", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tileA(0, 0);
const QPoint tileB(1, 0);
bs.placeBelt(tileA, Rotation::East);
bs.placeBelt(tileB, Rotation::East);
// Fill tileB to capacity.
bs.tryPutItem(tileB, makeItem("b1"));
bs.tryPutItem(tileB, makeItem("b2"));
// Place item in tileA — should be blocked.
bs.tryPutItem(tileA, makeItem("a1"));
bs.tick();
// Item in tileA must still be there.
REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value());
}
TEST_CASE("BeltSystem: belt back slot is capped at progress 0.5", "[belt]")
{
// Use progress/tick = 0.4 so the cap is observable: without it, back would
// advance to 0.8 while front is stuck at 1.0, and then need only 1 more tick
// after being promoted. With the cap it stays at 0.5 and needs 2 more ticks.
const double medBeltSpeed = 0.4 * static_cast<double>(kTickRateHz);
BeltSystem bs(medBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
// Advance front item to the output edge; it stays there (no next tile).
bs.tryPutItem(tile, makeItem("front_item"));
bs.tick(); // front: 0.4
bs.tick(); // front: 0.8
bs.tick(); // front: 1.0 (capped, stuck)
// Place back item; front is at 1.0 and not blocking (back < 1.0).
bs.tryPutItem(tile, makeItem("back_item"));
bs.tick(); // back: 0.4
bs.tick(); // back would reach 0.8 — must be capped at 0.5
// Remove front; back (now promoted to front) must be at 0.5, not 0.8.
REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value());
// At 0.4/tick, 0.5 → 0.9 after one tick — not at 1.0 yet.
bs.tick();
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
// 0.9 → 1.0 after a second tick — now available.
bs.tick();
REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value());
}
// ---------------------------------------------------------------------------
// clearTiles
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: clearTiles removes all items from specified tiles", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
bs.tryPutItem(tile, makeItem("copper_ore"));
bs.clearTiles({tile});
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
}
// ---------------------------------------------------------------------------
// forEachVisualItem
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: forEachVisualItem visits items inside viewport", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(5, 5);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
int count = 0;
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
REQUIRE(count == 1);
}
TEST_CASE("BeltSystem: forEachVisualItem skips items outside viewport", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(50, 50);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
int count = 0;
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
REQUIRE(count == 0);
}
TEST_CASE("BeltSystem: forEachVisualItem reports correct ItemType", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("copper_ingot"));
std::vector<ItemType> seen;
bs.forEachVisualItem(QRect(-1, -1, 10, 10), [&seen](VisualItem vi)
{
seen.push_back(vi.type);
});
REQUIRE(seen.size() == 1);
REQUIRE(seen[0].id == "copper_ingot");
}
// ---------------------------------------------------------------------------
// Splitter — basic alternation (no filters)
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]")
{
// Layout: tileIn -> splitter -> tileA (North output)
// -> tileB (South output)
// Pipeline per item: tileIn(1) -> back(2) -> front(3) -> output belt(4)
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
const QPoint tileA(1, -1); // North of splitter
const QPoint tileB(1, 1); // South of splitter
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.placeBelt(tileA, Rotation::North);
bs.placeBelt(tileB, Rotation::South);
bs.tryPutItem(tileIn, makeItem("item1"));
bs.tick(); // item1: tileIn -> splitter back (progress 0)
bs.tryPutItem(tileIn, makeItem("item2"));
bs.tick(); // item1 back -> 0.5 -> frontA; item2 advances but back is occupied
bs.tick(); // item1 frontA -> 1.0 -> tileA; item2 enters splitter back
bs.tick(); // item2 back -> 0.5 -> frontB; item1 at tileA output edge
bs.tick(); // item2 frontB -> 1.0 -> tileB
bs.tick(); // item2 at tileB output edge
const bool inA = bs.tryTakeItem(Port{tileA, Rotation::North}).has_value();
const bool inB = bs.tryTakeItem(Port{tileB, Rotation::South}).has_value();
// One item in each output — alternation worked.
REQUIRE(inA);
REQUIRE(inB);
}
// ---------------------------------------------------------------------------
// Splitter — filter routing
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: splitter routes to preferred output when item matches both filters", "[belt]")
{
// filterA = iron_ore, filterB = {} (accept all) → iron_ore matches both.
// With nextOutputIsA=true initially, alternation sends the item to A.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
const QPoint tileA(1, -1); // North output
const QPoint tileB(1, 1); // South output
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.placeBelt(tileA, Rotation::North);
bs.placeBelt(tileB, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back -> frontA (both match, alternation, preferred A)
bs.tick(); // frontA -> tileA
bs.tick(); // item at tileA output edge
REQUIRE(bs.tryTakeItem(Port{tileA, Rotation::North}).has_value());
REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter routes item to output A when only filter A matches", "[belt]")
{
// filterA = {iron_ore}, filterB = {copper_ore}: iron_ore matches A exclusively → goes to A.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back -> frontA (exclusive match to A)
bs.tick(); // frontA reaches 1.0; no downstream belt, waits for building pickup
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter routes item to output B when only filter B matches", "[belt]")
{
// filterA = {copper_ore}, filterB = {iron_ore}: iron_ore matches B exclusively → goes to B.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ore"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick();
bs.tick();
bs.tick();
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter alternates A then B when item matches both explicit filters", "[belt]")
{
// filterA = {iron_ore}, filterB = {iron_ore}: both match → strict alternation A, B, A.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
// Item 1 → preferred A (nextOutputIsA=true initially).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
// Item 2 → preferred B (nextOutputIsA toggled to false).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
// Item 3 → preferred A again (nextOutputIsA toggled back to true).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter routes unmatched item to the unfiltered output", "[belt]")
{
// filterA = {copper_ore} (non-empty), filterB = {} (accept all).
// iron_ore: matchesA=false, matchesB=true → goes to B.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter stalls when item matches neither filter", "[belt]")
{
// filterA = {copper_ore}, filterB = {iron_ingot}: iron_ore matches neither → stall.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ingot"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back reaches 0.5; routing fires but stalls (no filter match)
bs.tick(); // back stays at 0.5; stall persists
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blocked", "[belt]")
{
// filterA = filterB = {iron_ore}: both match → alternation.
// When preferred output is occupied, item goes to the other without toggling nextOutputIsA.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
// Item 1 → preferred A (nextOutputIsA=true → false after routing).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontA = item1 at 1.0
// Item 2 → preferred B (nextOutputIsA=false → true after routing). Take item2 to free frontB.
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontB = item2 at 1.0
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
// frontA still holds item1; nextOutputIsA=true (prefer A).
// Item 3: both match, preferred A is occupied → fallback to B without toggling nextOutputIsA.
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontB = item3 at 1.0
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item1 still in A
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); // item3 in B via fallback
// nextOutputIsA was not toggled by the fallback: next item should still prefer A.
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); // free frontA
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value()); // free frontB
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item4 → A (preferA still true)
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
// ---------------------------------------------------------------------------
// Splitter — direct building input (no output belts)
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: splitter back slot is capped at 0.5 and waits before routing", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // item enters splitter back at progress 0; routing not yet triggered
// Back has not yet reached 0.5 — front slots empty, nothing available.
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
bs.tick(); // back advances to 0.5, routes to frontA at progress 0
bs.tick(); // frontA advances to 1.0, available for building pickup
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
}
TEST_CASE("BeltSystem: splitter delivers item directly to building input via tryTakeItem", "[belt]")
{
// Bug 1: splitter could not insert into a building input with no belt in between.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
// No output belts — both outputs lead directly to building inputs.
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back -> frontA at progress 0
bs.tick(); // frontA reaches 1.0; no downstream belt, item waits for building pickup
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
const std::optional<Item> taken = bs.tryTakeItem(Port{tileSpl, Rotation::North});
REQUIRE(taken.has_value());
REQUIRE(taken->type.id == "iron_ore");
}
TEST_CASE("BeltSystem: splitter accepts new items after building pulls from front slot", "[belt]")
{
// Bug 2: when outputs had no belts, splitter never cleared its held state
// so no new items could enter.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.tryPutItem(tileIn, makeItem("item1"));
bs.tick();
bs.tick();
bs.tick(); // item1 now in frontA at 1.0
// Building pulls item1 — clears frontA; nextOutputIsA toggled to false.
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
// Feed item2; preferred is now South.
bs.tryPutItem(tileIn, makeItem("item2"));
bs.tick();
bs.tick();
bs.tick(); // item2 now in frontB at 1.0
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: tryPutItem succeeds directly on a splitter tile", "[belt]")
{
// Regression: buildings outputting onto a splitter tile were silently dropped
// because tryPutItem had no splitter case and returned false.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileSpl(0, 0);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East));
// Item should arrive at one of the output fronts after the splitter ticks through.
bs.tick(); // back advances to 0.5, routes to frontA
bs.tick(); // frontA reaches 1.0
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
}
TEST_CASE("BeltSystem: splitter alternates between two unregistered outputs (building inputs)", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
// item1 → frontA (preferred, nextOutputIsA=true)
bs.tryPutItem(tileIn, makeItem("item1"));
bs.tick();
bs.tick();
bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
// item2 → frontB (preferred, nextOutputIsA now false)
bs.tryPutItem(tileIn, makeItem("item2"));
bs.tick();
bs.tick();
bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
// item3 → frontA again (nextOutputIsA toggled back to true)
bs.tryPutItem(tileIn, makeItem("item3"));
bs.tick();
bs.tick();
bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
}
// ---------------------------------------------------------------------------
// Tunnel — pairing
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: tunnel pairing — basic pair within max distance", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
const QPoint exit(3, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
// With kFastBeltSpeed, items cross one tile per tick.
// entry tile: 1 tick to reach front progress 1.0
// transit: 3 tiles distance → 3 ticks
// exit tile: 1 tick to reach front progress 1.0
// Total: 1 (entry) + 1 (entry→transit) + 3 (transit) + 1 (transit→exit) + 1 (exit advance) = ~5-7 ticks
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE(bs.peekItem(Port{exit, Rotation::East}).has_value());
const std::optional<Item> taken = bs.tryTakeItem(Port{exit, Rotation::East});
REQUIRE(taken.has_value());
REQUIRE(taken->type.id == "iron_ore");
}
TEST_CASE("BeltSystem: tunnel pairing — wrong direction prevents pair", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
bs.placeTunnelExit(QPoint(3, 0), Rotation::North);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
// Exit faces North, not East — no pair formed, item stuck in entry.
REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::North}).has_value());
}
TEST_CASE("BeltSystem: tunnel pairing — beyond max distance prevents pair", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 2);
bs.placeTunnelExit(QPoint(3, 0), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing", "[belt]")
{
// Entry1 at (0,0) East, Entry2 at (2,0) East, Exit at (4,0) East.
// Entry2 is closer to Exit → Entry2 pairs with Exit; Entry1 is blocked by Entry2.
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
bs.placeTunnelEntry(QPoint(2, 0), Rotation::East, 10);
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
// Put item on Entry2 — should reach exit.
bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
bs.tryTakeItem(Port{QPoint(4, 0), Rotation::East});
// Put item on Entry1 — should NOT reach exit (Entry1 is unpaired).
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE_FALSE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: tunnel pairing — cross-dir entry between is ignored", "[belt]")
{
// Entry1 at (0,0) East, Entry2 at (2,0) North (different dir), Exit at (4,0) East.
// Entry2 faces North → ignored → Entry1 pairs with Exit.
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
bs.placeTunnelEntry(QPoint(2, 0), Rotation::North, 10);
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
}
// ---------------------------------------------------------------------------
// Tunnel — item transit
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: unpaired entry blocks items at front", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
// No exit placed — entry is unpaired.
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 10; ++i)
{
bs.tick();
}
// Item should not vanish — it stays in the entry.
// We can verify by placing an exit and seeing item eventually arrive.
bs.placeTunnelExit(QPoint(3, 0), Rotation::East);
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: demolish entry discards transit items", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
const QPoint exit(5, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
// Advance just enough for item to enter transit but not reach exit.
bs.tick(); // item enters entry front
bs.tick(); // entry front → transit (progress 0)
bs.removeTile(entry);
// Even with many more ticks, nothing arrives at exit.
for (int i = 0; i < 30; ++i)
{
bs.tick();
}
REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: clearTiles discards tunnel transit items", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
const QPoint exit(5, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
bs.tick();
bs.tick();
bs.clearTiles({entry});
for (int i = 0; i < 30; ++i)
{
bs.tick();
}
REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: belt to entry to transit to exit to belt full chain", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint beltIn(0, 0);
const QPoint entry(1, 0);
const QPoint exit(4, 0);
const QPoint beltOut(5, 0);
bs.placeBelt(beltIn, Rotation::East);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.placeBelt(beltOut, Rotation::East);
bs.tryPutItem(beltIn, makeItem("iron_ore"));
for (int i = 0; i < 30; ++i)
{
bs.tick();
}
// Item should have arrived on beltOut.
REQUIRE(bs.peekItem(eastPort(beltOut)).has_value());
const std::optional<Item> taken = bs.tryTakeItem(eastPort(beltOut));
REQUIRE(taken.has_value());
REQUIRE(taken->type.id == "iron_ore");
}
TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
const QPoint exit(3, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("item1"));
bs.tick();
bs.tick(); // item1 enters transit
bs.tryPutItem(entry, makeItem("item2"));
for (int i = 0; i < 30; ++i)
{
bs.tick();
}
// item1 should arrive first.
const std::optional<Item> taken1 = bs.tryTakeItem(Port{exit, Rotation::East});
REQUIRE(taken1.has_value());
REQUIRE(taken1->type.id == "item1");
for (int i = 0; i < 10; ++i)
{
bs.tick();
}
const std::optional<Item> taken2 = bs.tryTakeItem(Port{exit, Rotation::East});
REQUIRE(taken2.has_value());
REQUIRE(taken2->type.id == "item2");
}