#include "RenderStateComposer.h" #include "RuntimeLiveState.h" #include #include #include namespace { int gFailures = 0; void Expect(bool condition, const char* message) { if (condition) return; std::cerr << "FAIL: " << message << "\n"; ++gFailures; } ShaderParameterDefinition FloatDefinition(const std::string& id, const std::string& label) { ShaderParameterDefinition definition; definition.id = id; definition.label = label; definition.type = ShaderParameterType::Float; definition.defaultNumbers = { 0.0 }; definition.minNumbers = { 0.0 }; definition.maxNumbers = { 1.0 }; return definition; } ShaderParameterDefinition Vec2Definition(const std::string& id, const std::string& label) { ShaderParameterDefinition definition; definition.id = id; definition.label = label; definition.type = ShaderParameterType::Vec2; definition.defaultNumbers = { 0.0, 0.0 }; definition.minNumbers = { 0.0, 0.0 }; definition.maxNumbers = { 1.0, 1.0 }; return definition; } ShaderParameterDefinition TriggerDefinition(const std::string& id, const std::string& label) { ShaderParameterDefinition definition; definition.id = id; definition.label = label; definition.type = ShaderParameterType::Trigger; return definition; } JsonValue NumberArray(std::initializer_list numbers) { JsonValue value = JsonValue::MakeArray(); for (double number : numbers) value.pushBack(JsonValue(number)); return value; } RuntimeRenderState MakeLayerState() { RuntimeRenderState state; state.layerId = "layer-one"; state.shaderId = "test-shader"; state.shaderName = "Test Shader"; state.parameterDefinitions.push_back(FloatDefinition("amount", "Amount")); ShaderParameterValue amount; amount.numberValues = { 0.25 }; state.parameterValues["amount"] = amount; return state; } RuntimeRenderState MakeLayerStateWithDefinitions(const std::vector& definitions) { RuntimeRenderState state; state.layerId = "layer-one"; state.shaderId = "test-shader"; state.shaderName = "Test Shader"; state.parameterDefinitions = definitions; return state; } void TestRuntimeLiveStateAppliesLatestOscOverlay() { RuntimeLiveState liveState; RuntimeLiveOscUpdate first; first.routeKey = "layer-one\namount"; first.layerKey = "layer-one"; first.parameterKey = "amount"; first.targetValue = JsonValue(0.5); RuntimeLiveOscUpdate second = first; second.targetValue = JsonValue(0.75); liveState.ApplyOscUpdates({ first, second }); Expect(liveState.OverlayCount() == 1, "live state keeps one overlay per route"); std::vector states = { MakeLayerState() }; RuntimeLiveStateApplyOptions options; options.allowCommit = false; options.smoothing = 0.0; liveState.ApplyToLayerStates(states, options, nullptr); const auto valueIt = states[0].parameterValues.find("amount"); Expect(valueIt != states[0].parameterValues.end(), "overlay writes the target parameter"); Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.75) < 0.0001, "overlay applies the latest target value"); } void TestRuntimeLiveStateIgnoresStaleCommitCompletions() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(0.9); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerState() }; std::vector commitRequests; RuntimeLiveStateApplyOptions options; options.allowCommit = true; options.smoothing = 0.0; options.commitDelay = std::chrono::milliseconds(0); options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); liveState.ApplyToLayerStates(states, options, &commitRequests); Expect(commitRequests.size() == 1, "initial commit request is queued"); liveState.ApplyOscCommitCompletions({ { "other-route", commitRequests[0].generation } }); Expect(liveState.OverlayCount() == 1, "completion for another route does not remove overlay"); liveState.ApplyOscCommitCompletions({ { commitRequests[0].routeKey, commitRequests[0].generation + 1 } }); Expect(liveState.OverlayCount() == 1, "completion for another generation does not remove overlay"); RuntimeLiveOscUpdate newerUpdate = update; newerUpdate.targetValue = JsonValue(0.2); liveState.ApplyOscUpdates({ newerUpdate }); liveState.ApplyOscCommitCompletions({ { commitRequests[0].routeKey, commitRequests[0].generation } }); Expect(liveState.OverlayCount() == 1, "stale completion for previous generation is ignored after newer update"); } void TestRuntimeLiveStateQueuesAndCompletesCommit() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(0.9); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerState() }; std::vector commitRequests; RuntimeLiveStateApplyOptions options; options.allowCommit = true; options.smoothing = 0.0; options.commitDelay = std::chrono::milliseconds(0); options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); liveState.ApplyToLayerStates(states, options, &commitRequests); Expect(commitRequests.size() == 1, "live state queues a commit request once the overlay can settle"); Expect(commitRequests[0].routeKey == "layer-one\namount", "commit request preserves route"); Expect(commitRequests[0].generation == 1, "commit request carries overlay generation"); liveState.ApplyOscCommitCompletions({ { commitRequests[0].routeKey, commitRequests[0].generation } }); Expect(liveState.OverlayCount() == 0, "matching commit completion removes settled overlay"); } void TestRuntimeLiveStateQueuesOneCommitPerGeneration() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(0.8); liveState.ApplyOscUpdates({ update }); RuntimeLiveStateApplyOptions options; options.allowCommit = true; options.smoothing = 0.0; options.commitDelay = std::chrono::milliseconds(0); options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); std::vector states = { MakeLayerState() }; std::vector commitRequests; liveState.ApplyToLayerStates(states, options, &commitRequests); Expect(commitRequests.size() == 1, "first apply queues one commit for generation"); Expect(commitRequests[0].generation == 1, "first commit uses generation one"); commitRequests.clear(); options.now += std::chrono::milliseconds(1); liveState.ApplyToLayerStates(states, options, &commitRequests); Expect(commitRequests.empty(), "second apply does not duplicate commit for same generation"); RuntimeLiveOscUpdate newerUpdate = update; newerUpdate.targetValue = JsonValue(0.4); liveState.ApplyOscUpdates({ newerUpdate }); commitRequests.clear(); options.now += std::chrono::milliseconds(1); liveState.ApplyToLayerStates(states, options, &commitRequests); Expect(commitRequests.size() == 1, "newer update allows a new commit request"); Expect(commitRequests[0].generation == 2, "new commit uses newer generation"); } void TestRuntimeLiveStateSmoothingZeroAppliesTargetImmediately() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(1.0); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerState() }; RuntimeLiveStateApplyOptions options; options.smoothing = 0.0; liveState.ApplyToLayerStates(states, options, nullptr); const auto valueIt = states[0].parameterValues.find("amount"); Expect(valueIt != states[0].parameterValues.end(), "smoothing zero writes amount"); Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 1.0) < 0.0001, "smoothing zero applies target immediately"); } void TestRuntimeLiveStateSmoothingOneConvergesImmediately() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(1.0); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerState() }; RuntimeLiveStateApplyOptions options; options.smoothing = 1.0; options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(16); liveState.ApplyToLayerStates(states, options, nullptr); const auto valueIt = states[0].parameterValues.find("amount"); Expect(valueIt != states[0].parameterValues.end(), "smoothing one writes amount"); Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 1.0) < 0.0001, "smoothing one converges immediately"); } void TestRuntimeLiveStateSmoothingPartiallyConverges() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(1.0); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerState() }; ShaderParameterValue amount; amount.numberValues = { 0.0 }; states[0].parameterValues["amount"] = amount; RuntimeLiveStateApplyOptions options; options.smoothing = 0.5; options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(16); liveState.ApplyToLayerStates(states, options, nullptr); const auto valueIt = states[0].parameterValues.find("amount"); Expect(valueIt != states[0].parameterValues.end(), "partial smoothing writes amount"); Expect(!valueIt->second.numberValues.empty() && valueIt->second.numberValues[0] > 0.0 && valueIt->second.numberValues[0] < 1.0, "partial smoothing advances toward target without snapping"); } void TestRuntimeLiveStateSmoothingVectorSizeMismatchUsesTargetShape() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\noffset"; update.layerKey = "layer-one"; update.parameterKey = "offset"; update.targetValue = NumberArray({ 0.25, 0.75 }); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerStateWithDefinitions({ Vec2Definition("offset", "Offset") }) }; ShaderParameterValue malformedOffset; malformedOffset.numberValues = { 0.1 }; states[0].parameterValues["offset"] = malformedOffset; RuntimeLiveStateApplyOptions options; options.smoothing = 0.5; options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(16); liveState.ApplyToLayerStates(states, options, nullptr); const auto valueIt = states[0].parameterValues.find("offset"); Expect(valueIt != states[0].parameterValues.end(), "vector mismatch writes offset"); Expect(valueIt->second.numberValues.size() == 2, "vector mismatch restores target vector size"); Expect(valueIt->second.numberValues.size() == 2 && std::fabs(valueIt->second.numberValues[0] - 0.25) < 0.0001 && std::fabs(valueIt->second.numberValues[1] - 0.75) < 0.0001, "vector mismatch snaps to validated target shape"); } void TestRuntimeLiveStateTriggerOverlayIncrementsAndClears() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\npulse"; update.layerKey = "layer-one"; update.parameterKey = "pulse"; update.targetValue = JsonValue(true); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerStateWithDefinitions({ TriggerDefinition("pulse", "Pulse") }) }; states[0].timeSeconds = 42.0; ShaderParameterValue pulse; pulse.numberValues = { 2.0, 10.0 }; states[0].parameterValues["pulse"] = pulse; std::vector commitRequests; RuntimeLiveStateApplyOptions options; options.allowCommit = true; options.smoothing = 0.0; options.commitDelay = std::chrono::milliseconds(0); liveState.ApplyToLayerStates(states, options, &commitRequests); const auto valueIt = states[0].parameterValues.find("pulse"); Expect(valueIt != states[0].parameterValues.end(), "trigger overlay writes pulse"); Expect(valueIt->second.numberValues.size() == 2 && std::fabs(valueIt->second.numberValues[0] - 3.0) < 0.0001 && std::fabs(valueIt->second.numberValues[1] - 42.0) < 0.0001, "trigger overlay increments count and stamps layer time"); Expect(commitRequests.empty(), "trigger overlay does not queue commit"); Expect(liveState.OverlayCount() == 0, "trigger overlay clears after apply"); } void TestRuntimeLiveStateClearsOverlaysForLayerKey() { RuntimeLiveState liveState; RuntimeLiveOscUpdate first; first.routeKey = "layer-one\namount"; first.layerKey = "layer-one"; first.parameterKey = "amount"; first.targetValue = JsonValue(0.5); RuntimeLiveOscUpdate second = first; second.routeKey = "layer-two\namount"; second.layerKey = "layer-two"; second.targetValue = JsonValue(0.75); liveState.ApplyOscUpdates({ first, second }); liveState.ClearForLayerKey("layer-one"); Expect(liveState.OverlayCount() == 1, "layer-scoped invalidation only removes matching overlays"); std::vector states = { MakeLayerState() }; states[0].layerId = "layer-two"; RuntimeLiveStateApplyOptions options; options.smoothing = 0.0; liveState.ApplyToLayerStates(states, options, nullptr); const auto valueIt = states[0].parameterValues.find("amount"); Expect(valueIt != states[0].parameterValues.end() && !valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.75) < 0.0001, "unmatched layer invalidation preserves unrelated overlay"); } void TestRuntimeLiveStatePrunesRemovedLayerOverlay() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "removed-layer\namount"; update.layerKey = "removed-layer"; update.parameterKey = "amount"; update.targetValue = JsonValue(0.5); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerState() }; liveState.PruneIncompatibleOverlays(states); Expect(liveState.OverlayCount() == 0, "invalidation policy removes overlays for missing layers"); } void TestRuntimeLiveStatePrunesIncompatibleParameterOverlay() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(0.5); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerStateWithDefinitions({ FloatDefinition("other", "Other") }) }; liveState.PruneIncompatibleOverlays(states); Expect(liveState.OverlayCount() == 0, "invalidation policy removes overlays for missing parameters"); } void TestRuntimeLiveStatePreservesCompatibleOverlayAfterShaderReload() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "Amount"; update.targetValue = JsonValue(0.5); liveState.ApplyOscUpdates({ update }); std::vector states = { MakeLayerStateWithDefinitions({ FloatDefinition("amount-renamed", "Amount") }) }; liveState.PruneIncompatibleOverlays(states); Expect(liveState.OverlayCount() == 1, "invalidation policy preserves overlays that still map by control key"); RuntimeLiveStateApplyOptions options; options.smoothing = 0.0; liveState.ApplyToLayerStates(states, options, nullptr); const auto valueIt = states[0].parameterValues.find("amount-renamed"); Expect(valueIt != states[0].parameterValues.end() && !valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.5) < 0.0001, "compatible overlay applies to the reloaded parameter id"); } void TestRenderStateComposerBuildsFrameState() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "Test Shader"; update.parameterKey = "Amount"; update.targetValue = JsonValue(0.6); liveState.ApplyOscUpdates({ update }); LayeredRenderStateInput input; std::vector baseLayerStates = { MakeLayerState() }; input.committedLiveLayerStates = &baseLayerStates; input.transientAutomationOverlay = &liveState; input.allowTransientAutomationCommits = false; input.transientAutomationSmoothing = 0.0; RenderStateComposer composer; RenderStateCompositionResult result = composer.BuildFrameState(input); Expect(result.hasLayerStates, "composer reports that it composed base layer states"); Expect(result.layerStates.size() == 1, "composer returns composed layer state"); const auto valueIt = result.layerStates[0].parameterValues.find("amount"); Expect(valueIt != result.layerStates[0].parameterValues.end(), "composer applies live overlay through live state"); Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.6) < 0.0001, "composer uses OSC key matching against shader names and labels"); const auto baseValueIt = baseLayerStates[0].parameterValues.find("amount"); Expect(baseValueIt != baseLayerStates[0].parameterValues.end() && !baseValueIt->second.numberValues.empty() && std::fabs(baseValueIt->second.numberValues[0] - 0.25) < 0.0001, "composer leaves base layer states unchanged"); } void TestRenderStateComposerUsesCommittedLayerOverBaseLayer() { std::vector basePersistedLayerStates = { MakeLayerState() }; std::vector committedLiveLayerStates = { MakeLayerState() }; committedLiveLayerStates[0].parameterValues["amount"].numberValues = { 0.4 }; LayeredRenderStateInput input; input.basePersistedLayerStates = &basePersistedLayerStates; input.committedLiveLayerStates = &committedLiveLayerStates; RenderStateComposer composer; RenderStateCompositionResult result = composer.BuildFrameState(input); const auto valueIt = result.layerStates[0].parameterValues.find("amount"); Expect(valueIt != result.layerStates[0].parameterValues.end() && !valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.4) < 0.0001, "committed live layer overrides base persisted layer"); const auto baseValueIt = basePersistedLayerStates[0].parameterValues.find("amount"); Expect(baseValueIt != basePersistedLayerStates[0].parameterValues.end() && !baseValueIt->second.numberValues.empty() && std::fabs(baseValueIt->second.numberValues[0] - 0.25) < 0.0001, "committed override leaves base persisted layer unchanged"); } void TestRenderStateComposerUsesBaseLayerWhenCommittedLayerMissing() { std::vector basePersistedLayerStates = { MakeLayerState() }; LayeredRenderStateInput input; input.basePersistedLayerStates = &basePersistedLayerStates; RenderStateComposer composer; RenderStateCompositionResult result = composer.BuildFrameState(input); Expect(result.hasLayerStates, "composer can use base persisted layer states without committed layer states"); const auto valueIt = result.layerStates[0].parameterValues.find("amount"); Expect(valueIt != result.layerStates[0].parameterValues.end() && !valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.25) < 0.0001, "base persisted value is used when no committed live value exists"); } void TestRenderStateComposerQueuesCommitRequestsWhenEnabled() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(0.8); liveState.ApplyOscUpdates({ update }); std::vector baseLayerStates = { MakeLayerState() }; LayeredRenderStateInput input; input.committedLiveLayerStates = &baseLayerStates; input.transientAutomationOverlay = &liveState; input.allowTransientAutomationCommits = true; input.collectTransientAutomationCommitRequests = true; input.transientAutomationSmoothing = 0.0; input.transientAutomationCommitDelay = std::chrono::milliseconds(0); input.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); RenderStateComposer composer; RenderStateCompositionResult result = composer.BuildFrameState(input); Expect(result.commitRequests.size() == 1, "composer returns live commit requests when collection is enabled"); Expect(result.commitRequests[0].routeKey == "layer-one\namount", "composer commit request preserves route"); Expect(result.commitRequests[0].generation == 1, "composer commit request preserves generation"); } void TestRenderStateComposerSuppressesCommitCollection() { RuntimeLiveState liveState; RuntimeLiveOscUpdate update; update.routeKey = "layer-one\namount"; update.layerKey = "layer-one"; update.parameterKey = "amount"; update.targetValue = JsonValue(0.7); liveState.ApplyOscUpdates({ update }); std::vector baseLayerStates = { MakeLayerState() }; LayeredRenderStateInput input; input.committedLiveLayerStates = &baseLayerStates; input.transientAutomationOverlay = &liveState; input.allowTransientAutomationCommits = true; input.collectTransientAutomationCommitRequests = false; input.transientAutomationSmoothing = 0.0; input.transientAutomationCommitDelay = std::chrono::milliseconds(0); input.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); RenderStateComposer composer; RenderStateCompositionResult result = composer.BuildFrameState(input); Expect(result.commitRequests.empty(), "composer can apply overlays without collecting commit requests"); const auto valueIt = result.layerStates[0].parameterValues.find("amount"); Expect(valueIt != result.layerStates[0].parameterValues.end() && !valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.7) < 0.0001, "composer still applies overlays when commit collection is disabled"); } } int main() { TestRuntimeLiveStateAppliesLatestOscOverlay(); TestRuntimeLiveStateIgnoresStaleCommitCompletions(); TestRuntimeLiveStateQueuesAndCompletesCommit(); TestRuntimeLiveStateQueuesOneCommitPerGeneration(); TestRuntimeLiveStateSmoothingZeroAppliesTargetImmediately(); TestRuntimeLiveStateSmoothingOneConvergesImmediately(); TestRuntimeLiveStateSmoothingPartiallyConverges(); TestRuntimeLiveStateSmoothingVectorSizeMismatchUsesTargetShape(); TestRuntimeLiveStateTriggerOverlayIncrementsAndClears(); TestRuntimeLiveStateClearsOverlaysForLayerKey(); TestRuntimeLiveStatePrunesRemovedLayerOverlay(); TestRuntimeLiveStatePrunesIncompatibleParameterOverlay(); TestRuntimeLiveStatePreservesCompatibleOverlayAfterShaderReload(); TestRenderStateComposerBuildsFrameState(); TestRenderStateComposerUsesCommittedLayerOverBaseLayer(); TestRenderStateComposerUsesBaseLayerWhenCommittedLayerMissing(); TestRenderStateComposerQueuesCommitRequestsWhenEnabled(); TestRenderStateComposerSuppressesCommitCollection(); if (gFailures != 0) { std::cerr << gFailures << " RuntimeLiveState test failure(s).\n"; return 1; } std::cout << "RuntimeLiveState tests passed.\n"; return 0; }