feat: update asset browser
This commit is contained in:
@@ -11,6 +11,7 @@ add_executable(destrum_tests
|
||||
editor_tests.cpp
|
||||
audio_tests.cpp
|
||||
jobs_tests.cpp
|
||||
cooker_tests.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp
|
||||
@@ -32,13 +33,15 @@ target_compile_definitions(destrum_tests PRIVATE
|
||||
SDL_MAIN_HANDLED
|
||||
DESTRUM_ENGINE_ASSETS_SRC_DIR="${CMAKE_SOURCE_DIR}/destrum/assets_src"
|
||||
LIGHTKEEPER_ASSETS_SRC_DIR="${CMAKE_SOURCE_DIR}/lightkeeper/assets_src"
|
||||
DESTRUM_THECHEF_PATH="$<TARGET_FILE:TheChef>"
|
||||
)
|
||||
add_dependencies(destrum_tests TheChef)
|
||||
target_include_directories(destrum_tests PRIVATE "${CMAKE_SOURCE_DIR}/lightkeeper/include")
|
||||
target_link_libraries(destrum_tests PRIVATE destrum::destrum destrum::editor)
|
||||
|
||||
destrum_enable_warnings(destrum_tests)
|
||||
destrum_copy_runtime_dlls(destrum_tests)
|
||||
|
||||
foreach(suite core serialization physics assets graphics ui gameplay editor audio jobs)
|
||||
foreach(suite core serialization physics assets graphics ui gameplay editor audio jobs cooker)
|
||||
add_test(NAME destrum_tests.${suite} COMMAND destrum_tests ${suite})
|
||||
endforeach()
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "TestUtils.h"
|
||||
|
||||
using namespace test;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
struct CookDirs {
|
||||
fs::path root;
|
||||
fs::path input;
|
||||
fs::path output;
|
||||
};
|
||||
|
||||
CookDirs makeDirs(const char* name)
|
||||
{
|
||||
CookDirs dirs;
|
||||
dirs.root = fs::temp_directory_path() / name;
|
||||
fs::remove_all(dirs.root);
|
||||
dirs.input = dirs.root / "src";
|
||||
dirs.output = dirs.root / "out";
|
||||
fs::create_directories(dirs.input);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
void writeText(const fs::path& path, const std::string& text)
|
||||
{
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream{path, std::ios::binary} << text;
|
||||
}
|
||||
|
||||
// 2x2 uncompressed 32 bit TGA, top left origin
|
||||
void writeTga(const fs::path& path)
|
||||
{
|
||||
const std::uint8_t header[18] = {0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 32, 0x28};
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream file(path, std::ios::binary);
|
||||
file.write(reinterpret_cast<const char*>(header), sizeof(header));
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const std::uint8_t bgra[4] = {0, 0, 255, 255};
|
||||
file.write(reinterpret_cast<const char*>(bgra), sizeof(bgra));
|
||||
}
|
||||
}
|
||||
|
||||
int runChef(const CookDirs& dirs, const std::string& flags = {})
|
||||
{
|
||||
const fs::path log = dirs.root / "chef.log";
|
||||
std::string command = "\"" + std::string(DESTRUM_THECHEF_PATH) + "\" --input \"" + dirs.input.string() +
|
||||
"\" --output \"" + dirs.output.string() + "\" " + flags + " > \"" + log.string() +
|
||||
"\" 2>&1";
|
||||
#ifdef _WIN32
|
||||
// cmd.exe strips the outer quotes, the inner ones keep paths with spaces intact
|
||||
command = "\"" + command + "\"";
|
||||
#endif
|
||||
return std::system(command.c_str());
|
||||
}
|
||||
|
||||
void expectChef(const CookDirs& dirs, const std::string& flags, bool succeeds, const char* message)
|
||||
{
|
||||
if ((runChef(dirs, flags) == 0) != succeeds) {
|
||||
std::cerr << readFile(dirs.root / "chef.log") << '\n';
|
||||
check(false, message);
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json readManifest(const CookDirs& dirs)
|
||||
{
|
||||
return nlohmann::json::parse(readFile(dirs.output / "manifest.json"));
|
||||
}
|
||||
|
||||
const nlohmann::json* findEntry(const nlohmann::json& manifest, const std::string& src)
|
||||
{
|
||||
for (const auto& entry : manifest["assets"]) {
|
||||
if (entry["src"] == src) {
|
||||
return &entry;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Puts the output well before its dependency, so the next cook has to rebuild it without sleeping
|
||||
fs::file_time_type makeStale(const fs::path& output, const fs::path& dependency)
|
||||
{
|
||||
const auto stale = fs::last_write_time(dependency) - std::chrono::hours{1};
|
||||
fs::last_write_time(output, stale);
|
||||
return stale;
|
||||
}
|
||||
|
||||
bool hasTempFiles(const fs::path& dir)
|
||||
{
|
||||
for (const auto& entry : fs::recursive_directory_iterator(dir)) {
|
||||
if (entry.path().extension() == ".tmp") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void writeShaders(const CookDirs& dirs)
|
||||
{
|
||||
writeText(dirs.input / "shaders" / "include" / "common.glsl", "vec4 tint() { return vec4(1.0); }\n");
|
||||
writeText(dirs.input / "shaders" / "plain.frag",
|
||||
"#version 450\n"
|
||||
"#extension GL_GOOGLE_include_directive : require\n"
|
||||
"#include \"common.glsl\"\n"
|
||||
"layout(location = 0) out vec4 color;\n"
|
||||
"void main() { color = tint(); }\n");
|
||||
}
|
||||
|
||||
void writeModel(const CookDirs& dirs)
|
||||
{
|
||||
writeText(dirs.input / "models" / "tri.obj",
|
||||
"mtllib tri.mtl\n"
|
||||
"v 0 0 0\nv 1 0 0\nv 0 1 0\n"
|
||||
"vt 0 0\nvt 1 0\nvt 0 1\n"
|
||||
"vn 0 0 1\n"
|
||||
"usemtl red\n"
|
||||
"f 1/1/1 2/2/1 3/3/1\n");
|
||||
writeText(dirs.input / "models" / "tri.mtl", "newmtl red\nKd 1 0 0\n");
|
||||
}
|
||||
|
||||
void testCookRecordsEveryOutput()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_outputs");
|
||||
writeShaders(dirs);
|
||||
writeModel(dirs);
|
||||
writeTga(dirs.input / "textures" / "red.tga");
|
||||
writeText(dirs.input / "data" / "notes.txt", "hello");
|
||||
|
||||
expectChef(dirs, "", true, "a clean cook must succeed");
|
||||
|
||||
check(fs::exists(dirs.output / "shaders" / "plain.frag.spv"), "entry shaders compile to SPIR-V");
|
||||
check(fs::exists(dirs.output / "models" / "tri.obj.dmodel"), "models cook to .dmodel");
|
||||
check(fs::exists(dirs.output / "models" / "tri.obj"), "source models are still copied");
|
||||
check(fs::exists(dirs.output / "textures" / "red.tga.dtex"), "LDR images cook to .dtex");
|
||||
check(readFile(dirs.output / "data" / "notes.txt") == "hello", "unknown files are copied");
|
||||
check(!hasTempFiles(dirs.output), "a finished cook leaves no temporary files");
|
||||
|
||||
const auto manifest = readManifest(dirs);
|
||||
const auto* shader = findEntry(manifest, "shaders/plain.frag");
|
||||
check(shader && (*shader)["deps"] == nlohmann::json::array({"shaders/include/common.glsl"}),
|
||||
"shader includes are recorded as dependencies");
|
||||
const auto* model = findEntry(manifest, "models/tri.obj");
|
||||
check(model && (*model)["out"] == "models/tri.obj.dmodel", "the cooked model is the primary output");
|
||||
check(model && (*model)["outputs"].size() == 2, "a model owns its copy and its cooked file");
|
||||
check(model && (*model)["deps"] == nlohmann::json::array({"models/tri.mtl"}),
|
||||
".obj material libraries are recorded as dependencies");
|
||||
check(findEntry(manifest, "shaders/include/common.glsl") != nullptr, "includes are listed too");
|
||||
|
||||
expectChef(dirs, "--validate --strict", true, "a fresh cook must validate");
|
||||
}
|
||||
|
||||
void testUnchangedSourcesAreSkipped()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_unchanged");
|
||||
writeShaders(dirs);
|
||||
writeModel(dirs);
|
||||
writeTga(dirs.input / "red.tga");
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
const std::vector<fs::path> outputs = {
|
||||
dirs.output / "shaders" / "plain.frag.spv",
|
||||
dirs.output / "models" / "tri.obj.dmodel",
|
||||
dirs.output / "red.tga.dtex",
|
||||
dirs.output / "red.tga",
|
||||
};
|
||||
std::vector<fs::file_time_type> times;
|
||||
for (const auto& output : outputs) {
|
||||
times.push_back(fs::last_write_time(output));
|
||||
}
|
||||
|
||||
expectChef(dirs, "", true, "a second cook must succeed");
|
||||
for (std::size_t i = 0; i < outputs.size(); ++i) {
|
||||
check(fs::last_write_time(outputs[i]) == times[i], "an unchanged source must not be cooked again");
|
||||
}
|
||||
}
|
||||
|
||||
void testDependencyChangesRecook()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_dependencies");
|
||||
writeShaders(dirs);
|
||||
writeModel(dirs);
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
const fs::path spv = dirs.output / "shaders" / "plain.frag.spv";
|
||||
const auto staleSpv = makeStale(spv, dirs.input / "shaders" / "include" / "common.glsl");
|
||||
const fs::path model = dirs.output / "models" / "tri.obj.dmodel";
|
||||
const auto staleModel = makeStale(model, dirs.input / "models" / "tri.mtl");
|
||||
// The entry files themselves stay older than the outputs, only the dependencies are newer
|
||||
fs::last_write_time(dirs.input / "shaders" / "plain.frag", staleSpv - std::chrono::hours{1});
|
||||
fs::last_write_time(dirs.input / "models" / "tri.obj", staleModel - std::chrono::hours{1});
|
||||
|
||||
expectChef(dirs, "", true, "recooking must succeed");
|
||||
check(fs::last_write_time(spv) != staleSpv, "a changed include must recompile the shader");
|
||||
check(fs::last_write_time(model) != staleModel, "a changed material library must recook the model");
|
||||
}
|
||||
|
||||
void testReplacedSourceIsCopiedAgain()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_replaced");
|
||||
const fs::path source = dirs.input / "notes.txt";
|
||||
writeText(source, "new");
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
// Restoring an older file must still replace the copy, even though it isn't newer
|
||||
writeText(source, "old!");
|
||||
fs::last_write_time(source, fs::last_write_time(dirs.output / "notes.txt") - std::chrono::hours{24});
|
||||
expectChef(dirs, "", true, "recooking must succeed");
|
||||
check(readFile(dirs.output / "notes.txt") == "old!", "a replaced source must be copied again");
|
||||
}
|
||||
|
||||
void testDeletedSourcesAreRemoved()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_deleted");
|
||||
writeTga(dirs.input / "textures" / "old" / "gone.tga");
|
||||
writeText(dirs.input / "keep.txt", "keep");
|
||||
writeText(dirs.output / "hand_made.txt", "not ours");
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
check(fs::exists(dirs.output / "textures" / "old" / "gone.tga.dtex"), "the texture must be cooked first");
|
||||
|
||||
fs::remove_all(dirs.input / "textures");
|
||||
expectChef(dirs, "", true, "cooking after a delete must succeed");
|
||||
|
||||
check(!fs::exists(dirs.output / "textures" / "old" / "gone.tga.dtex"), "cooked outputs of deleted sources are removed");
|
||||
check(!fs::exists(dirs.output / "textures" / "old" / "gone.tga"), "copies of deleted sources are removed");
|
||||
check(!fs::exists(dirs.output / "textures"), "folders emptied by the cleanup are removed");
|
||||
check(fs::exists(dirs.output / "keep.txt"), "outputs of existing sources stay");
|
||||
check(fs::exists(dirs.output / "hand_made.txt"), "files the cooker never wrote are left alone");
|
||||
check(findEntry(readManifest(dirs), "textures/old/gone.tga") == nullptr, "deleted sources leave the manifest");
|
||||
|
||||
expectChef(dirs, "--validate", false, "validation must report files the cooker didn't write");
|
||||
fs::remove(dirs.output / "hand_made.txt");
|
||||
expectChef(dirs, "--validate", true, "validation passes once the stray file is gone");
|
||||
}
|
||||
|
||||
void testFailedCookKeepsLastGoodOutput()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_failure");
|
||||
writeShaders(dirs);
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
const fs::path spv = dirs.output / "shaders" / "plain.frag.spv";
|
||||
const std::string goodSpv = readFile(spv);
|
||||
|
||||
writeText(dirs.input / "shaders" / "plain.frag", "#version 450\nvoid main() { this is not glsl }\n");
|
||||
fs::last_write_time(dirs.input / "shaders" / "plain.frag", fs::last_write_time(spv) + std::chrono::hours{1});
|
||||
writeText(dirs.input / "added.txt", "added");
|
||||
|
||||
expectChef(dirs, "", false, "a shader that doesn't compile must fail the cook");
|
||||
check(readFile(spv) == goodSpv, "a failed compile must leave the previous SPIR-V untouched");
|
||||
check(fs::exists(dirs.output / "added.txt"), "one failing asset must not stop the others");
|
||||
check(!hasTempFiles(dirs.output), "a failed cook leaves no temporary files");
|
||||
|
||||
const auto manifest = readManifest(dirs);
|
||||
const auto* shader = findEntry(manifest, "shaders/plain.frag");
|
||||
check(shader && (*shader)["out"] == "shaders/plain.frag.spv", "the failed shader keeps its previous entry");
|
||||
check(findEntry(manifest, "added.txt") != nullptr, "the manifest is still written after a failure");
|
||||
|
||||
expectChef(dirs, "", false, "the broken shader must be retried, and fail again");
|
||||
writeShaders(dirs);
|
||||
expectChef(dirs, "", true, "fixing the shader must make the cook succeed");
|
||||
}
|
||||
|
||||
void testFallbacksAndStrictMode()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_fallback");
|
||||
writeText(dirs.input / "broken.png", "this is not a png");
|
||||
|
||||
expectChef(dirs, "", true, "a fallback is only a warning by default");
|
||||
check(fs::exists(dirs.output / "broken.png"), "the source is used when cooking fails");
|
||||
check(!fs::exists(dirs.output / "broken.png.dtex"), "no cooked file is written when cooking fails");
|
||||
const auto manifest = readManifest(dirs);
|
||||
const auto* entry = findEntry(manifest, "broken.png");
|
||||
check(entry && entry->contains("fallback"), "the manifest records the fallback");
|
||||
|
||||
expectChef(dirs, "--strict", false, "strict mode must fail on a remembered fallback");
|
||||
expectChef(dirs, "--validate --strict", false, "strict validation must report the fallback");
|
||||
|
||||
writeText(dirs.input / "chef.json", R"({"allowFallback": ["broken.png"]})");
|
||||
expectChef(dirs, "--strict", true, "strict mode must accept allowed fallbacks");
|
||||
expectChef(dirs, "--validate --strict", true, "strict validation must accept allowed fallbacks");
|
||||
check(!fs::exists(dirs.output / "chef.json"), "the cooker config isn't an asset");
|
||||
}
|
||||
|
||||
void testValidateFindsMissingOutputs()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_validate");
|
||||
writeShaders(dirs);
|
||||
writeText(dirs.input / "notes.txt", "hello");
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
fs::remove(dirs.output / "shaders" / "plain.frag.spv");
|
||||
expectChef(dirs, "--validate", false, "validation must report missing outputs");
|
||||
|
||||
expectChef(dirs, "", true, "recooking must succeed");
|
||||
writeText(dirs.input / "new.txt", "new");
|
||||
expectChef(dirs, "--validate", false, "validation must report sources that weren't cooked");
|
||||
}
|
||||
}
|
||||
|
||||
void RunCookerTests()
|
||||
{
|
||||
testCookRecordsEveryOutput();
|
||||
testUnchangedSourcesAreSkipped();
|
||||
testDependencyChangesRecook();
|
||||
testReplacedSourceIsCopiedAgain();
|
||||
testDeletedSourcesAreRemoved();
|
||||
testFailedCookKeepsLastGoodOutput();
|
||||
testFallbacksAndStrictMode();
|
||||
testValidateFindsMissingOutputs();
|
||||
}
|
||||
@@ -12,6 +12,7 @@ void RunGameplayTests();
|
||||
void RunEditorTests();
|
||||
void RunAudioTests();
|
||||
void RunJobsTests();
|
||||
void RunCookerTests();
|
||||
|
||||
namespace {
|
||||
struct Suite {
|
||||
@@ -30,6 +31,7 @@ namespace {
|
||||
{"editor", RunEditorTests},
|
||||
{"audio", RunAudioTests},
|
||||
{"jobs", RunJobsTests},
|
||||
{"cooker", RunCookerTests},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user