583 lines
27 KiB
C++
583 lines
27 KiB
C++
#include <algorithm>
|
|
#include <array>
|
|
#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/ComponentFactory.h>
|
|
#include <destrum/Serialization/MaterialSerialization.h>
|
|
#include <destrum/Serialization/Reflect.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;
|
|
environment.exposure = -1.5f;
|
|
environment.tonemapper = Tonemapper::Aces;
|
|
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) &&
|
|
close(loadedEnvironment.exposure, -1.5f) && loadedEnvironment.tonemapper == Tonemapper::Aces,
|
|
"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([®istered] { ++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 testMaterialJsonRoundTrip()
|
|
{
|
|
Material material = Material::SimpleColor(glm::vec3{0.2f, 0.4f, 0.6f}, "Crate");
|
|
material.metallicFactor = 0.3f;
|
|
material.roughnessFactor = 0.55f;
|
|
material.emissiveColor = {1.0f, 0.5f, 0.0f};
|
|
material.textureFilteringMode = TextureFilteringMode::Anisotropic;
|
|
material.blendMode = BlendMode::Transparent;
|
|
material.opacity = 0.25f;
|
|
material.diffuseTexture = 3;
|
|
material.emissiveTexture = 7;
|
|
material.normalTexture = 9;
|
|
|
|
// Texture 9 has no source file, it must be left out instead of saved as a dangling id
|
|
const nlohmann::json data = MaterialSerialization::ToJson(material, [](ImageID id) -> nlohmann::json {
|
|
if (id == 9) {
|
|
return nullptr;
|
|
}
|
|
return {{"path", "game://tex" + std::to_string(id) + ".png"}, {"srgb", id == 3}};
|
|
});
|
|
check(!MaterialSerialization::Validate(nlohmann::json{{"Crate", data}}).has_value(),
|
|
"a serialized material must pass validation");
|
|
check(!data.at("textures").contains("normal"), "textures without a source must not be saved");
|
|
|
|
const Material loaded = MaterialSerialization::FromJson(data, [](const nlohmann::json& texture) {
|
|
const std::string path = texture.at("path").get<std::string>();
|
|
return path == "game://tex3.png" ? ImageID{30} : ImageID{70};
|
|
});
|
|
check(loaded.name == "Crate" && loaded.baseColor == material.baseColor &&
|
|
close(loaded.metallicFactor, 0.3f) && close(loaded.roughnessFactor, 0.55f) &&
|
|
loaded.emissiveColor == material.emissiveColor &&
|
|
loaded.textureFilteringMode == TextureFilteringMode::Anisotropic &&
|
|
loaded.blendMode == BlendMode::Transparent && close(loaded.opacity, 0.25f),
|
|
"material factors must round-trip through json");
|
|
check(loaded.diffuseTexture == 30 && loaded.emissiveTexture == 70 &&
|
|
loaded.normalTexture == NULL_IMAGE_ID && loaded.metallicTexture == NULL_IMAGE_ID,
|
|
"material textures must round-trip through their slots");
|
|
}
|
|
|
|
void testSceneValidatesEmbeddedMaterials()
|
|
{
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_materials_scene.json";
|
|
Scene& scene = SceneManager::GetInstance().CreateScene("materials");
|
|
|
|
std::ofstream{path} << R"({"version":2,"objects":[],"materials":{"Bad":{"textures":{"diffuse":5}}}})";
|
|
check(!SceneSerializer::Load(scene, path), "a material texture without a path must be rejected");
|
|
|
|
std::ofstream{path} << R"({"version":2,"objects":[{"id":1,"name":"box"}],)"
|
|
R"("materials":{"Good":{"baseColor":[1,0,0],"textures":{"diffuse":{"path":"game://a.png"}}}}})";
|
|
check(SceneSerializer::Load(scene, path), "a scene with embedded materials must load");
|
|
std::filesystem::remove(path);
|
|
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
void testPlayModeSnapshotRestore()
|
|
{
|
|
Scene& scene = SceneManager::GetInstance().CreateScene("snapshot");
|
|
GameObject* box = scene.CreateGameObject("box");
|
|
box->GetTransform().SetLocalPosition({1.f, 2.f, 3.f});
|
|
scene.CreateGameObject("other");
|
|
scene.CommitPendingAdditions();
|
|
scene.SetSelectedObject(box->GetId());
|
|
|
|
auto snapshot = SceneSerializer::Capture(scene);
|
|
check(snapshot.has_value(), "a scene must be capturable without a file");
|
|
(*snapshot)["selected"] = box->GetId();
|
|
|
|
// What play mode does to the scene before Stop restores it
|
|
box->GetTransform().SetLocalPosition({9.f, 9.f, 9.f});
|
|
scene.CreateGameObject("spawned during play");
|
|
scene.CommitPendingAdditions();
|
|
|
|
check(SceneSerializer::LoadFromJson(scene, *snapshot), "restoring a snapshot must succeed");
|
|
scene.CommitPendingAdditions();
|
|
check(scene.GetObjects().size() == 2 && findByName(scene, "spawned during play") == nullptr,
|
|
"restoring must drop objects created after the snapshot");
|
|
const GameObject* restored = findByName(scene, "box");
|
|
check(restored != nullptr && close(restored->GetTransform().GetLocalPosition().x, 1.f),
|
|
"restoring must bring back the captured transforms");
|
|
check(scene.GetSelectedObject() == restored, "the selection must follow the restored object");
|
|
|
|
check(!SceneSerializer::LoadFromJson(scene, nlohmann::json{{"objects", 5}}),
|
|
"an invalid snapshot must be rejected");
|
|
check(scene.GetObjects().size() == 2, "a rejected snapshot must leave the scene alone");
|
|
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
void testTonemapperNames()
|
|
{
|
|
for (const Tonemapper tonemapper : {Tonemapper::None, Tonemapper::Aces, Tonemapper::PbrNeutral}) {
|
|
check(ParseTonemapper(ToString(tonemapper)) == tonemapper, "tonemapper names must round-trip");
|
|
}
|
|
check(!ParseTonemapper("filmic").has_value(), "unknown tonemapper names must be rejected");
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
enum class TestMode { Off, Slow, Fast };
|
|
constexpr std::array<const char*, 3> TestModeNames{"Off", "Slow", "Fast"};
|
|
|
|
class ReflectedComponent final : public Component {
|
|
public:
|
|
explicit ReflectedComponent(GameObject& owner) : Component(owner, "ReflectedComponent") {}
|
|
|
|
std::string GetTypeName() const override { return "ReflectedComponent"; }
|
|
|
|
void Reflect(FieldVisitor& v) override
|
|
{
|
|
v.Field("flag", flag);
|
|
v.Field("count", count, {.min = 0, .max = 10});
|
|
v.Field("index", index);
|
|
v.Field("speed", speed, {.min = 0.0});
|
|
v.Field("ratio", ratio);
|
|
v.Field("offset", offset);
|
|
v.Field("tint", tint, {.color = true});
|
|
v.Field("name", name);
|
|
v.Field("mode", mode, {.enumNames = TestModeNames});
|
|
v.Field("rawMode", rawMode);
|
|
v.Field("values", values);
|
|
v.Field("target", target);
|
|
v.Field("targets", targets);
|
|
v.Field("cache", cache, {.transient = true});
|
|
v.Field("extents", extents, {.min = 0.5});
|
|
v.Property("limited", limited, [this](int value) {
|
|
limited = std::min(value, 5);
|
|
++setterCalls;
|
|
});
|
|
}
|
|
|
|
bool flag{false};
|
|
int count{3};
|
|
std::size_t index{0};
|
|
float speed{1.0f};
|
|
double ratio{0.5};
|
|
glm::vec3 offset{0.0f};
|
|
glm::vec4 tint{1.0f};
|
|
std::string name{"default"};
|
|
TestMode mode{TestMode::Off};
|
|
TestMode rawMode{TestMode::Off};
|
|
std::vector<float> values;
|
|
ObjectRef target;
|
|
std::vector<ObjectRef> targets;
|
|
int cache{0};
|
|
glm::vec3 extents{1.0f};
|
|
int limited{0};
|
|
int setterCalls{0};
|
|
};
|
|
|
|
[[nodiscard]] bool throwsMentioning(ReflectedComponent& component, const nlohmann::json& data, const char* key)
|
|
{
|
|
try {
|
|
component.Deserialize(data);
|
|
} catch (const std::exception& exception) {
|
|
return std::string{exception.what()}.find(key) != std::string::npos;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void testReflectedRoundTrip()
|
|
{
|
|
GameObject object{"reflected"};
|
|
auto* component = object.AddComponent<ReflectedComponent>();
|
|
component->flag = true;
|
|
component->count = 7;
|
|
component->index = 4;
|
|
component->speed = 2.5f;
|
|
component->ratio = 0.25;
|
|
component->offset = glm::vec3{1.0f, -2.0f, 3.5f};
|
|
component->tint = glm::vec4{0.1f, 0.2f, 0.3f, 0.4f};
|
|
component->name = "named";
|
|
component->mode = TestMode::Fast;
|
|
component->rawMode = TestMode::Slow;
|
|
component->values = {1.0f, 2.0f};
|
|
component->target = ObjectRef(ObjectId{12});
|
|
component->targets = {ObjectRef(ObjectId{13}), ObjectRef(ObjectId{14})};
|
|
component->cache = 99;
|
|
|
|
const nlohmann::json data = component->Serialize();
|
|
check(data.at("mode") == "Fast", "enums with names must save their name");
|
|
check(data.at("rawMode") == 1, "enums without names must save their value");
|
|
check(data.at("targets") == nlohmann::json::array({13, 14}), "object refs must save their ids");
|
|
check(!data.contains("cache"), "transient fields must not be saved");
|
|
|
|
GameObject loadedObject{"loaded"};
|
|
auto* loaded = loadedObject.AddComponent<ReflectedComponent>();
|
|
loaded->Deserialize(data);
|
|
check(loaded->flag && loaded->count == 7 && loaded->index == 4, "bool and integer fields must round-trip");
|
|
check(loaded->speed == 2.5f && loaded->ratio == 0.25, "floating point fields must round-trip");
|
|
check(loaded->offset == component->offset && loaded->tint == component->tint, "vector fields must round-trip");
|
|
check(loaded->name == "named", "string fields must round-trip");
|
|
check(loaded->mode == TestMode::Fast && loaded->rawMode == TestMode::Slow, "enum fields must round-trip");
|
|
check(loaded->values == component->values, "vector fields must round-trip");
|
|
check(loaded->target == component->target && loaded->targets == component->targets, "object refs must round-trip");
|
|
check(loaded->cache == 0, "transient fields must not be loaded");
|
|
check(loaded->Serialize() == data, "a loaded component must save the same data");
|
|
}
|
|
|
|
void testReflectedLoadClampsAndKeepsDefaults()
|
|
{
|
|
GameObject object{"reflected"};
|
|
auto* component = object.AddComponent<ReflectedComponent>();
|
|
component->Deserialize({{"count", 50}, {"speed", -3.0f}, {"unknown", true}});
|
|
check(component->count == 10 && component->speed == 0.0f, "loading must clamp to min and max");
|
|
check(component->name == "default" && component->ratio == 0.5, "missing keys must keep their defaults");
|
|
|
|
component->Deserialize({{"mode", 2}});
|
|
check(component->mode == TestMode::Fast, "named enums must still load from a number");
|
|
|
|
component->Deserialize({{"extents", {2.0f, 0.1f, -1.0f}}});
|
|
check(component->extents == glm::vec3{2.0f, 0.5f, 0.5f}, "loading must clamp every component of a vector");
|
|
|
|
check(throwsMentioning(*component, {{"mode", "Turbo"}}, "\"mode\""), "an unknown enum name must fail and name the field");
|
|
check(throwsMentioning(*component, {{"offset", "up"}}, "\"offset\""), "a wrong type must fail and name the field");
|
|
}
|
|
|
|
void testReflectedProperties()
|
|
{
|
|
GameObject object{"reflected"};
|
|
auto* component = object.AddComponent<ReflectedComponent>();
|
|
component->Deserialize({{"flag", true}});
|
|
check(component->setterCalls == 0, "a property setter must not run when its key is missing");
|
|
|
|
component->Deserialize({{"limited", 9}});
|
|
check(component->limited == 5 && component->setterCalls == 1, "loading a property must go through its setter");
|
|
check(component->Serialize().at("limited") == 5, "a property must save its current value");
|
|
}
|
|
|
|
void testReflectedReferencesResolve()
|
|
{
|
|
ComponentFactory::Register<ReflectedComponent>("ReflectedComponent");
|
|
|
|
Scene& scene = SceneManager::GetInstance().CreateScene("reflected references");
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_reflected_refs_scene.json";
|
|
std::ofstream{path}
|
|
<< R"({"version":2,"objects":[)"
|
|
R"({"id":5,"name":"source","components":[{"type":"ReflectedComponent","data":{"target":7,"targets":[7,99,5]}}]},)"
|
|
R"({"id":7,"name":"other","components":[{"type":"ReflectedComponent","data":{"target":42}}]}]})";
|
|
|
|
check(SceneSerializer::Load(scene, path), "a scene with reflected references must load");
|
|
std::filesystem::remove(path);
|
|
scene.CommitPendingAdditions();
|
|
|
|
const GameObject* source = findByName(scene, "source");
|
|
const GameObject* other = findByName(scene, "other");
|
|
check(source != nullptr && other != nullptr, "both objects must load");
|
|
|
|
const auto* sourceComponent = source->GetComponent<ReflectedComponent>();
|
|
check(sourceComponent->target.Get(scene) == other, "an object ref must be remapped to the loaded object");
|
|
check(sourceComponent->targets.size() == 2 && sourceComponent->targets[0].Get(scene) == other &&
|
|
sourceComponent->targets[1].Get(scene) == source,
|
|
"object ref lists must be remapped and drop missing objects");
|
|
check(!other->GetComponent<ReflectedComponent>()->target.IsSet(), "a missing object ref must be cleared");
|
|
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
void testFieldLabels()
|
|
{
|
|
check(reflect_detail::PrettifyFieldName("openDuration") == "Open Duration", "camelCase keys must become words");
|
|
check(reflect_detail::PrettifyFieldName("cast_shadows") == "Cast Shadows", "snake_case keys must become words");
|
|
check(reflect_detail::PrettifyFieldName("useHDR") == "Use HDR", "acronyms must stay together");
|
|
}
|
|
}
|
|
|
|
void RunSerializationTests()
|
|
{
|
|
testReflectedRoundTrip();
|
|
testReflectedLoadClampsAndKeepsDefaults();
|
|
testReflectedProperties();
|
|
testReflectedReferencesResolve();
|
|
testFieldLabels();
|
|
testTonemapperNames();
|
|
testSceneRoundTrip();
|
|
testLegacySceneLoad();
|
|
testSceneLoadRollback();
|
|
testLightComponentRoundTrip();
|
|
testSceneSaveRejectsNonFinite();
|
|
testSceneValidationErrors();
|
|
testSceneLoadRestoresBindings();
|
|
testSceneLoadDropsMissingCamera();
|
|
testGlmJson();
|
|
testSceneFileIdsAreLocal();
|
|
testMaterialJsonRoundTrip();
|
|
testSceneValidatesEmbeddedMaterials();
|
|
testPlayModeSnapshotRestore();
|
|
}
|