#include "catch.hpp" #include #include #include #include #include #include "Blueprint.h" #include "BlueprintSerializer.h" #include "BuildingType.h" #include "ItemType.h" #include "Rotation.h" #include "ShipLayout.h" namespace { Blueprint makeBlueprintWith(const std::vector& buildings, const QString& name = "Test") { Blueprint bp; bp.name = name; bp.buildings = buildings; return bp; } BlueprintBuilding makeBuilding(BuildingType type, Rotation rotation, QPoint offset, std::string recipeId = "", std::optional shipLayout = std::nullopt) { BlueprintBuilding b; b.type = type; b.rotation = rotation; b.offset = offset; b.recipeId = std::move(recipeId); b.shipLayout = std::move(shipLayout); return b; } } // namespace // --------------------------------------------------------------------------- // Case 1 — single blueprint, one building: all fields survive round-trip // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: single blueprint round-trips all fields", "[serializer]") { const BlueprintBuilding building = makeBuilding(BuildingType::Miner, Rotation::East, QPoint(2, -1), "mine_iron_ore"); const Blueprint original = makeBlueprintWith({building}, "My Miner"); const std::string toml = BlueprintSerializer::serialize({original}); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.size() == 1); REQUIRE(loaded[0].name == "My Miner"); REQUIRE(loaded[0].buildings.size() == 1); const BlueprintBuilding& b = loaded[0].buildings[0]; REQUIRE(b.type == BuildingType::Miner); REQUIRE(b.rotation == Rotation::East); REQUIRE(b.offset == QPoint(2, -1)); REQUIRE(b.recipeId == "mine_iron_ore"); } // --------------------------------------------------------------------------- // Case 2 — multiple blueprints: count and order preserved // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: multiple blueprints preserve count and order", "[serializer]") { const Blueprint first = makeBlueprintWith( {makeBuilding(BuildingType::Belt, Rotation::North, QPoint(0, 0))}, "First"); const Blueprint second = makeBlueprintWith( {makeBuilding(BuildingType::Smelter, Rotation::South, QPoint(1, 0))}, "Second"); const Blueprint third = makeBlueprintWith( {makeBuilding(BuildingType::Assembler, Rotation::West, QPoint(0, 1))}, "Third"); const std::string toml = BlueprintSerializer::serialize({first, second, third}); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.size() == 3); REQUIRE(loaded[0].name == "First"); REQUIRE(loaded[1].name == "Second"); REQUIRE(loaded[2].name == "Third"); } // --------------------------------------------------------------------------- // Case 3 — all four Rotation values round-trip // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: all rotations round-trip", "[serializer]") { const std::vector rotations = { Rotation::North, Rotation::East, Rotation::South, Rotation::West }; for (const Rotation rot : rotations) { const Blueprint bp = makeBlueprintWith( {makeBuilding(BuildingType::Belt, rot, QPoint(0, 0))}); const std::string toml = BlueprintSerializer::serialize({bp}); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.size() == 1); REQUIRE(loaded[0].buildings.size() == 1); REQUIRE(loaded[0].buildings[0].rotation == rot); } } // --------------------------------------------------------------------------- // Case 5 — negative and zero offsets survive intact // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: negative and zero offsets round-trip", "[serializer]") { const Blueprint bp = makeBlueprintWith({ makeBuilding(BuildingType::Belt, Rotation::North, QPoint(0, 0)), makeBuilding(BuildingType::Belt, Rotation::North, QPoint(-3, -2)), makeBuilding(BuildingType::Belt, Rotation::North, QPoint(5, 4)), }); const std::string toml = BlueprintSerializer::serialize({bp}); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded[0].buildings[0].offset == QPoint(0, 0)); REQUIRE(loaded[0].buildings[1].offset == QPoint(-3, -2)); REQUIRE(loaded[0].buildings[2].offset == QPoint(5, 4)); } // --------------------------------------------------------------------------- // Case 6 — empty and non-empty recipeId both round-trip correctly // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: empty and non-empty recipeId round-trip", "[serializer]") { const Blueprint bp = makeBlueprintWith({ makeBuilding(BuildingType::Miner, Rotation::North, QPoint(0, 0), "mine_iron_ore"), makeBuilding(BuildingType::Assembler, Rotation::North, QPoint(3, 0), ""), }); const std::string toml = BlueprintSerializer::serialize({bp}); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded[0].buildings[0].recipeId == "mine_iron_ore"); REQUIRE(loaded[0].buildings[1].recipeId == ""); } // --------------------------------------------------------------------------- // Case 8 — empty blueprint list round-trips to empty vector // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: empty list round-trips to empty vector", "[serializer]") { const std::string toml = BlueprintSerializer::serialize({}); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.empty()); } // --------------------------------------------------------------------------- // Case 9 — malformed TOML throws std::runtime_error // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: malformed TOML throws", "[serializer]") { REQUIRE_THROWS_AS( BlueprintSerializer::deserialize("[[blueprint\nname = 42"), std::runtime_error); } // --------------------------------------------------------------------------- // Case 10 — unknown building type string throws std::runtime_error // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: unknown building type throws", "[serializer]") { const std::string badToml = "[[blueprint]]\n" "name = \"Broken\"\n" "buildings = [{type = \"unicorn\", rotation = \"north\"," " offset_x = 0, offset_y = 0, recipe_id = \"\"}]\n"; REQUIRE_THROWS_AS( BlueprintSerializer::deserialize(badToml), std::runtime_error); } // --------------------------------------------------------------------------- // Ship layout round-trip through TOML serialization // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: shipyard with modules round-trips ship layout", "[serializer]") { ShipLayoutConfig layout; PlacedModule pmA; pmA.moduleId = "laser_cannon"; pmA.position = QPoint(1, 1); pmA.rotation = Rotation::East; PlacedModule pmB; pmB.moduleId = "sensor_booster"; pmB.position = QPoint(0, 1); pmB.rotation = Rotation::South; layout.placedModules = { pmA, pmB }; const Blueprint original = makeBlueprintWith( { makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0), "interceptor", layout) }, "Gunship"); const std::string toml = BlueprintSerializer::serialize({ original }); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.size() == 1); REQUIRE(loaded[0].buildings.size() == 1); const BlueprintBuilding& b = loaded[0].buildings[0]; REQUIRE(b.shipLayout.has_value()); REQUIRE(b.shipLayout->placedModules.size() == 2); REQUIRE(b.shipLayout->placedModules[0].moduleId == "laser_cannon"); REQUIRE(b.shipLayout->placedModules[0].position == QPoint(1, 1)); REQUIRE(b.shipLayout->placedModules[0].rotation == Rotation::East); REQUIRE(b.shipLayout->placedModules[1].moduleId == "sensor_booster"); REQUIRE(b.shipLayout->placedModules[1].position == QPoint(0, 1)); REQUIRE(b.shipLayout->placedModules[1].rotation == Rotation::South); } TEST_CASE("BlueprintSerializer: building without ship layout round-trips with nullopt", "[serializer]") { // A shipyard with no shipLayout set — no modules key should be emitted, // and deserialization must leave shipLayout as nullopt. const Blueprint original = makeBlueprintWith( { makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0), "interceptor") }, "EmptyYard"); const std::string toml = BlueprintSerializer::serialize({ original }); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.size() == 1); REQUIRE(loaded[0].buildings.size() == 1); REQUIRE_FALSE(loaded[0].buildings[0].shipLayout.has_value()); } TEST_CASE("BlueprintSerializer: shipyard with empty modules list round-trips", "[serializer]") { // An explicitly empty layout (player cleared all modules) should round-trip // as a present but empty ShipLayoutConfig, distinct from nullopt. ShipLayoutConfig emptyLayout; const Blueprint original = makeBlueprintWith( { makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0), "interceptor", emptyLayout) }, "EmptyLayout"); const std::string toml = BlueprintSerializer::serialize({ original }); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.size() == 1); REQUIRE(loaded[0].buildings.size() == 1); REQUIRE(loaded[0].buildings[0].shipLayout.has_value()); REQUIRE(loaded[0].buildings[0].shipLayout->placedModules.empty()); } // --------------------------------------------------------------------------- // Splitter output filters round-trip through TOML serialization // (REQ-UI-BLUEPRINT-STORAGE). // --------------------------------------------------------------------------- TEST_CASE("BlueprintSerializer: splitter output filters round-trip", "[serializer]") { BlueprintBuilding splitter = makeBuilding(BuildingType::Splitter, Rotation::East, QPoint(0, 0)); splitter.splitterFilterA = { ItemType{"iron_ore"}, ItemType{"copper_ore"} }; splitter.splitterFilterB = { ItemType{"scrap"} }; const Blueprint original = makeBlueprintWith({ splitter }, "Sorter"); const std::string toml = BlueprintSerializer::serialize({ original }); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.size() == 1); REQUIRE(loaded[0].buildings.size() == 1); const BlueprintBuilding& b = loaded[0].buildings[0]; REQUIRE(b.splitterFilterA == std::vector{ ItemType{"iron_ore"}, ItemType{"copper_ore"} }); REQUIRE(b.splitterFilterB == std::vector{ ItemType{"scrap"} }); } TEST_CASE("BlueprintSerializer: splitter with no filters round-trips empty", "[serializer]") { const Blueprint original = makeBlueprintWith( { makeBuilding(BuildingType::Splitter, Rotation::East, QPoint(0, 0)) }, "Open"); const std::string toml = BlueprintSerializer::serialize({ original }); const std::vector loaded = BlueprintSerializer::deserialize(toml); REQUIRE(loaded.size() == 1); REQUIRE(loaded[0].buildings.size() == 1); REQUIRE(loaded[0].buildings[0].splitterFilterA.empty()); REQUIRE(loaded[0].buildings[0].splitterFilterB.empty()); }