project-mobile-command/tests/StorageModuleTest.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

459 lines
14 KiB
C++

#include "../src/modules/core/StorageModule.h"
#include <grove/JsonDataNode.h>
#include <grove/IIO.h>
#include <cassert>
#include <iostream>
#include <fstream>
#include <filesystem>
#include <vector>
#include <memory>
#include <queue>
#include <thread>
#include <chrono>
namespace fs = std::filesystem;
/**
* Mock IIO implementation for testing
* Simulates the pub/sub system without requiring full GroveEngine
*/
class MockIO : public grove::IIO {
public:
void publish(const std::string& topic, std::unique_ptr<grove::IDataNode> message) override {
m_publishedMessages.push({topic, std::move(message), 0});
std::cout << "[MockIO] Published: " << topic << std::endl;
}
void subscribe(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override {
m_subscriptions.push_back(topicPattern);
std::cout << "[MockIO] Subscribed: " << topicPattern << std::endl;
}
void subscribeLowFreq(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override {
m_subscriptions.push_back(topicPattern);
}
int hasMessages() const override {
return static_cast<int>(m_messageQueue.size());
}
grove::Message pullMessage() override {
if (m_messageQueue.empty()) {
throw std::runtime_error("No messages available");
}
auto msg = std::move(m_messageQueue.front());
m_messageQueue.pop();
return std::move(msg);
}
grove::IOHealth getHealth() const override {
grove::IOHealth health;
health.queueSize = static_cast<int>(m_messageQueue.size());
health.maxQueueSize = 1000;
health.dropping = false;
health.averageProcessingRate = 100.0f;
health.droppedMessageCount = 0;
return health;
}
grove::IOType getType() const override {
return grove::IOType::INTRA;
}
// Test helpers
void injectMessage(const std::string& topic, std::unique_ptr<grove::IDataNode> data) {
m_messageQueue.push({topic, std::move(data), 0});
}
bool wasPublished(const std::string& topic) const {
std::queue<grove::Message> tempQueue = m_publishedMessages;
while (!tempQueue.empty()) {
if (tempQueue.front().topic == topic) {
return true;
}
tempQueue.pop();
}
return false;
}
std::unique_ptr<grove::IDataNode> getLastPublished(const std::string& topic) {
std::vector<grove::Message> messages;
while (!m_publishedMessages.empty()) {
messages.push_back(std::move(m_publishedMessages.front()));
m_publishedMessages.pop();
}
grove::Message* found = nullptr;
for (auto& msg : messages) {
if (msg.topic == topic) {
found = &msg;
}
}
if (found) {
return std::move(found->data);
}
return nullptr;
}
void clearPublished() {
while (!m_publishedMessages.empty()) {
m_publishedMessages.pop();
}
}
private:
std::vector<std::string> m_subscriptions;
std::queue<grove::Message> m_messageQueue;
std::queue<grove::Message> m_publishedMessages;
};
/**
* Test helper: Clean up test save files
*/
void cleanupTestSaves(const std::string& savePath = "data/saves/") {
try {
if (fs::exists(savePath)) {
for (const auto& entry : fs::directory_iterator(savePath)) {
if (entry.is_regular_file()) {
std::string filename = entry.path().filename().string();
if (filename.find("test_") == 0 || filename.find("autosave") == 0) {
fs::remove(entry.path());
}
}
}
}
}
catch (const std::exception& e) {
std::cerr << "Failed to cleanup test saves: " << e.what() << std::endl;
}
}
/**
* Test 1: Save/Load Cycle Preserves Data
*/
void testSaveLoadCycle() {
std::cout << "\n=== Test 1: Save/Load Cycle ===" << std::endl;
cleanupTestSaves();
MockIO mockIO;
StorageModule storage;
// Configure module
auto config = std::make_unique<grove::JsonDataNode>("config");
config->setString("savePath", "data/saves/");
config->setDouble("autoSaveInterval", 300.0);
config->setInt("maxAutoSaves", 3);
storage.setConfiguration(*config, &mockIO, nullptr);
// Simulate module state response
auto moduleState = std::make_unique<grove::JsonDataNode>("state");
moduleState->setString("moduleName", "TestModule");
moduleState->setInt("testValue", 42);
moduleState->setString("testString", "Hello World");
mockIO.injectMessage("storage:module_state", std::move(moduleState));
// Request save
auto saveRequest = std::make_unique<grove::JsonDataNode>("request");
saveRequest->setString("filename", "test_save_load");
mockIO.injectMessage("game:request_save", std::move(saveRequest));
// Process messages
auto input = std::make_unique<grove::JsonDataNode>("input");
input->setDouble("deltaTime", 0.016);
storage.process(*input);
// Verify save_complete was published
assert(mockIO.wasPublished("storage:save_complete"));
std::cout << "✓ Save completed successfully" << std::endl;
// Verify file exists
assert(fs::exists("data/saves/test_save_load.json"));
std::cout << "✓ Save file created" << std::endl;
// Clear published messages
mockIO.clearPublished();
// Request load
auto loadRequest = std::make_unique<grove::JsonDataNode>("request");
loadRequest->setString("filename", "test_save_load");
mockIO.injectMessage("game:request_load", std::move(loadRequest));
storage.process(*input);
// Verify load_complete was published
assert(mockIO.wasPublished("storage:load_complete"));
std::cout << "✓ Load completed successfully" << std::endl;
// Verify restore messages were published
assert(mockIO.wasPublished("storage:restore_state:TestModule"));
std::cout << "✓ Module state restore requested" << std::endl;
std::cout << "✓ Test 1 PASSED\n" << std::endl;
}
/**
* Test 2: Auto-save Triggers Correctly
*/
void testAutoSave() {
std::cout << "\n=== Test 2: Auto-save Triggers ===" << std::endl;
cleanupTestSaves();
MockIO mockIO;
StorageModule storage;
// Configure with short auto-save interval
auto config = std::make_unique<grove::JsonDataNode>("config");
config->setString("savePath", "data/saves/");
config->setDouble("autoSaveInterval", 1.0); // 1 second for testing
config->setInt("maxAutoSaves", 3);
storage.setConfiguration(*config, &mockIO, nullptr);
// Process with accumulated time
auto input = std::make_unique<grove::JsonDataNode>("input");
input->setDouble("deltaTime", 1.5); // Exceed auto-save interval
storage.process(*input);
// Verify auto-save was triggered
assert(mockIO.wasPublished("storage:save_complete"));
std::cout << "✓ Auto-save triggered after interval" << std::endl;
// Verify autosave file was created
bool foundAutoSave = false;
for (const auto& entry : fs::directory_iterator("data/saves/")) {
std::string filename = entry.path().filename().string();
if (filename.find("autosave") == 0) {
foundAutoSave = true;
break;
}
}
assert(foundAutoSave);
std::cout << "✓ Auto-save file created" << std::endl;
std::cout << "✓ Test 2 PASSED\n" << std::endl;
}
/**
* Test 3: Invalid Save File Handling
*/
void testInvalidSaveFile() {
std::cout << "\n=== Test 3: Invalid Save File Handling ===" << std::endl;
cleanupTestSaves();
MockIO mockIO;
StorageModule storage;
auto config = std::make_unique<grove::JsonDataNode>("config");
config->setString("savePath", "data/saves/");
config->setDouble("autoSaveInterval", 300.0);
config->setInt("maxAutoSaves", 3);
storage.setConfiguration(*config, &mockIO, nullptr);
// Try to load non-existent file
auto loadRequest = std::make_unique<grove::JsonDataNode>("request");
loadRequest->setString("filename", "nonexistent_file");
mockIO.injectMessage("game:request_load", std::move(loadRequest));
auto input = std::make_unique<grove::JsonDataNode>("input");
storage.process(*input);
// Verify load_failed was published
assert(mockIO.wasPublished("storage:load_failed"));
std::cout << "✓ Load failure detected and published" << std::endl;
// Create corrupted save file
std::ofstream corruptedFile("data/saves/corrupted_save.json");
corruptedFile << "{ invalid json content }}}";
corruptedFile.close();
mockIO.clearPublished();
// Try to load corrupted file
loadRequest = std::make_unique<grove::JsonDataNode>("request");
loadRequest->setString("filename", "corrupted_save");
mockIO.injectMessage("game:request_load", std::move(loadRequest));
storage.process(*input);
// Verify load_failed was published
assert(mockIO.wasPublished("storage:load_failed"));
std::cout << "✓ Corrupted file detected and handled" << std::endl;
std::cout << "✓ Test 3 PASSED\n" << std::endl;
}
/**
* Test 4: Version Compatibility Check
*/
void testVersionCompatibility() {
std::cout << "\n=== Test 4: Version Compatibility ===" << std::endl;
cleanupTestSaves();
MockIO mockIO;
StorageModule storage;
auto config = std::make_unique<grove::JsonDataNode>("config");
config->setString("savePath", "data/saves/");
config->setDouble("autoSaveInterval", 300.0);
config->setInt("maxAutoSaves", 3);
storage.setConfiguration(*config, &mockIO, nullptr);
// Create a save file with different version
nlohmann::json saveJson = {
{"version", "0.2.0"},
{"game", "MobileCommand"},
{"timestamp", "2025-12-02T10:00:00Z"},
{"gameTime", 3600.0},
{"modules", {
{"TestModule", {
{"testValue", 123}
}}
}}
};
std::ofstream versionFile("data/saves/version_test.json");
versionFile << saveJson.dump(2);
versionFile.close();
// Load file with different version
auto loadRequest = std::make_unique<grove::JsonDataNode>("request");
loadRequest->setString("filename", "version_test");
mockIO.injectMessage("game:request_load", std::move(loadRequest));
auto input = std::make_unique<grove::JsonDataNode>("input");
storage.process(*input);
// Should load successfully (version is informational, not blocking)
assert(mockIO.wasPublished("storage:load_complete"));
std::cout << "✓ Different version loaded (with warning)" << std::endl;
std::cout << "✓ Test 4 PASSED\n" << std::endl;
}
/**
* Test 5: Auto-save Rotation (Max Auto-saves)
*/
void testAutoSaveRotation() {
std::cout << "\n=== Test 5: Auto-save Rotation ===" << std::endl;
cleanupTestSaves();
MockIO mockIO;
StorageModule storage;
// Configure with max 3 auto-saves
auto config = std::make_unique<grove::JsonDataNode>("config");
config->setString("savePath", "data/saves/");
config->setDouble("autoSaveInterval", 0.5);
config->setInt("maxAutoSaves", 3);
storage.setConfiguration(*config, &mockIO, nullptr);
auto input = std::make_unique<grove::JsonDataNode>("input");
// Trigger 5 auto-saves
for (int i = 0; i < 5; i++) {
input->setDouble("deltaTime", 0.6); // Exceed interval
storage.process(*input);
mockIO.clearPublished();
// Small delay to ensure different timestamps
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// Count auto-save files
int autoSaveCount = 0;
for (const auto& entry : fs::directory_iterator("data/saves/")) {
std::string filename = entry.path().filename().string();
if (filename.find("autosave") == 0) {
autoSaveCount++;
}
}
// Should have exactly 3 (oldest were deleted)
assert(autoSaveCount == 3);
std::cout << "✓ Auto-save rotation working (kept 3 newest)" << std::endl;
std::cout << "✓ Test 5 PASSED\n" << std::endl;
}
/**
* Test 6: State Preservation During Hot-Reload
*/
void testHotReloadState() {
std::cout << "\n=== Test 6: Hot-reload State Preservation ===" << std::endl;
MockIO mockIO;
StorageModule storage;
auto config = std::make_unique<grove::JsonDataNode>("config");
config->setString("savePath", "data/saves/");
config->setDouble("autoSaveInterval", 100.0);
config->setInt("maxAutoSaves", 3);
storage.setConfiguration(*config, &mockIO, nullptr);
// Simulate some time passing
auto input = std::make_unique<grove::JsonDataNode>("input");
input->setDouble("deltaTime", 50.0);
storage.process(*input);
// Get state
auto state = storage.getState();
double savedTime = state->getDouble("timeSinceLastAutoSave", 0.0);
assert(savedTime == 50.0);
std::cout << "✓ State captured: timeSinceLastAutoSave = " << savedTime << std::endl;
// Simulate module reload
StorageModule newStorage;
newStorage.setConfiguration(*config, &mockIO, nullptr);
newStorage.setState(*state);
// Verify state was restored
auto restoredState = newStorage.getState();
double restoredTime = restoredState->getDouble("timeSinceLastAutoSave", 0.0);
assert(restoredTime == 50.0);
std::cout << "✓ State restored: timeSinceLastAutoSave = " << restoredTime << std::endl;
std::cout << "✓ Test 6 PASSED\n" << std::endl;
}
/**
* Main test runner
*/
int main() {
std::cout << "========================================" << std::endl;
std::cout << "StorageModule Independent Validation Tests" << std::endl;
std::cout << "========================================" << std::endl;
try {
testSaveLoadCycle();
testAutoSave();
testInvalidSaveFile();
testVersionCompatibility();
testAutoSaveRotation();
testHotReloadState();
std::cout << "\n========================================" << std::endl;
std::cout << "ALL TESTS PASSED ✓" << std::endl;
std::cout << "========================================\n" << std::endl;
cleanupTestSaves();
return 0;
}
catch (const std::exception& e) {
std::cerr << "\n✗ TEST FAILED: " << e.what() << std::endl;
cleanupTestSaves();
return 1;
}
}