From bfaa3f5e0ef02656996c2cfec9eadf409e359fdf Mon Sep 17 00:00:00 2001 From: Aiden Date: Wed, 20 May 2026 14:37:24 +1000 Subject: [PATCH] optional preview frame --- CMakeLists.txt | 1 + config/runtime-host.json | 1 + src/README.md | 13 +- src/app/AppConfig.cpp | 3 +- src/app/AppConfig.h | 4 +- src/app/AppConfigProvider.cpp | 1 + src/app/RenderCadenceApp.h | 22 + src/frames/SystemFrameExchange.cpp | 87 ++- src/frames/SystemFrameExchange.h | 4 + src/preview/PreviewConfig.h | 28 + src/preview/PreviewWindowThread.cpp | 246 ++++++ src/preview/PreviewWindowThread.h | 47 ++ tests/CMakeLists.txt | 68 ++ tests/HealthTelemetryTests.cpp | 275 ------- tests/OscServerTests.cpp | 236 ------ tests/PersistenceWriterTests.cpp | 210 ----- ...adenceCompositorAppConfigProviderTests.cpp | 12 + ...derCadenceCompositorFrameExchangeTests.cpp | 128 +++ ...derCadenceCompositorPreviewConfigTests.cpp | 50 ++ tests/RenderCommandQueueTests.cpp | 152 ---- tests/RuntimeClockTests.cpp | 50 -- tests/RuntimeEventTypeTests.cpp | 726 ------------------ tests/RuntimeLiveStateTests.cpp | 611 --------------- tests/RuntimeStateLayerModelTests.cpp | 88 --- tests/RuntimeSubsystemTests.cpp | 377 --------- 25 files changed, 700 insertions(+), 2740 deletions(-) create mode 100644 src/preview/PreviewConfig.h create mode 100644 src/preview/PreviewWindowThread.cpp create mode 100644 src/preview/PreviewWindowThread.h delete mode 100644 tests/HealthTelemetryTests.cpp delete mode 100644 tests/OscServerTests.cpp delete mode 100644 tests/PersistenceWriterTests.cpp create mode 100644 tests/RenderCadenceCompositorPreviewConfigTests.cpp delete mode 100644 tests/RenderCommandQueueTests.cpp delete mode 100644 tests/RuntimeClockTests.cpp delete mode 100644 tests/RuntimeEventTypeTests.cpp delete mode 100644 tests/RuntimeLiveStateTests.cpp delete mode 100644 tests/RuntimeStateLayerModelTests.cpp delete mode 100644 tests/RuntimeSubsystemTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b86208e..a27fdab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ set(VIDEO_SHADER_INCLUDE_DIRS "${SRC_DIR}/json" "${SRC_DIR}/logging" "${SRC_DIR}/platform" + "${SRC_DIR}/preview" "${SRC_DIR}/render" "${SRC_DIR}/render/readback" "${SRC_DIR}/render/runtime" diff --git a/config/runtime-host.json b/config/runtime-host.json index b6944e7..c143ed3 100644 --- a/config/runtime-host.json +++ b/config/runtime-host.json @@ -10,6 +10,7 @@ "outputFrameRate": "59.94", "autoReload": true, "maxTemporalHistoryFrames": 12, + "previewEnabled": true, "previewFps": 30, "enableExternalKeying": true } diff --git a/src/README.md b/src/README.md index ad04edc..b23eb14 100644 --- a/src/README.md +++ b/src/README.md @@ -33,6 +33,12 @@ DeckLinkOutputThread consumes completed system-memory frames schedules them into DeckLink up to target depth never renders + +PreviewWindowThread + optionally owns a Win32/GDI preview window + copies the latest completed or scheduled system-memory frame without consuming it + skips preview ticks instead of waiting for the frame exchange lock + never calls GL, DeckLink, shader build, or render cadence code ``` Startup builds a small output preroll reserve before DeckLink scheduled playback starts. When DeckLink input is available, startup also waits briefly for three ready input frames before the render thread starts so the first render ticks are deliberate rather than lucky. @@ -74,6 +80,7 @@ Included now: - trigger parameters as latest-pulse controls with shader-visible count/time - startup config provider for `config/runtime-host.json` - quiet telemetry health monitor +- optional preview window fed from completed system-memory frames on its own thread - non-GL frame-exchange tests - non-GL input-mailbox tests @@ -87,7 +94,6 @@ Intentionally not included yet: - OSC control - persistent control/state writes - trigger event history for stacked repeated pulses -- preview - screenshots - persistence @@ -140,7 +146,7 @@ This tracks parity with `apps/LoopThroughWithOpenGLCompositing`. - [ ] Full runtime state store/read model - [ ] Persistent layer stack/config writes - [ ] OSC ingress -- [ ] Preview output +- [x] Preview output from a non-consuming system-memory tap - [ ] Screenshot capture - [ ] External keying support - [ ] Full V1 health/runtime presentation model @@ -198,9 +204,12 @@ Currently consumed fields: - `outputFrameRate` - `autoReload` - `maxTemporalHistoryFrames` +- `previewEnabled` - `previewFps` - `enableExternalKeying` +When `previewEnabled` is true, the preview window runs on `PreviewWindowThread`. It paints BGRA8 system-memory frames with Win32/GDI after render readback has already completed, so it does not bind GL and does not consume frames from DeckLink output. + The loaded config is treated as a read-only startup snapshot. Subsystems that need config should receive this snapshot or a narrowed config struct from app orchestration; they should not reload files independently. Supported CLI overrides: diff --git a/src/app/AppConfig.cpp b/src/app/AppConfig.cpp index b9adea7..2241c09 100644 --- a/src/app/AppConfig.cpp +++ b/src/app/AppConfig.cpp @@ -28,7 +28,8 @@ AppConfig DefaultAppConfig() config.outputFrameRate = "59.94"; config.autoReload = true; config.maxTemporalHistoryFrames = 12; - config.previewFps = 30.0; + config.previewEnabled = false; + config.previewFps = kDefaultPreviewFps; config.warmupCompletedFrames = 4; config.warmupTimeout = std::chrono::seconds(3); config.prerollTimeout = std::chrono::seconds(3); diff --git a/src/app/AppConfig.h b/src/app/AppConfig.h index 7509760..9d5ab8f 100644 --- a/src/app/AppConfig.h +++ b/src/app/AppConfig.h @@ -2,6 +2,7 @@ #include "../control/http/HttpControlServer.h" #include "../logging/Logger.h" +#include "../preview/PreviewConfig.h" #include "../telemetry/TelemetryHealthMonitor.h" #include "../video/DeckLinkOutput.h" #include "../video/DeckLinkOutputThread.h" @@ -29,7 +30,8 @@ struct AppConfig std::string outputFrameRate = "59.94"; bool autoReload = true; std::size_t maxTemporalHistoryFrames = 12; - double previewFps = 30.0; + bool previewEnabled = false; + double previewFps = kDefaultPreviewFps; std::size_t warmupCompletedFrames = 4; std::chrono::milliseconds warmupTimeout = std::chrono::seconds(3); std::chrono::milliseconds prerollTimeout = std::chrono::seconds(3); diff --git a/src/app/AppConfigProvider.cpp b/src/app/AppConfigProvider.cpp index c5ddbb4..39cdb52 100644 --- a/src/app/AppConfigProvider.cpp +++ b/src/app/AppConfigProvider.cpp @@ -132,6 +132,7 @@ bool AppConfigProvider::Load(const std::filesystem::path& path, std::string& err ApplyString(root, "outputFrameRate", mConfig.outputFrameRate); ApplyBool(root, "autoReload", mConfig.autoReload); ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames); + ApplyBool(root, "previewEnabled", mConfig.previewEnabled); ApplyDouble(root, "previewFps", mConfig.previewFps); ApplyBool(root, "enableExternalKeying", mConfig.deckLink.externalKeyingEnabled); diff --git a/src/app/RenderCadenceApp.h b/src/app/RenderCadenceApp.h index cc89742..fc3ea64 100644 --- a/src/app/RenderCadenceApp.h +++ b/src/app/RenderCadenceApp.h @@ -5,6 +5,7 @@ #include "RuntimeLayerController.h" #include "../logging/Logger.h" #include "../control/RuntimeStateJson.h" +#include "../preview/PreviewWindowThread.h" #include "../telemetry/TelemetryHealthMonitor.h" #include "../video/DeckLinkInput.h" #include "../video/DeckLinkOutput.h" @@ -94,6 +95,7 @@ public: return false; } + StartPreviewWindow(); StartOptionalVideoOutput(); mTelemetryHealth.Start(mFrameExchange, mOutput, mOutputThread, mRenderThread); StartHttpServer(); @@ -106,6 +108,7 @@ public: { mHttpServer.Stop(); mTelemetryHealth.Stop(); + mPreviewWindow.Stop(); mOutputThread.Stop(); mOutput.Stop(); mRuntimeLayers.Stop(); @@ -228,6 +231,24 @@ private: } } + void StartPreviewWindow() + { + if (!mConfig.previewEnabled) + return; + + PreviewWindowConfig previewConfig; + previewConfig.enabled = true; + previewConfig.fps = mConfig.previewFps; + std::string error; + if (mPreviewWindow.Start(mFrameExchange, previewConfig, error)) + { + Log("preview", "Preview window thread started."); + return; + } + + LogWarning("preview", "Preview window did not start: " + error); + } + std::string BuildStateJson() { CadenceTelemetrySnapshot telemetry = mHttpTelemetry.Sample(mFrameExchange, mOutput, mOutputThread, mRenderThread); @@ -281,6 +302,7 @@ private: TelemetryHealthMonitor mTelemetryHealth; CadenceTelemetry mHttpTelemetry; HttpControlServer mHttpServer; + PreviewWindowThread mPreviewWindow; RuntimeLayerController mRuntimeLayers; std::function mDeckLinkInputMetricsProvider; uint64_t mLastInputCapturedFrames = 0; diff --git a/src/frames/SystemFrameExchange.cpp b/src/frames/SystemFrameExchange.cpp index 7880789..2130b3f 100644 --- a/src/frames/SystemFrameExchange.cpp +++ b/src/frames/SystemFrameExchange.cpp @@ -29,6 +29,7 @@ void SystemFrameExchange::Configure(const SystemFrameExchangeConfig& config) slot.bytes.resize(byteCount); slot.state = SystemFrameSlotState::Free; slot.frameIndex = 0; + slot.previewReaders = 0; ++slot.generation; } @@ -110,16 +111,65 @@ bool SystemFrameExchange::ReleaseScheduledByBytes(void* bytes) if (slot.state != SystemFrameSlotState::Scheduled) return false; - slot.state = SystemFrameSlotState::Free; - slot.frameIndex = 0; - ++slot.generation; - mCondition.notify_all(); + ReleaseFreeIfUnreferencedLocked(slot); return true; } return false; } +bool SystemFrameExchange::TryAcquireLatestForPreview(SystemFrame& frame) +{ + std::unique_lock lock(mMutex, std::try_to_lock); + if (!lock.owns_lock()) + return false; + + std::size_t bestIndex = mSlots.size(); + uint64_t bestFrameIndex = 0; + for (std::size_t index = 0; index < mSlots.size(); ++index) + { + const Slot& slot = mSlots[index]; + if (slot.state != SystemFrameSlotState::Completed && slot.state != SystemFrameSlotState::Scheduled) + continue; + if (bestIndex == mSlots.size() || slot.frameIndex >= bestFrameIndex) + { + bestIndex = index; + bestFrameIndex = slot.frameIndex; + } + } + + if (bestIndex == mSlots.size()) + { + frame = SystemFrame(); + return false; + } + + Slot& slot = mSlots[bestIndex]; + ++slot.previewReaders; + FillFrameLocked(bestIndex, frame); + return true; +} + +bool SystemFrameExchange::ReleasePreviewFrame(const SystemFrame& frame) +{ + std::lock_guard lock(mMutex); + if (!IsValidLocked(frame)) + return false; + + Slot& slot = mSlots[frame.index]; + if (slot.previewReaders == 0) + return false; + + --slot.previewReaders; + if (slot.previewReaders == 0 && slot.state == SystemFrameSlotState::Free) + { + slot.frameIndex = 0; + ++slot.generation; + mCondition.notify_all(); + } + return true; +} + bool SystemFrameExchange::WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout) { std::unique_lock lock(mMutex); @@ -180,8 +230,11 @@ void SystemFrameExchange::Clear() for (Slot& slot : mSlots) { slot.state = SystemFrameSlotState::Free; - slot.frameIndex = 0; - ++slot.generation; + if (slot.previewReaders == 0) + { + slot.frameIndex = 0; + ++slot.generation; + } } mCondition.notify_all(); } @@ -198,7 +251,8 @@ SystemFrameExchangeMetrics SystemFrameExchange::Metrics() const switch (slot.state) { case SystemFrameSlotState::Free: - ++metrics.freeCount; + if (slot.previewReaders == 0) + ++metrics.freeCount; break; case SystemFrameSlotState::Rendering: ++metrics.renderingCount; @@ -222,6 +276,8 @@ bool SystemFrameExchange::AcquireFreeLocked(SystemFrame& frame) Slot& slot = mSlots[index]; if (slot.state != SystemFrameSlotState::Free) continue; + if (slot.previewReaders != 0) + continue; slot.state = SystemFrameSlotState::Rendering; ++slot.generation; @@ -242,17 +298,26 @@ bool SystemFrameExchange::DropOldestCompletedLocked() continue; Slot& slot = mSlots[index]; - slot.state = SystemFrameSlotState::Free; - slot.frameIndex = 0; - ++slot.generation; + ReleaseFreeIfUnreferencedLocked(slot); ++mCounters.completedDrops; - mCondition.notify_all(); return true; } return false; } +bool SystemFrameExchange::ReleaseFreeIfUnreferencedLocked(Slot& slot) +{ + slot.state = SystemFrameSlotState::Free; + if (slot.previewReaders != 0) + return false; + + slot.frameIndex = 0; + ++slot.generation; + mCondition.notify_all(); + return true; +} + void SystemFrameExchange::TrimCompletedLocked() { if (mConfig.maxCompletedFrames == 0) diff --git a/src/frames/SystemFrameExchange.h b/src/frames/SystemFrameExchange.h index 9767c7c..f47c825 100644 --- a/src/frames/SystemFrameExchange.h +++ b/src/frames/SystemFrameExchange.h @@ -21,6 +21,8 @@ public: bool PublishCompleted(const SystemFrame& frame); bool ConsumeCompletedForSchedule(SystemFrame& frame); bool ReleaseScheduledByBytes(void* bytes); + bool TryAcquireLatestForPreview(SystemFrame& frame); + bool ReleasePreviewFrame(const SystemFrame& frame); bool WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout); bool WaitForStableCompletedDepth( std::size_t targetDepth, @@ -37,10 +39,12 @@ private: SystemFrameSlotState state = SystemFrameSlotState::Free; uint64_t generation = 1; uint64_t frameIndex = 0; + std::size_t previewReaders = 0; }; bool AcquireFreeLocked(SystemFrame& frame); bool DropOldestCompletedLocked(); + bool ReleaseFreeIfUnreferencedLocked(Slot& slot); void TrimCompletedLocked(); bool IsValidLocked(const SystemFrame& frame) const; void FillFrameLocked(std::size_t index, SystemFrame& frame); diff --git a/src/preview/PreviewConfig.h b/src/preview/PreviewConfig.h new file mode 100644 index 0000000..fe45a8d --- /dev/null +++ b/src/preview/PreviewConfig.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace RenderCadenceCompositor +{ +constexpr double kDefaultPreviewFps = 30.0; +constexpr double kMinimumPreviewFps = 1.0; + +struct PreviewWindowConfig +{ + bool enabled = false; + double fps = kDefaultPreviewFps; + std::string title = "Render Cadence Preview"; +}; + +inline double NormalizePreviewFps(double fps) +{ + return fps >= kMinimumPreviewFps ? fps : kDefaultPreviewFps; +} + +inline unsigned PreviewTimerIntervalMilliseconds(double fps) +{ + const double normalizedFps = NormalizePreviewFps(fps); + const int intervalMilliseconds = static_cast(1000.0 / normalizedFps); + return static_cast(intervalMilliseconds > 0 ? intervalMilliseconds : 1); +} +} diff --git a/src/preview/PreviewWindowThread.cpp b/src/preview/PreviewWindowThread.cpp new file mode 100644 index 0000000..5542a6a --- /dev/null +++ b/src/preview/PreviewWindowThread.cpp @@ -0,0 +1,246 @@ +#include "PreviewWindowThread.h" + +#include "../frames/SystemFrameExchange.h" +#include "../logging/Logger.h" + +#include + +namespace RenderCadenceCompositor +{ +namespace +{ +const char* kPreviewWindowClassName = "RenderCadencePreviewWindow"; +constexpr UINT_PTR kPreviewTimerId = 1; +constexpr UINT kPreviewStopMessage = WM_APP + 1; + +void RegisterPreviewWindowClass() +{ + static bool registered = false; + if (registered) + return; + + WNDCLASSA windowClass = {}; + windowClass.lpfnWndProc = PreviewWindowThread::WindowProc; + windowClass.hInstance = GetModuleHandleA(nullptr); + windowClass.lpszClassName = kPreviewWindowClassName; + windowClass.hCursor = LoadCursor(nullptr, IDC_ARROW); + windowClass.hbrBackground = reinterpret_cast(COLOR_WINDOW + 1); + RegisterClassA(&windowClass); + registered = true; +} +} + +PreviewWindowThread::~PreviewWindowThread() +{ + Stop(); +} + +bool PreviewWindowThread::Start(SystemFrameExchange& exchange, const PreviewWindowConfig& config, std::string& error) +{ + if (!config.enabled) + return true; + if (mThread.joinable()) + { + error = "Preview window thread is already running."; + return false; + } + + mExchange = &exchange; + mConfig = config; + mStopRequested.store(false, std::memory_order_release); + mThread = std::thread(&PreviewWindowThread::ThreadMain, this); + return true; +} + +void PreviewWindowThread::Stop() +{ + mStopRequested.store(true, std::memory_order_release); + const DWORD threadId = mThreadId.load(std::memory_order_acquire); + if (threadId != 0) + PostThreadMessageA(threadId, kPreviewStopMessage, 0, 0); + if (mThread.joinable()) + mThread.join(); + mExchange = nullptr; + mRunning.store(false, std::memory_order_release); +} + +LRESULT CALLBACK PreviewWindowThread::WindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam) +{ + PreviewWindowThread* owner = reinterpret_cast(GetWindowLongPtrA(window, GWLP_USERDATA)); + if (message == WM_NCCREATE) + { + const CREATESTRUCTA* create = reinterpret_cast(lParam); + owner = reinterpret_cast(create->lpCreateParams); + SetWindowLongPtrA(window, GWLP_USERDATA, reinterpret_cast(owner)); + } + + switch (message) + { + case WM_PAINT: + if (owner != nullptr) + { + owner->Paint(window); + return 0; + } + break; + case WM_TIMER: + if (owner != nullptr && wParam == kPreviewTimerId) + { + InvalidateRect(window, nullptr, FALSE); + return 0; + } + break; + case WM_CLOSE: + if (owner != nullptr) + { + if (!owner->mStopRequested.load(std::memory_order_acquire)) + TryLog(LogLevel::Log, "preview", "Preview window closed by user."); + owner->mStopRequested.store(true, std::memory_order_release); + } + DestroyWindow(window); + return 0; + case WM_DESTROY: + if (owner != nullptr) + owner->mWindow = nullptr; + PostQuitMessage(0); + return 0; + default: + break; + } + + return DefWindowProcA(window, message, wParam, lParam); +} + +void PreviewWindowThread::ThreadMain() +{ + MSG message = {}; + PeekMessageA(&message, nullptr, WM_USER, WM_USER, PM_NOREMOVE); + mThreadId.store(GetCurrentThreadId(), std::memory_order_release); + mRunning.store(true, std::memory_order_release); + + std::string error; + if (!CreatePreviewWindow(error)) + { + TryLog(LogLevel::Error, "preview", error.empty() ? "Preview window creation failed." : error); + mThreadId.store(0, std::memory_order_release); + mRunning.store(false, std::memory_order_release); + return; + } + + if (!mStopRequested.load(std::memory_order_acquire)) + { + if (SetTimer(mWindow, kPreviewTimerId, PreviewTimerIntervalMilliseconds(mConfig.fps), nullptr) == 0) + TryLog(LogLevel::Error, "preview", "Preview window timer could not be started."); + else + TryLog(LogLevel::Log, "preview", "Preview window started."); + } + + while (!mStopRequested.load(std::memory_order_acquire)) + { + const BOOL result = GetMessageA(&message, nullptr, 0, 0); + if (result <= 0) + break; + if (message.message == kPreviewStopMessage) + { + mStopRequested.store(true, std::memory_order_release); + break; + } + TranslateMessage(&message); + DispatchMessageA(&message); + } + + if (mWindow != nullptr) + { + KillTimer(mWindow, kPreviewTimerId); + DestroyWindow(mWindow); + mWindow = nullptr; + } + + mThreadId.store(0, std::memory_order_release); + mRunning.store(false, std::memory_order_release); + TryLog(LogLevel::Log, "preview", "Preview window thread stopped."); +} + +bool PreviewWindowThread::CreatePreviewWindow(std::string& error) +{ + RegisterPreviewWindowClass(); + + mWindow = CreateWindowExA( + 0, + kPreviewWindowClassName, + mConfig.title.c_str(), + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, + CW_USEDEFAULT, + 960, + 540, + nullptr, + nullptr, + GetModuleHandleA(nullptr), + this); + + if (mWindow == nullptr) + { + error = "CreateWindowEx failed for preview window."; + return false; + } + + ShowWindow(mWindow, SW_SHOW); + UpdateWindow(mWindow); + return true; +} + +void PreviewWindowThread::Paint(HWND window) +{ + PAINTSTRUCT paint = {}; + HDC dc = BeginPaint(window, &paint); + + RECT client = {}; + GetClientRect(window, &client); + const int clientWidth = std::max(1L, client.right - client.left); + const int clientHeight = std::max(1L, client.bottom - client.top); + + SystemFrame frame; + const bool frameAcquired = mExchange != nullptr && mExchange->TryAcquireLatestForPreview(frame); + const bool canPaintFrame = + frameAcquired && + frame.bytes != nullptr && + frame.pixelFormat == VideoIOPixelFormat::Bgra8 && + frame.width > 0 && + frame.height > 0; + if (canPaintFrame) + { + BITMAPINFO bitmapInfo = {}; + bitmapInfo.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmapInfo.bmiHeader.biWidth = static_cast(frame.width); + bitmapInfo.bmiHeader.biHeight = -static_cast(frame.height); + bitmapInfo.bmiHeader.biPlanes = 1; + bitmapInfo.bmiHeader.biBitCount = 32; + bitmapInfo.bmiHeader.biCompression = BI_RGB; + + StretchDIBits( + dc, + 0, + 0, + clientWidth, + clientHeight, + 0, + 0, + static_cast(frame.width), + static_cast(frame.height), + frame.bytes, + &bitmapInfo, + DIB_RGB_COLORS, + SRCCOPY); + } + else + { + FillRect(dc, &client, reinterpret_cast(GetStockObject(BLACK_BRUSH))); + } + + if (frameAcquired) + mExchange->ReleasePreviewFrame(frame); + + EndPaint(window, &paint); +} +} diff --git a/src/preview/PreviewWindowThread.h b/src/preview/PreviewWindowThread.h new file mode 100644 index 0000000..b177ad7 --- /dev/null +++ b/src/preview/PreviewWindowThread.h @@ -0,0 +1,47 @@ +#pragma once + +#include "PreviewConfig.h" + +#include "../frames/SystemFrameTypes.h" + +#include +#include + +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include + +class SystemFrameExchange; + +namespace RenderCadenceCompositor +{ +class PreviewWindowThread +{ +public: + PreviewWindowThread() = default; + PreviewWindowThread(const PreviewWindowThread&) = delete; + PreviewWindowThread& operator=(const PreviewWindowThread&) = delete; + ~PreviewWindowThread(); + + bool Start(SystemFrameExchange& exchange, const PreviewWindowConfig& config, std::string& error); + void Stop(); + bool Running() const { return mRunning.load(std::memory_order_acquire); } + + static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam); + +private: + void ThreadMain(); + bool CreatePreviewWindow(std::string& error); + void Paint(HWND window); + + SystemFrameExchange* mExchange = nullptr; + PreviewWindowConfig mConfig; + std::thread mThread; + std::atomic mStopRequested{ false }; + std::atomic mRunning{ false }; + std::atomic mThreadId{ 0 }; + HWND mWindow = nullptr; +}; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4ea8238..84f61ab 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,6 +18,74 @@ add_video_shader_test(RenderCadenceCompositorTelemetryTests "${TEST_DIR}/RenderCadenceCompositorTelemetryTests.cpp" ) +add_video_shader_test(RenderCadenceCompositorFrameExchangeTests + "${SRC_DIR}/frames/SystemFrameExchange.cpp" + "${SRC_DIR}/video/VideoIOFormat.cpp" + "${TEST_DIR}/RenderCadenceCompositorFrameExchangeTests.cpp" +) + +add_video_shader_test(RenderCadenceCompositorInputFrameMailboxTests + "${SRC_DIR}/frames/InputFrameMailbox.cpp" + "${SRC_DIR}/video/VideoIOFormat.cpp" + "${TEST_DIR}/RenderCadenceCompositorInputFrameMailboxTests.cpp" +) + +add_video_shader_test(RenderCadenceCompositorPreviewConfigTests + "${TEST_DIR}/RenderCadenceCompositorPreviewConfigTests.cpp" +) + +add_video_shader_test(RenderCadenceCompositorRuntimeShaderParamsTests + "${SRC_DIR}/render/runtime/RuntimeShaderParams.cpp" + "${TEST_DIR}/RenderCadenceCompositorRuntimeShaderParamsTests.cpp" +) + +add_video_shader_test(RenderCadenceCompositorRuntimeLayerModelTests + "${SRC_DIR}/runtime/RuntimeLayerModel.cpp" + "${SRC_DIR}/runtime/RuntimeJson.cpp" + "${SRC_DIR}/runtime/RuntimeParameterUtils.cpp" + "${SRC_DIR}/runtime/SupportedShaderCatalog.cpp" + "${SRC_DIR}/shader/ShaderPackageRegistry.cpp" + "${TEST_DIR}/RenderCadenceCompositorRuntimeLayerModelTests.cpp" +) + +add_video_shader_test(RenderCadenceCompositorSupportedShaderCatalogTests + "${SRC_DIR}/runtime/RuntimeJson.cpp" + "${SRC_DIR}/runtime/SupportedShaderCatalog.cpp" + "${SRC_DIR}/shader/ShaderPackageRegistry.cpp" + "${TEST_DIR}/RenderCadenceCompositorSupportedShaderCatalogTests.cpp" +) + +add_video_shader_test(RenderCadenceCompositorRuntimeStateJsonTests + "${SRC_DIR}/app/AppConfig.cpp" + "${SRC_DIR}/app/AppConfigProvider.cpp" + "${SRC_DIR}/json/JsonWriter.cpp" + "${SRC_DIR}/runtime/RuntimeJson.cpp" + "${SRC_DIR}/runtime/RuntimeLayerModel.cpp" + "${SRC_DIR}/runtime/RuntimeParameterUtils.cpp" + "${SRC_DIR}/runtime/SupportedShaderCatalog.cpp" + "${SRC_DIR}/shader/ShaderPackageRegistry.cpp" + "${TEST_DIR}/RenderCadenceCompositorRuntimeStateJsonTests.cpp" +) + +add_video_shader_test(RenderCadenceCompositorHttpControlServerTests + "${SRC_DIR}/control/RuntimeControlCommand.cpp" + "${SRC_DIR}/control/http/HttpControlServer.cpp" + "${SRC_DIR}/control/http/HttpControlServerRoutes.cpp" + "${SRC_DIR}/control/http/HttpControlServerWebSocket.cpp" + "${SRC_DIR}/json/JsonWriter.cpp" + "${SRC_DIR}/logging/Logger.cpp" + "${SRC_DIR}/runtime/RuntimeJson.cpp" + "${TEST_DIR}/RenderCadenceCompositorHttpControlServerTests.cpp" +) +target_link_libraries(RenderCadenceCompositorHttpControlServerTests PRIVATE Ws2_32) + +add_video_shader_test(RenderCadenceCompositorAppConfigProviderTests + "${SRC_DIR}/app/AppConfig.cpp" + "${SRC_DIR}/app/AppConfigProvider.cpp" + "${SRC_DIR}/runtime/RuntimeJson.cpp" + "${TEST_DIR}/RenderCadenceCompositorAppConfigProviderTests.cpp" +) + add_video_shader_test(RuntimeJsonTests "${SRC_DIR}/runtime/RuntimeJson.cpp" "${TEST_DIR}/RuntimeJsonTests.cpp" diff --git a/tests/HealthTelemetryTests.cpp b/tests/HealthTelemetryTests.cpp deleted file mode 100644 index 974ba66..0000000 --- a/tests/HealthTelemetryTests.cpp +++ /dev/null @@ -1,275 +0,0 @@ -#include "HealthTelemetry.h" - -#include - -namespace -{ -int gFailures = 0; - -void Expect(bool condition, const char* message) -{ - if (condition) - return; - - std::cerr << "FAIL: " << message << "\n"; - ++gFailures; -} - -void TestRuntimeEventQueueMetrics() -{ - HealthTelemetry telemetry; - telemetry.RecordRuntimeEventQueueMetrics("runtime-events", 3, 64, 2, 12.5); - - const HealthTelemetry::RuntimeEventMetricsSnapshot metrics = telemetry.GetRuntimeEventMetricsSnapshot(); - Expect(metrics.queue.queueName == "runtime-events", "queue metrics store queue name"); - Expect(metrics.queue.depth == 3, "queue metrics store depth"); - Expect(metrics.queue.capacity == 64, "queue metrics store capacity"); - Expect(metrics.queue.droppedCount == 2, "queue metrics store dropped count"); - Expect(metrics.queue.oldestEventAgeMilliseconds == 12.5, "queue metrics store oldest event age"); -} - -void TestRuntimeEventDispatchStats() -{ - HealthTelemetry telemetry; - telemetry.RecordRuntimeEventDispatchStats(2, 5, 1, 0.75); - telemetry.RecordRuntimeEventDispatchStats(3, 6, 0, 0.25); - - const HealthTelemetry::Snapshot snapshot = telemetry.GetSnapshot(); - Expect(snapshot.runtimeEvents.dispatch.dispatchCallCount == 2, "dispatch stats count dispatch calls"); - Expect(snapshot.runtimeEvents.dispatch.dispatchedEventCount == 5, "dispatch stats accumulate dispatched events"); - Expect(snapshot.runtimeEvents.dispatch.handlerInvocationCount == 11, "dispatch stats accumulate handler invocations"); - Expect(snapshot.runtimeEvents.dispatch.handlerFailureCount == 1, "dispatch stats accumulate handler failures"); - Expect(snapshot.runtimeEvents.dispatch.lastDispatchDurationMilliseconds == 0.25, "dispatch stats store latest duration"); - Expect(snapshot.runtimeEvents.dispatch.maxDispatchDurationMilliseconds == 0.75, "dispatch stats store max duration"); -} - -void TestRuntimeEventTryRecord() -{ - HealthTelemetry telemetry; - Expect(telemetry.TryRecordRuntimeEventQueueMetrics("runtime-events", 1, 4, 0, -5.0), "try queue metrics succeeds when uncontended"); - Expect(telemetry.TryRecordRuntimeEventDispatchStats(1, 2, 0, -1.0), "try dispatch stats succeeds when uncontended"); - - const HealthTelemetry::RuntimeEventMetricsSnapshot metrics = telemetry.GetRuntimeEventMetricsSnapshot(); - Expect(metrics.queue.oldestEventAgeMilliseconds == 0.0, "queue age is clamped to non-negative values"); - Expect(metrics.dispatch.lastDispatchDurationMilliseconds == 0.0, "dispatch duration is clamped to non-negative values"); -} - -void TestPersistenceWriteHealth() -{ - HealthTelemetry telemetry; - telemetry.RecordPersistenceWriteResult(false, "runtime-state", "runtime/runtime_state.json", "UpdateLayerParameter", - "disk full", true); - - HealthTelemetry::PersistenceSnapshot persistence = telemetry.GetPersistenceSnapshot(); - Expect(persistence.writeFailureCount == 1, "persistence health counts write failures"); - Expect(!persistence.lastWriteSucceeded, "persistence health records failed write state"); - Expect(persistence.unsavedChanges, "persistence health reports unsaved changes after failure"); - Expect(persistence.newerRequestPending, "persistence health records pending newer request"); - Expect(persistence.lastTargetKind == "runtime-state", "persistence health records target kind"); - Expect(persistence.lastReason == "UpdateLayerParameter", "persistence health records reason"); - Expect(persistence.lastErrorMessage == "disk full", "persistence health records error message"); - - Expect(telemetry.TryRecordPersistenceWriteResult(true, "runtime-state", "runtime/runtime_state.json", "flush", "", false), - "try persistence health succeeds when uncontended"); - persistence = telemetry.GetPersistenceSnapshot(); - Expect(persistence.writeSuccessCount == 1, "persistence health counts write successes"); - Expect(persistence.lastWriteSucceeded, "persistence health records successful write state"); - Expect(!persistence.unsavedChanges, "persistence health clears unsaved changes after latest successful write with no pending request"); -} - -void TestBackendPlayoutHealth() -{ - HealthTelemetry telemetry; - telemetry.RecordBackendPlayoutHealth( - "Degraded", - "Dropped", - 1, - 4, - 12, - 0, - 3, - 4, - 10, - 2, - 1, - 8.5, - 7.25, - 12.0, - 1.0, - 6.5, - 0.5, - 8, - 11, - 3, - 2, - 2, - 1, - 2, - 5, - 3, - 1, - true, - "Output underrun"); - - const HealthTelemetry::BackendPlayoutSnapshot playout = telemetry.GetBackendPlayoutSnapshot(); - Expect(playout.lifecycleState == "Degraded", "backend playout health stores lifecycle state"); - Expect(playout.completionResult == "Dropped", "backend playout health stores completion result"); - Expect(playout.readyQueueDepth == 1, "backend playout health stores ready queue depth"); - Expect(playout.readyQueueCapacity == 4, "backend playout health stores ready queue capacity"); - Expect(playout.minReadyQueueDepth == 0, "backend playout health stores min ready queue depth"); - Expect(playout.maxReadyQueueDepth == 3, "backend playout health stores max ready queue depth"); - Expect(playout.readyQueueZeroDepthCount == 4, "backend playout health stores zero-depth queue samples"); - Expect(playout.readyQueueDroppedCount == 2, "backend playout health stores queue dropped count"); - Expect(playout.readyQueueUnderrunCount == 1, "backend playout health stores queue underrun count"); - Expect(playout.outputRenderMilliseconds == 8.5, "backend playout health stores output render duration"); - Expect(playout.smoothedOutputRenderMilliseconds == 7.25, "backend playout health stores smoothed output render duration"); - Expect(playout.maxOutputRenderMilliseconds == 12.0, "backend playout health stores max output render duration"); - Expect(playout.outputFrameAcquireMilliseconds == 1.0, "backend playout health stores output frame acquire duration"); - Expect(playout.outputFrameRenderRequestMilliseconds == 6.5, "backend playout health stores output render request duration"); - Expect(playout.outputFrameEndAccessMilliseconds == 0.5, "backend playout health stores output frame end access duration"); - Expect(playout.completedFrameIndex == 8, "backend playout health stores completed index"); - Expect(playout.scheduledFrameIndex == 11, "backend playout health stores scheduled index"); - Expect(playout.scheduledLeadFrames == 3, "backend playout health stores synthetic scheduled lead"); - Expect(playout.measuredLagFrames == 2, "backend playout health stores measured lag"); - Expect(playout.catchUpFrames == 2, "backend playout health stores catch-up frames"); - Expect(playout.lateStreak == 1, "backend playout health stores late streak"); - Expect(playout.dropStreak == 2, "backend playout health stores drop streak"); - Expect(playout.lateFrameCount == 5, "backend playout health stores late frame count"); - Expect(playout.droppedFrameCount == 3, "backend playout health stores dropped frame count"); - Expect(playout.flushedFrameCount == 1, "backend playout health stores flushed frame count"); - Expect(playout.degraded, "backend playout health stores degraded state"); - Expect(playout.statusMessage == "Output underrun", "backend playout health stores status message"); - - Expect(telemetry.TryRecordBackendPlayoutHealth( - "Running", - "Completed", - 2, - 4, - 13, - 1, - 3, - 4, - 11, - 2, - 1, - -5.0, - -4.0, - -3.0, - -2.0, - -1.0, - -0.5, - 9, - 12, - 3, - 0, - 0, - 0, - 0, - 5, - 3, - 1, - false, - ""), - "try backend playout health succeeds when uncontended"); - const HealthTelemetry::Snapshot snapshot = telemetry.GetSnapshot(); - Expect(snapshot.backendPlayout.lifecycleState == "Running", "full health snapshot includes backend playout state"); - Expect(!snapshot.backendPlayout.degraded, "full health snapshot includes backend degraded state"); -} - -void TestOutputRenderPipelineTiming() -{ - HealthTelemetry telemetry; - telemetry.RecordOutputRenderQueueWait(2.5); - telemetry.RecordOutputRenderPipelineTiming(1.0, 0.5, 0.25, 0.75, 0.125, 0.375, 0.1, 0.2, 0.3, 0.4, 3.5, true, true, false); - Expect(telemetry.TryRecordOutputRenderPipelineTiming(-1.0, -2.0, -3.0, -4.0, -5.0, -6.0, -7.0, -8.0, -9.0, -10.0, -11.0, false, false, true), - "try output render timing succeeds when uncontended"); - - const HealthTelemetry::BackendPlayoutSnapshot playout = telemetry.GetBackendPlayoutSnapshot(); - Expect(playout.outputRenderQueueWaitMilliseconds == 2.5, "output render timing stores queue wait"); - Expect(playout.outputRenderDrawMilliseconds == 0.0, "output render timing clamps draw duration"); - Expect(playout.outputReadbackFenceWaitMilliseconds == 0.0, "output render timing clamps fence wait duration"); - Expect(playout.outputReadbackMapMilliseconds == 0.0, "output render timing clamps map duration"); - Expect(playout.outputReadbackCopyMilliseconds == 0.0, "output render timing clamps readback copy duration"); - Expect(playout.outputCachedCopyMilliseconds == 0.0, "output render timing clamps cached copy duration"); - Expect(playout.outputAsyncQueueMilliseconds == 0.0, "output render timing clamps async queue duration"); - Expect(playout.outputAsyncQueueBufferMilliseconds == 0.0, "output render timing clamps async queue buffer duration"); - Expect(playout.outputAsyncQueueSetupMilliseconds == 0.0, "output render timing clamps async queue setup duration"); - Expect(playout.outputAsyncQueueReadPixelsMilliseconds == 0.0, "output render timing clamps async queue read pixels duration"); - Expect(playout.outputAsyncQueueFenceMilliseconds == 0.0, "output render timing clamps async queue fence duration"); - Expect(playout.outputSyncReadMilliseconds == 0.0, "output render timing clamps sync read duration"); - Expect(playout.outputAsyncReadbackMissCount == 1, "output render timing counts async readback misses"); - Expect(playout.outputCachedFallbackCount == 1, "output render timing counts cached fallbacks"); - Expect(playout.outputSyncFallbackCount == 1, "output render timing counts sync fallbacks"); -} - -void TestSystemMemoryPlayoutStats() -{ - HealthTelemetry telemetry; - telemetry.RecordSystemMemoryPlayoutStats(2, 3, 1, 4, 5, 6, 12.5, 24.0); - - HealthTelemetry::BackendPlayoutSnapshot playout = telemetry.GetBackendPlayoutSnapshot(); - Expect(playout.systemFramePoolFree == 2, "system-memory playout stores free frame count"); - Expect(playout.systemFramePoolReady == 3, "system-memory playout stores ready frame count"); - Expect(playout.systemFramePoolScheduled == 1, "system-memory playout stores scheduled frame count"); - Expect(playout.systemFrameUnderrunCount == 4, "system-memory playout stores underrun count"); - Expect(playout.systemFrameRepeatCount == 5, "system-memory playout stores repeat count"); - Expect(playout.systemFrameDropCount == 6, "system-memory playout stores drop count"); - Expect(playout.systemFrameAgeAtScheduleMilliseconds == 12.5, "system-memory playout stores schedule age"); - Expect(playout.systemFrameAgeAtCompletionMilliseconds == 24.0, "system-memory playout stores completion age"); - - Expect(telemetry.TryRecordSystemMemoryPlayoutStats(1, 0, 2, 7, 8, 9, -1.0, -2.0), - "try system-memory playout stats succeeds when uncontended"); - playout = telemetry.GetBackendPlayoutSnapshot(); - Expect(playout.systemFramePoolFree == 1, "try system-memory playout stores free frame count"); - Expect(playout.systemFramePoolReady == 0, "try system-memory playout stores ready frame count"); - Expect(playout.systemFramePoolScheduled == 2, "try system-memory playout stores scheduled frame count"); - Expect(playout.systemFrameUnderrunCount == 7, "try system-memory playout stores underrun count"); - Expect(playout.systemFrameRepeatCount == 8, "try system-memory playout stores repeat count"); - Expect(playout.systemFrameDropCount == 9, "try system-memory playout stores drop count"); - Expect(playout.systemFrameAgeAtScheduleMilliseconds == 0.0, "system-memory playout clamps negative schedule age"); - Expect(playout.systemFrameAgeAtCompletionMilliseconds == 0.0, "system-memory playout clamps negative completion age"); -} - -void TestDeckLinkBufferTelemetry() -{ - HealthTelemetry telemetry; - telemetry.RecordDeckLinkBufferTelemetry(true, 4, 5, 0.25, 2); - - HealthTelemetry::BackendPlayoutSnapshot playout = telemetry.GetBackendPlayoutSnapshot(); - Expect(playout.actualDeckLinkBufferedFramesAvailable, "DeckLink buffer telemetry records availability"); - Expect(playout.actualDeckLinkBufferedFrames == 4, "DeckLink buffer telemetry stores actual device depth"); - Expect(playout.targetDeckLinkBufferedFrames == 5, "DeckLink buffer telemetry stores target device depth"); - Expect(playout.deckLinkScheduleCallMilliseconds == 0.25, "DeckLink buffer telemetry stores schedule call duration"); - Expect(playout.deckLinkScheduleFailureCount == 2, "DeckLink buffer telemetry stores schedule failures"); - - Expect(telemetry.TryRecordDeckLinkBufferTelemetry(false, 9, 3, -1.0, 7), - "try DeckLink buffer telemetry succeeds when uncontended"); - playout = telemetry.GetBackendPlayoutSnapshot(); - Expect(!playout.actualDeckLinkBufferedFramesAvailable, "DeckLink buffer telemetry records unavailable device depth"); - Expect(playout.actualDeckLinkBufferedFrames == 0, "unavailable DeckLink device depth clears actual count"); - Expect(playout.targetDeckLinkBufferedFrames == 3, "try DeckLink buffer telemetry stores target device depth"); - Expect(playout.deckLinkScheduleCallMilliseconds == 0.0, "DeckLink buffer telemetry clamps negative schedule call duration"); - Expect(playout.deckLinkScheduleFailureCount == 7, "try DeckLink buffer telemetry stores schedule failures"); -} -} - -int main() -{ - TestRuntimeEventQueueMetrics(); - TestRuntimeEventDispatchStats(); - TestRuntimeEventTryRecord(); - TestPersistenceWriteHealth(); - TestBackendPlayoutHealth(); - TestOutputRenderPipelineTiming(); - TestSystemMemoryPlayoutStats(); - TestDeckLinkBufferTelemetry(); - - if (gFailures != 0) - { - std::cerr << gFailures << " HealthTelemetry test failure(s).\n"; - return 1; - } - - std::cout << "HealthTelemetry tests passed.\n"; - return 0; -} diff --git a/tests/OscServerTests.cpp b/tests/OscServerTests.cpp deleted file mode 100644 index ef1d75f..0000000 --- a/tests/OscServerTests.cpp +++ /dev/null @@ -1,236 +0,0 @@ -#include "OscServer.h" - -#include -#include -#include -#include -#include - -namespace -{ -int gFailures = 0; - -void Expect(bool condition, const char* message) -{ - if (condition) - return; - - std::cerr << "FAIL: " << message << "\n"; - ++gFailures; -} - -void AppendPaddedString(std::vector& packet, const std::string& text) -{ - packet.insert(packet.end(), text.begin(), text.end()); - packet.push_back('\0'); - while (packet.size() % 4 != 0) - packet.push_back('\0'); -} - -void AppendInt32(std::vector& packet, int value) -{ - const unsigned int bits = static_cast(value); - packet.push_back(static_cast((bits >> 24) & 0xff)); - packet.push_back(static_cast((bits >> 16) & 0xff)); - packet.push_back(static_cast((bits >> 8) & 0xff)); - packet.push_back(static_cast(bits & 0xff)); -} - -void AppendFloat32(std::vector& packet, float value) -{ - unsigned int bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - AppendInt32(packet, static_cast(bits)); -} - -void AppendFloat64(std::vector& packet, double value) -{ - uint64_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - for (int shift = 56; shift >= 0; shift -= 8) - packet.push_back(static_cast((bits >> shift) & 0xff)); -} - -std::vector BuildOscPacket(const std::string& address, const std::string& typeTags) -{ - std::vector packet; - AppendPaddedString(packet, address); - AppendPaddedString(packet, typeTags); - return packet; -} -} - -struct OscServerTestAccess -{ - using Message = OscServer::OscMessage; - - static bool Decode(OscServer& server, const std::vector& packet, Message& message, std::string& error) - { - return server.DecodeMessage(packet.data(), static_cast(packet.size()), message, error); - } - - static bool Dispatch(OscServer& server, const Message& message, std::string& error) - { - return server.DispatchMessage(message, error); - } - - static bool TryParseBindAddress(const std::string& bindAddress, in_addr& address, std::string& error) - { - return OscServer::TryParseBindAddress(bindAddress, address, error); - } - - static void SetUpdateParameterCallback( - OscServer& server, - const std::function& callback) - { - server.mCallbacks.updateParameter = callback; - } -}; - -namespace -{ -void TestDecodeFloatMessage() -{ - OscServer server; - std::vector packet = BuildOscPacket("/VideoShaderToys/VHS/intensity", ",f"); - AppendFloat32(packet, 0.75f); - - OscServerTestAccess::Message message; - std::string error; - Expect(OscServerTestAccess::Decode(server, packet, message, error), "float OSC message decodes"); - Expect(message.address == "/VideoShaderToys/VHS/intensity", "float OSC address is preserved"); - Expect(message.valueJson.find("0.75") == 0, "float OSC value becomes JSON number"); -} - -void TestDecodeDoubleMessage() -{ - OscServer server; - std::vector packet = BuildOscPacket("/VideoShaderToys/fisheye-reproject/panDegrees", ",d"); - AppendFloat64(packet, 51.5); - - OscServerTestAccess::Message message; - std::string error; - Expect(OscServerTestAccess::Decode(server, packet, message, error), "double OSC message decodes"); - Expect(message.address == "/VideoShaderToys/fisheye-reproject/panDegrees", "double OSC address is preserved"); - Expect(message.valueJson.find("51.5") == 0, "double OSC value becomes JSON number"); -} - -void TestDecodeVectorMessage() -{ - OscServer server; - std::vector packet = BuildOscPacket("/VideoShaderToys/video-transform/pan", ",ff"); - AppendFloat32(packet, 0.25f); - AppendFloat32(packet, -0.5f); - - OscServerTestAccess::Message message; - std::string error; - Expect(OscServerTestAccess::Decode(server, packet, message, error), "multi-float OSC message decodes"); - Expect(message.address == "/VideoShaderToys/video-transform/pan", "multi-float OSC address is preserved"); - Expect(message.valueJson.find("[0.25,-0.5") == 0, "multi-float OSC value becomes JSON array"); -} - -void TestDecodeIntStringAndBoolMessages() -{ - OscServer server; - - std::vector intPacket = BuildOscPacket("/VideoShaderToys/layer-1/mode", ",i"); - AppendInt32(intPacket, 3); - OscServerTestAccess::Message intMessage; - std::string error; - Expect(OscServerTestAccess::Decode(server, intPacket, intMessage, error), "int OSC message decodes"); - Expect(intMessage.valueJson == "3", "int OSC value becomes JSON number"); - - std::vector stringPacket = BuildOscPacket("/VideoShaderToys/layer-1/mode", ",s"); - AppendPaddedString(stringPacket, "equisolid"); - OscServerTestAccess::Message stringMessage; - error.clear(); - Expect(OscServerTestAccess::Decode(server, stringPacket, stringMessage, error), "string OSC message decodes"); - Expect(stringMessage.valueJson == "\"equisolid\"", "string OSC value becomes JSON string"); - - std::vector boolPacket = BuildOscPacket("/VideoShaderToys/layer-1/enabled", ",T"); - OscServerTestAccess::Message boolMessage; - error.clear(); - Expect(OscServerTestAccess::Decode(server, boolPacket, boolMessage, error), "boolean OSC message decodes"); - Expect(boolMessage.valueJson == "true", "true OSC typetag becomes JSON boolean"); -} - -void TestDispatchValidAddress() -{ - OscServer server; - std::string layerKey; - std::string parameterKey; - std::string valueJson; - OscServerTestAccess::SetUpdateParameterCallback(server, [&](const std::string& layer, const std::string& parameter, const std::string& value, std::string&) - { - layerKey = layer; - parameterKey = parameter; - valueJson = value; - return true; - }); - - OscServerTestAccess::Message message; - message.address = "/VideoShaderToys/VHS/intensity"; - message.valueJson = "0.5"; - std::string error; - Expect(OscServerTestAccess::Dispatch(server, message, error), "valid OSC control address dispatches"); - Expect(layerKey == "VHS", "dispatch extracts layer key"); - Expect(parameterKey == "intensity", "dispatch extracts parameter key"); - Expect(valueJson == "0.5", "dispatch forwards JSON value"); -} - -void TestRejectsUnsupportedAddress() -{ - OscServer server; - bool called = false; - OscServerTestAccess::SetUpdateParameterCallback(server, [&](const std::string&, const std::string&, const std::string&, std::string&) - { - called = true; - return true; - }); - - OscServerTestAccess::Message message; - message.address = "/OtherApp/VHS/intensity"; - message.valueJson = "0.5"; - std::string error; - Expect(!OscServerTestAccess::Dispatch(server, message, error), "unsupported OSC namespace is rejected"); - Expect(!called, "unsupported OSC namespace does not invoke callback"); - Expect(!error.empty(), "unsupported OSC address reports an error"); -} - -void TestParsesOscBindAddress() -{ - in_addr loopback = {}; - std::string error; - Expect(OscServerTestAccess::TryParseBindAddress("127.0.0.1", loopback, error), "loopback OSC bind address parses"); - Expect(loopback.S_un.S_addr != 0, "loopback OSC bind address produces a socket address"); - - in_addr wildcard = {}; - error.clear(); - Expect(OscServerTestAccess::TryParseBindAddress("0.0.0.0", wildcard, error), "wildcard OSC bind address parses"); - - in_addr invalid = {}; - error.clear(); - Expect(!OscServerTestAccess::TryParseBindAddress("localhost", invalid, error), "hostname OSC bind address is rejected"); - Expect(!error.empty(), "invalid OSC bind address reports an error"); -} -} - -int main() -{ - TestDecodeFloatMessage(); - TestDecodeDoubleMessage(); - TestDecodeVectorMessage(); - TestDecodeIntStringAndBoolMessages(); - TestDispatchValidAddress(); - TestRejectsUnsupportedAddress(); - TestParsesOscBindAddress(); - - if (gFailures != 0) - { - std::cerr << gFailures << " OscServer test failure(s).\n"; - return 1; - } - - std::cout << "OscServer tests passed.\n"; - return 0; -} diff --git a/tests/PersistenceWriterTests.cpp b/tests/PersistenceWriterTests.cpp deleted file mode 100644 index 0734171..0000000 --- a/tests/PersistenceWriterTests.cpp +++ /dev/null @@ -1,210 +0,0 @@ -#include "PersistenceWriter.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace -{ -int gFailures = 0; - -void Expect(bool condition, const char* message) -{ - if (condition) - return; - - std::cerr << "FAIL: " << message << "\n"; - ++gFailures; -} - -PersistenceSnapshot MakeRuntimeSnapshot(const std::string& contents) -{ - PersistenceSnapshot snapshot; - snapshot.targetKind = PersistenceTargetKind::RuntimeState; - snapshot.targetPath = std::filesystem::temp_directory_path() / "video-shader-persistence-writer-test.json"; - snapshot.contents = contents; - snapshot.reason = "test"; - snapshot.debounceKey = "runtime-state"; - snapshot.debounceAllowed = true; - return snapshot; -} - -void TestDebouncedRequestsCoalesceToNewestSnapshot() -{ - std::mutex mutex; - std::vector writtenSnapshots; - PersistenceWriter writer( - std::chrono::milliseconds(1000), - [&](const PersistenceSnapshot& snapshot, std::string&) { - std::lock_guard lock(mutex); - writtenSnapshots.push_back(snapshot); - return true; - }); - - std::string error; - Expect(writer.EnqueueSnapshot(MakeRuntimeSnapshot("first"), error), "first debounced snapshot enqueues"); - Expect(writer.EnqueueSnapshot(MakeRuntimeSnapshot("second"), error), "second debounced snapshot enqueues"); - - PersistenceWriterMetrics metrics = writer.GetMetrics(); - Expect(metrics.pendingCount == 1, "debounced snapshots share one pending slot"); - Expect(metrics.enqueuedCount == 1, "first debounced snapshot counts as enqueue"); - Expect(metrics.coalescedCount == 1, "second debounced snapshot counts as coalesced"); - - Expect(writer.StopAndFlush(std::chrono::seconds(1), error), "flush drains debounced snapshots"); - - { - std::lock_guard lock(mutex); - Expect(writtenSnapshots.size() == 1, "flush writes one coalesced snapshot"); - Expect(!writtenSnapshots.empty() && writtenSnapshots[0].contents == "second", "coalesced writer keeps newest snapshot"); - } - - metrics = writer.GetMetrics(); - Expect(metrics.pendingCount == 0, "flush drains pending debounced snapshot"); - Expect(metrics.writtenCount == 1, "flush records one successful write"); -} - -void TestImmediateRequestsAreNotCoalesced() -{ - std::mutex mutex; - std::vector writtenSnapshots; - PersistenceWriter writer( - std::chrono::milliseconds(1000), - [&](const PersistenceSnapshot& snapshot, std::string&) { - std::lock_guard lock(mutex); - writtenSnapshots.push_back(snapshot); - return true; - }); - - PersistenceSnapshot first = MakeRuntimeSnapshot("first"); - first.debounceAllowed = false; - PersistenceSnapshot second = MakeRuntimeSnapshot("second"); - second.debounceAllowed = false; - - std::string error; - Expect(writer.EnqueueSnapshot(first, error), "first immediate snapshot enqueues"); - Expect(writer.EnqueueSnapshot(second, error), "second immediate snapshot enqueues"); - Expect(writer.StopAndFlush(std::chrono::seconds(1), error), "flush drains immediate snapshots"); - - { - std::lock_guard lock(mutex); - Expect(writtenSnapshots.size() == 2, "immediate snapshots are written independently"); - Expect(writtenSnapshots.size() == 2 && writtenSnapshots[0].contents == "first" && writtenSnapshots[1].contents == "second", - "immediate snapshots preserve order"); - } -} - -void TestWriteFailureReportsStructuredResult() -{ - std::vector results; - PersistenceWriter writer( - std::chrono::milliseconds(1), - [](const PersistenceSnapshot&, std::string& error) { - error = "simulated failure"; - return false; - }); - writer.SetResultCallback([&results](const PersistenceWriteResult& result) { - results.push_back(result); - }); - - PersistenceSnapshot snapshot = MakeRuntimeSnapshot("payload"); - snapshot.debounceAllowed = false; - snapshot.reason = "failure-test"; - - std::string error; - Expect(writer.EnqueueSnapshot(snapshot, error), "failing snapshot still enqueues"); - Expect(writer.StopAndFlush(std::chrono::seconds(1), error), "flush reports failing snapshot result"); - - Expect(results.size() == 1, "writer reports one failure result"); - Expect(!results.empty() && !results[0].succeeded, "writer result records failure"); - Expect(!results.empty() && results[0].reason == "failure-test", "writer result preserves reason"); - Expect(!results.empty() && results[0].errorMessage == "simulated failure", "writer result preserves error message"); - Expect(!results.empty() && !results[0].newerRequestPending, "writer result reports no newer pending request"); - Expect(writer.GetMetrics().failedCount == 1, "writer metrics count failed writes"); -} - -void TestShutdownFlushDrainsPendingSnapshotAndRejectsNewRequests() -{ - std::mutex mutex; - std::vector writtenSnapshots; - PersistenceWriter writer( - std::chrono::milliseconds(1000), - [&](const PersistenceSnapshot& snapshot, std::string&) { - std::lock_guard lock(mutex); - writtenSnapshots.push_back(snapshot); - return true; - }); - - std::string error; - Expect(writer.EnqueueSnapshot(MakeRuntimeSnapshot("pending"), error), "pending snapshot enqueues before shutdown"); - Expect(writer.StopAndFlush(std::chrono::seconds(1), error), "bounded shutdown flush completes"); - - { - std::lock_guard lock(mutex); - Expect(writtenSnapshots.size() == 1, "shutdown flush writes pending debounced snapshot"); - Expect(!writtenSnapshots.empty() && writtenSnapshots[0].contents == "pending", "shutdown flush preserves pending snapshot contents"); - } - - Expect(!writer.EnqueueSnapshot(MakeRuntimeSnapshot("late"), error), "writer rejects requests after shutdown flush"); -} - -void TestShutdownFlushTimeoutCanBeRetried() -{ - std::mutex mutex; - std::condition_variable condition; - bool sinkStarted = false; - bool releaseSink = false; - PersistenceWriter writer( - std::chrono::milliseconds(1), - [&](const PersistenceSnapshot&, std::string&) { - std::unique_lock lock(mutex); - sinkStarted = true; - condition.notify_all(); - condition.wait(lock, [&]() { return releaseSink; }); - return true; - }); - - PersistenceSnapshot snapshot = MakeRuntimeSnapshot("slow"); - snapshot.debounceAllowed = false; - - std::string error; - Expect(writer.EnqueueSnapshot(snapshot, error), "slow snapshot enqueues"); - { - std::unique_lock lock(mutex); - Expect(condition.wait_for(lock, std::chrono::seconds(1), [&]() { return sinkStarted; }), - "slow sink starts before timeout test"); - } - - Expect(!writer.StopAndFlush(std::chrono::milliseconds(10), error), "bounded shutdown flush reports timeout"); - Expect(error.find("Timed out") != std::string::npos, "shutdown timeout returns a useful error"); - - { - std::lock_guard lock(mutex); - releaseSink = true; - } - condition.notify_all(); - error.clear(); - Expect(writer.StopAndFlush(std::chrono::seconds(1), error), "shutdown flush can complete after earlier timeout"); -} -} - -int main() -{ - TestDebouncedRequestsCoalesceToNewestSnapshot(); - TestImmediateRequestsAreNotCoalesced(); - TestWriteFailureReportsStructuredResult(); - TestShutdownFlushDrainsPendingSnapshotAndRejectsNewRequests(); - TestShutdownFlushTimeoutCanBeRetried(); - - if (gFailures != 0) - { - std::cerr << gFailures << " persistence writer test(s) failed.\n"; - return 1; - } - - std::cout << "Persistence writer tests passed.\n"; - return 0; -} diff --git a/tests/RenderCadenceCompositorAppConfigProviderTests.cpp b/tests/RenderCadenceCompositorAppConfigProviderTests.cpp index 25eff1d..e73e741 100644 --- a/tests/RenderCadenceCompositorAppConfigProviderTests.cpp +++ b/tests/RenderCadenceCompositorAppConfigProviderTests.cpp @@ -36,6 +36,7 @@ std::filesystem::path WriteConfigFixture() << " \"outputFrameRate\": \"60\",\n" << " \"autoReload\": false,\n" << " \"maxTemporalHistoryFrames\": 8,\n" + << " \"previewEnabled\": true,\n" << " \"previewFps\": 24,\n" << " \"enableExternalKeying\": true\n" << "}\n"; @@ -66,6 +67,7 @@ void TestLoadsRuntimeHostConfig() Expect(config.outputFrameRate == "60", "output frame rate loads"); Expect(!config.autoReload, "auto reload loads"); Expect(config.maxTemporalHistoryFrames == 8, "history length loads"); + Expect(config.previewEnabled, "preview enabled toggle loads"); Expect(config.previewFps == 24.0, "preview fps loads"); Expect(config.deckLink.externalKeyingEnabled, "external keying loads"); @@ -91,6 +93,15 @@ void TestCommandLineOverrides() Expect(config.http.preferredPort == 8282, "port CLI override applies"); } +void TestPreviewDefaultsAreOptIn() +{ + using namespace RenderCadenceCompositor; + + const AppConfig config = DefaultAppConfig(); + Expect(!config.previewEnabled, "preview is disabled by default"); + Expect(config.previewFps == 30.0, "preview fps default is 30"); +} + void TestHelpers() { using namespace RenderCadenceCompositor; @@ -118,6 +129,7 @@ int main() { TestLoadsRuntimeHostConfig(); TestCommandLineOverrides(); + TestPreviewDefaultsAreOptIn(); TestHelpers(); if (gFailures != 0) diff --git a/tests/RenderCadenceCompositorFrameExchangeTests.cpp b/tests/RenderCadenceCompositorFrameExchangeTests.cpp index 62bd540..bac73ec 100644 --- a/tests/RenderCadenceCompositorFrameExchangeTests.cpp +++ b/tests/RenderCadenceCompositorFrameExchangeTests.cpp @@ -2,6 +2,7 @@ #include #include +#include #include namespace @@ -184,6 +185,127 @@ void TestCompletedPollMissIsCounted() Expect(metrics.completedPollMisses == 1, "completed poll miss is counted"); } +void TestLatestPublishedFrameCanBePreviewedWithoutConsuming() +{ + SystemFrameExchange exchange(MakeConfig(2)); + + SystemFrame frame; + Expect(exchange.AcquireForRender(frame), "copy snapshot frame can be acquired"); + frame.frameIndex = 77; + const unsigned char marker = 0x42; + std::memset(frame.bytes, marker, static_cast(frame.rowBytes) * frame.height); + Expect(exchange.PublishCompleted(frame), "copy snapshot frame can be completed"); + + SystemFrame preview; + Expect(exchange.TryAcquireLatestForPreview(preview), "latest published frame can be acquired for preview"); + Expect(preview.frameIndex == 77, "preview frame keeps frame index"); + Expect(preview.width == 4 && preview.height == 3, "preview frame keeps frame dimensions"); + Expect(preview.bytes != nullptr && static_cast(preview.bytes)[0] == marker, "preview frame points at frame bytes"); + Expect(exchange.ReleasePreviewFrame(preview), "preview frame can be released"); + + SystemFrame scheduled; + Expect(exchange.ConsumeCompletedForSchedule(scheduled), "previewing frame does not consume completed frame"); +} + +void TestLatestPublishedFrameCanPreviewScheduledFrame() +{ + SystemFrameExchange exchange(MakeConfig(1)); + + SystemFrame frame; + Expect(exchange.AcquireForRender(frame), "scheduled snapshot frame can be acquired"); + frame.frameIndex = 88; + Expect(exchange.PublishCompleted(frame), "scheduled snapshot frame can be completed"); + + SystemFrame scheduled; + Expect(exchange.ConsumeCompletedForSchedule(scheduled), "snapshot test frame can be scheduled"); + + SystemFrame preview; + Expect(exchange.TryAcquireLatestForPreview(preview), "latest scheduled frame can be acquired for preview"); + Expect(preview.frameIndex == 88, "scheduled preview keeps frame index"); + Expect(exchange.ReleasePreviewFrame(preview), "scheduled preview frame can be released"); +} + +void TestPreviewFramePinsReleasedSlotUntilPreviewRelease() +{ + SystemFrameExchange exchange(MakeConfig(1)); + + SystemFrame frame; + Expect(exchange.AcquireForRender(frame), "preview pin frame can be acquired"); + frame.frameIndex = 99; + Expect(exchange.PublishCompleted(frame), "preview pin frame can be completed"); + + SystemFrame preview; + Expect(exchange.TryAcquireLatestForPreview(preview), "preview can acquire frame before schedule"); + + SystemFrame scheduled; + Expect(exchange.ConsumeCompletedForSchedule(scheduled), "previewed frame can still be scheduled"); + Expect(exchange.ReleaseScheduledByBytes(scheduled.bytes), "scheduled frame can be released while preview holds it"); + + SystemFrame blocked; + Expect(!exchange.AcquireForRender(blocked), "preview reader prevents immediate slot reuse"); + Expect(exchange.ReleasePreviewFrame(preview), "preview pin can be released"); + Expect(exchange.AcquireForRender(blocked), "slot can be reused after preview release"); +} + +void TestMultiplePreviewReadersPinReleasedSlotUntilAllRelease() +{ + SystemFrameExchange exchange(MakeConfig(1)); + + SystemFrame frame; + Expect(exchange.AcquireForRender(frame), "multi-preview frame can be acquired"); + frame.frameIndex = 100; + Expect(exchange.PublishCompleted(frame), "multi-preview frame can be completed"); + + SystemFrame firstPreview; + SystemFrame secondPreview; + Expect(exchange.TryAcquireLatestForPreview(firstPreview), "first preview reader can acquire"); + Expect(exchange.TryAcquireLatestForPreview(secondPreview), "second preview reader can acquire"); + + SystemFrame scheduled; + Expect(exchange.ConsumeCompletedForSchedule(scheduled), "multi-preview frame can be scheduled"); + Expect(exchange.ReleaseScheduledByBytes(scheduled.bytes), "multi-preview scheduled frame can be released"); + + SystemFrame blocked; + Expect(!exchange.AcquireForRender(blocked), "slot remains pinned while two preview readers exist"); + Expect(exchange.ReleasePreviewFrame(firstPreview), "first preview reader can release"); + Expect(!exchange.AcquireForRender(blocked), "slot remains pinned until last preview reader releases"); + Expect(exchange.ReleasePreviewFrame(secondPreview), "second preview reader can release"); + Expect(exchange.AcquireForRender(blocked), "slot is reusable after all preview readers release"); +} + +void TestInvalidPreviewReleaseIsRejected() +{ + SystemFrameExchange exchange(MakeConfig(1)); + + SystemFrame invalid; + Expect(!exchange.ReleasePreviewFrame(invalid), "empty preview frame release is rejected"); + + SystemFrame frame; + Expect(exchange.AcquireForRender(frame), "invalid release source frame can be acquired"); + frame.frameIndex = 101; + Expect(exchange.PublishCompleted(frame), "invalid release source frame can be completed"); + + SystemFrame preview; + Expect(exchange.TryAcquireLatestForPreview(preview), "preview frame can be acquired for invalid release test"); + Expect(exchange.ReleasePreviewFrame(preview), "valid preview release succeeds"); + Expect(!exchange.ReleasePreviewFrame(preview), "double preview release is rejected"); +} + +void TestStalePreviewReleaseIsRejectedAfterReconfigure() +{ + SystemFrameExchange exchange(MakeConfig(1)); + + SystemFrame frame; + Expect(exchange.AcquireForRender(frame), "stale preview source frame can be acquired"); + frame.frameIndex = 102; + Expect(exchange.PublishCompleted(frame), "stale preview source frame can be completed"); + + SystemFrame preview; + Expect(exchange.TryAcquireLatestForPreview(preview), "preview frame can be acquired before reconfigure"); + exchange.Configure(MakeConfig(1)); + Expect(!exchange.ReleasePreviewFrame(preview), "preview release after reconfigure is rejected as stale"); +} + void TestStableCompletedDepthCanBeObserved() { SystemFrameExchange exchange(MakeConfig(1)); @@ -214,6 +336,12 @@ int main() TestGenerationValidationRejectsStaleFrames(); TestPixelFormatAwareSizing(); TestCompletedPollMissIsCounted(); + TestLatestPublishedFrameCanBePreviewedWithoutConsuming(); + TestLatestPublishedFrameCanPreviewScheduledFrame(); + TestPreviewFramePinsReleasedSlotUntilPreviewRelease(); + TestMultiplePreviewReadersPinReleasedSlotUntilAllRelease(); + TestInvalidPreviewReleaseIsRejected(); + TestStalePreviewReleaseIsRejectedAfterReconfigure(); TestStableCompletedDepthCanBeObserved(); TestStableCompletedDepthTimesOut(); diff --git a/tests/RenderCadenceCompositorPreviewConfigTests.cpp b/tests/RenderCadenceCompositorPreviewConfigTests.cpp new file mode 100644 index 0000000..c7f2e3e --- /dev/null +++ b/tests/RenderCadenceCompositorPreviewConfigTests.cpp @@ -0,0 +1,50 @@ +#include "PreviewConfig.h" + +#include + +namespace +{ +int gFailures = 0; + +void Expect(bool condition, const char* message) +{ + if (condition) + return; + + std::cerr << "FAIL: " << message << "\n"; + ++gFailures; +} + +void TestTimerIntervalUsesConfiguredFps() +{ + Expect(RenderCadenceCompositor::PreviewTimerIntervalMilliseconds(25.0) == 40, "25 fps maps to 40 ms"); + Expect(RenderCadenceCompositor::PreviewTimerIntervalMilliseconds(50.0) == 20, "50 fps maps to 20 ms"); +} + +void TestInvalidFpsUsesDefault() +{ + Expect( + RenderCadenceCompositor::PreviewTimerIntervalMilliseconds(0.0) == + RenderCadenceCompositor::PreviewTimerIntervalMilliseconds(RenderCadenceCompositor::kDefaultPreviewFps), + "zero preview fps uses default interval"); + Expect( + RenderCadenceCompositor::PreviewTimerIntervalMilliseconds(-10.0) == + RenderCadenceCompositor::PreviewTimerIntervalMilliseconds(RenderCadenceCompositor::kDefaultPreviewFps), + "negative preview fps uses default interval"); +} +} + +int main() +{ + TestTimerIntervalUsesConfiguredFps(); + TestInvalidFpsUsesDefault(); + + if (gFailures != 0) + { + std::cerr << gFailures << " preview config test failure(s).\n"; + return 1; + } + + std::cout << "RenderCadenceCompositor preview config tests passed.\n"; + return 0; +} diff --git a/tests/RenderCommandQueueTests.cpp b/tests/RenderCommandQueueTests.cpp deleted file mode 100644 index 0efafdb..0000000 --- a/tests/RenderCommandQueueTests.cpp +++ /dev/null @@ -1,152 +0,0 @@ -#include "RenderCommandQueue.h" - -#include - -namespace -{ -int gFailures = 0; - -void Expect(bool condition, const char* message) -{ - if (condition) - return; - - std::cerr << "FAIL: " << message << "\n"; - ++gFailures; -} - -void TestPreviewRequestUsesLatestValue() -{ - RenderCommandQueue queue; - queue.RequestPreviewPresent({ 1920, 1080 }); - queue.RequestPreviewPresent({ 1280, 720 }); - - const RenderCommandQueueMetrics metrics = queue.GetMetrics(); - Expect(metrics.depth == 1, "preview requests coalesce to one pending command"); - Expect(metrics.enqueuedCount == 1, "first preview request is counted as enqueued"); - Expect(metrics.coalescedCount == 1, "second preview request is counted as coalesced"); - - RenderPreviewPresentRequest request; - Expect(queue.TryTakePreviewPresent(request), "preview request can be consumed"); - Expect(request.outputFrameWidth == 1280 && request.outputFrameHeight == 720, "latest preview request wins"); - Expect(!queue.TryTakePreviewPresent(request), "preview request is removed after consume"); - Expect(queue.GetMetrics().depth == 0, "preview consume empties queue depth"); -} - -void TestScreenshotRequestUsesLatestValue() -{ - RenderCommandQueue queue; - queue.RequestScreenshotCapture({ 640, 360 }); - queue.RequestScreenshotCapture({ 3840, 2160 }); - - RenderScreenshotCaptureRequest request; - Expect(queue.TryTakeScreenshotCapture(request), "screenshot request can be consumed"); - Expect(request.width == 3840 && request.height == 2160, "latest screenshot request wins"); - Expect(!queue.TryTakeScreenshotCapture(request), "screenshot request is removed after consume"); -} - -void TestRenderResetScopesCoalesceToStrongestRequest() -{ - RenderCommandQueue queue; - queue.RequestRenderReset(RenderCommandResetScope::TemporalHistoryOnly); - queue.RequestRenderReset(RenderCommandResetScope::ShaderFeedbackOnly); - - RenderCommandResetScope scope = RenderCommandResetScope::None; - Expect(queue.TryTakeRenderReset(scope), "render reset request can be consumed"); - Expect(scope == RenderCommandResetScope::TemporalHistoryAndFeedback, "temporal and feedback reset requests merge"); - Expect(!queue.TryTakeRenderReset(scope), "render reset request is removed after consume"); - - queue.RequestRenderReset(RenderCommandResetScope::None); - Expect(queue.GetMetrics().depth == 0, "none reset request is ignored"); -} - -void TestInputUploadRequestUsesLatestValue() -{ - int firstPixel = 1; - int secondPixel = 2; - RenderCommandQueue queue; - - RenderInputUploadRequest firstRequest; - firstRequest.inputFrame.bytes = &firstPixel; - firstRequest.inputFrame.width = 1920; - firstRequest.videoState.captureTextureWidth = 1920; - firstRequest.ownedBytes = { 1, 2, 3, 4 }; - queue.RequestInputUpload(firstRequest); - - RenderInputUploadRequest secondRequest; - secondRequest.inputFrame.bytes = &secondPixel; - secondRequest.inputFrame.width = 1280; - secondRequest.videoState.captureTextureWidth = 1280; - secondRequest.ownedBytes = { 5, 6 }; - queue.RequestInputUpload(secondRequest); - - const RenderCommandQueueMetrics metrics = queue.GetMetrics(); - Expect(metrics.depth == 1, "input upload requests coalesce to one pending command"); - Expect(metrics.enqueuedCount == 1, "first input upload request is counted as enqueued"); - Expect(metrics.coalescedCount == 1, "second input upload request is counted as coalesced"); - - RenderInputUploadRequest request; - Expect(queue.TryTakeInputUpload(request), "input upload request can be consumed"); - Expect(request.inputFrame.bytes == &secondPixel, "latest input upload bytes pointer wins"); - Expect(request.inputFrame.width == 1280, "latest input upload frame wins"); - Expect(request.videoState.captureTextureWidth == 1280, "latest input upload state wins"); - Expect(request.ownedBytes.size() == 2 && request.ownedBytes[0] == 5 && request.ownedBytes[1] == 6, "latest input upload owned bytes win"); - Expect(!queue.TryTakeInputUpload(request), "input upload request is removed after consume"); -} - -void TestOutputFrameRequestsAreFifo() -{ - RenderCommandQueue queue; - RenderOutputFrameRequest firstRequest; - firstRequest.videoState.outputFrameSize.width = 1920; - firstRequest.completion.result = VideoIOCompletionResult::Completed; - queue.RequestOutputFrame(firstRequest); - - RenderOutputFrameRequest secondRequest; - secondRequest.videoState.outputFrameSize.width = 1280; - secondRequest.completion.result = VideoIOCompletionResult::Dropped; - queue.RequestOutputFrame(secondRequest); - - Expect(queue.GetMetrics().depth == 2, "output frame requests are queued independently"); - - RenderOutputFrameRequest request; - Expect(queue.TryTakeOutputFrame(request), "first output request can be consumed"); - Expect(request.videoState.outputFrameSize.width == 1920, "first output request is consumed first"); - Expect(request.completion.result == VideoIOCompletionResult::Completed, "first output completion is preserved"); - Expect(queue.TryTakeOutputFrame(request), "second output request can be consumed"); - Expect(request.videoState.outputFrameSize.width == 1280, "second output request is consumed second"); - Expect(request.completion.result == VideoIOCompletionResult::Dropped, "second output completion is preserved"); - Expect(!queue.TryTakeOutputFrame(request), "output queue is empty after consuming all requests"); -} - -void TestIndependentCommandKindsShareDepth() -{ - RenderCommandQueue queue; - queue.RequestPreviewPresent({ 1, 2 }); - queue.RequestScreenshotCapture({ 3, 4 }); - queue.RequestInputUpload({}); - queue.RequestOutputFrame({}); - queue.RequestRenderReset(RenderCommandResetScope::TemporalHistoryOnly); - - Expect(queue.GetMetrics().depth == 5, "independent command kinds each contribute to depth"); -} -} - -int main() -{ - TestPreviewRequestUsesLatestValue(); - TestScreenshotRequestUsesLatestValue(); - TestRenderResetScopesCoalesceToStrongestRequest(); - TestInputUploadRequestUsesLatestValue(); - TestOutputFrameRequestsAreFifo(); - TestIndependentCommandKindsShareDepth(); - - if (gFailures != 0) - { - std::cerr << gFailures << " RenderCommandQueue test failure(s).\n"; - return 1; - } - - std::cout << "RenderCommandQueue tests passed.\n"; - return 0; -} diff --git a/tests/RuntimeClockTests.cpp b/tests/RuntimeClockTests.cpp deleted file mode 100644 index 6b274a0..0000000 --- a/tests/RuntimeClockTests.cpp +++ /dev/null @@ -1,50 +0,0 @@ -#include "RuntimeClock.h" - -#include -#include - -namespace -{ -int gFailures = 0; - -void Expect(bool condition, const char* message) -{ - if (condition) - return; - - std::cerr << "FAIL: " << message << "\n"; - ++gFailures; -} - -void TestUtcSecondsOfDay() -{ - const RuntimeClockSnapshot midnight = MakeRuntimeClockSnapshot(0); - Expect(midnight.utcTimeSeconds == 0.0, "Unix epoch starts at UTC midnight"); - - const RuntimeClockSnapshot midday = MakeRuntimeClockSnapshot(12 * 3600 + 34 * 60 + 56); - Expect(midday.utcTimeSeconds == 45296.0, "UTC time of day is seconds since midnight"); -} - -void TestOffsetLooksLikeTimezoneOffset() -{ - const RuntimeClockSnapshot snapshot = MakeRuntimeClockSnapshot(12 * 3600); - Expect(std::fmod(snapshot.utcOffsetSeconds, 60.0) == 0.0, "UTC offset is minute-aligned"); - Expect(snapshot.utcOffsetSeconds >= -14.0 * 3600.0 && snapshot.utcOffsetSeconds <= 14.0 * 3600.0, - "UTC offset is in the normal timezone range"); -} -} - -int main() -{ - TestUtcSecondsOfDay(); - TestOffsetLooksLikeTimezoneOffset(); - - if (gFailures != 0) - { - std::cerr << gFailures << " RuntimeClock test failure(s).\n"; - return 1; - } - - std::cout << "RuntimeClock tests passed.\n"; - return 0; -} diff --git a/tests/RuntimeEventTypeTests.cpp b/tests/RuntimeEventTypeTests.cpp deleted file mode 100644 index d447813..0000000 --- a/tests/RuntimeEventTypeTests.cpp +++ /dev/null @@ -1,726 +0,0 @@ -#include "RuntimeEvent.h" -#include "RuntimeEventCoalescingQueue.h" -#include "RuntimeEventDispatcher.h" -#include "RuntimeEventQueue.h" -#include "RuntimeEventType.h" -#include "RuntimeEventPayloads.h" -#include "RuntimeEventTestHarness.h" - -#include -#include -#include -#include -#include - -namespace -{ -int gFailures = 0; - -void Expect(bool condition, const char* message) -{ - if (condition) - return; - - std::cerr << "FAIL: " << message << "\n"; - ++gFailures; -} - -void TestRuntimeEventTypeNames() -{ - Expect(RuntimeEventTypeName(RuntimeEventType::Unknown) == "Unknown", "unknown event type has a stable name"); - Expect(RuntimeEventTypeName(RuntimeEventType::OscCommitRequested) == "OscCommitRequested", "control event name is stable"); - Expect(RuntimeEventTypeName(RuntimeEventType::RuntimeMutationAccepted) == "RuntimeMutationAccepted", "runtime event name is stable"); - Expect(RuntimeEventTypeName(RuntimeEventType::ShaderBuildPrepared) == "ShaderBuildPrepared", "shader build event name is stable"); - Expect(RuntimeEventTypeName(RuntimeEventType::RenderSnapshotPublished) == "RenderSnapshotPublished", "render event name is stable"); - Expect(RuntimeEventTypeName(RuntimeEventType::BackendStateChanged) == "BackendStateChanged", "backend event name is stable"); - Expect(RuntimeEventTypeName(RuntimeEventType::QueueDepthChanged) == "QueueDepthChanged", "telemetry event name is stable"); -} - -void TestRuntimeEventPayloadTypes() -{ - OscCommitRequestedEvent oscCommit; - oscCommit.routeKey = "layer-1\namount"; - oscCommit.layerKey = "layer-1"; - oscCommit.parameterKey = "amount"; - oscCommit.generation = 42; - Expect(RuntimeEventPayloadType(oscCommit) == RuntimeEventType::OscCommitRequested, "OSC commit payload maps to OSC commit event type"); - Expect(oscCommit.generation == 42, "OSC commit payload keeps generation"); - - RuntimeMutationEvent acceptedMutation; - acceptedMutation.action = "SetLayerShader"; - acceptedMutation.accepted = true; - acceptedMutation.shaderBuildRequested = true; - acceptedMutation.persistenceRequested = true; - Expect(RuntimeEventPayloadType(acceptedMutation) == RuntimeEventType::RuntimeMutationAccepted, "accepted mutation payload maps to accepted event type"); - Expect(acceptedMutation.shaderBuildRequested, "mutation payload carries shader build follow-up"); - Expect(acceptedMutation.persistenceRequested, "mutation payload carries persistence follow-up"); - - RuntimeMutationEvent rejectedMutation; - rejectedMutation.accepted = false; - rejectedMutation.errorMessage = "Unknown layer."; - Expect(RuntimeEventPayloadType(rejectedMutation) == RuntimeEventType::RuntimeMutationRejected, "rejected mutation payload maps to rejected event type"); - Expect(rejectedMutation.errorMessage == "Unknown layer.", "mutation payload carries rejection error"); - - RuntimePersistenceRequestedEvent persistence; - persistence.request = PersistenceRequest::RuntimeStateRequest("UpdateLayerParameter"); - Expect(RuntimeEventPayloadType(persistence) == RuntimeEventType::RuntimePersistenceRequested, "runtime persistence payload maps to persistence event type"); - Expect(persistence.request.targetKind == PersistenceTargetKind::RuntimeState, "runtime persistence payload carries target kind"); - Expect(persistence.request.reason == "UpdateLayerParameter", "runtime persistence payload carries request reason"); - Expect(persistence.request.debounceAllowed, "runtime persistence payload carries debounce policy"); - Expect(persistence.request.debounceKey == "runtime-state", "runtime persistence payload carries debounce key"); - - PersistenceSnapshot persistenceSnapshot; - persistenceSnapshot.targetKind = PersistenceTargetKind::RuntimeState; - persistenceSnapshot.reason = persistence.request.reason; - persistenceSnapshot.contents = "{}"; - Expect(persistenceSnapshot.reason == "UpdateLayerParameter", "persistence snapshot carries capture reason"); - Expect(persistenceSnapshot.contents == "{}", "persistence snapshot carries serialized content"); - - FileChangeDetectedEvent fileChange; - fileChange.path = "PollRuntimeStoreChanges"; - fileChange.shaderPackageCandidate = true; - Expect(RuntimeEventPayloadType(fileChange) == RuntimeEventType::FileChangeDetected, "file change payload maps to file change event type"); - Expect(fileChange.shaderPackageCandidate, "file change payload carries shader package candidate flag"); - - ManualReloadRequestedEvent manualReload; - manualReload.preserveFeedbackState = true; - manualReload.reason = "RequestShaderReload"; - Expect(RuntimeEventPayloadType(manualReload) == RuntimeEventType::ManualReloadRequested, "manual reload payload maps to manual reload event type"); - Expect(manualReload.preserveFeedbackState, "manual reload payload carries feedback preservation policy"); - - ShaderBuildEvent preparedBuild; - preparedBuild.phase = RuntimeEventShaderBuildPhase::Prepared; - preparedBuild.inputWidth = 1920; - preparedBuild.inputHeight = 1080; - Expect(RuntimeEventPayloadType(preparedBuild) == RuntimeEventType::ShaderBuildPrepared, "shader build payload maps by phase"); - Expect(preparedBuild.inputWidth == 1920 && preparedBuild.inputHeight == 1080, "shader build payload carries input dimensions"); - - RenderResetEvent appliedReset; - appliedReset.scope = RuntimeEventRenderResetScope::TemporalHistoryAndFeedback; - appliedReset.applied = true; - Expect(RuntimeEventPayloadType(appliedReset) == RuntimeEventType::RenderResetApplied, "render reset payload maps applied state"); - Expect(appliedReset.scope == RuntimeEventRenderResetScope::TemporalHistoryAndFeedback, "render reset payload carries reset scope"); - - RenderSnapshotPublishRequestedEvent snapshotRequest; - snapshotRequest.outputWidth = 1920; - snapshotRequest.outputHeight = 1080; - snapshotRequest.reason = "test"; - Expect(RuntimeEventPayloadType(snapshotRequest) == RuntimeEventType::RenderSnapshotPublishRequested, "render snapshot request payload maps to request event"); - - RenderSnapshotPublishedEvent snapshotPublished; - snapshotPublished.snapshotVersion = 3; - snapshotPublished.parameterVersion = 4; - snapshotPublished.layerCount = 2; - Expect(RuntimeEventPayloadType(snapshotPublished) == RuntimeEventType::RenderSnapshotPublished, "render snapshot published payload maps to published event"); - Expect(snapshotPublished.layerCount == 2, "render snapshot published payload carries layer count"); - - OutputFrameCompletedEvent completedFrame; - completedFrame.result = "Completed"; - Expect(RuntimeEventPayloadType(completedFrame) == RuntimeEventType::OutputFrameCompleted, "completed output frame payload maps to completed event"); - completedFrame.result = "DisplayedLate"; - Expect(RuntimeEventPayloadType(completedFrame) == RuntimeEventType::OutputLateFrameDetected, "late output frame payload maps to late event"); - completedFrame.result = "Dropped"; - Expect(RuntimeEventPayloadType(completedFrame) == RuntimeEventType::OutputDroppedFrameDetected, "dropped output frame payload maps to dropped event"); - - TimingSampleRecordedEvent timingSample; - timingSample.subsystem = "RuntimeEventDispatcher"; - timingSample.metric = "dispatchDuration"; - timingSample.value = 0.5; - timingSample.unit = "ms"; - Expect(RuntimeEventPayloadType(timingSample) == RuntimeEventType::TimingSampleRecorded, "timing sample payload maps to timing event"); - - QueueDepthChangedEvent queueDepth; - queueDepth.queueName = "runtime-events"; - queueDepth.depth = 1; - queueDepth.capacity = 16; - Expect(RuntimeEventPayloadType(queueDepth) == RuntimeEventType::QueueDepthChanged, "queue depth payload maps to queue depth event"); - - SubsystemWarningEvent warning; - warning.subsystem = "VideoBackend"; - warning.warningKey = "late-frame"; - Expect(RuntimeEventPayloadType(warning) == RuntimeEventType::SubsystemWarningRaised, "warning payload maps to raised event by default"); - warning.cleared = true; - Expect(RuntimeEventPayloadType(warning) == RuntimeEventType::SubsystemWarningCleared, "warning payload maps to cleared event when marked cleared"); -} - -void TestRuntimeEventEnvelope() -{ - const auto createdAt = std::chrono::steady_clock::now(); - - OscCommitRequestedEvent oscCommit; - oscCommit.routeKey = "layer-1\namount"; - oscCommit.layerKey = "layer-1"; - oscCommit.parameterKey = "amount"; - oscCommit.generation = 7; - - RuntimeEvent event = MakeRuntimeEvent(oscCommit, "ControlServices", 12, createdAt); - Expect(event.type == RuntimeEventType::OscCommitRequested, "runtime event infers type from payload"); - Expect(event.sequence == 12, "runtime event stores sequence"); - Expect(event.source == "ControlServices", "runtime event stores source"); - Expect(event.createdAt == createdAt, "runtime event stores creation time"); - Expect(event.HasPayload(), "runtime event reports payload presence"); - Expect(event.PayloadMatchesType(), "runtime event payload matches inferred type"); - - const auto* payload = std::get_if(&event.payload); - Expect(payload && payload->generation == 7, "runtime event stores typed payload in variant"); - - event.type = RuntimeEventType::RuntimeMutationAccepted; - Expect(!event.PayloadMatchesType(), "runtime event detects mismatched payload and type"); - - RuntimeEvent empty; - Expect(!empty.HasPayload(), "default runtime event has no payload"); - Expect(empty.PayloadMatchesType(), "default runtime event unknown type matches empty payload"); - - RuntimeMutationEvent acceptedMutation; - acceptedMutation.accepted = true; - acceptedMutation.action = "SetLayerBypass"; - RuntimeEvent acceptedEvent = MakeRuntimeEvent(acceptedMutation, "RuntimeCoordinator", 13, createdAt); - Expect(acceptedEvent.type == RuntimeEventType::RuntimeMutationAccepted, "runtime event handles payloads with dynamic accepted mapping"); - - RuntimeMutationEvent rejectedMutation; - rejectedMutation.accepted = false; - rejectedMutation.action = "SetLayerBypass"; - RuntimeEvent rejectedEvent = MakeRuntimeEvent(rejectedMutation, "RuntimeCoordinator", 14, createdAt); - Expect(rejectedEvent.type == RuntimeEventType::RuntimeMutationRejected, "runtime event handles payloads with dynamic rejected mapping"); -} - -void TestRuntimeEventQueue() -{ - RuntimeEventQueue queue(2); - const auto createdAt = std::chrono::steady_clock::now() - std::chrono::milliseconds(5); - - RuntimeStateBroadcastRequestedEvent firstPayload; - firstPayload.reason = "first"; - RuntimeStateBroadcastRequestedEvent secondPayload; - secondPayload.reason = "second"; - RuntimeStateBroadcastRequestedEvent thirdPayload; - thirdPayload.reason = "third"; - - Expect(queue.Push(MakeRuntimeEvent(firstPayload, "test", 1, createdAt)), "queue accepts first event"); - Expect(queue.Push(MakeRuntimeEvent(secondPayload, "test", 2, createdAt)), "queue accepts second event"); - Expect(!queue.Push(MakeRuntimeEvent(thirdPayload, "test", 3, createdAt)), "queue rejects event when capacity is full"); - - RuntimeEventQueueMetrics fullMetrics = queue.GetMetrics(std::chrono::steady_clock::now()); - Expect(fullMetrics.depth == 2, "queue metrics report depth"); - Expect(fullMetrics.capacity == 2, "queue metrics report capacity"); - Expect(fullMetrics.droppedCount == 1, "queue metrics report dropped count"); - Expect(fullMetrics.oldestEventAgeMilliseconds >= 0.0, "queue metrics report oldest event age"); - - RuntimeEvent event; - Expect(queue.TryPop(event), "queue pops first event"); - Expect(event.sequence == 1, "queue preserves FIFO order"); - - std::vector drained = queue.Drain(); - Expect(drained.size() == 1, "queue drains remaining event"); - Expect(drained[0].sequence == 2, "drained event preserves FIFO order"); - Expect(queue.Depth() == 0, "queue is empty after drain"); -} - -void TestRuntimeEventDispatcher() -{ - RuntimeEventDispatcher dispatcher(4); - int allHandlerCount = 0; - int broadcastHandlerCount = 0; - int failureHandlerCount = 0; - - dispatcher.SubscribeAll([&](const RuntimeEvent& event) { - Expect(event.sequence != 0, "dispatcher assigns sequence before all-handler dispatch"); - ++allHandlerCount; - }); - dispatcher.Subscribe(RuntimeEventType::RuntimeStateBroadcastRequested, [&](const RuntimeEvent& event) { - Expect(event.type == RuntimeEventType::RuntimeStateBroadcastRequested, "dispatcher invokes type-specific handler for matching event"); - ++broadcastHandlerCount; - }); - dispatcher.Subscribe(RuntimeEventType::RuntimeStateBroadcastRequested, [&](const RuntimeEvent&) { - ++failureHandlerCount; - throw std::runtime_error("test handler failure"); - }); - - RuntimeStateBroadcastRequestedEvent broadcast; - broadcast.reason = "test"; - Expect(dispatcher.PublishPayload(broadcast, "test"), "dispatcher publishes payload"); - Expect(dispatcher.QueueDepth() == 1, "dispatcher reports queued depth"); - - RuntimeEventDispatchResult result = dispatcher.DispatchPending(); - Expect(result.dispatchedEvents == 1, "dispatcher reports dispatched event count"); - Expect(result.handlerInvocations == 3, "dispatcher reports handler invocation count"); - Expect(result.handlerFailures == 1, "dispatcher catches and reports handler failures"); - Expect(allHandlerCount == 1, "dispatcher invoked all-handler"); - Expect(broadcastHandlerCount == 1, "dispatcher invoked type-specific handler"); - Expect(failureHandlerCount == 1, "dispatcher invoked failing handler"); - Expect(dispatcher.QueueDepth() == 0, "dispatcher queue is empty after dispatch"); - - RuntimeEvent mismatched = MakeRuntimeEvent(broadcast, "test"); - mismatched.type = RuntimeEventType::ShaderBuildRequested; - Expect(!dispatcher.Publish(mismatched), "dispatcher rejects mismatched event type and payload"); - - RuntimeEventDispatcher tinyDispatcher(1); - RuntimeMutationEvent acceptedMutation; - acceptedMutation.accepted = true; - Expect(tinyDispatcher.PublishPayload(acceptedMutation, "test"), "tiny dispatcher accepts first FIFO event"); - Expect(!tinyDispatcher.PublishPayload(acceptedMutation, "test"), "tiny dispatcher rejects FIFO event when queue is full"); - RuntimeEventQueueMetrics metrics = tinyDispatcher.GetQueueMetrics(); - Expect(metrics.droppedCount == 1, "dispatcher exposes queue drop metrics"); -} - -void TestRuntimeEventDispatcherCoalescing() -{ - RuntimeEventDispatcher dispatcher(4); - std::string seenReason; - std::string seenShaderMessage; - double seenTimingValue = 0.0; - int broadcastHandlerCount = 0; - int shaderHandlerCount = 0; - int timingHandlerCount = 0; - - dispatcher.Subscribe(RuntimeEventType::RuntimeStateBroadcastRequested, [&](const RuntimeEvent& event) { - const auto* payload = std::get_if(&event.payload); - if (payload) - seenReason = payload->reason; - ++broadcastHandlerCount; - }); - dispatcher.Subscribe(RuntimeEventType::ShaderBuildRequested, [&](const RuntimeEvent& event) { - const auto* payload = std::get_if(&event.payload); - if (payload) - seenShaderMessage = payload->message; - ++shaderHandlerCount; - }); - dispatcher.Subscribe(RuntimeEventType::TimingSampleRecorded, [&](const RuntimeEvent& event) { - const auto* payload = std::get_if(&event.payload); - if (payload) - seenTimingValue = payload->value; - ++timingHandlerCount; - }); - - RuntimeStateBroadcastRequestedEvent first; - first.reason = "parameter"; - RuntimeStateBroadcastRequestedEvent second; - second.reason = "reload"; - - Expect(dispatcher.PublishPayload(first, "RuntimeCoordinator"), "dispatcher accepts first coalescable event"); - Expect(dispatcher.PublishPayload(second, "RuntimeCoordinator"), "dispatcher coalesces second matching event"); - RuntimeEventQueueMetrics queuedMetrics = dispatcher.GetQueueMetrics(); - Expect(queuedMetrics.depth == 1, "dispatcher reports coalesced event depth"); - Expect(queuedMetrics.coalescedCount == 1, "dispatcher reports coalesced event count"); - - RuntimeEventDispatchResult result = dispatcher.DispatchPending(); - Expect(result.dispatchedEvents == 1, "dispatcher dispatches one coalesced event"); - Expect(broadcastHandlerCount == 1, "dispatcher invokes handler once for coalesced event"); - Expect(seenReason == "reload", "dispatcher dispatches latest coalesced payload"); - - ShaderBuildEvent shaderFirst; - shaderFirst.phase = RuntimeEventShaderBuildPhase::Requested; - shaderFirst.inputWidth = 1920; - shaderFirst.inputHeight = 1080; - shaderFirst.preserveFeedbackState = true; - shaderFirst.message = "first"; - ShaderBuildEvent shaderSecond = shaderFirst; - shaderSecond.message = "second"; - Expect(dispatcher.PublishPayload(shaderFirst, "RuntimeCoordinator"), "dispatcher accepts first shader build request"); - Expect(dispatcher.PublishPayload(shaderSecond, "RuntimeCoordinator"), "dispatcher coalesces matching shader build request"); - result = dispatcher.DispatchPending(); - Expect(result.dispatchedEvents == 1, "dispatcher dispatches one coalesced shader build request"); - Expect(shaderHandlerCount == 1, "dispatcher invokes shader handler once for matching coalesced request"); - Expect(seenShaderMessage == "second", "dispatcher dispatches latest shader build request payload"); - - TimingSampleRecordedEvent timingFirst; - timingFirst.subsystem = "RuntimeEventDispatcher"; - timingFirst.metric = "dispatchDuration"; - timingFirst.value = 1.0; - timingFirst.unit = "ms"; - TimingSampleRecordedEvent timingSecond = timingFirst; - timingSecond.value = 2.0; - Expect(dispatcher.PublishPayload(timingFirst, "HealthTelemetry"), "dispatcher accepts first timing sample"); - Expect(dispatcher.PublishPayload(timingSecond, "HealthTelemetry"), "dispatcher coalesces matching timing sample"); - result = dispatcher.DispatchPending(); - Expect(result.dispatchedEvents == 1, "dispatcher dispatches one coalesced timing sample"); - Expect(timingHandlerCount == 1, "dispatcher invokes timing handler once for matching coalesced sample"); - Expect(seenTimingValue == 2.0, "dispatcher dispatches latest timing sample payload"); -} - -void TestRuntimeEventCoalescingQueue() -{ - RuntimeEventCoalescingQueue queue(2); - const auto createdAt = std::chrono::steady_clock::now() - std::chrono::milliseconds(5); - - OscValueReceivedEvent first; - first.routeKey = "layer-1\namount"; - first.layerKey = "layer-1"; - first.parameterKey = "amount"; - first.valueJson = "0.1"; - first.generation = 1; - - OscValueReceivedEvent replacement = first; - replacement.valueJson = "0.9"; - replacement.generation = 2; - - OscValueReceivedEvent otherRoute; - otherRoute.routeKey = "layer-2\namount"; - otherRoute.layerKey = "layer-2"; - otherRoute.parameterKey = "amount"; - otherRoute.valueJson = "0.3"; - otherRoute.generation = 3; - - OscValueReceivedEvent overflow; - overflow.routeKey = "layer-3\namount"; - overflow.layerKey = "layer-3"; - overflow.parameterKey = "amount"; - overflow.valueJson = "0.4"; - overflow.generation = 4; - - Expect(queue.Push(MakeRuntimeEvent(first, "ControlServices", 1, createdAt)), "coalescing queue accepts first keyed event"); - Expect(queue.Push(MakeRuntimeEvent(replacement, "ControlServices", 2, std::chrono::steady_clock::now())), "coalescing queue replaces matching keyed event"); - Expect(queue.Push(MakeRuntimeEvent(otherRoute, "ControlServices", 3, createdAt)), "coalescing queue accepts second keyed event"); - Expect(!queue.Push(MakeRuntimeEvent(overflow, "ControlServices", 4, createdAt)), "coalescing queue rejects new key when full"); - - RuntimeEventCoalescingQueueMetrics metrics = queue.GetMetrics(std::chrono::steady_clock::now()); - Expect(metrics.depth == 2, "coalescing queue metrics report unique-key depth"); - Expect(metrics.capacity == 2, "coalescing queue metrics report capacity"); - Expect(metrics.coalescedCount == 1, "coalescing queue metrics report coalesced count"); - Expect(metrics.droppedCount == 1, "coalescing queue metrics report dropped count"); - Expect(metrics.oldestEventAgeMilliseconds >= 0.0, "coalescing queue metrics report oldest event age"); - - std::vector drained = queue.Drain(); - Expect(drained.size() == 2, "coalescing queue drains unique events"); - Expect(drained[0].sequence == 2, "coalescing queue keeps latest replacement event"); - Expect(drained[1].sequence == 3, "coalescing queue preserves first-seen key order"); - - const auto* latestPayload = std::get_if(&drained[0].payload); - Expect(latestPayload && latestPayload->valueJson == "0.9", "coalescing queue keeps latest payload value"); - Expect(queue.Depth() == 0, "coalescing queue is empty after drain"); -} - -void TestRuntimeEventCoalescingCustomKey() -{ - RuntimeEventCoalescingQueue queue(4, [](const RuntimeEvent& event) { - return std::string(RuntimeEventTypeName(event.type)); - }); - - RuntimeStateBroadcastRequestedEvent first; - first.reason = "parameter"; - RuntimeStateBroadcastRequestedEvent second; - second.reason = "reload"; - - Expect(queue.Push(MakeRuntimeEvent(first, "RuntimeCoordinator", 10)), "custom-key coalescing queue accepts first event"); - Expect(queue.Push(MakeRuntimeEvent(second, "RuntimeCoordinator", 11)), "custom-key coalescing queue coalesces second event by type"); - - std::vector drained = queue.Drain(); - Expect(drained.size() == 1, "custom-key coalescing queue drains one coalesced event"); - Expect(drained[0].sequence == 11, "custom-key coalescing queue keeps latest event"); - - const auto* payload = std::get_if(&drained[0].payload); - Expect(payload && payload->reason == "reload", "custom-key coalescing queue keeps latest typed payload"); -} - -void TestRuntimeEventTestHarness() -{ - RuntimeEventTestHarness harness; - - RuntimeStateBroadcastRequestedEvent broadcast; - broadcast.reason = "parameter"; - RuntimeEventDispatchResult firstDispatch = harness.PublishAndDispatch(broadcast, "RuntimeCoordinator"); - Expect(firstDispatch.dispatchedEvents == 1, "test harness publishes and dispatches payloads"); - Expect(harness.SeenCount() == 1, "test harness records dispatched events"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeStateBroadcastRequested) == 1, "test harness counts seen events by type"); - - const RuntimeEvent* seenBroadcast = harness.LastSeen(RuntimeEventType::RuntimeStateBroadcastRequested); - Expect(seenBroadcast && seenBroadcast->source == "RuntimeCoordinator", "test harness returns last seen event by type"); - - harness.ClearSeen(); - Expect(harness.SeenCount() == 0, "test harness clears seen events"); - - OscValueReceivedEvent first; - first.routeKey = "layer-1\namount"; - first.layerKey = "layer-1"; - first.parameterKey = "amount"; - first.valueJson = "0.1"; - first.generation = 1; - - OscValueReceivedEvent replacement = first; - replacement.valueJson = "0.8"; - replacement.generation = 2; - - Expect(harness.PublishCoalesced(first, "ControlServices", 20), "test harness accepts first coalesced payload"); - Expect(harness.PublishCoalesced(replacement, "ControlServices", 21), "test harness accepts replacement coalesced payload"); - RuntimeEventDispatchResult coalescedDispatch = harness.FlushCoalescedAndDispatch(); - Expect(coalescedDispatch.dispatchedEvents == 1, "test harness dispatches one coalesced event"); - Expect(harness.SeenCount(RuntimeEventType::OscValueReceived) == 1, "test harness records coalesced event"); - - const RuntimeEvent* seenOsc = harness.LastSeen(RuntimeEventType::OscValueReceived); - const auto* seenPayload = seenOsc ? std::get_if(&seenOsc->payload) : nullptr; - Expect(seenPayload && seenPayload->valueJson == "0.8", "test harness keeps latest coalesced payload"); -} - -void TestAcceptedMutationFollowUps() -{ - RuntimeEventTestHarness harness; - - RuntimeMutationEvent mutation; - mutation.action = "SetLayerShader"; - mutation.accepted = true; - mutation.runtimeStateChanged = true; - mutation.runtimeStateBroadcastRequired = true; - mutation.shaderBuildRequested = true; - mutation.persistenceRequested = true; - - RuntimeStateChangedEvent stateChanged; - stateChanged.reason = mutation.action; - stateChanged.persistenceRequested = true; - - RuntimePersistenceRequestedEvent persistence; - persistence.request = PersistenceRequest::RuntimeStateRequest(mutation.action); - - RuntimeReloadRequestedEvent reload; - reload.reason = mutation.action; - reload.preserveFeedbackState = false; - - ShaderBuildEvent build; - build.phase = RuntimeEventShaderBuildPhase::Requested; - build.succeeded = true; - build.message = "Shader rebuild queued."; - - Expect(harness.Publish(mutation, "RuntimeCoordinator"), "accepted mutation event publishes"); - Expect(harness.Publish(stateChanged, "RuntimeCoordinator"), "state changed follow-up publishes"); - Expect(harness.Publish(persistence, "RuntimeCoordinator"), "persistence follow-up publishes"); - Expect(harness.Publish(reload, "RuntimeCoordinator"), "reload follow-up publishes"); - Expect(harness.Publish(build, "RuntimeCoordinator"), "shader build follow-up publishes"); - - RuntimeEventDispatchResult result = harness.DispatchPending(); - Expect(result.dispatchedEvents == 5, "accepted mutation dispatches every expected follow-up"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeMutationAccepted) == 1, "accepted mutation fact is observed"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeStateChanged) == 1, "accepted mutation publishes state changed follow-up"); - Expect(harness.SeenCount(RuntimeEventType::RuntimePersistenceRequested) == 1, "accepted mutation publishes persistence follow-up"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeReloadRequested) == 1, "accepted mutation publishes reload follow-up"); - Expect(harness.SeenCount(RuntimeEventType::ShaderBuildRequested) == 1, "accepted mutation publishes shader build follow-up"); - - const RuntimeEvent* persistenceEvent = harness.LastSeen(RuntimeEventType::RuntimePersistenceRequested); - const auto* persistencePayload = persistenceEvent ? std::get_if(&persistenceEvent->payload) : nullptr; - Expect(persistencePayload && persistencePayload->request.reason == "SetLayerShader", "persistence follow-up preserves mutation action reason"); - Expect(persistencePayload && persistencePayload->request.debounceKey == "runtime-state", "persistence follow-up preserves debounce key"); -} - -void TestAppLevelBroadcastAndBuildCoalescing() -{ - RuntimeEventTestHarness harness; - - RuntimeMutationEvent firstMutation; - firstMutation.action = "SetLayerShader"; - firstMutation.accepted = true; - firstMutation.runtimeStateChanged = true; - firstMutation.runtimeStateBroadcastRequired = true; - firstMutation.shaderBuildRequested = true; - - RuntimeMutationEvent secondMutation = firstMutation; - secondMutation.action = "LoadStackPreset"; - - RuntimeStateBroadcastRequestedEvent firstBroadcast; - firstBroadcast.reason = "SetLayerShader"; - RuntimeStateBroadcastRequestedEvent secondBroadcast; - secondBroadcast.reason = "LoadStackPreset"; - - ShaderBuildEvent firstBuild; - firstBuild.phase = RuntimeEventShaderBuildPhase::Requested; - firstBuild.inputWidth = 1920; - firstBuild.inputHeight = 1080; - firstBuild.preserveFeedbackState = false; - firstBuild.message = "first build request"; - ShaderBuildEvent secondBuild = firstBuild; - secondBuild.message = "second build request"; - - Expect(harness.Publish(firstMutation, "RuntimeCoordinator"), "first accepted mutation fact publishes"); - Expect(harness.Publish(firstBroadcast, "RuntimeUpdateController"), "first broadcast request publishes through app dispatcher"); - Expect(harness.Publish(firstBuild, "RuntimeCoordinator"), "first shader build request publishes through app dispatcher"); - Expect(harness.Publish(secondMutation, "RuntimeCoordinator"), "second accepted mutation fact publishes"); - Expect(harness.Publish(secondBroadcast, "RuntimeUpdateController"), "second broadcast request coalesces through app dispatcher"); - Expect(harness.Publish(secondBuild, "RuntimeCoordinator"), "second shader build request coalesces through app dispatcher"); - - RuntimeEventQueueMetrics metrics = harness.Dispatcher().GetQueueMetrics(); - Expect(metrics.depth == 4, "app dispatcher keeps FIFO facts plus coalesced broadcast/build requests"); - Expect(metrics.coalescedCount == 2, "app dispatcher reports broadcast and build coalescing"); - - RuntimeEventDispatchResult result = harness.DispatchPending(); - Expect(result.dispatchedEvents == 4, "app dispatcher dispatches FIFO facts plus one broadcast and one build request"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeMutationAccepted) == 2, "app dispatcher preserves every accepted mutation fact"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeStateBroadcastRequested) == 1, "app dispatcher coalesces broadcast requests"); - Expect(harness.SeenCount(RuntimeEventType::ShaderBuildRequested) == 1, "app dispatcher coalesces matching shader build requests"); - - const RuntimeEvent* broadcastEvent = harness.LastSeen(RuntimeEventType::RuntimeStateBroadcastRequested); - const auto* broadcastPayload = broadcastEvent ? std::get_if(&broadcastEvent->payload) : nullptr; - Expect(broadcastPayload && broadcastPayload->reason == "LoadStackPreset", "app dispatcher dispatches latest broadcast request"); - - const RuntimeEvent* buildEvent = harness.LastSeen(RuntimeEventType::ShaderBuildRequested); - const auto* buildPayload = buildEvent ? std::get_if(&buildEvent->payload) : nullptr; - Expect(buildPayload && buildPayload->message == "second build request", "app dispatcher dispatches latest shader build request"); -} - -void TestManualReloadBridgeEvents() -{ - RuntimeEventTestHarness harness; - - ManualReloadRequestedEvent manualReload; - manualReload.preserveFeedbackState = true; - manualReload.reason = "RequestShaderReload"; - - RuntimeReloadRequestedEvent runtimeReload; - runtimeReload.preserveFeedbackState = true; - runtimeReload.reason = "RequestShaderReload"; - - ShaderBuildEvent shaderBuild; - shaderBuild.phase = RuntimeEventShaderBuildPhase::Requested; - shaderBuild.preserveFeedbackState = true; - shaderBuild.message = "Shader rebuild queued."; - - Expect(harness.Publish(manualReload, "RuntimeCoordinator"), "manual reload ingress event publishes"); - Expect(harness.Publish(runtimeReload, "RuntimeCoordinator"), "manual reload bridge publishes runtime reload request"); - Expect(harness.Publish(shaderBuild, "RuntimeCoordinator"), "manual reload bridge publishes shader build request"); - - RuntimeEventDispatchResult result = harness.DispatchPending(); - Expect(result.dispatchedEvents == 3, "manual reload bridge dispatches ingress and follow-up events"); - Expect(harness.SeenCount(RuntimeEventType::ManualReloadRequested) == 1, "manual reload ingress event is observed"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeReloadRequested) == 1, "manual reload runtime reload follow-up is observed"); - Expect(harness.SeenCount(RuntimeEventType::ShaderBuildRequested) == 1, "manual reload shader build follow-up is observed"); - - const RuntimeEvent* reloadEvent = harness.LastSeen(RuntimeEventType::RuntimeReloadRequested); - const auto* reloadPayload = reloadEvent ? std::get_if(&reloadEvent->payload) : nullptr; - Expect(reloadPayload && reloadPayload->preserveFeedbackState, "manual reload bridge preserves feedback policy in runtime reload event"); -} - -void TestFileReloadBridgeEvents() -{ - RuntimeEventTestHarness harness; - - FileChangeDetectedEvent fileChange; - fileChange.path = "PollRuntimeStoreChanges"; - fileChange.shaderPackageCandidate = true; - - RuntimeReloadRequestedEvent runtimeReload; - runtimeReload.preserveFeedbackState = false; - runtimeReload.reason = "PollRuntimeStoreChanges"; - - ShaderBuildEvent shaderBuild; - shaderBuild.phase = RuntimeEventShaderBuildPhase::Requested; - shaderBuild.preserveFeedbackState = false; - shaderBuild.message = "Shader rebuild queued."; - - Expect(harness.Publish(fileChange, "RuntimeCoordinator"), "file change ingress event publishes"); - Expect(harness.Publish(runtimeReload, "RuntimeCoordinator"), "file change bridge publishes runtime reload request"); - Expect(harness.Publish(shaderBuild, "RuntimeCoordinator"), "file change bridge publishes shader build request"); - - RuntimeEventDispatchResult result = harness.DispatchPending(); - Expect(result.dispatchedEvents == 3, "file reload bridge dispatches ingress and follow-up events"); - Expect(harness.SeenCount(RuntimeEventType::FileChangeDetected) == 1, "file change ingress event is observed"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeReloadRequested) == 1, "file reload runtime reload follow-up is observed"); - Expect(harness.SeenCount(RuntimeEventType::ShaderBuildRequested) == 1, "file reload shader build follow-up is observed"); - - const RuntimeEvent* fileEvent = harness.LastSeen(RuntimeEventType::FileChangeDetected); - const auto* filePayload = fileEvent ? std::get_if(&fileEvent->payload) : nullptr; - Expect(filePayload && filePayload->shaderPackageCandidate, "file reload bridge marks shader package candidate changes"); -} - -void TestRejectedMutationHasNoDownstreamFollowUps() -{ - RuntimeEventTestHarness harness; - - RuntimeMutationEvent mutation; - mutation.action = "SetLayerShader"; - mutation.accepted = false; - mutation.errorMessage = "Unknown shader id: missing"; - - Expect(harness.Publish(mutation, "RuntimeCoordinator"), "rejected mutation event publishes"); - RuntimeEventDispatchResult result = harness.DispatchPending(); - Expect(result.dispatchedEvents == 1, "rejected mutation dispatches only the rejection fact"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeMutationRejected) == 1, "rejected mutation fact is observed"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeStateChanged) == 0, "rejected mutation has no state follow-up"); - Expect(harness.SeenCount(RuntimeEventType::RuntimePersistenceRequested) == 0, "rejected mutation has no persistence follow-up"); - Expect(harness.SeenCount(RuntimeEventType::RuntimeReloadRequested) == 0, "rejected mutation has no reload follow-up"); - Expect(harness.SeenCount(RuntimeEventType::ShaderBuildRequested) == 0, "rejected mutation has no shader build follow-up"); - - const RuntimeEvent* rejectedEvent = harness.LastSeen(RuntimeEventType::RuntimeMutationRejected); - const auto* rejectedPayload = rejectedEvent ? std::get_if(&rejectedEvent->payload) : nullptr; - Expect(rejectedPayload && rejectedPayload->errorMessage == "Unknown shader id: missing", "rejected mutation preserves error message"); -} - -void TestShaderBuildGenerationEventMatching() -{ - RuntimeEventTestHarness harness; - std::size_t handledBuilds = 0; - uint64_t handledGeneration = 0; - - harness.Dispatcher().Subscribe(RuntimeEventType::ShaderBuildPrepared, [&](const RuntimeEvent& event) { - const auto* payload = std::get_if(&event.payload); - if (!payload || payload->generation != 7) - return; - - ++handledBuilds; - handledGeneration = payload->generation; - }); - - ShaderBuildEvent stale; - stale.phase = RuntimeEventShaderBuildPhase::Prepared; - stale.generation = 6; - stale.succeeded = true; - - ShaderBuildEvent current = stale; - current.generation = 7; - - Expect(harness.Publish(stale, "ShaderBuildQueue"), "stale shader build event publishes"); - Expect(harness.Publish(current, "ShaderBuildQueue"), "current shader build event publishes"); - RuntimeEventDispatchResult result = harness.DispatchPending(); - Expect(result.dispatchedEvents == 2, "shader build readiness events dispatch in order"); - Expect(harness.SeenCount(RuntimeEventType::ShaderBuildPrepared) == 2, "both shader build readiness facts are observable"); - Expect(handledBuilds == 1, "generation-aware handler applies only the expected build once"); - Expect(handledGeneration == 7, "generation-aware handler records the expected generation"); -} - -void TestHandlerFailureCanBecomeTelemetryEvent() -{ - RuntimeEventTestHarness harness; - harness.Dispatcher().Subscribe(RuntimeEventType::RuntimeStateBroadcastRequested, [](const RuntimeEvent&) { - throw std::runtime_error("handler failed"); - }); - - RuntimeStateBroadcastRequestedEvent broadcast; - broadcast.reason = "test"; - Expect(harness.Publish(broadcast, "test"), "broadcast event publishes before failing handler"); - RuntimeEventDispatchResult result = harness.DispatchPending(); - Expect(result.handlerFailures == 1, "dispatcher reports handler failure for telemetry"); - - TimingSampleRecordedEvent timing; - timing.subsystem = "RuntimeEventDispatcher"; - timing.metric = "handlerFailures"; - timing.value = static_cast(result.handlerFailures); - timing.unit = "count"; - Expect(harness.Publish(timing, "HealthTelemetry"), "handler failure timing sample publishes"); - harness.DispatchPending(); - Expect(harness.SeenCount(RuntimeEventType::TimingSampleRecorded) == 1, "handler failure can be observed as telemetry event"); -} -} - -int main() -{ - TestRuntimeEventTypeNames(); - TestRuntimeEventPayloadTypes(); - TestRuntimeEventEnvelope(); - TestRuntimeEventQueue(); - TestRuntimeEventDispatcher(); - TestRuntimeEventDispatcherCoalescing(); - TestRuntimeEventCoalescingQueue(); - TestRuntimeEventCoalescingCustomKey(); - TestRuntimeEventTestHarness(); - TestAcceptedMutationFollowUps(); - TestAppLevelBroadcastAndBuildCoalescing(); - TestManualReloadBridgeEvents(); - TestFileReloadBridgeEvents(); - TestRejectedMutationHasNoDownstreamFollowUps(); - TestShaderBuildGenerationEventMatching(); - TestHandlerFailureCanBecomeTelemetryEvent(); - - if (gFailures != 0) - { - std::cerr << gFailures << " RuntimeEventType test failure(s).\n"; - return 1; - } - - std::cout << "RuntimeEventType tests passed.\n"; - return 0; -} diff --git a/tests/RuntimeLiveStateTests.cpp b/tests/RuntimeLiveStateTests.cpp deleted file mode 100644 index 9a1f745..0000000 --- a/tests/RuntimeLiveStateTests.cpp +++ /dev/null @@ -1,611 +0,0 @@ -#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; -} diff --git a/tests/RuntimeStateLayerModelTests.cpp b/tests/RuntimeStateLayerModelTests.cpp deleted file mode 100644 index 693ee07..0000000 --- a/tests/RuntimeStateLayerModelTests.cpp +++ /dev/null @@ -1,88 +0,0 @@ -#include "RuntimeStateLayerModel.h" - -#include - -namespace -{ -int gFailures = 0; - -void Expect(bool condition, const char* message) -{ - if (condition) - return; - - std::cerr << "FAIL: " << message << "\n"; - ++gFailures; -} - -void TestLayerPrecedence() -{ - Expect(RuntimeStateLayerCompositionPrecedence(RuntimeStateLayerKind::BasePersisted) == 0, - "base persisted state is the first composition layer"); - Expect(RuntimeStateLayerCompositionPrecedence(RuntimeStateLayerKind::CommittedLive) == 1, - "committed live state overlays persisted state"); - Expect(RuntimeStateLayerCompositionPrecedence(RuntimeStateLayerKind::TransientAutomation) == 2, - "transient automation overlays committed state"); - Expect(!RuntimeStateLayerParticipatesInParameterComposition(RuntimeStateLayerKind::RenderLocal), - "render-local state does not participate in parameter composition"); - Expect(!RuntimeStateLayerParticipatesInParameterComposition(RuntimeStateLayerKind::HealthConfig), - "health/config state does not participate in parameter composition"); -} - -void TestFieldClassification() -{ - Expect(ClassifyRuntimeStateField(RuntimeStateField::PersistedLayerStack) == RuntimeStateLayerKind::BasePersisted, - "layer stack is base persisted state"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::PersistedParameterValues) == RuntimeStateLayerKind::BasePersisted, - "saved parameters are base persisted state"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::CommittedSessionParameterValues) == RuntimeStateLayerKind::CommittedLive, - "session parameter values are committed live state"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::RuntimeCompileReloadFlags) == RuntimeStateLayerKind::CommittedLive, - "compile/reload flags are committed session coordination state"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::TransientOscOverlay) == RuntimeStateLayerKind::TransientAutomation, - "OSC overlays are transient automation state"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::TransientAutomationCommitState) == RuntimeStateLayerKind::TransientAutomation, - "overlay commit generations are transient automation state"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::RenderLocalTemporalHistory) == RuntimeStateLayerKind::RenderLocal, - "temporal history is render-local state"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::RenderLocalFeedbackState) == RuntimeStateLayerKind::RenderLocal, - "feedback state is render-local state"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::RuntimeConfiguration) == RuntimeStateLayerKind::HealthConfig, - "runtime configuration is outside parameter composition"); - Expect(ClassifyRuntimeStateField(RuntimeStateField::HealthTelemetry) == RuntimeStateLayerKind::HealthConfig, - "health telemetry is outside parameter composition"); -} - -void TestInventoriesStayInSyncWithEnums() -{ - const std::vector layers = GetRuntimeStateLayerInventory(); - Expect(layers.size() == 5, "layer inventory names all Phase 5 state categories"); - - const std::vector fields = GetRuntimeStateFieldInventory(); - Expect(fields.size() == 14, "field inventory names current state categories"); - for (const RuntimeStateFieldDescriptor& field : fields) - { - Expect(field.layerKind == ClassifyRuntimeStateField(field.field), - "field inventory layer kind matches classifier"); - Expect(field.name != nullptr && field.name[0] != '\0', "field inventory has a display name"); - Expect(field.currentOwner != nullptr && field.currentOwner[0] != '\0', "field inventory has an owner"); - } -} -} - -int main() -{ - TestLayerPrecedence(); - TestFieldClassification(); - TestInventoriesStayInSyncWithEnums(); - - if (gFailures != 0) - { - std::cerr << gFailures << " RuntimeStateLayerModel test failure(s).\n"; - return 1; - } - - std::cout << "RuntimeStateLayerModel tests passed.\n"; - return 0; -} - diff --git a/tests/RuntimeSubsystemTests.cpp b/tests/RuntimeSubsystemTests.cpp deleted file mode 100644 index d5fc3b5..0000000 --- a/tests/RuntimeSubsystemTests.cpp +++ /dev/null @@ -1,377 +0,0 @@ -#include "LayerStackStore.h" -#include "RuntimeCoordinator.h" -#include "RuntimeEventDispatcher.h" -#include "RuntimeStateJson.h" -#include "RuntimeStore.h" -#include "ShaderPackageCatalog.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -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"); - - const PersistenceRequest snapshotRequest = PersistenceRequest::RuntimeStateRequest("unit-test"); - const PersistenceSnapshot snapshot = store.BuildRuntimeStatePersistenceSnapshot(snapshotRequest); - Expect(snapshot.targetKind == PersistenceTargetKind::RuntimeState, "runtime store builds a runtime-state persistence snapshot"); - Expect(snapshot.reason == "unit-test", "runtime-state persistence snapshot preserves request reason"); - Expect(snapshot.targetPath.filename().string() == "runtime_state.json", "runtime-state persistence snapshot targets the runtime state file"); - Expect(snapshot.contents.find("\"layers\"") != std::string::npos, "runtime-state persistence snapshot contains serialized layer state"); - Expect(store.RequestPersistence(PersistenceRequest::RuntimeStateRequest("unit-test-request"), error), - "runtime store accepts runtime-state persistence requests"); - PersistenceRequest unsupportedRequest; - unsupportedRequest.targetKind = PersistenceTargetKind::StackPreset; - unsupportedRequest.reason = "unsupported-unit-test"; - Expect(!store.RequestPersistence(unsupportedRequest, error), "runtime store rejects unsupported persistence request targets"); - - RuntimeEventDispatcher dispatcher(64); - std::vector seenEvents; - dispatcher.SubscribeAll([&seenEvents](const RuntimeEvent& event) { - seenEvents.push_back(event); - }); - - RuntimeCoordinator coordinator(store, dispatcher); - auto dispatchAndClear = [&]() { - dispatcher.DispatchPending(); - const std::vector events = seenEvents; - seenEvents.clear(); - return events; - }; - auto countEvents = [](const std::vector& events, RuntimeEventType type) { - return static_cast(std::count_if(events.begin(), events.end(), - [type](const RuntimeEvent& event) { return event.type == type; })); - }; - auto persistenceReason = [](const std::vector& events) { - for (const RuntimeEvent& event : events) - { - if (event.type != RuntimeEventType::RuntimePersistenceRequested) - continue; - const auto* payload = std::get_if(&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 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 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 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 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 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 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; -}