project-mobile-command/tests/TrainBuilderModuleTest.cpp
StillHammer 0953451fea Implement 7 modules: 4 core (game-agnostic) + 3 MC-specific
Core Modules (game-agnostic, reusable for WarFactory):
- ResourceModule: Inventory, crafting system (465 lines)
- StorageModule: Save/load with pub/sub state collection (424 lines)
- CombatModule: Combat resolver, damage/armor/morale (580 lines)
- EventModule: JSON event scripting with choices/outcomes (651 lines)

MC-Specific Modules:
- GameModule v2: State machine + event subscriptions (updated)
- TrainBuilderModule: 3 wagons, 2-axis balance, performance malus (530 lines)
- ExpeditionModule: A→B expeditions, team management, events integration (641 lines)

Features:
- All modules hot-reload compatible (state preservation)
- Pure pub/sub architecture (zero direct coupling)
- 7 config files (resources, storage, combat, events, train, expeditions)
- 7 test suites (GameModuleTest: 12/12 PASSED)
- CMakeLists.txt updated for all modules + tests

Total: ~3,500 lines of production code + comprehensive tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 16:40:54 +08:00

345 lines
13 KiB
C++

#include <gtest/gtest.h>
#include "../src/modules/mc_specific/TrainBuilderModule.h"
#include <grove/JsonDataNode.h>
#include <grove/IntraIO.h>
#include <grove/IntraIOManager.h>
#include <memory>
/**
* TrainBuilderModule Tests
*
* These tests validate the TrainBuilderModule implementation independently
* without requiring the full game engine to be running.
*
* Test Coverage:
* 1. Configuration loading (3 wagons)
* 2. Balance calculation (lateral + longitudinal)
* 3. Performance malus (speed + fuel)
* 4. Cargo weight updates balance
* 5. Hot-reload state preservation
* 6. Damage updates wagon health
*/
class TrainBuilderModuleTest : public ::testing::Test {
protected:
void SetUp() override {
// Create test configuration with 3 wagons
config = std::make_unique<grove::JsonDataNode>("config");
// Wagons node
auto wagonsNode = std::make_unique<grove::JsonDataNode>("wagons");
// Locomotive (centered, front)
auto locoNode = std::make_unique<grove::JsonDataNode>("locomotive");
locoNode->setString("type", "locomotive");
locoNode->setDouble("health", 100.0);
locoNode->setDouble("armor", 50.0);
locoNode->setDouble("weight", 20000.0);
locoNode->setDouble("capacity", 0.0);
auto locoPos = std::make_unique<grove::JsonDataNode>("position");
locoPos->setDouble("x", 0.0);
locoPos->setDouble("y", 0.0);
locoPos->setDouble("z", 0.0);
locoNode->setChild("position", std::move(locoPos));
wagonsNode->setChild("locomotive", std::move(locoNode));
// Cargo wagon (left side)
auto cargoNode = std::make_unique<grove::JsonDataNode>("cargo_1");
cargoNode->setString("type", "cargo");
cargoNode->setDouble("health", 80.0);
cargoNode->setDouble("armor", 30.0);
cargoNode->setDouble("weight", 5000.0);
cargoNode->setDouble("capacity", 10000.0);
auto cargoPos = std::make_unique<grove::JsonDataNode>("position");
cargoPos->setDouble("x", -5.0);
cargoPos->setDouble("y", 0.0);
cargoPos->setDouble("z", 0.0);
cargoNode->setChild("position", std::move(cargoPos));
wagonsNode->setChild("cargo_1", std::move(cargoNode));
// Workshop wagon (right side)
auto workshopNode = std::make_unique<grove::JsonDataNode>("workshop_1");
workshopNode->setString("type", "workshop");
workshopNode->setDouble("health", 70.0);
workshopNode->setDouble("armor", 20.0);
workshopNode->setDouble("weight", 8000.0);
workshopNode->setDouble("capacity", 5000.0);
auto workshopPos = std::make_unique<grove::JsonDataNode>("position");
workshopPos->setDouble("x", 5.0);
workshopPos->setDouble("y", 0.0);
workshopPos->setDouble("z", 0.0);
workshopNode->setChild("position", std::move(workshopPos));
wagonsNode->setChild("workshop_1", std::move(workshopNode));
config->setChild("wagons", std::move(wagonsNode));
// Balance thresholds
auto thresholdsNode = std::make_unique<grove::JsonDataNode>("balanceThresholds");
thresholdsNode->setDouble("lateral_warning", 0.2);
thresholdsNode->setDouble("longitudinal_warning", 0.3);
config->setChild("balanceThresholds", std::move(thresholdsNode));
// Create IIO instance
io = grove::createIntraIOInstance("test_module");
// Create module
module = std::make_unique<mc::TrainBuilderModule>();
}
void TearDown() override {
module->shutdown();
module.reset();
io.reset();
config.reset();
}
std::unique_ptr<grove::JsonDataNode> config;
std::shared_ptr<grove::IntraIO> io;
std::unique_ptr<mc::TrainBuilderModule> module;
};
// Test 1: 3 wagons load correctly
TEST_F(TrainBuilderModuleTest, LoadsThreeWagonsFromConfig) {
module->setConfiguration(*config, io.get(), nullptr);
const auto& wagons = module->getWagons();
ASSERT_EQ(wagons.size(), 3) << "Should load 3 wagons from config";
// Verify locomotive
auto* loco = module->getWagon("locomotive");
ASSERT_NE(loco, nullptr);
EXPECT_EQ(loco->type, "locomotive");
EXPECT_FLOAT_EQ(loco->weight, 20000.0f);
EXPECT_FLOAT_EQ(loco->health, 100.0f);
EXPECT_FLOAT_EQ(loco->armor, 50.0f);
// Verify cargo
auto* cargo = module->getWagon("cargo_1");
ASSERT_NE(cargo, nullptr);
EXPECT_EQ(cargo->type, "cargo");
EXPECT_FLOAT_EQ(cargo->weight, 5000.0f);
EXPECT_FLOAT_EQ(cargo->capacity, 10000.0f);
// Verify workshop
auto* workshop = module->getWagon("workshop_1");
ASSERT_NE(workshop, nullptr);
EXPECT_EQ(workshop->type, "workshop");
EXPECT_FLOAT_EQ(workshop->weight, 8000.0f);
EXPECT_FLOAT_EQ(workshop->capacity, 5000.0f);
}
// Test 2: Balance calculation correct (lateral + longitudinal)
TEST_F(TrainBuilderModuleTest, CalculatesBalanceCorrectly) {
module->setConfiguration(*config, io.get(), nullptr);
auto balance = module->getBalance();
// Total weight: 20000 (loco) + 5000 (cargo) + 8000 (workshop) = 33000
// Left weight: 5000 (cargo at x=-5)
// Right weight: 20000 (loco at x=0) + 8000 (workshop at x=5) = 28000
// Lateral offset: (28000 - 5000) / 33000 = 23000 / 33000 ≈ 0.697 (right-heavy)
// All wagons at z=0, so longitudinal should be 0
EXPECT_NEAR(balance.longitudinalOffset, 0.0f, 0.01f) << "Longitudinal should be balanced";
EXPECT_GT(balance.lateralOffset, 0.5f) << "Should be right-heavy";
EXPECT_LT(balance.lateralOffset, 1.0f) << "Lateral offset should be < 1.0";
// Balance score should be non-zero due to lateral imbalance
EXPECT_GT(balance.balanceScore, 0.0f) << "Balance score should indicate imbalance";
}
// Test 3: Malus applied correctly
TEST_F(TrainBuilderModuleTest, AppliesPerformanceMalus) {
module->setConfiguration(*config, io.get(), nullptr);
auto balance = module->getBalance();
// With imbalance, speed should be reduced
EXPECT_LT(balance.speedMalus, 1.0f) << "Speed should be reduced due to imbalance";
EXPECT_GT(balance.speedMalus, 0.5f) << "Speed malus should not be too extreme";
// Fuel consumption should be increased
EXPECT_GT(balance.fuelMalus, 1.0f) << "Fuel consumption should increase with imbalance";
EXPECT_LT(balance.fuelMalus, 1.5f) << "Fuel malus should not be too extreme";
// Relationship: higher balance score = lower speed, higher fuel
float expectedSpeedMalus = 1.0f - (balance.balanceScore * 0.5f);
float expectedFuelMalus = 1.0f + (balance.balanceScore * 0.5f);
EXPECT_NEAR(balance.speedMalus, expectedSpeedMalus, 0.01f) << "Speed malus formula incorrect";
EXPECT_NEAR(balance.fuelMalus, expectedFuelMalus, 0.01f) << "Fuel malus formula incorrect";
}
// Test 4: Cargo weight updates balance
TEST_F(TrainBuilderModuleTest, CargoWeightUpdatesBalance) {
module->setConfiguration(*config, io.get(), nullptr);
// Get initial balance
auto initialBalance = module->getBalance();
// Simulate cargo being added (resource:inventory_changed)
auto inventoryMsg = std::make_unique<grove::JsonDataNode>("inventory_changed");
inventoryMsg->setString("resource_id", "scrap_metal");
inventoryMsg->setInt("total", 5000); // 5000 units = 5000kg at 1kg/unit
inventoryMsg->setInt("delta", 5000);
io->publish("resource:inventory_changed", std::move(inventoryMsg));
// Process the message
auto input = std::make_unique<grove::JsonDataNode>("input");
input->setDouble("deltaTime", 0.1);
module->process(*input);
// Get new balance
auto newBalance = module->getBalance();
// Balance should have changed due to cargo weight
EXPECT_NE(initialBalance.balanceScore, newBalance.balanceScore)
<< "Balance should change when cargo is added";
// Cargo wagons should have weight
auto* cargo = module->getWagon("cargo_1");
ASSERT_NE(cargo, nullptr);
EXPECT_GT(cargo->cargoWeight, 0.0f) << "Cargo wagon should have cargo weight";
}
// Test 5: Hot-reload state preservation
TEST_F(TrainBuilderModuleTest, PreservesStateOnHotReload) {
module->setConfiguration(*config, io.get(), nullptr);
// Simulate some cargo
auto inventoryMsg = std::make_unique<grove::JsonDataNode>("inventory_changed");
inventoryMsg->setString("resource_id", "scrap_metal");
inventoryMsg->setInt("total", 3000);
inventoryMsg->setInt("delta", 3000);
io->publish("resource:inventory_changed", std::move(inventoryMsg));
auto input = std::make_unique<grove::JsonDataNode>("input");
input->setDouble("deltaTime", 0.1);
module->process(*input);
// Get state before reload
auto state = module->getState();
auto balanceBefore = module->getBalance();
size_t wagonCountBefore = module->getWagons().size();
// Create new module instance (simulating hot-reload)
auto newModule = std::make_unique<mc::TrainBuilderModule>();
newModule->setConfiguration(*config, io.get(), nullptr);
newModule->setState(*state);
// Verify state restored
EXPECT_EQ(newModule->getWagons().size(), wagonCountBefore)
<< "Wagon count should be preserved";
auto balanceAfter = newModule->getBalance();
EXPECT_NEAR(balanceAfter.balanceScore, balanceBefore.balanceScore, 0.001f)
<< "Balance score should be preserved";
EXPECT_NEAR(balanceAfter.lateralOffset, balanceBefore.lateralOffset, 0.001f)
<< "Lateral offset should be preserved";
EXPECT_NEAR(balanceAfter.speedMalus, balanceBefore.speedMalus, 0.001f)
<< "Speed malus should be preserved";
newModule->shutdown();
}
// Test 6: Damage updates wagon health
TEST_F(TrainBuilderModuleTest, DamageUpdatesWagonHealth) {
module->setConfiguration(*config, io.get(), nullptr);
auto* cargo = module->getWagon("cargo_1");
ASSERT_NE(cargo, nullptr);
float initialHealth = cargo->health;
float armor = cargo->armor;
// Simulate damage to cargo wagon
auto damageMsg = std::make_unique<grove::JsonDataNode>("damage_received");
damageMsg->setString("target", "cargo_1");
damageMsg->setDouble("damage", 50.0); // 50 damage
io->publish("combat:damage_received", std::move(damageMsg));
// Process the message
auto input = std::make_unique<grove::JsonDataNode>("input");
input->setDouble("deltaTime", 0.1);
module->process(*input);
// Verify health reduced
// Effective damage = 50 - (30 armor * 0.5) = 50 - 15 = 35
float expectedHealth = initialHealth - 35.0f;
EXPECT_NEAR(cargo->health, expectedHealth, 0.1f)
<< "Wagon health should be reduced by effective damage";
}
// Test 7: Add/Remove wagon operations
TEST_F(TrainBuilderModuleTest, AddAndRemoveWagons) {
module->setConfiguration(*config, io.get(), nullptr);
ASSERT_EQ(module->getWagons().size(), 3);
// Add a new wagon
mc::Wagon newWagon;
newWagon.id = "cargo_2";
newWagon.type = "cargo";
newWagon.health = 80.0f;
newWagon.maxHealth = 80.0f;
newWagon.armor = 30.0f;
newWagon.weight = 5000.0f;
newWagon.capacity = 10000.0f;
newWagon.cargoWeight = 0.0f;
newWagon.totalWeight = 5000.0f;
newWagon.position.x = -10.0f;
newWagon.position.y = 0.0f;
newWagon.position.z = 0.0f;
EXPECT_TRUE(module->addWagon(newWagon));
EXPECT_EQ(module->getWagons().size(), 4);
// Verify wagon added
auto* addedWagon = module->getWagon("cargo_2");
ASSERT_NE(addedWagon, nullptr);
EXPECT_EQ(addedWagon->type, "cargo");
// Remove wagon
EXPECT_TRUE(module->removeWagon("cargo_2"));
EXPECT_EQ(module->getWagons().size(), 3);
// Verify wagon removed
auto* removedWagon = module->getWagon("cargo_2");
EXPECT_EQ(removedWagon, nullptr);
}
// Test 8: Capacity tracking
TEST_F(TrainBuilderModuleTest, TracksCargoCapacity) {
module->setConfiguration(*config, io.get(), nullptr);
// Total capacity: cargo (10000) + workshop (5000) = 15000
float totalCapacity = module->getTotalCargoCapacity();
EXPECT_FLOAT_EQ(totalCapacity, 15000.0f);
// Initially no cargo
float usedCapacity = module->getTotalCargoUsed();
EXPECT_FLOAT_EQ(usedCapacity, 0.0f);
// Add cargo
auto inventoryMsg = std::make_unique<grove::JsonDataNode>("inventory_changed");
inventoryMsg->setString("resource_id", "scrap_metal");
inventoryMsg->setInt("total", 2000);
inventoryMsg->setInt("delta", 2000);
io->publish("resource:inventory_changed", std::move(inventoryMsg));
auto input = std::make_unique<grove::JsonDataNode>("input");
input->setDouble("deltaTime", 0.1);
module->process(*input);
// Used capacity should increase
usedCapacity = module->getTotalCargoUsed();
EXPECT_GT(usedCapacity, 0.0f);
EXPECT_LE(usedCapacity, totalCapacity);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}