feat: split tests into per-suite files

This commit is contained in:
2026-10-19 00:45:29 +02:00
parent 92c1daf396
commit 3a04c1884a
11 changed files with 2425 additions and 2128 deletions
+463
View File
@@ -0,0 +1,463 @@
#include <cstring>
#include <filesystem>
#include <fstream>
#include <memory>
#include <string>
#include <glm/gtc/packing.hpp>
#include <destrum/Assets/AssetManager.h>
#include <destrum/Assets/AssetReference.h>
#include <destrum/Assets/ModelFile.h>
#include <destrum/Assets/TextureFile.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Serialization/GlmJson.h>
#include <destrum/Components/Animator.h>
#include <destrum/Graphics/Material.h>
#include <destrum/Graphics/imageLoader.h>
#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<Animator>();
Skeleton skeleton;
Joint joint;
joint.id = 0;
joint.localTranslation = glm::vec3{0.0f};
joint.localRotation = glm::identity<glm::quat>();
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<SkeletalAnimation>();
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<glm::quat>();
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<glm::quat>(), .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<std::uint8_t>& 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<std::uint32_t>{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<std::string>{"root", "spine"} &&
loaded.skeleton.parentIndex == std::vector<int>{-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<std::string>{"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<std::uint8_t>(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<float> 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<std::uint8_t>& 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 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 RunAssetsTests()
{
testAssetPathValidation();
testAnimatorAssetReferences();
testModelFileRoundTrip();
testModelFileRejectsBadData();
testModelFileFromImportedModel();
testAssetManagerLoadsCookedModels();
testTextureFileRoundTrip();
testImageLoaderReadsCookedTextures();
testAssetReferenceCacheKeys();
}