365 lines
16 KiB
C++
365 lines
16 KiB
C++
#include "LayerStackStore.h"
|
|
#include "RuntimeCoordinator.h"
|
|
#include "RuntimeEventDispatcher.h"
|
|
#include "RuntimeStateJson.h"
|
|
#include "RuntimeStore.h"
|
|
#include "ShaderPackageCatalog.h"
|
|
|
|
#include <chrono>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <map>
|
|
#include <string>
|
|
#include <variant>
|
|
#include <windows.h>
|
|
|
|
namespace
|
|
{
|
|
int gFailures = 0;
|
|
|
|
void Expect(bool condition, const char* message)
|
|
{
|
|
if (condition)
|
|
return;
|
|
|
|
std::cerr << "FAIL: " << message << "\n";
|
|
++gFailures;
|
|
}
|
|
|
|
std::filesystem::path MakeTestRoot()
|
|
{
|
|
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
std::filesystem::path root = std::filesystem::temp_directory_path() / ("video-shader-runtime-subsystem-tests-" + std::to_string(stamp));
|
|
std::filesystem::create_directories(root);
|
|
return root;
|
|
}
|
|
|
|
void WriteFile(const std::filesystem::path& path, const std::string& contents)
|
|
{
|
|
std::filesystem::create_directories(path.parent_path());
|
|
std::ofstream output(path, std::ios::binary);
|
|
output << contents;
|
|
}
|
|
|
|
void WriteShaderPackage(const std::filesystem::path& root, const std::string& directoryName, const std::string& manifest)
|
|
{
|
|
const std::filesystem::path packageRoot = root / directoryName;
|
|
WriteFile(packageRoot / "shader.json", manifest);
|
|
WriteFile(packageRoot / "shader.slang", "float4 shadeVideo(float2 uv) { return float4(uv, 0.0, 1.0); }\n");
|
|
}
|
|
|
|
std::filesystem::path GetCurrentDirectoryPath()
|
|
{
|
|
char buffer[MAX_PATH] = {};
|
|
GetCurrentDirectoryA(MAX_PATH, buffer);
|
|
return std::filesystem::path(buffer);
|
|
}
|
|
|
|
class ScopedCurrentDirectory
|
|
{
|
|
public:
|
|
explicit ScopedCurrentDirectory(const std::filesystem::path& path) :
|
|
mPrevious(GetCurrentDirectoryPath())
|
|
{
|
|
SetCurrentDirectoryA(path.string().c_str());
|
|
}
|
|
|
|
~ScopedCurrentDirectory()
|
|
{
|
|
SetCurrentDirectoryA(mPrevious.string().c_str());
|
|
}
|
|
|
|
private:
|
|
std::filesystem::path mPrevious;
|
|
};
|
|
|
|
ShaderPackageCatalog BuildCatalog(const std::filesystem::path& root)
|
|
{
|
|
ShaderPackageCatalog catalog;
|
|
std::string error;
|
|
Expect(catalog.Scan(root, 4, error), "shader package catalog scans test packages");
|
|
Expect(error.empty(), "catalog scan does not report an error");
|
|
return catalog;
|
|
}
|
|
|
|
void TestLayerDefaultsAndCrud()
|
|
{
|
|
const std::filesystem::path root = MakeTestRoot();
|
|
WriteShaderPackage(root, "alpha", R"({
|
|
"id": "alpha",
|
|
"name": "Alpha",
|
|
"parameters": [
|
|
{ "id": "gain", "label": "Gain", "type": "float", "default": 0.25, "min": 0, "max": 1 },
|
|
{ "id": "enabled", "label": "Enabled", "type": "bool", "default": true }
|
|
]
|
|
})");
|
|
WriteShaderPackage(root, "beta", R"({
|
|
"id": "beta",
|
|
"name": "Beta",
|
|
"parameters": [
|
|
{ "id": "mode", "label": "Mode", "type": "enum", "default": "soft", "options": [
|
|
{ "value": "soft", "label": "Soft" },
|
|
{ "value": "hard", "label": "Hard" }
|
|
] }
|
|
]
|
|
})");
|
|
|
|
ShaderPackageCatalog catalog = BuildCatalog(root);
|
|
LayerStackStore layers;
|
|
std::string error;
|
|
Expect(layers.CreateLayer(catalog, "alpha", error), "layer store creates a layer for a known shader");
|
|
Expect(layers.LayerCount() == 1, "created layer is stored");
|
|
Expect(!layers.CreateLayer(catalog, "missing", error), "layer store rejects unknown shaders");
|
|
|
|
LayerStackStore::StoredParameterSnapshot snapshot;
|
|
Expect(layers.TryGetParameterById(catalog, layers.Layers()[0].id, "gain", snapshot, error), "parameter lookup by id succeeds");
|
|
Expect(snapshot.currentValue.numberValues.size() == 1 && snapshot.currentValue.numberValues[0] == 0.25, "default float value is persisted");
|
|
|
|
ShaderParameterValue value;
|
|
value.numberValues = { 0.75 };
|
|
Expect(layers.SetParameterValue(layers.Layers()[0].id, "gain", value, error), "parameter value can be updated");
|
|
Expect(layers.TryGetParameterById(catalog, layers.Layers()[0].id, "gain", snapshot, error), "updated parameter can be read");
|
|
Expect(snapshot.currentValue.numberValues.size() == 1 && snapshot.currentValue.numberValues[0] == 0.75, "updated float value is retained");
|
|
|
|
Expect(layers.SetLayerShaderSelection(catalog, layers.Layers()[0].id, "beta", error), "layer shader selection can change");
|
|
Expect(layers.Layers()[0].shaderId == "beta", "new shader id is stored");
|
|
Expect(layers.TryGetParameterById(catalog, layers.Layers()[0].id, "mode", snapshot, error), "new shader defaults are applied");
|
|
Expect(snapshot.currentValue.enumValue == "soft", "enum default is applied after shader change");
|
|
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void TestMoveClassificationAndPresetLoad()
|
|
{
|
|
const std::filesystem::path root = MakeTestRoot();
|
|
WriteShaderPackage(root, "alpha", R"({
|
|
"id": "alpha",
|
|
"name": "Alpha",
|
|
"parameters": [{ "id": "gain", "label": "Gain", "type": "float", "default": 0.5, "min": 0, "max": 1 }]
|
|
})");
|
|
WriteShaderPackage(root, "beta", R"({
|
|
"id": "beta",
|
|
"name": "Beta",
|
|
"parameters": []
|
|
})");
|
|
|
|
ShaderPackageCatalog catalog = BuildCatalog(root);
|
|
LayerStackStore layers;
|
|
std::string error;
|
|
Expect(layers.CreateLayer(catalog, "alpha", error), "first test layer is created");
|
|
Expect(layers.CreateLayer(catalog, "beta", error), "second test layer is created");
|
|
const std::string firstLayerId = layers.Layers()[0].id;
|
|
const std::string secondLayerId = layers.Layers()[1].id;
|
|
|
|
bool shouldMove = true;
|
|
Expect(layers.ResolveLayerMove(firstLayerId, -1, shouldMove, error), "top layer move up is classified");
|
|
Expect(!shouldMove, "top layer move up is a no-op");
|
|
Expect(layers.ResolveLayerMove(firstLayerId, 1, shouldMove, error), "top layer move down is classified");
|
|
Expect(shouldMove, "top layer move down should move");
|
|
Expect(layers.MoveLayer(firstLayerId, 1, error), "top layer moves down");
|
|
Expect(layers.Layers()[0].id == secondLayerId && layers.Layers()[1].id == firstLayerId, "layer order changed after move");
|
|
|
|
JsonValue preset = layers.BuildStackPresetValue(catalog, "Look One");
|
|
LayerStackStore loaded;
|
|
Expect(loaded.LoadStackPresetValue(catalog, preset, error), "stack preset value loads into a fresh layer store");
|
|
Expect(loaded.LayerCount() == 2, "loaded preset preserves layer count");
|
|
Expect(loaded.Layers()[0].shaderId == "beta" && loaded.Layers()[1].shaderId == "alpha", "loaded preset preserves shader order");
|
|
Expect(loaded.Layers()[0].id != secondLayerId, "loaded preset generates fresh layer ids");
|
|
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void TestRuntimeStateJsonReadModelSerialization()
|
|
{
|
|
ShaderPackage package;
|
|
package.id = "alpha";
|
|
package.displayName = "Alpha";
|
|
package.feedback.enabled = true;
|
|
package.feedback.writePassId = "main";
|
|
package.temporal.enabled = true;
|
|
package.temporal.historySource = TemporalHistorySource::Source;
|
|
package.temporal.requestedHistoryLength = 3;
|
|
package.temporal.effectiveHistoryLength = 3;
|
|
|
|
ShaderParameterDefinition gain;
|
|
gain.id = "gain";
|
|
gain.label = "Gain";
|
|
gain.type = ShaderParameterType::Float;
|
|
gain.defaultNumbers = { 0.5 };
|
|
gain.minNumbers = { 0.0 };
|
|
gain.maxNumbers = { 1.0 };
|
|
package.parameters.push_back(gain);
|
|
|
|
LayerStackStore::LayerPersistentState layer;
|
|
layer.id = "layer-1";
|
|
layer.shaderId = "alpha";
|
|
ShaderParameterValue gainValue;
|
|
gainValue.numberValues = { 0.8 };
|
|
layer.parameterValues["gain"] = gainValue;
|
|
|
|
JsonValue layersJson = RuntimeStateJson::SerializeLayerStack({ layer }, { { "alpha", package } });
|
|
Expect(layersJson.isArray() && layersJson.asArray().size() == 1, "runtime state layer serialization emits one layer");
|
|
|
|
const JsonValue& layerJson = layersJson.asArray()[0];
|
|
Expect(layerJson.find("shaderName") && layerJson.find("shaderName")->asString() == "Alpha", "serialized layer includes shader display name");
|
|
Expect(layerJson.find("temporal") && layerJson.find("temporal")->isObject(), "serialized layer includes temporal metadata");
|
|
Expect(layerJson.find("feedback") && layerJson.find("feedback")->isObject(), "serialized layer includes feedback metadata");
|
|
|
|
const JsonValue* parameters = layerJson.find("parameters");
|
|
Expect(parameters && parameters->isArray() && parameters->asArray().size() == 1, "serialized layer includes parameter metadata");
|
|
const JsonValue* value = parameters->asArray()[0].find("value");
|
|
Expect(value && value->asNumber() == 0.8, "serialized parameter includes current value");
|
|
}
|
|
|
|
void TestRuntimeCoordinatorPersistenceEvents()
|
|
{
|
|
const std::filesystem::path root = MakeTestRoot();
|
|
WriteFile(root / "CMakeLists.txt", "cmake_minimum_required(VERSION 3.24)\n");
|
|
std::filesystem::create_directories(root / "apps" / "LoopThroughWithOpenGLCompositing");
|
|
std::filesystem::create_directories(root / "runtime" / "templates");
|
|
WriteShaderPackage(root / "shaders", "alpha", R"({
|
|
"id": "alpha",
|
|
"name": "Alpha",
|
|
"parameters": [{ "id": "gain", "label": "Gain", "type": "float", "default": 0.5, "min": 0, "max": 1 }]
|
|
})");
|
|
WriteShaderPackage(root / "shaders", "beta", R"({
|
|
"id": "beta",
|
|
"name": "Beta",
|
|
"parameters": [{ "id": "amount", "label": "Amount", "type": "float", "default": 0.25, "min": 0, "max": 1 }]
|
|
})");
|
|
|
|
{
|
|
ScopedCurrentDirectory scopedDirectory(root);
|
|
RuntimeStore store;
|
|
std::string error;
|
|
Expect(store.InitializeStore(error), "runtime store initializes in isolated fixture");
|
|
Expect(error.empty(), "runtime store initialization has no error");
|
|
|
|
RuntimeEventDispatcher dispatcher(64);
|
|
std::vector<RuntimeEvent> seenEvents;
|
|
dispatcher.SubscribeAll([&seenEvents](const RuntimeEvent& event) {
|
|
seenEvents.push_back(event);
|
|
});
|
|
|
|
RuntimeCoordinator coordinator(store, dispatcher);
|
|
auto dispatchAndClear = [&]() {
|
|
dispatcher.DispatchPending();
|
|
const std::vector<RuntimeEvent> events = seenEvents;
|
|
seenEvents.clear();
|
|
return events;
|
|
};
|
|
auto countEvents = [](const std::vector<RuntimeEvent>& events, RuntimeEventType type) {
|
|
return static_cast<std::size_t>(std::count_if(events.begin(), events.end(),
|
|
[type](const RuntimeEvent& event) { return event.type == type; }));
|
|
};
|
|
auto persistenceReason = [](const std::vector<RuntimeEvent>& events) {
|
|
for (const RuntimeEvent& event : events)
|
|
{
|
|
if (event.type != RuntimeEventType::RuntimePersistenceRequested)
|
|
continue;
|
|
const auto* payload = std::get_if<RuntimePersistenceRequestedEvent>(&event.payload);
|
|
return payload ? payload->request.reason : std::string();
|
|
}
|
|
return std::string();
|
|
};
|
|
auto expectAcceptedPersistence = [&](const RuntimeCoordinatorResult& result, const std::string& reason, const char* message) {
|
|
const std::vector<RuntimeEvent> events = dispatchAndClear();
|
|
Expect(result.accepted, message);
|
|
Expect(result.persistenceRequested, "accepted persistent mutation marks coordinator result");
|
|
Expect(countEvents(events, RuntimeEventType::RuntimeMutationAccepted) == 1, "persistent mutation publishes accepted fact");
|
|
Expect(countEvents(events, RuntimeEventType::RuntimePersistenceRequested) == 1, "persistent mutation publishes persistence request");
|
|
Expect(persistenceReason(events) == reason, "persistence request preserves coordinator action reason");
|
|
};
|
|
|
|
std::vector<RuntimeStore::LayerPersistentState> layers = store.CopyLayerStates();
|
|
Expect(layers.size() == 1, "isolated fixture starts with a default layer");
|
|
const std::string alphaLayerId = layers.empty() ? std::string() : layers[0].id;
|
|
|
|
expectAcceptedPersistence(coordinator.UpdateLayerParameter(alphaLayerId, "gain", JsonValue(0.75)), "UpdateLayerParameter",
|
|
"parameter changes are accepted");
|
|
|
|
RuntimeCoordinatorResult resetResult = coordinator.ResetLayerParameters(alphaLayerId);
|
|
std::vector<RuntimeEvent> resetEvents = dispatchAndClear();
|
|
Expect(resetResult.accepted, "parameter reset is accepted");
|
|
Expect(resetResult.transientOscInvalidation == RuntimeCoordinatorTransientOscInvalidation::Layer,
|
|
"parameter reset requests layer-scoped transient OSC invalidation");
|
|
Expect(resetResult.transientOscLayerKey == alphaLayerId, "parameter reset invalidates the target layer overlays");
|
|
Expect(countEvents(resetEvents, RuntimeEventType::RuntimeMutationAccepted) == 1, "parameter reset publishes accepted fact");
|
|
|
|
expectAcceptedPersistence(coordinator.AddLayer("beta"), "AddLayer", "stack edits are accepted");
|
|
layers = store.CopyLayerStates();
|
|
Expect(layers.size() == 2, "stack edit creates a second layer");
|
|
const std::string betaLayerId = layers.size() > 1 ? layers[1].id : std::string();
|
|
expectAcceptedPersistence(coordinator.MoveLayer(betaLayerId, -1), "MoveLayer", "layer order edits are accepted");
|
|
|
|
expectAcceptedPersistence(coordinator.SaveStackPreset("Look One"), "SaveStackPreset", "preset save is accepted");
|
|
expectAcceptedPersistence(coordinator.LoadStackPreset("Look One"), "LoadStackPreset", "preset load is accepted");
|
|
|
|
RuntimeCoordinatorResult rejected = coordinator.UpdateLayerParameter(alphaLayerId, "missing", JsonValue(0.5));
|
|
std::vector<RuntimeEvent> rejectedEvents = dispatchAndClear();
|
|
Expect(!rejected.accepted, "invalid parameter mutation is rejected");
|
|
Expect(!rejected.persistenceRequested, "rejected mutation does not mark persistence");
|
|
Expect(countEvents(rejectedEvents, RuntimeEventType::RuntimeMutationRejected) == 1, "rejected mutation publishes rejection fact");
|
|
Expect(countEvents(rejectedEvents, RuntimeEventType::RuntimePersistenceRequested) == 0, "rejected mutation publishes no persistence request");
|
|
|
|
OscOverlayEvent overlay;
|
|
overlay.routeKey = "alpha\ngain";
|
|
overlay.layerKey = "alpha";
|
|
overlay.parameterKey = "gain";
|
|
Expect(dispatcher.PublishPayload(overlay, "RuntimeLiveState"), "OSC overlay event publishes");
|
|
std::vector<RuntimeEvent> overlayEvents = dispatchAndClear();
|
|
Expect(countEvents(overlayEvents, RuntimeEventType::OscOverlayApplied) == 1, "transient OSC overlay is observable");
|
|
Expect(countEvents(overlayEvents, RuntimeEventType::RuntimePersistenceRequested) == 0, "transient OSC overlay does not request persistence");
|
|
|
|
RuntimeCoordinatorResult oscCommitResult = coordinator.CommitOscParameterByControlKey("alpha", "gain", JsonValue(0.2));
|
|
std::vector<RuntimeEvent> oscCommitEvents = dispatchAndClear();
|
|
Expect(oscCommitResult.accepted, "accepted OSC commit updates committed session state");
|
|
Expect(!oscCommitResult.persistenceRequested, "settled OSC commit does not request persistence by default");
|
|
Expect(countEvents(oscCommitEvents, RuntimeEventType::RuntimeMutationAccepted) == 1, "settled OSC commit publishes accepted fact");
|
|
Expect(countEvents(oscCommitEvents, RuntimeEventType::RuntimeStateChanged) == 1, "settled OSC commit publishes state change");
|
|
Expect(countEvents(oscCommitEvents, RuntimeEventType::RuntimePersistenceRequested) == 0, "settled OSC commit publishes no persistence request");
|
|
RuntimeStore::StoredParameterSnapshot oscCommitSnapshot;
|
|
Expect(store.TryGetStoredParameterByControlKey("alpha", "gain", oscCommitSnapshot, error), "settled OSC commit can be read back");
|
|
Expect(!oscCommitSnapshot.currentValue.numberValues.empty() &&
|
|
oscCommitSnapshot.currentValue.numberValues[0] == 0.2,
|
|
"settled OSC commit updates the committed session value");
|
|
|
|
CommittedLiveStateReadModel committedLiveState = store.BuildCommittedLiveStateReadModel();
|
|
Expect(!committedLiveState.layers.empty(), "committed live read model exposes current session layers");
|
|
const auto committedLayerIt = std::find_if(committedLiveState.layers.begin(), committedLiveState.layers.end(),
|
|
[&oscCommitSnapshot](const RuntimeStore::LayerPersistentState& layer) { return layer.id == oscCommitSnapshot.layerId; });
|
|
Expect(committedLayerIt != committedLiveState.layers.end(), "committed live read model preserves layer identity");
|
|
if (committedLayerIt != committedLiveState.layers.end())
|
|
{
|
|
const auto committedValueIt = committedLayerIt->parameterValues.find("gain");
|
|
Expect(committedValueIt != committedLayerIt->parameterValues.end() &&
|
|
!committedValueIt->second.numberValues.empty() &&
|
|
committedValueIt->second.numberValues[0] == 0.2,
|
|
"committed live read model includes session-only OSC commit value");
|
|
}
|
|
Expect(committedLiveState.packagesById.find("alpha") != committedLiveState.packagesById.end(),
|
|
"committed live read model carries package definitions for snapshot publication");
|
|
}
|
|
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
}
|
|
|
|
int main()
|
|
{
|
|
TestLayerDefaultsAndCrud();
|
|
TestMoveClassificationAndPresetLoad();
|
|
TestRuntimeStateJsonReadModelSerialization();
|
|
TestRuntimeCoordinatorPersistenceEvents();
|
|
|
|
if (gFailures != 0)
|
|
{
|
|
std::cerr << gFailures << " RuntimeSubsystem test failure(s).\n";
|
|
return 1;
|
|
}
|
|
|
|
std::cout << "RuntimeSubsystem tests passed.\n";
|
|
return 0;
|
|
}
|