Every display naming an item now shows that item's production tooltip, not only the selection panel's chips: the header block stock, the total cost of a building multi-selection, the remaining scrap of a debris selection, the icons of every recipe summary, and a blueprint card's cost. ItemTooltip moves out of the selection panel and takes an ItemTooltipContext of its own -- an item is named all over the UI, and the panel's context carries visuals and a debug flag no tooltip reads. A recipe line wraps each icon with its amount so the pair can be pointed at as one statement, and attaches tooltips only where asked: a module button and the item tooltip itself draw the same line and stay silent. Hover only wherever the display sits on something the player clicks, whose click is not free to explain. Note that a recipe line on an option button can no longer be transparent to the mouse -- Qt never looks inside a transparent widget for the cursor -- so it relies on press propagation to keep picking the option. The header block stock loses world.building_blocks_tooltip, showing what every other block icon shows instead. The Expand button keeps no tooltip: its cost is painted into its face with nowhere to hang one, and the button is due to be removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
560 lines
18 KiB
C++
560 lines
18 KiB
C++
#include "catch.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <system_error>
|
|
#include <vector>
|
|
|
|
#include "BuildingType.h"
|
|
#include "ConfigLoader.h"
|
|
#include "UnlocksConfig.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 = CONFIG_DIR;
|
|
const GameConfig cfg = ConfigLoader::loadFromDirectory(configDir);
|
|
|
|
// world.toml
|
|
REQUIRE(cfg.world.heightTiles == 60);
|
|
REQUIRE(cfg.world.refundPercentage == 75);
|
|
REQUIRE(cfg.world.beltSpeed_tps == Approx(2.0));
|
|
REQUIRE(cfg.world.regions.asteroidWidth_tiles == 40);
|
|
REQUIRE(cfg.world.regions.playerBufferWidth_tiles == 10);
|
|
REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15);
|
|
REQUIRE(cfg.world.expansion.columnsPerExpansion_tiles == 10);
|
|
REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(0) == Approx(400.0));
|
|
REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(1) == Approx(800.0));
|
|
REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0));
|
|
REQUIRE(cfg.world.orbitFactor == Approx(0.8));
|
|
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
|
|
REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));
|
|
|
|
// Optional header artifact tooltip (REQ-UI-ARTIFACTS-TOOLTIP).
|
|
REQUIRE(cfg.world.artifactTooltip.has_value());
|
|
REQUIRE(*cfg.world.artifactTooltip ==
|
|
"Choose the artifact reward when destroying enemy stations; collect enough to win.");
|
|
|
|
// Spot-check that a config-derived formula computes as expected.
|
|
// threat_rate_formula = "x": evaluates to the input value.
|
|
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0));
|
|
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.0));
|
|
|
|
// targeting: distance score 1/(1+x) and overclaim penalty max(0.5, 1-0.1*x).
|
|
REQUIRE(cfg.world.targeting.hysteresis == Approx(0.10));
|
|
REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(0.0) == Approx(1.0));
|
|
REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(1.0) == Approx(0.5));
|
|
REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(0.0) == Approx(1.0));
|
|
REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(5.0) == Approx(0.5));
|
|
|
|
// 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);
|
|
// Miner has no output-buffer-capacity override; the Salvage Bay does.
|
|
REQUIRE_FALSE(minerIt->outputBufferCapacity.has_value());
|
|
|
|
const auto salvageBayIt = std::find_if(
|
|
cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(),
|
|
[](const BuildingDef& b) { return b.type == BuildingType::SalvageBay; });
|
|
REQUIRE(salvageBayIt != cfg.buildings.buildings.end());
|
|
REQUIRE(salvageBayIt->outputBufferCapacity.has_value());
|
|
REQUIRE(*salvageBayIt->outputBufferCapacity == 20);
|
|
|
|
// Optional per-building tooltip (REQ-UI-BUILD-TOOLTIP): the Salvage Bay
|
|
// defines one; the Miner leaves it unset.
|
|
REQUIRE(salvageBayIt->tooltip.has_value());
|
|
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
|
|
REQUIRE_FALSE(minerIt->tooltip.has_value());
|
|
|
|
// recipes.toml -- the reprocessing cycle is written as three weighted output groups,
|
|
// each yielding one item (REQ-MAT-OUTPUT-GROUP).
|
|
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->outputGroups.size() == 3);
|
|
REQUIRE(reproIt->outputGroups[0].probability.has_value());
|
|
REQUIRE(reproIt->outputGroups[0].items.size() == 1);
|
|
|
|
// The `outputs = [...]` shorthand loads as one group carrying no weight: with a single
|
|
// group nothing is picked, so there is nothing to weigh.
|
|
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->outputGroups.size() == 1);
|
|
REQUIRE(ironIngotIt->outputGroups[0].items.size() == 1);
|
|
REQUIRE_FALSE(ironIngotIt->outputGroups[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());
|
|
|
|
// modules.toml — optional per-module tooltip (REQ-MOD-UI-MODULE-TOOLTIP):
|
|
// armor_plate defines one; salvager leaves it unset.
|
|
const auto armorPlateIt = std::find_if(
|
|
cfg.modules.modules.begin(), cfg.modules.modules.end(),
|
|
[](const ModuleDef& m) { return m.id == "armor_plate"; });
|
|
REQUIRE(armorPlateIt != cfg.modules.modules.end());
|
|
REQUIRE(armorPlateIt->tooltip.has_value());
|
|
REQUIRE(*armorPlateIt->tooltip == "Adds a large flat bonus to hit points.");
|
|
|
|
const auto salvagerModuleIt = std::find_if(
|
|
cfg.modules.modules.begin(), cfg.modules.modules.end(),
|
|
[](const ModuleDef& m) { return m.id == "salvager"; });
|
|
REQUIRE(salvagerModuleIt != cfg.modules.modules.end());
|
|
REQUIRE_FALSE(salvagerModuleIt->tooltip.has_value());
|
|
|
|
// 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
|
|
deconstruction_time_seconds = 0.1
|
|
debris_despawn_seconds = 30
|
|
scrap_per_threat = 0.01
|
|
tile_size_m = 10
|
|
belt_speed_mps = 20
|
|
starting_building_blocks = 100
|
|
tunnel_max_distance_tiles = 10
|
|
departure_interval_seconds = 20
|
|
orbit_factor = 0.8
|
|
rally_orbit_radius_tiles = 5.0
|
|
|
|
[regions]
|
|
asteroid_width_tiles = 40
|
|
player_buffer_width_tiles = 10
|
|
contest_zone_width_tiles = 30
|
|
# enemy_buffer_width_tiles intentionally missing
|
|
|
|
[expansion]
|
|
columns_per_expansion_tiles = 10
|
|
cost_building_blocks_formula = "400 * 2^x"
|
|
|
|
[push]
|
|
push_expand_columns_tiles = 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_tiles") != 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
|
|
deconstruction_time_seconds = 0.1
|
|
debris_despawn_seconds = 30
|
|
scrap_per_threat = 0.01
|
|
tile_size_m = 10
|
|
belt_speed_mps = 20
|
|
starting_building_blocks = 100
|
|
tunnel_max_distance_tiles = 10
|
|
departure_interval_seconds = 20
|
|
orbit_factor = 0.8
|
|
rally_orbit_radius_tiles = 5.0
|
|
|
|
[regions]
|
|
asteroid_width_tiles = 40
|
|
player_buffer_width_tiles = 10
|
|
contest_zone_width_tiles = 30
|
|
enemy_buffer_width_tiles = 15
|
|
|
|
[expansion]
|
|
columns_per_expansion_tiles = 10
|
|
cost_building_blocks_formula = "400 * 2^x"
|
|
|
|
[push]
|
|
push_expand_columns_tiles = 20
|
|
boss_advance_seconds = 60
|
|
|
|
[waves]
|
|
threat_rate_formula = "1 +"
|
|
ship_level_formula = "1 + x / 10"
|
|
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
|
|
deconstruction_time_seconds = 0.1
|
|
debris_despawn_seconds = 30
|
|
scrap_per_threat = 0.01
|
|
tile_size_m = 10
|
|
belt_speed_mps = 20
|
|
|
|
[regions]
|
|
asteroid_width_tiles = 40
|
|
player_buffer_width_tiles = 10
|
|
contest_zone_width_tiles = 30
|
|
enemy_buffer_width_tiles = 15
|
|
|
|
[expansion]
|
|
columns_per_expansion_tiles = 10
|
|
cost_building_blocks_formula = "400 * 2^x"
|
|
|
|
[push]
|
|
push_expand_columns_tiles = 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);
|
|
}
|
|
|
|
// --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------
|
|
|
|
namespace
|
|
{
|
|
|
|
// Copies every regular file from CONFIG_DIR into dir, giving tests a full,
|
|
// valid config set they can then perturb before calling loadFromDirectory.
|
|
void copyConfigInto(const std::filesystem::path& dir)
|
|
{
|
|
for (const std::filesystem::directory_entry& entry :
|
|
std::filesystem::directory_iterator(std::string(CONFIG_DIR)))
|
|
{
|
|
if (entry.is_regular_file())
|
|
{
|
|
std::filesystem::copy_file(entry.path(), dir / entry.path().filename());
|
|
}
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("loadUnlocks parses id, station_level, requires, and grants", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
writeFile(dir.path() / "unlocks.toml", R"(
|
|
[[unlock]]
|
|
id = "base"
|
|
station_level = 0
|
|
buildings = ["salvage_bay"]
|
|
|
|
[[unlock]]
|
|
id = "gated"
|
|
station_level = 3
|
|
requires = ["base"]
|
|
ships = ["cruiser"]
|
|
modules = ["railgun_m"]
|
|
recipes = ["shortcut_steel_plate"]
|
|
)");
|
|
|
|
const UnlocksConfig cfg = ConfigLoader::loadUnlocks((dir.path() / "unlocks.toml").string());
|
|
REQUIRE(cfg.groups.size() == 2);
|
|
CHECK(cfg.groups[0].id == "base");
|
|
CHECK(cfg.groups[0].stationLevel == 0);
|
|
CHECK(cfg.groups[0].buildings == std::vector<std::string>{"salvage_bay"});
|
|
CHECK(cfg.groups[1].requiredGroupIds == std::vector<std::string>{"base"});
|
|
CHECK(cfg.groups[1].ships == std::vector<std::string>{"cruiser"});
|
|
CHECK(cfg.groups[1].modules == std::vector<std::string>{"railgun_m"});
|
|
CHECK(cfg.groups[1].recipes == std::vector<std::string>{"shortcut_steel_plate"});
|
|
}
|
|
|
|
TEST_CASE("validateUnlocks rejects a grant that names no definition", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
copyConfigInto(dir.path());
|
|
|
|
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
|
|
out << "\n[[unlock]]\nid = \"bogus\"\nstation_level = 0\nships = [\"___nope___\"]\n";
|
|
out.close();
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadFromDirectory(dir.path().string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
REQUIRE(std::string(e.what()).find("___nope___") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("validateUnlocks rejects an id granted by two groups", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
copyConfigInto(dir.path());
|
|
|
|
// repair_ship is already granted by the test config's unlocks.toml.
|
|
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
|
|
out << "\n[[unlock]]\nid = \"dup\"\nstation_level = 0\nships = [\"repair_ship\"]\n";
|
|
out.close();
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadFromDirectory(dir.path().string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
REQUIRE(std::string(e.what()).find("already granted") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("validateUnlocks rejects a non-assembler recipe grant", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
copyConfigInto(dir.path());
|
|
|
|
// mine_iron_ore is a miner recipe, not an assembler recipe.
|
|
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
|
|
out << "\n[[unlock]]\nid = \"badrecipe\"\nstation_level = 0\nrecipes = [\"mine_iron_ore\"]\n";
|
|
out.close();
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadFromDirectory(dir.path().string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
REQUIRE(std::string(e.what()).find("mine_iron_ore") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("validateUnlocks rejects requires naming an unknown group", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
copyConfigInto(dir.path());
|
|
|
|
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
|
|
out << "\n[[unlock]]\nid = \"needsmissing\"\nstation_level = 0\n"
|
|
"requires = [\"___missing___\"]\nships = [\"interceptor\"]\n";
|
|
out.close();
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadFromDirectory(dir.path().string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
REQUIRE(std::string(e.what()).find("___missing___") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("validateUnlocks rejects a group that grants nothing", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
copyConfigInto(dir.path());
|
|
|
|
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
|
|
out << "\n[[unlock]]\nid = \"empty\"\nstation_level = 0\n";
|
|
out.close();
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadFromDirectory(dir.path().string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
REQUIRE(std::string(e.what()).find("grants no items") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("validateUnlocks accepts a well-formed unlock group", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
copyConfigInto(dir.path());
|
|
|
|
// salvage_ship starts unlocked (not granted elsewhere); repair_ship is an
|
|
// existing group id, so this new group is valid.
|
|
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
|
|
out << "\n[[unlock]]\nid = \"extra\"\nstation_level = 2\n"
|
|
"requires = [\"repair_ship\"]\nships = [\"salvage_ship\"]\n";
|
|
out.close();
|
|
|
|
REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string()));
|
|
}
|
|
|