#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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(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(); 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{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(), rotation.w), "quaternions must serialize as [x, y, z, w]"); check(quat.get() == rotation, "quaternions must round-trip through json"); bool rejected = false; try { (void)nlohmann::json::array({1.0f, 2.0f}).get(); } 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 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(); 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 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 values; ObjectRef target; std::vector 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(); 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(); 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(); 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(); 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"); 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(); 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()->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(); }