#include #include #include #include #include #include #include #include #include #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(header), sizeof(header)); for (int i = 0; i < 4; ++i) { const std::uint8_t bgra[4] = {0, 0, 255, 255}; file.write(reinterpret_cast(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 outputs = { dirs.output / "shaders" / "plain.frag.spv", dirs.output / "models" / "tri.obj.dmodel", dirs.output / "red.tga.dtex", dirs.output / "red.tga", }; std::vector 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(); }