Files
Destrum/tests/serialization_tests.cpp
T

299 lines
14 KiB
C++

#include <cmath>
#include <filesystem>
#include <fstream>
#include <string>
#include <unordered_set>
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Scene/SceneManager.h>
#include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/SceneSerializer.h>
#include <destrum/Components/CameraComponent.h>
#include <destrum/Components/LightComponent.h>
#include <destrum/Components/Physics/Rigidbody.h>
#include "TestUtils.h"
using namespace test;
namespace {
void testSceneRoundTrip()
{
Scene& scene = SceneManager::GetInstance().CreateScene("round-trip");
GameObject* parent = scene.CreateGameObject("parent");
GameObject* child = scene.CreateGameObject("child");
child->GetTransform().SetLocalPosition({1.f, 2.f, 3.f});
child->GetTransform().SetParent(&parent->GetTransform(), false);
scene.CommitPendingAdditions();
SceneEnvironment& environment = scene.GetEnvironment();
environment.ambientColor = {0.2f, 0.3f, 0.4f};
environment.ambientIntensity = 2.0f;
environment.fogColor = {0.6f, 0.7f, 0.8f};
environment.fogDensity = 0.05f;
environment.skyboxRotationSpeed = 0.5f;
environment.skyboxRotation = 1.0f;
scene.Update(1.0f);
check(close(environment.skyboxRotation, 1.5f), "scene updates must spin the skybox");
const auto path = std::filesystem::temp_directory_path() / "destrum_scene_test.json";
check(SceneSerializer::Save(scene, path), "scene save must succeed");
check(SceneSerializer::Load(scene, path), "scene load must succeed");
std::filesystem::remove(path);
check(scene.GetObjects().size() == 2, "scene round-trip must preserve objects");
const auto* loadedChild = scene.GetObjects().at(1).get();
check(loadedChild->GetTransform().GetParent() != nullptr,
"scene round-trip must preserve hierarchy");
check(close(loadedChild->GetTransform().GetLocalPosition().x, 1.f),
"scene round-trip must preserve local transforms");
const SceneEnvironment& loadedEnvironment = scene.GetEnvironment();
check(loadedEnvironment.ambientColor == glm::vec3{0.2f, 0.3f, 0.4f} &&
close(loadedEnvironment.ambientIntensity, 2.0f) &&
loadedEnvironment.fogColor == glm::vec3{0.6f, 0.7f, 0.8f} &&
close(loadedEnvironment.fogDensity, 0.05f) &&
close(loadedEnvironment.skyboxRotationSpeed, 0.5f) &&
close(loadedEnvironment.skyboxRotation, 1.5f),
"scene round-trip must preserve the environment settings");
SceneManager::GetInstance().Destroy();
}
void testLegacySceneLoad()
{
Scene& scene = SceneManager::GetInstance().CreateScene("legacy");
const auto path = std::filesystem::temp_directory_path() / "destrum_legacy_scene.json";
std::ofstream{path}
<< R"({"version":1,"name":"legacy","objects":[{"id":9001,"name":"object","active":true,"transform":{},"parent":0,"components":[]}]})";
check(SceneSerializer::Load(scene, path),
"version 1 placeholder transforms must remain loadable");
check(scene.GetObjects().size() == 1,
"legacy scene load must create its object");
std::filesystem::remove(path);
SceneManager::GetInstance().Destroy();
}
void testSceneLoadRollback()
{
Scene& scene = SceneManager::GetInstance().CreateScene("rollback");
scene.CreateGameObject("original");
scene.CommitPendingAdditions();
const auto path = std::filesystem::temp_directory_path() / "destrum_bad_scene.json";
std::ofstream{path}
<< R"({"version":2,"name":"bad","objects":[{"id":9101,"name":"bad","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"Rigidbody","enabled":true,"data":{}}]}]})";
check(!SceneSerializer::Load(scene, path),
"scene loading must reject a rigidbody without a collider");
check(scene.GetObjects().size() == 1 && scene.GetObjects().at(0)->GetName() == "original",
"failed scene loading must preserve the live scene");
std::filesystem::remove(path);
SceneManager::GetInstance().Destroy();
}
void testLightComponentRoundTrip()
{
GameObject object{"light"};
auto* light = object.AddComponent<LightComponent>(LightType::Spot);
light->SetColor(glm::vec3{1.0f, 0.5f, 0.25f});
light->SetIntensity(4.0f);
light->SetRange(7.5f);
light->SetConeAngles(0.2f, 0.4f);
light->SetCastShadows(true);
const auto data = light->Serialize();
GameObject loadedObject{"loaded light"};
auto* loaded = loadedObject.AddComponent<LightComponent>();
loaded->Deserialize(data);
check(loaded->GetType() == LightType::Spot,
"LightComponent must round-trip its type");
check(loaded->GetColor() == glm::vec3{1.0f, 0.5f, 0.25f},
"LightComponent must round-trip its color");
check(loaded->GetIntensity() == 4.0f && loaded->GetRange() == 7.5f,
"LightComponent must round-trip intensity and range");
check(loaded->GetInnerConeAngle() == 0.2f && loaded->GetOuterConeAngle() == 0.4f,
"LightComponent must round-trip its cone angles");
check(loaded->GetCastShadows(),
"LightComponent must round-trip its cast shadows flag");
loaded->SetConeAngles(0.5f, 0.1f);
check(loaded->GetOuterConeAngle() >= loaded->GetInnerConeAngle(),
"LightComponent outer cone must never be smaller than the inner cone");
loaded->SetRange(0.0f);
check(loaded->GetRange() > 0.0f,
"LightComponent range must stay positive");
}
void testSceneSaveRejectsNonFinite()
{
Scene& scene = SceneManager::GetInstance().CreateScene("non-finite");
GameObject* object = scene.CreateGameObject("object");
scene.CommitPendingAdditions();
const auto path = std::filesystem::temp_directory_path() / "destrum_non_finite_scene.json";
check(SceneSerializer::Save(scene, path), "a finite scene must save");
const std::string goodContents = readFile(path);
object->GetTransform().SetLocalPosition({std::nanf(""), 0.0f, 0.0f});
check(!SceneSerializer::Save(scene, path), "a scene with NaN values must not save");
check(readFile(path) == goodContents, "a failed save must leave the previous file untouched");
check(!std::filesystem::exists(std::filesystem::path{path} += ".tmp"),
"a failed save must not leave its temp file behind");
std::filesystem::remove(path);
SceneManager::GetInstance().Destroy();
}
void testSceneValidationErrors()
{
const auto path = std::filesystem::temp_directory_path() / "destrum_invalid_scene.json";
std::ofstream{path}
<< R"({"version":2,"name":"bad","objects":[{"id":7,"name":"Lamp","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"NotAComponent","enabled":true,"data":{}}]}]})";
const auto result = SceneSerializer::LoadSceneFile(path);
check(!result.success, "a scene with an unknown component must not load");
check(result.errorMessage.find("\"Lamp\"") != std::string::npos &&
result.errorMessage.find("NotAComponent") != std::string::npos &&
result.errorMessage.find("unknown component type") != std::string::npos,
"scene validation errors must name the object, component and problem");
std::ofstream{path}
<< R"({"version":2,"objects":[{"id":7,"name":"Lamp","components":[{"type":"Spinner","data":["message","hi"]}]}]})";
const auto arrayData = SceneSerializer::LoadSceneFile(path);
check(!arrayData.success && arrayData.errorMessage.find("must be an object") != std::string::npos,
"component data that is not an object must be rejected with a reason");
std::filesystem::remove(path);
}
void testSceneLoadRestoresBindings()
{
Scene& scene = SceneManager::GetInstance().CreateScene("bindings");
int registered = 0;
int unregistered = 0;
scene.SetRegisterBindings([&registered] { ++registered; });
scene.SetUnregisterBindings([&unregistered] { ++unregistered; });
scene.Load();
const int registeredBefore = registered;
const int unregisteredBefore = unregistered;
scene.CreateGameObject("object");
scene.CommitPendingAdditions();
const auto path = std::filesystem::temp_directory_path() / "destrum_bindings_scene.json";
check(SceneSerializer::Save(scene, path), "the bindings scene must save");
check(SceneSerializer::Load(scene, path), "the bindings scene must load");
std::filesystem::remove(path);
check(registered - registeredBefore == 1 && unregistered - unregisteredBefore == 1, "loading a scene in place must register its bindings again");
scene.SetRegisterBindings(nullptr);
scene.SetUnregisterBindings(nullptr);
SceneManager::GetInstance().Destroy();
}
void testSceneLoadDropsMissingCamera()
{
Scene& scene = SceneManager::GetInstance().CreateScene("missing camera");
const auto path = std::filesystem::temp_directory_path() / "destrum_missing_camera_scene.json";
std::ofstream{path}
<< R"({"version":2,"currentCamera":4242,"objects":[{"id":11,"name":"object"}]})";
check(SceneSerializer::Load(scene, path), "a scene with a dangling camera id must still load");
check(scene.GetCurrentCamera() == nullptr, "a dangling current camera id must be ignored");
std::filesystem::remove(path);
SceneManager::GetInstance().Destroy();
}
void testGlmJson()
{
const nlohmann::json vec = glm::vec3{1.0f, 2.5f, -3.0f};
check(vec == nlohmann::json::array({1.0f, 2.5f, -3.0f}), "vec3 must serialize as a 3 number array");
check(vec.get<glm::vec3>() == glm::vec3{1.0f, 2.5f, -3.0f}, "vec3 must round-trip through json");
const glm::quat rotation = glm::angleAxis(0.5f, glm::vec3{0.0f, 1.0f, 0.0f});
const nlohmann::json quat = rotation;
check(quat.size() == 4 && close(quat.at(3).get<float>(), rotation.w),
"quaternions must serialize as [x, y, z, w]");
check(quat.get<glm::quat>() == rotation, "quaternions must round-trip through json");
bool rejected = false;
try {
(void)nlohmann::json::array({1.0f, 2.0f}).get<glm::vec3>();
} catch (const std::exception&) {
rejected = true;
}
check(rejected, "a vec3 must reject arrays of the wrong size");
const nlohmann::json data = {{"axis", nlohmann::json::array({0, 1, 0})}};
check(data.value("axis", glm::vec3{0.0f}) == glm::vec3{0.0f, 1.0f, 0.0f} &&
data.value("missing", glm::vec3{7.0f}) == glm::vec3{7.0f},
"json value() must work with glm defaults");
}
void testSceneFileIdsAreLocal()
{
const auto path = std::filesystem::temp_directory_path() / "destrum_local_ids_scene.json";
std::ofstream{path}
<< R"({"version":2,"currentCamera":2,"objects":[)"
R"({"id":1,"name":"root"},)"
R"({"id":2,"name":"child","parent":1,"components":[{"type":"CameraComponent"}]}]})";
Scene& first = SceneManager::GetInstance().CreateScene("local ids a");
Scene& second = SceneManager::GetInstance().CreateScene("local ids b");
check(SceneSerializer::Load(first, path), "the first copy of the scene must load");
check(SceneSerializer::Load(second, path), "the second copy of the scene must load");
std::filesystem::remove(path);
first.CommitPendingAdditions();
second.CommitPendingAdditions();
std::unordered_set<ObjectId> ids;
std::size_t count = 0;
for (const Scene* scene : {&first, &second}) {
for (const auto& object : scene->GetObjects()) {
ids.insert(object->GetId());
++count;
}
}
check(count == 4 && ids.size() == 4, "loading one file into two scenes must not duplicate ids");
for (const Scene* scene : {&first, &second}) {
const CameraComponent* camera = scene->GetCurrentCamera();
check(camera != nullptr && camera->GetGameObject()->GetName() == "child" &&
camera->GetGameObject()->GetScene() == scene,
"the current camera must be remapped to the object in its own scene");
const Transform* parent = camera->GetGameObject()->GetTransform().GetParent();
check(parent != nullptr && parent->GetOwner()->GetName() == "root" &&
parent->GetOwner()->GetScene() == scene,
"parents must be remapped within each loaded scene");
}
const GameObject* fresh = first.CreateGameObject("fresh");
check(!ids.contains(fresh->GetId()), "objects created after a load must get unused ids");
SceneManager::GetInstance().Destroy();
}
}
void RunSerializationTests()
{
testSceneRoundTrip();
testLegacySceneLoad();
testSceneLoadRollback();
testLightComponentRoundTrip();
testSceneSaveRejectsNonFinite();
testSceneValidationErrors();
testSceneLoadRestoresBindings();
testSceneLoadDropsMissingCamera();
testGlmJson();
testSceneFileIdsAreLocal();
}