#include #include #include #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 testAssetPathValidation() { const auto root = std::filesystem::temp_directory_path() / "destrum_asset_test"; std::filesystem::create_directories(root / "assets" / "engine"); std::filesystem::create_directories(root / "assets" / "game"); std::ofstream{root / "assets" / "engine" / "asset.txt"} << "asset"; std::ofstream{root / "assets" / "engine" / "cooked.asset"} << "cooked"; std::ofstream{root / "assets" / "engine" / "manifest.json"} << R"({"version":1,"assets":[{"src":"asset.txt","out":"cooked.asset","type":"raw","mtime_epoch_ns":-5020137004799419216,"size_bytes":355972}]})"; auto& assetFs = AssetFS::GetInstance(); assetFs.Init(root); check(std::filesystem::exists(assetFs.GetFullPath("engine://asset.txt")), "valid virtual asset paths must resolve"); check(assetFs.GetCookedPathForFile("engine://asset.txt").filename() == "cooked.asset", "manifest output paths must resolve to cooked assets"); bool rejected = false; try { (void)assetFs.GetFullPath("engine://../outside.txt"); } catch (const std::exception&) { rejected = true; } check(rejected, "asset traversal must be rejected"); const auto outside = root / "outside"; const auto link = root / "assets" / "engine" / "link"; std::filesystem::create_directories(outside); std::error_code symlinkError; std::filesystem::create_directory_symlink(outside, link, symlinkError); if (!symlinkError) { bool symlinkEscapeRejected = false; try { (void)assetFs.GetFullPath("engine://link/missing.txt"); } catch (const std::exception&) { symlinkEscapeRejected = true; } check(symlinkEscapeRejected, "symlink traversal must be rejected for missing targets"); } std::filesystem::remove_all(root); assetFs.Reset(); } void testAnimatorAssetReferences() { GameObject object{"animated"}; auto* animator = object.AddComponent(); Skeleton skeleton; Joint joint; joint.id = 0; joint.localTranslation = glm::vec3{0.0f}; joint.localRotation = glm::identity(); joint.localScale = glm::vec3{1.0f}; skeleton.joints.push_back(joint); skeleton.inverseBindMatrices.push_back(glm::mat4{1.0f}); skeleton.jointNames.push_back("root"); skeleton.assetReference.path = "engine://character.fbx"; buildParentIndex(skeleton); animator->setSkeleton(std::move(skeleton)); auto clip = std::make_shared(); clip->name = "walk"; clip->assetReference.path = "engine://walk.fbx"; clip->assetReference.subresource = "animation:0"; clip->duration = 1.0f; SkeletalAnimation::Keyframe keyframe; keyframe.time = 0.0f; keyframe.translation = glm::vec3{0.0f}; keyframe.rotation = glm::identity(); keyframe.scale = glm::vec3{1.0f}; SkeletalAnimation::Track track; track.jointIndex = 0; track.keyframes.push_back(keyframe); clip->tracks.push_back(std::move(track)); animator->addClip(std::move(clip)); animator->play("walk"); const auto data = animator->Serialize(); check(data.contains("assetReferences"), "Animator scenes must store asset references"); check(data.at("assetReferences").at("skeleton").at("path") == "engine://character.fbx", "Animator serialization must store the skeleton source"); check(data.at("assetReferences").at("animations").at(0).at("asset").at("subresource") == "animation:0", "Animator serialization must store the animation subresource"); check(data.dump().find("keyframes") == std::string::npos, "Animator scene data must not embed animation keyframes"); } ModelDoc::ModelAsset makeTestModelAsset() { Skeleton skeleton; skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"}; skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}}; skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}}; skeleton.joints = { {.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}}, {.id = 1, .localScale = glm::vec3{2.0f}}, }; skeleton.jointNames = {"root", "spine"}; buildParentIndex(skeleton); CPUMesh mesh; mesh.indices = {0, 1, 2}; mesh.vertices = { {.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}}, {.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}}, {.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f}, }; mesh.skinningData = { {.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}}, {.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, {.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, }; mesh.name = "body"; mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"}; mesh.minPos = glm::vec3{0.0f}; mesh.maxPos = glm::vec3{1.0f}; mesh.skeleton = skeleton; ModelDoc::MeshPrimitive primitive; primitive.mesh = mesh; primitive.materialIndex = 0; primitive.nodeName = "Body"; primitive.sourceMeshIndex = 3; primitive.skinned = true; primitive.nodeWorldTransform = glm::mat4{3.0f}; ModelDoc::MaterialInfo material; material.name = "skin"; material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"}; material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f}; material.emissiveColor = glm::vec3{0.1f}; material.metallicFactor = 0.2f; material.roughnessFactor = 0.9f; material.opacity = 0.5f; material.textures = {{ .semantic = ModelDoc::TextureSemantic::BaseColor, .rawPath = "skin.png", .path = "textures/skin.png", .assimpType = aiTextureType_DIFFUSE, .slot = 1, .embedded = false, }}; SkeletalAnimation animation; animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"}; animation.tracks = {{ .jointIndex = 1, .keyframes = { {.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity(), .scale = glm::vec3{1.0f}}, {.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}}, }, }}; animation.duration = 0.5f; animation.looped = false; animation.name = "wave"; animation.startFrame = 4; animation.events = {{2, {"footstep", "dust"}}}; ModelDoc::ModelAsset model; model.sourcePath = "engine://character.fbx"; model.primitives = {primitive}; model.materials = {material}; model.skeleton = skeleton; model.animations = {animation}; return model; } bool modelFileReadThrows(const std::vector& bytes) { try { (void)ModelFile::Read(bytes); } catch (const std::exception&) { return true; } return false; } void testModelFileRoundTrip() { const auto bytes = ModelFile::Write(makeTestModelAsset()); const auto loaded = ModelFile::Read(bytes); check(ModelFile::Write(loaded) == bytes, "a cooked model must write back to exactly the same bytes"); check(loaded.sourcePath == "engine://character.fbx", "cooked models must keep their source path"); check(loaded.primitives.size() == 1, "cooked models must keep their primitives"); const auto& primitive = loaded.primitives.front(); check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned, "cooked primitives must keep their import metadata"); check(primitive.mesh.indices == std::vector{0, 1, 2}, "cooked meshes must keep their indices"); check(primitive.mesh.vertices.size() == 3 && primitive.mesh.vertices[0].uv_x == 0.25f && primitive.mesh.vertices[1].tangent.w == -1.0f, "cooked meshes must keep their vertices"); check(primitive.mesh.skinningData[0].jointIds.y == 1 && primitive.mesh.skinningData[0].weights.x == 0.5f, "cooked meshes must keep their skinning data"); check(primitive.mesh.skeleton.has_value() && primitive.mesh.skeleton->jointNames.size() == 2, "cooked skinned meshes must keep their skeleton"); check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f, "cooked models must keep their materials"); const auto& texture = loaded.materials.front().textures.front(); check(texture.semantic == ModelDoc::TextureSemantic::BaseColor && texture.path == "textures/skin.png" && texture.assimpType == aiTextureType_DIFFUSE && texture.slot == 1 && !texture.embedded, "cooked materials must keep their textures"); check(loaded.skeleton.jointNames == std::vector{"root", "spine"} && loaded.skeleton.parentIndex == std::vector{-1, 0} && loaded.skeleton.joints[1].localScale == glm::vec3{2.0f}, "cooked models must keep their skeleton"); check(loaded.animations.size() == 1, "cooked models must keep their animations"); const auto& animation = loaded.animations.front(); check(animation.name == "wave" && !animation.looped && animation.startFrame == 4, "cooked animations must keep their settings"); check(animation.tracks.front().keyframes[1].translation.y == 2.0f && animation.tracks.front().keyframes[1].rotation.x == 1.0f, "cooked animations must keep their keyframes"); check(animation.events.at(2) == std::vector{"footstep", "dust"}, "cooked animations must keep their events"); } void testModelFileRejectsBadData() { const auto bytes = ModelFile::Write(makeTestModelAsset()); auto truncated = bytes; truncated.resize(bytes.size() / 2); check(modelFileReadThrows(truncated), "truncated cooked models must be rejected"); auto wrongMagic = bytes; wrongMagic[0] = 'X'; check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected"); auto wrongVersion = bytes; wrongVersion[4] = static_cast(wrongVersion[4] + 1); check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected"); auto trailingData = bytes; trailingData.push_back(0); check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected"); } bool loadModelThrows(std::string_view reference, const ModelDoc::LoadOptions& options) { try { (void)AssetManager::GetInstance().LoadModel(reference, options); } catch (const std::exception&) { return true; } return false; } void testModelFileFromImportedModel() { const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx"; const auto imported = ModelDoc::LoadModel(source.generic_string()); const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel"; ModelFile::WriteToFile(imported, path); const auto loaded = ModelFile::ReadFromFile(path); std::filesystem::remove(path); check(!imported.primitives.empty(), "the test model must import with at least one primitive"); check(loaded.primitives.size() == imported.primitives.size() && loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() && loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices, "an imported model must survive being cooked"); check(ModelFile::Write(loaded) == ModelFile::Write(imported), "a cooked imported model must match the import exactly"); } void testAssetManagerLoadsCookedModels() { const auto root = std::filesystem::temp_directory_path() / "destrum_cooked_model_test"; const auto engineDir = root / "assets" / "engine"; std::filesystem::remove_all(root); std::filesystem::create_directories(engineDir); std::filesystem::create_directories(root / "assets" / "game"); // The source can't be imported, so a successful load has to come from the cooked file std::ofstream{engineDir / "model.fbx"} << "not a model"; ModelFile::WriteToFile(makeTestModelAsset(), engineDir / "model.fbx.dmodel"); std::ofstream{engineDir / "manifest.json"} << R"({"version":1,"assets":[{"src":"model.fbx","out":"model.fbx.dmodel","type":"model","mtime_epoch_ns":0,"size_bytes":0}]})"; auto& assetFs = AssetFS::GetInstance(); assetFs.Init(root); const auto model = AssetManager::GetInstance().LoadModel("engine://model.fbx"); check(model.primitives.size() == 1 && model.animations.size() == 1, "cooked models must be loaded instead of importing the source"); check(model.sourcePath == "engine://model.fbx" && model.primitives.front().mesh.assetReference.path == "engine://model.fbx" && model.primitives.front().mesh.skeleton->assetReference.path == "engine://model.fbx", "cooked models must be referenced by their virtual path"); check(model.materials.front().textures.front().path == std::filesystem::weakly_canonical(engineDir / "textures/skin.png").generic_string(), "cooked texture paths must be resolved next to the cooked model"); ModelDoc::LoadOptions withoutExtras{}; withoutExtras.loadMaterials = false; withoutExtras.loadAnimations = false; const auto stripped = AssetManager::GetInstance().LoadModel("engine://model.fbx", withoutExtras); check(stripped.materials.empty() && stripped.animations.empty() && stripped.primitives.front().materialIndex == ModelDoc::InvalidIndex, "cooked models must drop what the load options didn't ask for"); ModelDoc::LoadOptions withoutSkeleton{}; withoutSkeleton.loadSkeleton = false; check(loadModelThrows("engine://model.fbx", withoutSkeleton), "skinned cooked models must be imported when loaded without a skeleton"); ModelDoc::LoadOptions otherImport{}; otherImport.flipUVs = false; check(loadModelThrows("engine://model.fbx", otherImport), "cooked models must be imported when the import options differ from the cook options"); assetFs.Reset(); std::filesystem::remove_all(root); } TextureFile::Texture makeTestTexture() { const std::vector rgba{ 0.0f, 0.25f, 0.5f, 1.0f, 2.0f, 16.0f, 100.0f, 1.0f, }; return TextureFile::FromRGBA32Float(2, 1, rgba); } bool textureFileReadThrows(const std::vector& bytes) { try { (void)TextureFile::Read(bytes); } catch (const std::exception&) { return true; } return false; } void testTextureFileRoundTrip() { const auto texture = makeTestTexture(); check(texture.pixels.size() == 2 * TextureFile::BytesPerPixel(TextureFile::PixelFormat::RGBA16Float), "cooked textures must store every pixel"); std::uint64_t secondPixel = 0; std::memcpy(&secondPixel, texture.pixels.data() + 8, sizeof(secondPixel)); const glm::vec4 unpacked = glm::unpackHalf4x16(secondPixel); check(unpacked == glm::vec4{2.0f, 16.0f, 100.0f, 1.0f}, "half float conversion must keep HDR values above 1"); const auto bytes = TextureFile::Write(texture); const auto loaded = TextureFile::Read(bytes); check(loaded.width == 2 && loaded.height == 1 && loaded.format == TextureFile::PixelFormat::RGBA16Float && loaded.pixels == texture.pixels, "cooked textures must round-trip"); auto truncated = bytes; truncated.resize(bytes.size() - 1); check(textureFileReadThrows(truncated), "truncated cooked textures must be rejected"); auto wrongMagic = bytes; wrongMagic[0] = 'X'; check(textureFileReadThrows(wrongMagic), "files that aren't cooked textures must be rejected"); auto wrongSize = bytes; wrongSize[8] = 3; check(textureFileReadThrows(wrongSize), "cooked textures whose size doesn't match their pixels must be rejected"); } void testImageLoaderReadsCookedTextures() { const auto path = std::filesystem::temp_directory_path() / "destrum_texture_test.dtex"; const auto texture = makeTestTexture(); TextureFile::WriteToFile(texture, path); const auto image = util::loadImage(path, TextureIntent::ColorSrgb); std::filesystem::remove(path); check(image.vkFormat == VK_FORMAT_R16G16B16A16_SFLOAT && image.width == 2 && image.height == 1 && image.byteSize == texture.pixels.size(), "cooked textures must load as half float images"); check(image.pixels != nullptr && std::memcmp(image.pixels, texture.pixels.data(), texture.pixels.size()) == 0, "cooked texture pixels must be uploaded as stored"); } void testLdrTextureFile() { const std::vector rgba{255, 0, 0, 255, 0, 128, 255, 64}; const auto texture = TextureFile::FromRGBA8(2, 1, rgba); const auto loaded = TextureFile::Read(TextureFile::Write(texture)); check(loaded.format == TextureFile::PixelFormat::RGBA8 && loaded.pixels == rgba, "8 bit cooked textures must round-trip"); const auto dir = std::filesystem::temp_directory_path() / "destrum_ldr_texture_test"; std::filesystem::create_directories(dir); const auto source = dir / "missing_source.png"; auto cooked = source; cooked += TextureFile::Extension; TextureFile::WriteToFile(texture, cooked); const auto color = util::loadImage(source, TextureIntent::ColorSrgb); const auto data = util::loadImage(source, TextureIntent::DataLinear); std::filesystem::remove_all(dir); check(color.vkFormat == VK_FORMAT_R8G8B8A8_SRGB && data.vkFormat == VK_FORMAT_R8G8B8A8_UNORM, "8 bit cooked textures must pick their color space from the intent"); check(color.width == 2 && color.height == 1 && color.pixels != nullptr && std::memcmp(color.pixels, rgba.data(), rgba.size()) == 0, "loading a source image must use its cooked sibling"); } bool packOpenThrows(const std::filesystem::path& path) { try { (void)PackFile::Reader{path}; } catch (const std::exception&) { return true; } return false; } void testPackFileRoundTrip() { const auto dir = std::filesystem::temp_directory_path() / "destrum_pack_file_test"; std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); std::ofstream{dir / "a.txt", std::ios::binary} << "hello"; std::ofstream{dir / "empty.txt", std::ios::binary}; const auto pack = dir / "test.dpak"; PackFile::Write({{"engine/a.txt", dir / "a.txt"}, {"game/empty.txt", dir / "empty.txt"}}, pack); const PackFile::Reader reader{pack}; const PackFile::Entry* text = reader.Find("engine/a.txt"); const PackFile::Entry* empty = reader.Find("game/empty.txt"); check(reader.Entries().size() == 2 && text != nullptr && empty != nullptr && !reader.Find("a.txt"), "packs must list every file by its full path"); const auto bytes = reader.Read(*text); check(std::string(bytes.begin(), bytes.end()) == "hello" && reader.Read(*empty).empty(), "packed files must read back as written"); bool duplicateRejected = false; try { PackFile::Write({{"engine/a.txt", dir / "a.txt"}, {"engine/a.txt", dir / "a.txt"}}, dir / "dupe.dpak"); } catch (const std::exception&) { duplicateRejected = true; } check(duplicateRejected, "packs can't hold two files at one path"); std::ofstream{dir / "bad.dpak", std::ios::binary} << "DTEX not a pack"; check(packOpenThrows(dir / "bad.dpak"), "files that aren't packs must be rejected"); const auto size = std::filesystem::file_size(pack); std::filesystem::resize_file(pack, size - 4); check(packOpenThrows(pack), "truncated packs must be rejected"); std::filesystem::remove_all(dir); } void testAssetFSMountsPacks() { const auto root = std::filesystem::temp_directory_path() / "destrum_packed_assets_test"; const auto staging = root / "staging"; std::filesystem::remove_all(root); std::filesystem::create_directories(staging); std::ofstream{staging / "notes.txt", std::ios::binary} << "packed"; std::ofstream{staging / "notes.txt.meta", std::ios::binary} << R"({"guid":"0123456789abcdef0123456789abcdef"})"; std::ofstream{staging / "model.fbx", std::ios::binary} << "not a model"; ModelFile::WriteToFile(makeTestModelAsset(), staging / "model.fbx.dmodel"); const std::vector rgba{255, 0, 0, 255}; TextureFile::WriteToFile(TextureFile::FromRGBA8(1, 1, rgba), staging / "tex.png.dtex"); std::ofstream{staging / "manifest.json", std::ios::binary} << R"({"version":2,"assets":[{"src":"model.fbx","out":"model.fbx.dmodel","type":"model"}]})"; PackFile::Write({ {"engine/manifest.json", staging / "manifest.json"}, {"engine/model.fbx", staging / "model.fbx"}, {"engine/model.fbx.dmodel", staging / "model.fbx.dmodel"}, {"engine/tex.png.dtex", staging / "tex.png.dtex"}, {"game/notes.txt", staging / "notes.txt"}, {"game/notes.txt.meta", staging / "notes.txt.meta"}, }, root / AssetFS::PackName); // A loose copy next to the pack must not be picked up std::filesystem::create_directories(root / "assets" / "game"); std::ofstream{root / "assets" / "game" / "notes.txt", std::ios::binary} << "loose"; std::ofstream{root / "assets" / "game" / "loose.txt", std::ios::binary} << "loose"; auto& assetFs = AssetFS::GetInstance(); assetFs.Init(root); check(assetFs.IsPacked() && assetFs.GetMounts().size() == 2, "a pack next to the exe must mount every scheme in it"); const auto notes = assetFs.ReadBytes("game://notes.txt"); check(std::string(notes.begin(), notes.end()) == "packed", "packed mounts must read from the pack"); check(assetFs.GetFullPath("game://notes.txt") == (root / "assets" / "game" / "notes.txt").lexically_normal(), "packed files must keep the full path they have unpacked"); check(assetFs.Exists(assetFs.GetFullPath("game://notes.txt")) && !assetFs.Exists(assetFs.GetFullPath("game://loose.txt")), "only packed files exist under a packed mount"); check(assetFs.ToVirtualPath(assetFs.GetFullPath("engine://tex.png")) == "engine://tex.png", "packed full paths must map back to virtual paths"); check(!assetFs.WriteFile("game://notes.txt", "changed"), "packed mounts are read only"); const auto model = AssetManager::GetInstance().LoadModel("engine://model.fbx"); check(model.primitives.size() == 1, "cooked models must load from the pack"); const auto image = util::loadImage(assetFs.GetFullPath("engine://tex.png"), TextureIntent::ColorSrgb); check(image.width == 1 && image.pixels != nullptr && std::memcmp(image.pixels, rgba.data(), rgba.size()) == 0, "images must find their cooked sibling in the pack"); auto& database = AssetDatabase::GetInstance(); database.Refresh(); check(database.FindPath(*AssetGuid::Parse("0123456789abcdef0123456789abcdef")) == "game://notes.txt", "asset ids must be read from packed meta files"); database.Clear(); assetFs.Reset(); std::filesystem::remove_all(root); } void testAssetReferenceCacheKeys() { const AssetReference reference{ .path = "game://CharacterMedium.fbx", .subresource = "mesh:0", }; const auto key = reference.cacheKey(); check(key == "game://CharacterMedium.fbx#mesh:0", "asset references must produce stable cache keys"); const auto parsed = AssetReference::fromCacheKey(key); check(parsed.has_value() && parsed->path == reference.path && parsed->subresource == reference.subresource, "asset cache keys must recover their source reference"); check(!AssetReference::fromCacheKey("CharacterMedium.fbx").has_value(), "plain cache names must not be treated as file-backed assets"); } void testAssetGuids() { const AssetGuid first = AssetGuid::Generate(); const AssetGuid second = AssetGuid::Generate(); check(first.IsValid() && second.IsValid() && first != second, "generated asset ids must be valid and unique"); const std::string text = first.ToString(); check(text.size() == 32 && AssetGuid::Parse(text) == first, "asset ids must round-trip through their text form"); check(!AssetGuid::Parse("1234").has_value() && !AssetGuid::Parse(std::string(32, 'z')).has_value() && !AssetGuid::Parse(std::string(32, '0')).has_value(), "malformed or empty asset ids must be rejected"); } void testAssetDatabaseFollowsMovedAssets() { auto& database = AssetDatabase::GetInstance(); database.Clear(); const AssetGuid guid = AssetGuid::Generate(); database.Register("game://models/crate.glb", guid); check(database.FindGuid("game://models/crate.glb") == guid && database.FindPath(guid) == "game://models/crate.glb", "registered assets must be found by path and by id"); const nlohmann::json reference = database.WriteReference("game://models/crate.glb", "mesh:0"); check(reference.at("guid") == guid.ToString() && reference.at("sub") == "mesh:0", "references to known assets must carry the id and subresource"); check(!database.WriteReference("game://unknown.png").contains("guid"), "assets without a meta file must not get an id outside a save"); database.Clear(); database.Register("game://props/crate.glb", guid); const auto moved = database.ReadReference(reference); check(moved.has_value() && moved->path == "game://props/crate.glb" && moved->subresource == "mesh:0", "references must follow their id when the asset moved"); const auto byPath = database.ReadReference(nlohmann::json{{"path", "game://other.png"}}); check(byPath.has_value() && byPath->path == "game://other.png", "references without a known id must keep their path"); check(!database.ReadReference(nlohmann::json{{"guid", guid.ToString()}}).has_value(), "a reference needs a path to fall back on"); database.Clear(); } void testAssetTypes() { check(AssetTypes::IsModel("game://a/B.GLB") && AssetTypes::IsImage("x.Png") && AssetTypes::IsMaterial("m.dmat") && AssetTypes::IsScene("game://scenes/main.scene") && AssetTypes::IsPrefab("p.prefab") && AssetTypes::IsMeta("a.glb.meta"), "asset types must be recognised by extension, ignoring case"); check(!AssetTypes::IsModel("model.glb.meta") && !AssetTypes::IsScene("scene.json"), "only the last extension decides the asset type"); check(MaterialSerialization::IsFileKey("game://materials/a.dmat") && !MaterialSerialization::IsFileKey("local:3:1") && !MaterialSerialization::IsFileKey("game://a.glb#material:0"), "only .dmat paths are material files"); } void testMainSceneFileIsValid() { std::ifstream file{std::filesystem::path{LIGHTKEEPER_ASSETS_SRC_DIR} / "scenes" / "main.scene"}; const auto root = nlohmann::json::parse(file, nullptr, false); check(!root.is_discarded() && root.at("version") == ObjectSerialization::CurrentObjectVersion && root.at("objects").size() > 100, "the main scene must be saved in the current format with the whole level"); check(!MaterialSerialization::Validate(root.at("materials")).has_value(), "the main scene's local materials must be valid"); } } void RunAssetsTests() { testAssetPathValidation(); testAnimatorAssetReferences(); testModelFileRoundTrip(); testModelFileRejectsBadData(); testModelFileFromImportedModel(); testAssetManagerLoadsCookedModels(); testTextureFileRoundTrip(); testImageLoaderReadsCookedTextures(); testLdrTextureFile(); testPackFileRoundTrip(); testAssetFSMountsPacks(); testAssetReferenceCacheKeys(); testAssetGuids(); testAssetDatabaseFollowsMovedAssets(); testAssetTypes(); testMainSceneFileIsValid(); }