add basic types and fix cmake
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
|
||||
add_files(
|
||||
TEST_FILES
|
||||
TEST_FILES
|
||||
|
||||
test.cpp
|
||||
test.cpp
|
||||
FormulaTest.cpp
|
||||
ConfigLoaderTest.cpp
|
||||
)
|
||||
|
||||
318
src/test/ConfigLoaderTest.cpp
Normal file
318
src/test/ConfigLoaderTest.cpp
Normal file
@@ -0,0 +1,318 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
#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<std::uintptr_t>(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 = DOTA_FACTORY_CONFIG_DIR;
|
||||
const GameConfig cfg = ConfigLoader::loadFromDirectory(configDir);
|
||||
|
||||
// world.toml
|
||||
REQUIRE(cfg.world.heightTiles == 60);
|
||||
REQUIRE(cfg.world.refundPercentage == 75);
|
||||
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 a combat section; 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(interceptorIt->combat.has_value());
|
||||
REQUIRE_FALSE(interceptorIt->salvage.has_value());
|
||||
REQUIRE_FALSE(interceptorIt->repair.has_value());
|
||||
REQUIRE(interceptorIt->combat->damageFormula.evaluate(5.0) == Approx(20.0)); // "10 + 2*x"
|
||||
|
||||
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_FALSE(salvageShipIt->combat.has_value());
|
||||
REQUIRE(salvageShipIt->salvage.has_value());
|
||||
REQUIRE(salvageShipIt->salvage->cargoCapacity == 10);
|
||||
|
||||
// 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
|
||||
|
||||
[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
|
||||
|
||||
[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"
|
||||
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
|
||||
|
||||
[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);
|
||||
}
|
||||
75
src/test/FormulaTest.cpp
Normal file
75
src/test/FormulaTest.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#include "Formula.h"
|
||||
|
||||
TEST_CASE("Formula compiles a constant expression", "[formula]")
|
||||
{
|
||||
const Formula f = Formula::compile("42");
|
||||
|
||||
REQUIRE(f.isValid());
|
||||
REQUIRE(f.evaluate(0.0) == Approx(42.0));
|
||||
REQUIRE(f.evaluate(100.0) == Approx(42.0));
|
||||
}
|
||||
|
||||
TEST_CASE("Formula evaluates a linear expression in x", "[formula]")
|
||||
{
|
||||
// Matches world.toml [waves].threat_rate_formula default.
|
||||
const Formula f = Formula::compile("1*x - 30");
|
||||
|
||||
REQUIRE(f.evaluate(0.0) == Approx(-30.0));
|
||||
REQUIRE(f.evaluate(30.0) == Approx(0.0));
|
||||
REQUIRE(f.evaluate(60.0) == Approx(30.0));
|
||||
}
|
||||
|
||||
TEST_CASE("Formula evaluates a polynomial in x", "[formula]")
|
||||
{
|
||||
const Formula f = Formula::compile("2 + 3*x*x");
|
||||
|
||||
REQUIRE(f.evaluate(0.0) == Approx(2.0));
|
||||
REQUIRE(f.evaluate(2.0) == Approx(14.0));
|
||||
REQUIRE(f.evaluate(4.0) == Approx(50.0));
|
||||
}
|
||||
|
||||
TEST_CASE("Formula retains its source string", "[formula]")
|
||||
{
|
||||
const std::string source = "10 + x / 5";
|
||||
const Formula f = Formula::compile(source);
|
||||
|
||||
REQUIRE(f.source() == source);
|
||||
}
|
||||
|
||||
TEST_CASE("Formula throws on malformed source", "[formula]")
|
||||
{
|
||||
REQUIRE_THROWS_AS(Formula::compile("1 * + x"), std::runtime_error);
|
||||
REQUIRE_THROWS_AS(Formula::compile(""), std::runtime_error);
|
||||
REQUIRE_THROWS_AS(Formula::compile("unknown_var"), std::runtime_error);
|
||||
}
|
||||
|
||||
TEST_CASE("Formula survives move construction", "[formula]")
|
||||
{
|
||||
// tinyexpr bakes the bound variable's address into the compiled tree.
|
||||
// Formula keeps x on the heap so that address stays valid across moves.
|
||||
Formula original = Formula::compile("x * 2");
|
||||
Formula moved(std::move(original));
|
||||
|
||||
REQUIRE(moved.evaluate(21.0) == Approx(42.0));
|
||||
}
|
||||
|
||||
TEST_CASE("Formula survives move assignment", "[formula]")
|
||||
{
|
||||
Formula a = Formula::compile("x + 1");
|
||||
Formula b = Formula::compile("x + 100");
|
||||
|
||||
b = std::move(a);
|
||||
|
||||
REQUIRE(b.evaluate(5.0) == Approx(6.0));
|
||||
}
|
||||
|
||||
TEST_CASE("Default-constructed Formula is invalid and throws on evaluate", "[formula]")
|
||||
{
|
||||
const Formula f;
|
||||
REQUIRE_FALSE(f.isValid());
|
||||
REQUIRE_THROWS_AS(f.evaluate(0.0), std::runtime_error);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
#define CATCH_CONFIG_MAIN // This tells Catch to provide a main() function
|
||||
#include "catch/catch.hpp"
|
||||
#include "catch.hpp"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user