test(shader): variants through the Shader API and through Build()
ShaderCompile registers one source twice and checks that the variant gets its own output name, takes the other #ifdef branch and receives the value of a NAME=VALUE define. Staleness is pinned per output: while only the plain output exists the variant must still be due, or Build() would never compile a variant added to a built project. Malformed defines and a variant with a path separator must be refused by name. ShaderVariantBuild drives the same through Build(), which is where the entries compile in parallel into one directory: a variant added to an already built configuration is compiled into its own file, and two entries with one output name fail the build without touching the existing output. Its fixture tests the macro with `#if`, which pins that a bare define is 1 and not empty. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
ddaab5a32a
commit
51d87a990f
6 changed files with 229 additions and 1 deletions
6
tests/ShaderVariantBuild/fixture/main.cpp
Normal file
6
tests/ShaderVariantBuild/fixture/main.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
int main() {
|
||||
return 0;
|
||||
}
|
||||
12
tests/ShaderVariantBuild/fixture/tint.frag
Normal file
12
tests/ShaderVariantBuild/fixture/tint.frag
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#version 450
|
||||
|
||||
// Built twice by the ShaderVariantBuild test: as is, and with TINT_RED defined.
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
#if TINT_RED
|
||||
outColor = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
#else
|
||||
outColor = vec4(0.0, 1.0, 0.0, 1.0);
|
||||
#endif
|
||||
}
|
||||
121
tests/ShaderVariantBuild/main.cpp
Normal file
121
tests/ShaderVariantBuild/main.cpp
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
import std;
|
||||
import Crafter.Build;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Crafter;
|
||||
|
||||
// One shader source can be registered more than once, each entry with its own
|
||||
// defines and a variant that names its output ("tint.spv", "tint.red.spv").
|
||||
// Build() compiles the entries in parallel into one directory, which is where
|
||||
// the feature can go wrong in ways the Shader API alone does not show:
|
||||
//
|
||||
// - both entries must land in the bin dir, with different contents;
|
||||
// - an entry added later must still be compiled when the other one's output
|
||||
// is already there and up to date;
|
||||
// - two entries that would write the same file (defines set, variant
|
||||
// forgotten) used to race silently: the file held whichever compile
|
||||
// finished last, the build reported success, and Check() called both up
|
||||
// to date from then on. That has to be a build error naming the cause.
|
||||
//
|
||||
// The fixture's `#if TINT_RED` also pins that a bare define is defined to 1,
|
||||
// like the compiler's -D: an empty definition would not compile.
|
||||
namespace {
|
||||
std::int32_t Failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
|
||||
// Shader outputs are compared by mtime against the source, and the test
|
||||
// adds an entry between passes, so work on a fresh copy outside the repo.
|
||||
fs::path StageFixture() {
|
||||
fs::path source = fs::current_path() / "tests" / "ShaderVariantBuild" / "fixture";
|
||||
fs::path staged = fs::temp_directory_path() / "crafter-build-shader-variant-build";
|
||||
fs::remove_all(staged);
|
||||
fs::copy(source, staged, fs::copy_options::recursive);
|
||||
return staged;
|
||||
}
|
||||
|
||||
Configuration MakeApp(const fs::path& staged) {
|
||||
Configuration app;
|
||||
app.path = staged;
|
||||
app.name = "tinted";
|
||||
app.outputName = "tinted";
|
||||
app.target = HostTarget();
|
||||
app.type = ConfigurationType::Executable;
|
||||
std::array<fs::path, 0> ifaces = {};
|
||||
std::array<fs::path, 1> impls = { "main" };
|
||||
app.GetInterfacesAndImplementations(ifaces, impls);
|
||||
return app;
|
||||
}
|
||||
|
||||
Shader& AddTint(Configuration& app, const fs::path& staged) {
|
||||
return app.shaders.emplace_back(staged / "tint.frag", std::string("main"), ShaderType::Fragment);
|
||||
}
|
||||
|
||||
BuildResult BuildOnce(Configuration& app) {
|
||||
// A fresh depResults per pass: the map memoizes a Configuration's
|
||||
// build for the duration of one pass.
|
||||
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
|
||||
std::mutex depMutex;
|
||||
return Build(app, depResults, depMutex);
|
||||
}
|
||||
|
||||
std::string ReadAll(const fs::path& file) {
|
||||
std::ifstream stream(file, std::ios::binary);
|
||||
return std::string(std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path staged = StageFixture();
|
||||
|
||||
// Pass 1: the plain entry alone.
|
||||
Configuration app = MakeApp(staged);
|
||||
AddTint(app, staged);
|
||||
BuildResult first = BuildOnce(app);
|
||||
Check(first.result.empty(), std::format("the plain entry builds: {}", first.result));
|
||||
const fs::path plainSpv = app.BinDir() / "tint.spv";
|
||||
const fs::path redSpv = app.BinDir() / "tint.red.spv";
|
||||
Check(fs::exists(plainSpv), "tint.spv is in the bin dir");
|
||||
Check(!fs::exists(redSpv), "no variant output before the variant is registered");
|
||||
|
||||
// Pass 2: the variant is added while tint.spv is already up to date. It
|
||||
// has its own staleness check and must be compiled, into its own file.
|
||||
Shader& red = AddTint(app, staged);
|
||||
red.defines = {"TINT_RED"};
|
||||
red.variant = "red";
|
||||
BuildResult second = BuildOnce(app);
|
||||
Check(second.result.empty(), std::format("plain + variant builds: {}", second.result));
|
||||
Check(fs::exists(redSpv), "tint.red.spv is in the bin dir");
|
||||
const std::string plainBytes = ReadAll(plainSpv);
|
||||
const std::string redBytes = ReadAll(redSpv);
|
||||
Check(!plainBytes.empty() && !redBytes.empty(), "both outputs have content");
|
||||
Check(plainBytes != redBytes, "the variant took the other #if branch");
|
||||
|
||||
// Pass 3: the same define without a variant. Both entries would write
|
||||
// tint.spv, so the build has to stop and say so, and must leave the file
|
||||
// from pass 2 alone.
|
||||
Configuration clash = MakeApp(staged);
|
||||
AddTint(clash, staged);
|
||||
Shader& unnamed = AddTint(clash, staged);
|
||||
unnamed.defines = {"TINT_RED"};
|
||||
BuildResult third = BuildOnce(clash);
|
||||
Check(!third.result.empty(), "two entries with one output name fail the build");
|
||||
Check(third.result.contains("tint.spv") && third.result.contains("variant"), std::format("the error names the file and the fix: {}", third.result));
|
||||
Check(ReadAll(plainSpv) == plainBytes, "the existing output is not overwritten by the failed build");
|
||||
|
||||
std::error_code ec;
|
||||
fs::remove_all(staged, ec);
|
||||
|
||||
if (Failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", Failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue