121 lines
5.1 KiB
C++
121 lines
5.1 KiB
C++
|
|
// 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;
|
||
|
|
}
|