#include "catch.hpp" #include #include #include #include #include #include #include "BuildingType.h" #include "ConfigLoader.h" namespace { // Writes content to a file at path; creates parent directories if needed. // Used to materialize malformed TOML for error-path coverage. void writeFile(const std::filesystem::path& path, const std::string& content) { std::filesystem::create_directories(path.parent_path()); std::ofstream out(path); out << content; } // RAII temp directory: allocated in ctor, recursively removed in dtor. class TempConfigDir { public: TempConfigDir() { const std::filesystem::path base = std::filesystem::temp_directory_path(); for (int i = 0; i < 10000; ++i) { const std::filesystem::path candidate = base / ("dota_factory_test_" + std::to_string(i) + "_" + std::to_string(reinterpret_cast(this))); if (!std::filesystem::exists(candidate)) { std::filesystem::create_directories(candidate); m_path = candidate; return; } } throw std::runtime_error("Could not allocate temp directory for test"); } ~TempConfigDir() { std::error_code ec; std::filesystem::remove_all(m_path, ec); } TempConfigDir(const TempConfigDir&) = delete; TempConfigDir& operator=(const TempConfigDir&) = delete; const std::filesystem::path& path() const { return m_path; } private: std::filesystem::path m_path; }; } // namespace TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[config]") { const std::string configDir = CONFIG_DIR; const GameConfig cfg = ConfigLoader::loadFromDirectory(configDir); // world.toml REQUIRE(cfg.world.heightTiles == 60); REQUIRE(cfg.world.refundPercentage == 75); REQUIRE(cfg.world.beltSpeedTilesPerSecond == Approx(2.0)); REQUIRE(cfg.world.regions.asteroidWidth == 40); REQUIRE(cfg.world.regions.playerBufferWidth == 10); REQUIRE(cfg.world.regions.enemyBufferWidth == 15); REQUIRE(cfg.world.expansion.columnsPerExpansion == 10); REQUIRE(cfg.world.push.scalingFactor == Approx(1.2)); // Spot-check that a config-derived formula computes as expected. // threat_rate_formula = "1*x - 30": zero at x=30, 30 at x=60. REQUIRE(cfg.world.waves.threatRateFormula.evaluate(30.0) == Approx(0.0)); REQUIRE(cfg.world.waves.threatRateFormula.evaluate(60.0) == Approx(30.0)); // buildings.toml REQUIRE(cfg.buildings.buildings.size() >= 8); const auto minerIt = std::find_if( cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(), [](const BuildingDef& b) { return b.type == BuildingType::Miner; }); REQUIRE(minerIt != cfg.buildings.buildings.end()); REQUIRE(minerIt->cost == 15); REQUIRE(minerIt->surfaceMask.size() == 2); // recipes.toml — reprocessing cycle has three weighted outputs. const auto reproIt = std::find_if( cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), [](const RecipeDef& r) { return r.id == "reprocessing_cycle"; }); REQUIRE(reproIt != cfg.recipes.recipes.end()); REQUIRE(reproIt->building == BuildingType::ReprocessingPlant); REQUIRE(reproIt->outputs.size() == 3); REQUIRE(reproIt->outputs[0].probability.has_value()); // Non-reprocessing recipes don't carry probability. const auto ironIngotIt = std::find_if( cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), [](const RecipeDef& r) { return r.id == "iron_ingot"; }); REQUIRE(ironIngotIt != cfg.recipes.recipes.end()); REQUIRE(ironIngotIt->outputs.size() == 1); REQUIRE_FALSE(ironIngotIt->outputs[0].probability.has_value()); // ships.toml — combat ships have default_modules with a weapon; salvage ships don't. const auto interceptorIt = std::find_if( cfg.ships.ships.begin(), cfg.ships.ships.end(), [](const ShipDef& s) { return s.id == "interceptor"; }); REQUIRE(interceptorIt != cfg.ships.ships.end()); REQUIRE_FALSE(interceptorIt->defaultModules.empty()); REQUIRE(interceptorIt->defaultModules[0].moduleId == "laser_cannon"); const auto salvageShipIt = std::find_if( cfg.ships.ships.begin(), cfg.ships.ships.end(), [](const ShipDef& s) { return s.id == "salvage_ship"; }); REQUIRE(salvageShipIt != cfg.ships.ships.end()); REQUIRE(salvageShipIt->defaultModules.empty()); // stations.toml REQUIRE(cfg.stations.playerStation.level == 5); REQUIRE(cfg.stations.playerStation.hpFormula.evaluate(5.0) == Approx(500.0)); // 300 + 40*5 REQUIRE(cfg.stations.enemyStation.hpFormula.evaluate(0.0) == Approx(300.0)); // 300 + 150*0 } TEST_CASE("Loading a non-existent file throws", "[config]") { REQUIRE_THROWS(ConfigLoader::loadWorld("does/not/exist.toml")); } TEST_CASE("Malformed TOML is rejected with a file-identifying message", "[config]") { TempConfigDir dir; writeFile(dir.path() / "world.toml", "this is = not [ valid toml\n"); try { ConfigLoader::loadWorld((dir.path() / "world.toml").string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { const std::string msg = e.what(); REQUIRE(msg.find("world.toml") != std::string::npos); } } TEST_CASE("Missing field in world.toml is rejected with the field path", "[config]") { TempConfigDir dir; writeFile(dir.path() / "world.toml", R"( [world] height_tiles = 60 refund_percentage = 75 scrap_despawn_seconds = 30 belt_speed_tiles_per_second = 2 starting_building_blocks = 100 tunnel_max_distance = 10 departure_interval_seconds = 20 [regions] asteroid_width = 40 player_buffer_width = 10 contest_zone_width = 30 # enemy_buffer_width intentionally missing [expansion] columns_per_expansion = 10 cost_building_blocks = 200 [push] push_expand_columns = 20 scaling_factor = 1.2 [waves] threat_rate_formula = "1*x - 30" ship_level_formula = "1 + x / 120" gap_min_seconds = 15 gap_max_seconds = 45 spawn_duration_seconds = 10 )"); try { ConfigLoader::loadWorld((dir.path() / "world.toml").string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { const std::string msg = e.what(); REQUIRE(msg.find("enemy_buffer_width") != std::string::npos); } } TEST_CASE("Malformed formula in world.toml is rejected with field identification", "[config]") { TempConfigDir dir; writeFile(dir.path() / "world.toml", R"( [world] height_tiles = 60 refund_percentage = 75 scrap_despawn_seconds = 30 belt_speed_tiles_per_second = 2 starting_building_blocks = 100 tunnel_max_distance = 10 departure_interval_seconds = 20 [regions] asteroid_width = 40 player_buffer_width = 10 contest_zone_width = 30 enemy_buffer_width = 15 [expansion] columns_per_expansion = 10 cost_building_blocks = 200 [push] push_expand_columns = 20 scaling_factor = 1.2 [waves] threat_rate_formula = "1 +" ship_level_formula = "1 + x / 120" gap_min_seconds = 15 gap_max_seconds = 45 spawn_duration_seconds = 10 )"); try { ConfigLoader::loadWorld((dir.path() / "world.toml").string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { const std::string msg = e.what(); REQUIRE(msg.find("threat_rate_formula") != std::string::npos); REQUIRE(msg.find("formula") != std::string::npos); } } TEST_CASE("Inverted wave gap range is rejected", "[config]") { TempConfigDir dir; writeFile(dir.path() / "world.toml", R"( [world] height_tiles = 60 refund_percentage = 75 scrap_despawn_seconds = 30 belt_speed_tiles_per_second = 2 [regions] asteroid_width = 40 player_buffer_width = 10 contest_zone_width = 30 enemy_buffer_width = 15 [expansion] columns_per_expansion = 10 cost_building_blocks = 200 [push] push_expand_columns = 20 scaling_factor = 1.2 [waves] threat_rate_formula = "1*x - 30" ship_level_formula = "1 + x / 120" gap_min_seconds = 45 gap_max_seconds = 15 spawn_duration_seconds = 10 )"); REQUIRE_THROWS_AS( ConfigLoader::loadWorld((dir.path() / "world.toml").string()), std::runtime_error); } TEST_CASE("Unknown building id in buildings.toml is rejected with the id in the message", "[config]") { TempConfigDir dir; writeFile(dir.path() / "buildings.toml", R"( [[building]] id = "fictional_machine" cost = 10 player_placeable = true construction_time_seconds = 5 surface_mask = ["AA"] )"); try { ConfigLoader::loadBuildings((dir.path() / "buildings.toml").string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { const std::string msg = e.what(); REQUIRE(msg.find("fictional_machine") != std::string::npos); } } TEST_CASE("Recipe referencing an unknown building is rejected", "[config]") { TempConfigDir dir; writeFile(dir.path() / "recipes.toml", R"( [[recipe]] id = "bogus" building = "imaginary_factory" inputs = [] outputs = [{item = "foo", amount = 1}] duration_seconds = 1.0 )"); REQUIRE_THROWS_AS( ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()), std::runtime_error); }